From 5f4ca7e9c96d428438e4c64b40d5ec2cfcf66723 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 1 Aug 2025 17:10:16 +0200 Subject: [PATCH 01/22] Added first thoughts on fe architecture and admin-setup --- README.md | 8 +++ project.inlang/messages/de.json | 7 +- project.inlang/messages/en.json | 7 +- src/app.css | 4 ++ .../layouts/empty-layout/action.svelte | 7 ++ .../components/layouts/empty-layout/index.ts | 5 ++ .../layouts/empty-layout/main.svelte | 9 +++ .../layouts/empty-layout/root.svelte | 18 +++++ src/lib/components/layouts/index.ts | 3 + .../templates/{ => language-switch}/index.ts | 0 .../language-switch.svelte | 12 +++- .../components/ui/combobox/combobox.svelte | 15 +++-- .../ui/page/horizontal-page-padding.svelte | 7 +- .../components/ui/page/page-with-claim.svelte | 67 ++++++++++--------- src/lib/components/ui/typography/text.svelte | 15 ++++- src/routes/(pages)/+page.svelte | 7 +- src/routes/(pages)/setup/+page.svelte | 28 ++++++++ src/server-hooks/authHandle.ts | 4 ++ src/server-hooks/loggingHandle.ts | 4 ++ 19 files changed, 178 insertions(+), 49 deletions(-) create mode 100644 src/lib/components/layouts/empty-layout/action.svelte create mode 100644 src/lib/components/layouts/empty-layout/index.ts create mode 100644 src/lib/components/layouts/empty-layout/main.svelte create mode 100644 src/lib/components/layouts/empty-layout/root.svelte create mode 100644 src/lib/components/layouts/index.ts rename src/lib/components/templates/{ => language-switch}/index.ts (100%) rename src/lib/components/templates/{ => language-switch}/language-switch.svelte (62%) create mode 100644 src/routes/(pages)/setup/+page.svelte diff --git a/README.md b/README.md index cb36eea..cb07b75 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ End-to-end encrypted appointment booking platform +## Local development + +- `npm install` +- `npm run docker:dev:up` +- `npm run db:migrate` +- `npm run db:push` +- `npm run dev` + ## Funding This project is funded by: diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 5fe9afb..a121791 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -6,5 +6,10 @@ "label": "Sprache wählen", "search": "Sprache suchen", "notFound": "Sprache nicht gefunden" - } + }, + "setup": { + "start": "Konfiguration starten" + }, + "slogan": "Ende zu Ende verschlüsselte Platform für Termine.", + "logo": "OpenReception Logo: Ein 3D-Pixel-Art mit Kalender, der auch ein Schloss ist" } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 0f9e789..734b079 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -6,5 +6,10 @@ "label": "Select Language", "search": "Search Language", "notFound": "Language not found" - } + }, + "setup": { + "start": "Start configuration" + }, + "slogan": "End-to-End encrypted appointment booking platform.", + "logo": "OpenReception Logo: A 3D pixel art with calendar that's also a lock" } diff --git a/src/app.css b/src/app.css index 29cefe3..b5ce99e 100644 --- a/src/app.css +++ b/src/app.css @@ -138,4 +138,8 @@ background-color: var(--background); color: var(--foreground); } + + .max-w-sm { + max-width: 42rem; + } } diff --git a/src/lib/components/layouts/empty-layout/action.svelte b/src/lib/components/layouts/empty-layout/action.svelte new file mode 100644 index 0000000..eac18d1 --- /dev/null +++ b/src/lib/components/layouts/empty-layout/action.svelte @@ -0,0 +1,7 @@ + + +{@render children?.()} diff --git a/src/lib/components/layouts/empty-layout/index.ts b/src/lib/components/layouts/empty-layout/index.ts new file mode 100644 index 0000000..5f51c7f --- /dev/null +++ b/src/lib/components/layouts/empty-layout/index.ts @@ -0,0 +1,5 @@ +import Root from "./root.svelte"; +import Action from "./action.svelte"; +import Main from "./main.svelte"; + +export { Root, Action, Main }; diff --git a/src/lib/components/layouts/empty-layout/main.svelte b/src/lib/components/layouts/empty-layout/main.svelte new file mode 100644 index 0000000..ec9757b --- /dev/null +++ b/src/lib/components/layouts/empty-layout/main.svelte @@ -0,0 +1,9 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/layouts/empty-layout/root.svelte b/src/lib/components/layouts/empty-layout/root.svelte new file mode 100644 index 0000000..421d472 --- /dev/null +++ b/src/lib/components/layouts/empty-layout/root.svelte @@ -0,0 +1,18 @@ + + + +
+ {@render children?.()} +
+
diff --git a/src/lib/components/layouts/index.ts b/src/lib/components/layouts/index.ts new file mode 100644 index 0000000..91cd395 --- /dev/null +++ b/src/lib/components/layouts/index.ts @@ -0,0 +1,3 @@ +import * as EmptyLayout from "./empty-layout"; + +export { EmptyLayout }; diff --git a/src/lib/components/templates/index.ts b/src/lib/components/templates/language-switch/index.ts similarity index 100% rename from src/lib/components/templates/index.ts rename to src/lib/components/templates/language-switch/index.ts diff --git a/src/lib/components/templates/language-switch.svelte b/src/lib/components/templates/language-switch/language-switch.svelte similarity index 62% rename from src/lib/components/templates/language-switch.svelte rename to src/lib/components/templates/language-switch/language-switch.svelte index a82e6dd..890ed83 100644 --- a/src/lib/components/templates/language-switch.svelte +++ b/src/lib/components/templates/language-switch/language-switch.svelte @@ -1,10 +1,17 @@ -
+
diff --git a/src/lib/components/ui/combobox/combobox.svelte b/src/lib/components/ui/combobox/combobox.svelte index e95177b..7286e41 100644 --- a/src/lib/components/ui/combobox/combobox.svelte +++ b/src/lib/components/ui/combobox/combobox.svelte @@ -4,14 +4,17 @@ import { tick } from "svelte"; import * as Command from "$lib/components/ui/command/index.js"; import * as Popover from "$lib/components/ui/popover/index.js"; - import { Button } from "$lib/components/ui/button/index.js"; + import { Button, type ButtonSize, type ButtonVariant } from "$lib/components/ui/button/index.js"; import { cn } from "$lib/utils.js"; const { labels, value, options = [], - onChange + onChange, + class: className = "", + triggerVariant = "outline", + triggerSize = "default" }: { labels: { placeholder: string; @@ -20,6 +23,9 @@ }; value: string; onChange: (value: string) => void; + triggerVariant?: ButtonVariant; + triggerSize?: ButtonSize; + class?: string; options?: { label: string; value: string; keywords?: string[] }[]; } = $props(); @@ -42,8 +48,9 @@ - {/if} +
+ {#if isWithLanguageSwitch} + + - + {/if} + {@render children?.()} + + + {#if dev} + + Viewport: +
xs
+ + + + + +
+ {/if} + + {m.poweredBy()} + OpenReception + + {#if dev} + + {/if} +
diff --git a/src/lib/components/ui/typography/text.svelte b/src/lib/components/ui/typography/text.svelte index c268b3b..6499063 100644 --- a/src/lib/components/ui/typography/text.svelte +++ b/src/lib/components/ui/typography/text.svelte @@ -10,24 +10,35 @@ md: "leading-7 [&:not(:first-child)]:mt-6", sm: "text-sm font-medium leading-none", xs: "text-xs" + }, + color: { + default: "", + darker: "text-darker", + dark: "text-dark", + medium: "text-medium", + light: "text-light", + lighter: "text-lighter" } }, defaultVariants: { - style: "xs" + style: "xs", + color: "default" } }); let { ref = $bindable(null), style, + color = "default", class: className, children, ...restProps }: WithElementRef> & { style: VariantProps["style"]; + color?: VariantProps["color"]; } = $props(); - + {@render children?.()} diff --git a/src/routes/(pages)/+page.svelte b/src/routes/(pages)/+page.svelte index 5cb8e51..daf147a 100644 --- a/src/routes/(pages)/+page.svelte +++ b/src/routes/(pages)/+page.svelte @@ -1,8 +1,6 @@ @@ -14,7 +12,6 @@
- {m.welcome()} Environment configuration is {#await data.streamed.isEnvOk} @@ -23,8 +20,6 @@ {isEnvOk ? "OK" : "NOT OK"} {/await}. - -
diff --git a/src/routes/(pages)/setup/+page.svelte b/src/routes/(pages)/setup/+page.svelte new file mode 100644 index 0000000..27b5e49 --- /dev/null +++ b/src/routes/(pages)/setup/+page.svelte @@ -0,0 +1,28 @@ + + + + {m.welcome()} - OpenReception + + +{#snippet action()}{/snippet} + + + + + {m.logo()} + {m.welcome()} + {m.slogan()} + + + + + + diff --git a/src/server-hooks/authHandle.ts b/src/server-hooks/authHandle.ts index 1d6d252..d3123c4 100644 --- a/src/server-hooks/authHandle.ts +++ b/src/server-hooks/authHandle.ts @@ -37,6 +37,10 @@ export const authHandle: Handle = async ({ event, resolve }) => { const { url, request } = 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)); diff --git a/src/server-hooks/loggingHandle.ts b/src/server-hooks/loggingHandle.ts index 3649c6d..6d163a6 100644 --- a/src/server-hooks/loggingHandle.ts +++ b/src/server-hooks/loggingHandle.ts @@ -10,6 +10,10 @@ import { logger } from "$lib/logger"; * @returns {Promise} The response with applied headers and rate limiting */ export const loggingHandle: Handle = async ({ event, resolve }) => { + if (!event.url.pathname.startsWith("/api")) { + return resolve(event); + } + const start = Date.now(); const requestLogger = logger.setContext("REQUEST"); From 084022944e217ab52f72bd21854622b4da75040e Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 19 Aug 2025 21:02:50 +0200 Subject: [PATCH 02/22] Added admin account setup, email confirmation -wip --- package-lock.json | 689 ++++++++++++++++++ package.json | 3 + project.inlang/messages/de.json | 54 +- project.inlang/messages/en.json | 56 +- src/app.css | 7 +- src/app.d.ts | 3 + .../layouts/centered-card/action-hint.svelte | 10 + .../layouts/centered-card/action.svelte | 9 + .../layouts/centered-card/description.svelte | 11 + .../layouts/centered-card/header.svelte | 9 + .../components/layouts/centered-card/index.ts | 9 + .../layouts/centered-card/main.svelte | 9 + .../layouts/centered-card/root.svelte | 21 + .../layouts/centered-card/title.svelte | 11 + .../layouts/empty-layout/root.svelte | 2 +- src/lib/components/layouts/index.ts | 3 +- .../empty-state/center-loading-state.svelte | 14 + .../templates/empty-state/empty-state.svelte | 18 + .../components/templates/empty-state/index.ts | 4 + src/lib/components/ui/button/button.svelte | 15 +- src/lib/components/ui/form/form-button.svelte | 8 + .../ui/form/form-description.svelte | 23 + .../ui/form/form-element-field.svelte | 24 + .../ui/form/form-field-errors.svelte | 33 + src/lib/components/ui/form/form-field.svelte | 29 + .../components/ui/form/form-fieldset.svelte | 15 + src/lib/components/ui/form/form-label.svelte | 24 + src/lib/components/ui/form/form-legend.svelte | 16 + src/lib/components/ui/form/form-root.svelte | 24 + src/lib/components/ui/form/index.ts | 35 + src/lib/components/ui/input/index.ts | 7 + src/lib/components/ui/input/input.svelte | 51 ++ src/lib/components/ui/label/index.ts | 7 + src/lib/components/ui/label/label.svelte | 20 + src/lib/components/ui/skeleton/index.ts | 7 + .../components/ui/skeleton/skeleton.svelte | 17 + src/lib/components/ui/sonner/index.ts | 1 + src/lib/components/ui/sonner/sonner.svelte | 13 + .../components/ui/typography/headline.svelte | 6 +- src/lib/components/ui/typography/text.svelte | 2 +- src/lib/const/routes.ts | 8 + .../email/templates/confirmation.de.html | 281 ++++--- src/routes/(pages)/+layout.svelte | 4 +- src/routes/(pages)/+page.server.ts | 12 + .../(pages)/confirm/[token]/+page.server.ts | 24 + .../(pages)/confirm/[token]/+page.svelte | 75 ++ src/routes/(pages)/login/+page.svelte | 1 + src/routes/(pages)/setup/+layout.server.ts | 15 + src/routes/(pages)/setup/+page.svelte | 9 +- .../(pages)/setup/check-email/+page.server.ts | 37 + .../(pages)/setup/check-email/+page.svelte | 91 +++ .../(pages)/setup/check-email/schema.ts | 8 + .../create-admin-account/+page.server.ts | 45 ++ .../setup/create-admin-account/+page.svelte | 53 ++ .../create-account-form.svelte | 104 +++ .../setup/create-admin-account/schema.ts | 10 + 56 files changed, 1953 insertions(+), 143 deletions(-) create mode 100644 src/lib/components/layouts/centered-card/action-hint.svelte create mode 100644 src/lib/components/layouts/centered-card/action.svelte create mode 100644 src/lib/components/layouts/centered-card/description.svelte create mode 100644 src/lib/components/layouts/centered-card/header.svelte create mode 100644 src/lib/components/layouts/centered-card/index.ts create mode 100644 src/lib/components/layouts/centered-card/main.svelte create mode 100644 src/lib/components/layouts/centered-card/root.svelte create mode 100644 src/lib/components/layouts/centered-card/title.svelte create mode 100644 src/lib/components/templates/empty-state/center-loading-state.svelte create mode 100644 src/lib/components/templates/empty-state/empty-state.svelte create mode 100644 src/lib/components/templates/empty-state/index.ts create mode 100644 src/lib/components/ui/form/form-button.svelte create mode 100644 src/lib/components/ui/form/form-description.svelte create mode 100644 src/lib/components/ui/form/form-element-field.svelte create mode 100644 src/lib/components/ui/form/form-field-errors.svelte create mode 100644 src/lib/components/ui/form/form-field.svelte create mode 100644 src/lib/components/ui/form/form-fieldset.svelte create mode 100644 src/lib/components/ui/form/form-label.svelte create mode 100644 src/lib/components/ui/form/form-legend.svelte create mode 100644 src/lib/components/ui/form/form-root.svelte create mode 100644 src/lib/components/ui/form/index.ts create mode 100644 src/lib/components/ui/input/index.ts create mode 100644 src/lib/components/ui/input/input.svelte create mode 100644 src/lib/components/ui/label/index.ts create mode 100644 src/lib/components/ui/label/label.svelte create mode 100644 src/lib/components/ui/skeleton/index.ts create mode 100644 src/lib/components/ui/skeleton/skeleton.svelte create mode 100644 src/lib/components/ui/sonner/index.ts create mode 100644 src/lib/components/ui/sonner/sonner.svelte create mode 100644 src/lib/const/routes.ts create mode 100644 src/routes/(pages)/confirm/[token]/+page.server.ts create mode 100644 src/routes/(pages)/confirm/[token]/+page.svelte create mode 100644 src/routes/(pages)/login/+page.svelte create mode 100644 src/routes/(pages)/setup/+layout.server.ts create mode 100644 src/routes/(pages)/setup/check-email/+page.server.ts create mode 100644 src/routes/(pages)/setup/check-email/+page.svelte create mode 100644 src/routes/(pages)/setup/check-email/schema.ts create mode 100644 src/routes/(pages)/setup/create-admin-account/+page.server.ts create mode 100644 src/routes/(pages)/setup/create-admin-account/+page.svelte create mode 100644 src/routes/(pages)/setup/create-admin-account/create-account-form.svelte create mode 100644 src/routes/(pages)/setup/create-admin-account/schema.ts diff --git a/package-lock.json b/package-lock.json index 8751463..4da4c5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "eslint": "^9.29.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-svelte": "^3.9.3", + "formsnap": "^2.0.1", "globals": "^16.2.0", "jsdom": "^26.1.0", "prettier": "^3.5.3", @@ -57,6 +58,8 @@ "prettier-plugin-tailwindcss": "^0.6.13", "svelte": "^5.34.7", "svelte-check": "^4.2.2", + "svelte-sonner": "^1.0.5", + "sveltekit-superforms": "^2.27.1", "tailwind-merge": "^3.3.1", "tailwind-variants": "^1.0.0", "tailwindcss": "^4.1.10", @@ -87,6 +90,25 @@ "node": ">=6.0.0" } }, + "node_modules/@ark/schema": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.46.0.tgz", + "integrity": "sha512-c2UQdKgP2eqqDArfBqQIJppxJHvNNXuQPeuSPlDML4rjw+f1cu0qAlzOG4b8ujgm9ctIDWwhpyw6gjG5ledIVQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@ark/util": "0.46.0" + } + }, + "node_modules/@ark/util": { + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.46.0.tgz", + "integrity": "sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -1299,6 +1321,14 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/schemasafe": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", + "integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@floating-ui/core": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", @@ -1327,6 +1357,58 @@ "dev": true, "license": "MIT" }, + "node_modules/@gcornut/valibot-json-schema": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@gcornut/valibot-json-schema/-/valibot-json-schema-0.42.0.tgz", + "integrity": "sha512-4Et4AN6wmqeA0PfU5Clkv/IS27wiefsWf6TemAZrb75uzkClYEFavim7SboeKwbll9Nbsn2Iv0LT/HS5H7orZg==", + "dev": true, + "optional": true, + "dependencies": { + "valibot": "~0.42.0" + }, + "bin": { + "valibot-json-schema": "bin/index.js" + }, + "optionalDependencies": { + "@types/json-schema": ">= 7.0.14", + "esbuild-runner": ">= 2.2.2" + } + }, + "node_modules/@gcornut/valibot-json-schema/node_modules/valibot": { + "version": "0.42.1", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.42.1.tgz", + "integrity": "sha512-3keXV29Ar5b//Hqi4MbSdV7lfVp6zuYLZuA9V1PvQUsXqogr+u5lvLPLk3A4f74VUXDnf/JfWMN6sB+koJ/FFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1647,6 +1729,14 @@ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "license": "MIT" }, + "node_modules/@poppinss/macroable": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@poppinss/macroable/-/macroable-1.0.5.tgz", + "integrity": "sha512-6u61y1HHd090MEk1Av0/1btDmm2Hh/+XoJj+HgFYRh9koUPI822ybJbwLHuqjLNCiY+o1gRykg2igEqOf/VBZw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.6", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.6.tgz", @@ -2020,6 +2110,33 @@ "win32" ] }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/@sinclair/typebox": { "version": "0.31.28", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz", @@ -2037,6 +2154,14 @@ "sqlite-wasm": "bin/index.js" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", @@ -2629,6 +2754,49 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.15.2", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.2.tgz", + "integrity": "sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@typeschema/class-validator": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@typeschema/class-validator/-/class-validator-0.3.0.tgz", + "integrity": "sha512-OJSFeZDIQ8EK1HTljKLT5CItM2wsbgczLN8tMEfz3I1Lmhc5TBfkZ0eikFzUC16tI3d1Nag7um6TfCgp2I2Bww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@typeschema/core": "0.14.0" + }, + "peerDependencies": { + "class-validator": "^0.14.1" + }, + "peerDependenciesMeta": { + "class-validator": { + "optional": true + } + } + }, + "node_modules/@typeschema/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@typeschema/core/-/core-0.14.0.tgz", + "integrity": "sha512-Ia6PtZHcL3KqsAWXjMi5xIyZ7XMH4aSnOQes8mfMLx+wGFGtGRNlwe6Y7cYvX+WfNK67OL0/HSe9t8QDygV0/w==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "@types/json-schema": "^7.0.15" + }, + "peerDependenciesMeta": { + "@types/json-schema": { + "optional": true + } + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.34.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.1.tgz", @@ -2886,6 +3054,38 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vinejs/compiler": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vinejs/compiler/-/compiler-3.0.0.tgz", + "integrity": "sha512-v9Lsv59nR56+bmy2p0+czjZxsLHwaibJ+SV5iK9JJfehlJMa501jUJQqqz4X/OqKXrxtE3uTQmSqjUqzF3B2mw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@vinejs/vine": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@vinejs/vine/-/vine-3.0.1.tgz", + "integrity": "sha512-ZtvYkYpZOYdvbws3uaOAvTFuvFXoQGAtmzeiXu+XSMGxi5GVsODpoI9Xu9TplEMuD/5fmAtBbKb9cQHkWkLXDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@poppinss/macroable": "^1.0.4", + "@types/validator": "^13.12.2", + "@vinejs/compiler": "^3.0.0", + "camelcase": "^8.0.0", + "dayjs": "^1.11.13", + "dlv": "^1.1.3", + "normalize-url": "^8.0.1", + "validator": "^13.12.0" + }, + "engines": { + "node": ">=18.16.0" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -3113,6 +3313,18 @@ "node": ">= 0.4" } }, + "node_modules/arktype": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.1.20.tgz", + "integrity": "sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@ark/schema": "0.46.0", + "@ark/util": "0.46.0" + } + }, "node_modules/array-timsort": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", @@ -3266,6 +3478,20 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -3333,6 +3559,19 @@ "node": ">=18" } }, + "node_modules/class-validator": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", + "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.11.1", + "validator": "^13.9.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3545,6 +3784,14 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -3636,6 +3883,14 @@ "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", "license": "MIT" }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", @@ -3811,6 +4066,18 @@ } } }, + "node_modules/effect": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.17.7.tgz", + "integrity": "sha512-dpt0ONUn3zzAuul6k4nC/coTTw27AL5nhkORXgTi6NfMPzqWYa1M05oKmOMTxpVSTKepqXVcW9vIwkuaaqx9zA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/enabled": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", @@ -3918,6 +4185,32 @@ "esbuild": ">=0.12 <1" } }, + "node_modules/esbuild-runner": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esbuild-runner/-/esbuild-runner-2.2.2.tgz", + "integrity": "sha512-fRFVXcmYVmSmtYm2mL8RlUASt2TDkGh3uRcvHFOKNr/T58VrfVeKD9uT9nlgxk96u0LS0ehS/GY7Da/bXWKkhw==", + "dev": true, + "license": "Apache License 2.0", + "optional": true, + "dependencies": { + "source-map-support": "0.5.21", + "tslib": "2.4.0" + }, + "bin": { + "esr": "bin/esr.js" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/esbuild-runner/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4202,6 +4495,30 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4353,6 +4670,47 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", "license": "MIT" }, + "node_modules/formsnap": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/formsnap/-/formsnap-2.0.1.tgz", + "integrity": "sha512-iJSe4YKd/W6WhLwKDVJU9FQeaJRpEFuolhju7ZXlRpUVyDdqFdMP8AUBICgnVvQPyP41IPAlBa/v0Eo35iE6wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "svelte-toolbelt": "^0.5.0" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "sveltekit-superforms": "^2.19.0" + } + }, + "node_modules/formsnap/node_modules/svelte-toolbelt": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.5.0.tgz", + "integrity": "sha512-t3tenZcnfQoIeRuQf/jBU7bvTeT3TGkcEE+1EUr5orp0lR7NEpprflpuie3x9Dn0W9nOKqs3HwKGJeeN5Ok1sQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.126" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -4739,6 +5097,21 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/jose": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz", @@ -4822,6 +5195,21 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4905,6 +5293,14 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.12", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.12.tgz", + "integrity": "sha512-aWVR6xXYYRvnK0v/uIwkf5Lthq9Jpn0N8TISW/oDTWlYB2sOimuiLn9Q26aUw4KxkJoiT8ACdiw44Y8VwKFIfQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -5244,6 +5640,13 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/memoize-weak": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/memoize-weak/-/memoize-weak-1.0.2.tgz", + "integrity": "sha512-gj39xkrjEw7nCn4nJ1M5ms6+MyMlyiGmttzsqAUsAKn6bYKwuTHh/AO3cKPF8IBrTIYTxb0wWXFs3E//Y8VoWQ==", + "dev": true, + "license": "ISC" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5434,6 +5837,20 @@ "node": ">=6.0.0" } }, + "node_modules/normalize-url": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz", + "integrity": "sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nwsapi": { "version": "2.2.20", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", @@ -5912,6 +6329,14 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5922,6 +6347,24 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6426,6 +6869,17 @@ "inline-style-parser": "0.2.4" } }, + "node_modules/superstruct": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", + "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6543,6 +6997,36 @@ "node": ">=4" } }, + "node_modules/svelte-sonner": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.5.tgz", + "integrity": "sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "runed": "^0.28.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/svelte-sonner/node_modules/runed": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", + "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, "node_modules/svelte-toolbelt": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz", @@ -6578,6 +7062,111 @@ "svelte": "^5.7.0" } }, + "node_modules/sveltekit-superforms": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/sveltekit-superforms/-/sveltekit-superforms-2.27.1.tgz", + "integrity": "sha512-cvq2AevkZ0Zrk0w0gNM3kjcnJMtJ0jzu+2zqDoM9a+lZa+8bGpNl4YqxVkemiJNkGnFgNC8xr5xF5BlMzjookQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ciscoheat" + }, + { + "type": "ko-fi", + "url": "https://ko-fi.com/ciscoheat" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=NY7F5ALHHSVQS" + } + ], + "license": "MIT", + "dependencies": { + "devalue": "^5.1.1", + "memoize-weak": "^1.0.2", + "ts-deepmerge": "^7.0.3" + }, + "optionalDependencies": { + "@exodus/schemasafe": "^1.3.0", + "@gcornut/valibot-json-schema": "^0.42.0", + "@sinclair/typebox": "^0.34.35", + "@typeschema/class-validator": "^0.3.0", + "@vinejs/vine": "^3.0.1", + "arktype": "^2.1.20", + "class-validator": "^0.14.2", + "effect": "^3.16.7", + "joi": "^17.13.3", + "json-schema-to-ts": "^3.1.1", + "superstruct": "^2.0.2", + "valibot": "^1.1.0", + "yup": "^1.6.1", + "zod": "^3.25.64", + "zod-to-json-schema": "^3.24.5" + }, + "peerDependencies": { + "@exodus/schemasafe": "^1.3.0", + "@sinclair/typebox": "^0.34.28", + "@sveltejs/kit": "1.x || 2.x", + "@typeschema/class-validator": "^0.3.0", + "@vinejs/vine": "^1.8.0 || ^2.0.0 || ^3.0.0", + "arktype": ">=2.0.0-rc.23", + "class-validator": "^0.14.1", + "effect": "^3.13.7", + "joi": "^17.13.1", + "superstruct": "^2.0.2", + "svelte": "3.x || 4.x || >=5.0.0-next.51", + "valibot": "^1.0.0", + "yup": "^1.4.0", + "zod": "^3.25.0" + }, + "peerDependenciesMeta": { + "@exodus/schemasafe": { + "optional": true + }, + "@sinclair/typebox": { + "optional": true + }, + "@typeschema/class-validator": { + "optional": true + }, + "@vinejs/vine": { + "optional": true + }, + "arktype": { + "optional": true + }, + "class-validator": { + "optional": true + }, + "effect": { + "optional": true + }, + "joi": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "valibot": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/sveltekit-superforms/node_modules/@sinclair/typebox": { + "version": "0.34.40", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.40.tgz", + "integrity": "sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -6672,6 +7261,14 @@ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6765,6 +7362,14 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -6809,6 +7414,14 @@ "node": ">= 14.0.0" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -6822,6 +7435,16 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-deepmerge": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-7.0.3.tgz", + "integrity": "sha512-Du/ZW2RfwV/D4cmA5rXafYjBQVuvu4qGiEEla4EmEHVHgRdx68Gftx7i66jn2bzHPwSVZY36Ae6OuDn9el4ZKA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14.13.1" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6852,6 +7475,20 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -6957,6 +7594,33 @@ "uuidv7": "cli.js" } }, + "node_modules/valibot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/validator": { + "version": "13.15.15", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", + "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", @@ -7381,6 +8045,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.0.tgz", + "integrity": "sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "node_modules/zimmerframe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", @@ -7395,6 +8073,17 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "dev": true, + "license": "ISC", + "optional": true, + "peerDependencies": { + "zod": "^3.24.1" + } } } } diff --git a/package.json b/package.json index d9a7ee5..3fb41c6 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "eslint": "^9.29.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-svelte": "^3.9.3", + "formsnap": "^2.0.1", "globals": "^16.2.0", "jsdom": "^26.1.0", "prettier": "^3.5.3", @@ -65,6 +66,8 @@ "prettier-plugin-tailwindcss": "^0.6.13", "svelte": "^5.34.7", "svelte-check": "^4.2.2", + "svelte-sonner": "^1.0.5", + "sveltekit-superforms": "^2.27.1", "tailwind-merge": "^3.3.1", "tailwind-variants": "^1.0.0", "tailwindcss": "^4.1.10", diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index a121791..4181048 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -8,8 +8,58 @@ "notFound": "Sprache nicht gefunden" }, "setup": { - "start": "Konfiguration starten" + "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" + "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" + } + }, + "login": { + "or": "Oder", + "usePasskey": "Passkey verwenden", + "usePassphrase": "Passphrase verwenden" + }, + "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" + } + } } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 734b079..f854e6d 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -8,8 +8,60 @@ "notFound": "Language not found" }, "setup": { - "start": "Start configuration" + "start": "Start configuration", + "create_admin_account": { + "title": "Create Admin Account", + "description": "We’ll start by adding your admin account. It will be used to manage this instance.", + "action": "Send Verification E-Mail", + "success": "Account created!", + "error": "An unkonwn error occured." + }, + "verify_email": { + "title": "Check your E-Mail", + "description": "We’ve send you a magic link to {email}", + "action": "Resend E-Mail", + "success": "E-Mail was resent" + }, + "confirm": { + "success": { + "title": "Good job!", + "description": "You’ve secured this instance.", + "hint": "Next step is setting up a tenant to collect appointments", + "action": "Add first Tenant" + }, + "error": { + "title": "Verification failed", + "description": "The magic link you’ve tried is incorrect or outdated" + } + } }, "slogan": "End-to-End encrypted appointment booking platform.", - "logo": "OpenReception Logo: A 3D pixel art with calendar that's also a lock" + "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" + } + }, + "login": { + "or": "Or", + "usePasskey": "use passkey", + "usePassphrase": "use passphrase", + "orUse": "Or {button}" + }, + "confirm": { + "success": { + "title": "You’re all set!", + "description": "You’ve successfully secured your account", + "action": "Proceed" + }, + "error": { + "title": "Verification failed", + "description": "The magic link you’ve tried is incorrect or outdated", + "action": "Request new E-Mail" + } + } } diff --git a/src/app.css b/src/app.css index b5ce99e..a856a7a 100644 --- a/src/app.css +++ b/src/app.css @@ -126,6 +126,9 @@ --color-medium: var(--medium); --color-dark: var(--dark); --color-darker: var(--darker); + + --max-w-sm: 400px; + --max-w-md: 640px; } @layer base { @@ -138,8 +141,4 @@ background-color: var(--background); color: var(--foreground); } - - .max-w-sm { - max-width: 42rem; - } } diff --git a/src/app.d.ts b/src/app.d.ts index a7c8026..8876c95 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -13,6 +13,9 @@ declare global { // interface PageState {} // interface Platform {} } + interface PageState { + email?: string; + } } export {}; diff --git a/src/lib/components/layouts/centered-card/action-hint.svelte b/src/lib/components/layouts/centered-card/action-hint.svelte new file mode 100644 index 0000000..e053c8d --- /dev/null +++ b/src/lib/components/layouts/centered-card/action-hint.svelte @@ -0,0 +1,10 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/layouts/centered-card/action.svelte b/src/lib/components/layouts/centered-card/action.svelte new file mode 100644 index 0000000..eab531b --- /dev/null +++ b/src/lib/components/layouts/centered-card/action.svelte @@ -0,0 +1,9 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/layouts/centered-card/description.svelte b/src/lib/components/layouts/centered-card/description.svelte new file mode 100644 index 0000000..df36383 --- /dev/null +++ b/src/lib/components/layouts/centered-card/description.svelte @@ -0,0 +1,11 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/layouts/centered-card/header.svelte b/src/lib/components/layouts/centered-card/header.svelte new file mode 100644 index 0000000..0f8d05d --- /dev/null +++ b/src/lib/components/layouts/centered-card/header.svelte @@ -0,0 +1,9 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/layouts/centered-card/index.ts b/src/lib/components/layouts/centered-card/index.ts new file mode 100644 index 0000000..b88fb2e --- /dev/null +++ b/src/lib/components/layouts/centered-card/index.ts @@ -0,0 +1,9 @@ +import Action from "./action.svelte"; +import ActionHint from "./action-hint.svelte"; +import Description from "./description.svelte"; +import Header from "./header.svelte"; +import Main from "./main.svelte"; +import Root from "./root.svelte"; +import Title from "./title.svelte"; + +export { Action, ActionHint, Description, Header, Main, Root, Title }; diff --git a/src/lib/components/layouts/centered-card/main.svelte b/src/lib/components/layouts/centered-card/main.svelte new file mode 100644 index 0000000..0838849 --- /dev/null +++ b/src/lib/components/layouts/centered-card/main.svelte @@ -0,0 +1,9 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/layouts/centered-card/root.svelte b/src/lib/components/layouts/centered-card/root.svelte new file mode 100644 index 0000000..3715f1d --- /dev/null +++ b/src/lib/components/layouts/centered-card/root.svelte @@ -0,0 +1,21 @@ + + + + + {@render children?.()} + + diff --git a/src/lib/components/layouts/centered-card/title.svelte b/src/lib/components/layouts/centered-card/title.svelte new file mode 100644 index 0000000..9efb262 --- /dev/null +++ b/src/lib/components/layouts/centered-card/title.svelte @@ -0,0 +1,11 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/layouts/empty-layout/root.svelte b/src/lib/components/layouts/empty-layout/root.svelte index 421d472..c0150e2 100644 --- a/src/lib/components/layouts/empty-layout/root.svelte +++ b/src/lib/components/layouts/empty-layout/root.svelte @@ -9,7 +9,7 @@
diff --git a/src/lib/components/layouts/index.ts b/src/lib/components/layouts/index.ts index 91cd395..be69dbe 100644 --- a/src/lib/components/layouts/index.ts +++ b/src/lib/components/layouts/index.ts @@ -1,3 +1,4 @@ import * as EmptyLayout from "./empty-layout"; +import * as CenteredCard from "./centered-card"; -export { EmptyLayout }; +export { EmptyLayout, CenteredCard }; diff --git a/src/lib/components/templates/empty-state/center-loading-state.svelte b/src/lib/components/templates/empty-state/center-loading-state.svelte new file mode 100644 index 0000000..5f4cad6 --- /dev/null +++ b/src/lib/components/templates/empty-state/center-loading-state.svelte @@ -0,0 +1,14 @@ + + +
+ + + + +
diff --git a/src/lib/components/templates/empty-state/empty-state.svelte b/src/lib/components/templates/empty-state/empty-state.svelte new file mode 100644 index 0000000..e07ec90 --- /dev/null +++ b/src/lib/components/templates/empty-state/empty-state.svelte @@ -0,0 +1,18 @@ + + +
+ + {headline} + {description} +
diff --git a/src/lib/components/templates/empty-state/index.ts b/src/lib/components/templates/empty-state/index.ts new file mode 100644 index 0000000..eeba8e0 --- /dev/null +++ b/src/lib/components/templates/empty-state/index.ts @@ -0,0 +1,4 @@ +import CenterState from "./empty-state.svelte"; +import CenterLoadingState from "./center-loading-state.svelte"; + +export { CenterState, CenterLoadingState }; diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte index 4da437d..46d9ec8 100644 --- a/src/lib/components/ui/button/button.svelte +++ b/src/lib/components/ui/button/button.svelte @@ -1,10 +1,11 @@ @@ -49,6 +52,7 @@ type = "button", disabled, children, + isLoading, ...restProps }: ButtonProps = $props(); @@ -75,6 +79,9 @@ {disabled} {...restProps} > + {#if isLoading} + + {/if} {@render children?.()} {/if} diff --git a/src/lib/components/ui/form/form-button.svelte b/src/lib/components/ui/form/form-button.svelte new file mode 100644 index 0000000..12c6c85 --- /dev/null +++ b/src/lib/components/ui/form/form-button.svelte @@ -0,0 +1,8 @@ + + + + {:else} + + {/if} + {:else} + + {/if} + {/await} + + + diff --git a/src/routes/(pages)/login/+page.svelte b/src/routes/(pages)/login/+page.svelte new file mode 100644 index 0000000..76f6852 --- /dev/null +++ b/src/routes/(pages)/login/+page.svelte @@ -0,0 +1 @@ +Hello from login diff --git a/src/routes/(pages)/setup/+layout.server.ts b/src/routes/(pages)/setup/+layout.server.ts new file mode 100644 index 0000000..bdeb9ff --- /dev/null +++ b/src/routes/(pages)/setup/+layout.server.ts @@ -0,0 +1,15 @@ +import { ROUTES } from "$lib/const/routes.js"; +import { UserService } from "$lib/server/services/user-service"; +import { redirect } from "@sveltejs/kit"; + +export const load = async (event) => { + // Some routes should not be accessed, once an admin is created + const cleanedId = event.route.id.replace("/(pages)", ""); + const blocklist = [ROUTES.SETUP.MAIN, ROUTES.SETUP.CREATE_ADMIN_ACCOUNT]; + + // Check if global admin exists + const adminExists = await UserService.adminExists(); + if (blocklist.includes(cleanedId) && adminExists) { + redirect(302, ROUTES.LOGIN); + } +}; diff --git a/src/routes/(pages)/setup/+page.svelte b/src/routes/(pages)/setup/+page.svelte index 27b5e49..24fe485 100644 --- a/src/routes/(pages)/setup/+page.svelte +++ b/src/routes/(pages)/setup/+page.svelte @@ -4,16 +4,13 @@ import { Button } from "$lib/components/ui/button"; import { PageWithClaim } from "$lib/components/ui/page"; import { Headline, Text } from "$lib/components/ui/typography"; - - export let data; + import { ROUTES } from "$lib/const/routes.js"; {m.welcome()} - OpenReception -{#snippet action()}{/snippet} - @@ -22,7 +19,9 @@ {m.slogan()} - + diff --git a/src/routes/(pages)/setup/check-email/+page.server.ts b/src/routes/(pages)/setup/check-email/+page.server.ts new file mode 100644 index 0000000..679ae30 --- /dev/null +++ b/src/routes/(pages)/setup/check-email/+page.server.ts @@ -0,0 +1,37 @@ +import { superValidate } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import type { Actions, PageServerLoad } from "./$types"; +import { formSchema } from "./schema"; +import { UserService } from "$lib/server/services/user-service"; +import { fail } from "@sveltejs/kit"; +import logger from "$lib/logger"; + +const log = logger.setContext("Setup"); + +export const load: PageServerLoad = async () => { + return { + form: await superValidate(zod(formSchema)) + }; +}; + +export const actions: Actions = { + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); + + if (!form.valid) { + return fail(400, { + form + }); + } + + await UserService.resendConfirmationEmail(form.data.email); + + log.debug("Resent confirmation e-mail", { + email: form.data.email + }); + + return { + form + }; + } +}; diff --git a/src/routes/(pages)/setup/check-email/+page.svelte b/src/routes/(pages)/setup/check-email/+page.svelte new file mode 100644 index 0000000..5339a5f --- /dev/null +++ b/src/routes/(pages)/setup/check-email/+page.svelte @@ -0,0 +1,91 @@ + + + + {m["setup.verify_email.title"]()} - OpenReception + + + + + + + + + + + + {m["setup.verify_email.action"]()} + + + + + diff --git a/src/routes/(pages)/setup/check-email/schema.ts b/src/routes/(pages)/setup/check-email/schema.ts new file mode 100644 index 0000000..9c07ee8 --- /dev/null +++ b/src/routes/(pages)/setup/check-email/schema.ts @@ -0,0 +1,8 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + email: z.string().email(m["form.errors.email"]()) +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/setup/create-admin-account/+page.server.ts b/src/routes/(pages)/setup/create-admin-account/+page.server.ts new file mode 100644 index 0000000..29ffbed --- /dev/null +++ b/src/routes/(pages)/setup/create-admin-account/+page.server.ts @@ -0,0 +1,45 @@ +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"; + +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 + }); + } + + // Create admin account + const admin = await UserService.createUser({ + name: "Admin", + email: form.data.email, + passphrase: form.data.passphrase, + language: form.data.language + }); + const hasPasskey = false; + + log.debug("Admin account created successfully", { + adminId: admin.id, + email: admin.email, + authMethod: hasPasskey ? "passkey" : "passphrase" + // passkeyId: hasPasskey ? body.passkey.id : undefined + }); + + return { form }; + } +}; diff --git a/src/routes/(pages)/setup/create-admin-account/+page.svelte b/src/routes/(pages)/setup/create-admin-account/+page.svelte new file mode 100644 index 0000000..c7a3574 --- /dev/null +++ b/src/routes/(pages)/setup/create-admin-account/+page.svelte @@ -0,0 +1,53 @@ + + + + {m["setup.create_admin_account.title"]()} - OpenReception + + + + + + + {m["setup.create_admin_account.title"]()} + + + {m["setup.create_admin_account.description"]()} + + + + + + + + {m["setup.create_admin_account.action"]()} + + + + diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte new file mode 100644 index 0000000..ef68386 --- /dev/null +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -0,0 +1,104 @@ + + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["form.passphrase"]()} + + {/snippet} + + + + {m["form.passphraseRequirements"]()} + {m["login.or"]()} + {#if $isUsingPasskey} + + {:else} + + {/if}. + + + diff --git a/src/routes/(pages)/setup/create-admin-account/schema.ts b/src/routes/(pages)/setup/create-admin-account/schema.ts new file mode 100644 index 0000000..0f7271d --- /dev/null +++ b/src/routes/(pages)/setup/create-admin-account/schema.ts @@ -0,0 +1,10 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + email: z.string().email(m["form.errors.email"]()), + passphrase: z.string().min(30, m["form.errors.passphrase"]()), + language: z.enum(["de", "en"]) +}); + +export type FormSchema = typeof formSchema; From f2776674e709ccb5b73e2f28198fedd3b36b8298 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 20 Aug 2025 08:17:31 +0200 Subject: [PATCH 03/22] Added resend page --- project.inlang/messages/de.json | 7 +++ project.inlang/messages/en.json | 7 +++ src/lib/const/routes.ts | 1 + .../(pages)/confirm/[token]/+page.server.ts | 17 ++++-- .../(pages)/confirm/[token]/+page.svelte | 9 ++-- .../(pages)/confirm/resend/+page.server.ts | 33 ++++++++++++ .../(pages)/confirm/resend/+page.svelte | 53 +++++++++++++++++++ .../resend/resend-confirmation-form.svelte | 43 +++++++++++++++ src/routes/(pages)/confirm/resend/schema.ts | 8 +++ 9 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 src/routes/(pages)/confirm/resend/+page.server.ts create mode 100644 src/routes/(pages)/confirm/resend/+page.svelte create mode 100644 src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte create mode 100644 src/routes/(pages)/confirm/resend/schema.ts diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 4181048..11d17aa 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -60,6 +60,13 @@ "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" } } } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index f854e6d..fc907d7 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -62,6 +62,13 @@ "title": "Verification failed", "description": "The magic link you’ve tried is incorrect or outdated", "action": "Request new E-Mail" + }, + "title": "E-Mail verification", + "resend": { + "title": "Resend Verification E-Mail", + "description": "Enter your e-mail address to get the verification link again.", + "action": "Resend E-Mail", + "success": "E-Mail sent" } } } diff --git a/src/lib/const/routes.ts b/src/lib/const/routes.ts index 907676a..1c6551a 100644 --- a/src/lib/const/routes.ts +++ b/src/lib/const/routes.ts @@ -4,5 +4,6 @@ export const ROUTES = { CREATE_ADMIN_ACCOUNT: "/setup/create-admin-account", CHECK_EMAIL: "/setup/check-email" }, + RESEND_CONFIRMATION: "/confirm/resend", LOGIN: "/login" }; diff --git a/src/routes/(pages)/confirm/[token]/+page.server.ts b/src/routes/(pages)/confirm/[token]/+page.server.ts index d9fb838..f14eb9e 100644 --- a/src/routes/(pages)/confirm/[token]/+page.server.ts +++ b/src/routes/(pages)/confirm/[token]/+page.server.ts @@ -1,5 +1,8 @@ +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", { @@ -9,11 +12,17 @@ export const load: PageServerLoad = async (event) => { }, body: JSON.stringify({ token: event.params.token }) }) - .then((resp) => { + .then(async (resp) => { const success = resp.status < 400; - // TODO: Check if this is the first account - const isSetup = true; - return { success, isSetup }; + try { + const body = await resp.json(); + // TODO: Check if this is the first account + 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 { diff --git a/src/routes/(pages)/confirm/[token]/+page.svelte b/src/routes/(pages)/confirm/[token]/+page.svelte index a39eded..5a0cd9d 100644 --- a/src/routes/(pages)/confirm/[token]/+page.svelte +++ b/src/routes/(pages)/confirm/[token]/+page.svelte @@ -8,12 +8,13 @@ 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; - {m["setup.verify_email.title"]()} - OpenReception + {m["confirm.title"]()} - OpenReception @@ -56,16 +57,16 @@ {m["setup.confirm.success.hint"]()} - {:else} - {/if} {:else} - {/if} diff --git a/src/routes/(pages)/confirm/resend/+page.server.ts b/src/routes/(pages)/confirm/resend/+page.server.ts new file mode 100644 index 0000000..c215441 --- /dev/null +++ b/src/routes/(pages)/confirm/resend/+page.server.ts @@ -0,0 +1,33 @@ +import { fail } from "@sveltejs/kit"; +import { superValidate } from "sveltekit-superforms"; +import { zod } from "sveltekit-superforms/adapters"; +import type { Actions, PageServerLoad } from "./$types"; +import { formSchema } from "./schema"; + +export const load: PageServerLoad = async () => { + return { + form: await superValidate(zod(formSchema)) + }; +}; + +export const actions: Actions = { + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); + + if (!form.valid) { + return fail(400, { + form + }); + } + + await event.fetch("/api/auth/resend-confirmation", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ email: form.data.email }) + }); + + return { form }; + } +}; diff --git a/src/routes/(pages)/confirm/resend/+page.svelte b/src/routes/(pages)/confirm/resend/+page.svelte new file mode 100644 index 0000000..a65b080 --- /dev/null +++ b/src/routes/(pages)/confirm/resend/+page.svelte @@ -0,0 +1,53 @@ + + + + {m["confirm.resend.title"]()} - OpenReception + + + + + + + {m["confirm.resend.title"]()} + + + {m["confirm.resend.description"]()} + + + + + + + + {m["confirm.resend.action"]()} + + + + diff --git a/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte b/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte new file mode 100644 index 0000000..d506b48 --- /dev/null +++ b/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte @@ -0,0 +1,43 @@ + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + diff --git a/src/routes/(pages)/confirm/resend/schema.ts b/src/routes/(pages)/confirm/resend/schema.ts new file mode 100644 index 0000000..9c07ee8 --- /dev/null +++ b/src/routes/(pages)/confirm/resend/schema.ts @@ -0,0 +1,8 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + email: z.string().email(m["form.errors.email"]()) +}); + +export type FormSchema = typeof formSchema; From e0ef05d40500227b699dbca36227cb5be390dd6a Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Wed, 20 Aug 2025 11:42:17 +0200 Subject: [PATCH 04/22] Return isSetup flag on confirm route. --- src/lib/server/services/user-service.ts | 11 ++++++++--- src/routes/api/auth/confirm/+server.ts | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index efb9939..99e16d5 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -1,6 +1,6 @@ import { centralDb } from "../db"; import * as centralSchema from "../db/central-schema"; -import { eq, desc, gt, and } from "drizzle-orm"; +import { eq, desc, gt, and, count } from "drizzle-orm"; import type { InferInsertModel } from "drizzle-orm"; import z from "zod/v4"; import { NotFoundError, ValidationError } from "../utils/errors"; @@ -221,7 +221,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 +267,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 +276,8 @@ export class UserService { }); return { - recoveryPassphrase: user.recoveryPassphrase || undefined + recoveryPassphrase: user.recoveryPassphrase || undefined, + isSetup: countResult[0].count === 1 }; } catch (error) { if (error instanceof NotFoundError) throw error; diff --git a/src/routes/api/auth/confirm/+server.ts b/src/routes/api/auth/confirm/+server.ts index 27eae45..7b5557e 100644 --- a/src/routes/api/auth/confirm/+server.ts +++ b/src/routes/api/auth/confirm/+server.ts @@ -36,7 +36,11 @@ registerOpenAPIRoute("/auth/confirm", "POST", { schema: { type: "object", properties: { - message: { type: "string", description: "Success message" } + message: { type: "string", description: "Success message" }, + isSetup: { + type: "string", + description: "Whether this is the first account that was setup on the server" + } }, required: ["message"] }, @@ -73,8 +77,9 @@ export const POST: RequestHandler = async ({ request }) => { const confirmationResult = await UserService.confirm(body.token); - const response: Record = { - message: "User account confirmed successfully. You can now log in." + const response: Record = { + message: "User account confirmed successfully. You can now log in.", + isSetup: confirmationResult.isSetup }; // Include recovery passphrase if it exists (for WebAuthn-only users) From 78faa5f6914339b3b1f98ed4cba1cad0d1ffd7b9 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Thu, 21 Aug 2025 13:29:12 +0200 Subject: [PATCH 05/22] Allow /challenge to be called also for registration purposes. --- src/routes/api/admin/init/+server.ts | 17 ++- src/routes/api/auth/challenge/+server.ts | 133 ++++++++++++++++------- src/routes/api/auth/register/+server.ts | 17 ++- 3 files changed, 123 insertions(+), 44 deletions(-) diff --git a/src/routes/api/admin/init/+server.ts b/src/routes/api/admin/init/+server.ts index a970806..cc7f832 100644 --- a/src/routes/api/admin/init/+server.ts +++ b/src/routes/api/admin/init/+server.ts @@ -105,7 +105,7 @@ registerOpenAPIRoute("/admin/init", "POST", { } }); -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, cookies }) => { const log = logger.setContext("API"); try { @@ -144,12 +144,27 @@ export const POST: RequestHandler = async ({ request }) => { // 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: "/" }); + await UserService.addPasskey(admin.id, { id: body.passkey.id, publicKey: body.passkey.publicKey, counter: body.passkey.counter || 0, deviceName: body.passkey.deviceName || "Unknown Device" }); + + log.debug("Passkey added to admin account", { + adminId: admin.id, + passkeyId: body.passkey.id + }); } log.debug("Admin account created successfully", { diff --git a/src/routes/api/auth/challenge/+server.ts b/src/routes/api/auth/challenge/+server.ts index 5b942bd..90966ea 100644 --- a/src/routes/api/auth/challenge/+server.ts +++ b/src/routes/api/auth/challenge/+server.ts @@ -5,12 +5,13 @@ 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 +34,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 +46,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 +63,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 +74,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 +86,111 @@ 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[]; + }> = []; + + if (user) { + // Get user's registered passkeys for login + const passkeys = await WebAuthnService.getUserPasskeys(user.id); + + // 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 + })); - // 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 - })); - - logger.debug("WebAuthn challenge generated", { - userId: user.id, - email: user.email, - passkeyCount: passkeys.length, - challenge: challenge.substring(0, 8) + "..." - }); + 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); + return json({ challenge, allowCredentials, timeout: 60000, // 60 seconds - rpId: "localhost", // TODO: Configure for production - userVerification: "preferred" + rpId, + userVerification: "preferred", + isRegistration }); } catch (error) { logger.error("Challenge generation error", { error: String(error) }); diff --git a/src/routes/api/auth/register/+server.ts b/src/routes/api/auth/register/+server.ts index 65449a7..c983070 100644 --- a/src/routes/api/auth/register/+server.ts +++ b/src/routes/api/auth/register/+server.ts @@ -123,7 +123,7 @@ registerOpenAPIRoute("/auth/register", "POST", { } }); -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, cookies }) => { const log = logger.setContext("API"); try { @@ -191,12 +191,27 @@ export const POST: RequestHandler = async ({ request }) => { // 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: "/" }); + await UserService.addPasskey(user.id, { id: body.passkey.id, publicKey: body.passkey.publicKey, counter: body.passkey.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 From 151cbd5438a3c818f04f7bef06292d9c861433b8 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Thu, 21 Aug 2025 14:17:35 +0200 Subject: [PATCH 06/22] randomBytes import --- src/lib/server/auth/webauthn-service.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/lib/server/auth/webauthn-service.ts b/src/lib/server/auth/webauthn-service.ts index a92963f..ef0269d 100644 --- a/src/lib/server/auth/webauthn-service.ts +++ b/src/lib/server/auth/webauthn-service.ts @@ -1,7 +1,7 @@ import { centralDb } from "$lib/server/db"; import { 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"); @@ -189,9 +189,7 @@ 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"); } /** From 91fb1fa11503db4047a5d003ae3acaa0400c6cb5 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Fri, 22 Aug 2025 13:49:22 +0200 Subject: [PATCH 07/22] Determine correct url for confirmation pages in mail templates. --- .../email/__tests__/generate-base-url.test.ts | 313 ++++++++++++++++++ src/lib/server/email/email-service.ts | 49 ++- .../email/templates/confirmation.de.html | 297 ++++++++--------- .../email/templates/confirmation.de.txt | 2 + .../email/templates/confirmation.en.html | 1 + .../email/templates/confirmation.en.txt | 2 + src/lib/server/services/user-service.ts | 11 +- .../(pages)/setup/check-email/+page.server.ts | 2 +- .../create-admin-account/+page.server.ts | 2 +- src/routes/api/admin/init/+server.ts | 4 +- src/routes/api/auth/register/+server.ts | 4 +- .../api/auth/resend-confirmation/+server.ts | 4 +- src/server-hooks/authHandle.ts | 2 + 13 files changed, 531 insertions(+), 162 deletions(-) create mode 100644 src/lib/server/email/__tests__/generate-base-url.test.ts diff --git a/src/lib/server/email/__tests__/generate-base-url.test.ts b/src/lib/server/email/__tests__/generate-base-url.test.ts new file mode 100644 index 0000000..83c1799 --- /dev/null +++ b/src/lib/server/email/__tests__/generate-base-url.test.ts @@ -0,0 +1,313 @@ +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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + logo: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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", + primaryColor: "#000000", + backgroundColor: "#ffffff", + 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"); + }); + }); +}); \ No newline at end of file diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index 949cab4..2d5e2c3 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -187,12 +187,47 @@ export async function sendAppointmentUpdatedEmail( }); } +/** + * Generate base URL for email templates based on request URL and tenant + * @param {URL} requestUrl - The request URL + * @param {SelectTenant | null} tenant - Tenant information (null for global admin) + * @returns {string} The appropriate base URL + */ +export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): string { + const protocol = requestUrl.protocol; + const port = requestUrl.port ? `:${requestUrl.port}` : ''; + const hostname = requestUrl.hostname; + + // In development, always use the original hostname regardless of tenant + if (hostname === 'localhost' || hostname.startsWith('127.') || hostname.startsWith('192.168.')) { + return `${protocol}//${hostname}${port}`; + } + + // In production, handle tenant subdomains + if (tenant?.shortName) { + const parts = hostname.split('.'); + + if (parts.length > 2) { + // Complex subdomain - use only the last two parts (domain.tld) and add tenant + const domain = parts.slice(-2).join('.'); + return `${protocol}//${tenant.shortName}.${domain}${port}`; + } else { + // Main domain, prepend tenant subdomain + return `${protocol}//${tenant.shortName}.${hostname}${port}`; + } + } + + // For global admin or no tenant, use main domain + return `${protocol}//${hostname}${port}`; +} + /** * Send registration confirmation email with one-time code * @param {SelectClient | SelectStaff} user - Database user object * @param {SelectTenant} tenant - Tenant information for branding * @param {string} confirmationCode - One-time confirmation code * @param {number} [expirationMinutes=15] - Code expiration time in minutes + * @param {URL} [requestUrl] - Request URL for generating baseUrl * @throws {Error} When email sending fails * @returns {Promise} */ @@ -200,13 +235,23 @@ export async function sendConfirmationEmail( user: { id: string; email: string | null; name: string | null; language?: string | null }, tenant: SelectTenant, confirmationCode: string, - expirationMinutes: number = 15 + expirationMinutes: number = 15, + requestUrl?: URL ): Promise { const recipient = createEmailRecipient(user); const language = (recipient.language as Language) || "en"; const subject = language === "en" ? "Confirm Your Registration" : "Registrierung bestätigen"; - await sendTemplatedEmail("confirmation", recipient, subject, language, tenant, { + // Generate appropriate base URL if request URL is provided + const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : 'http://localhost:5173'; + + // Create enhanced tenant object with baseUrl + const tenantWithBaseUrl = { + ...tenant, + baseUrl + }; + + await sendTemplatedEmail("confirmation", recipient, subject, language, tenantWithBaseUrl, { confirmationCode, expirationMinutes }); diff --git a/src/lib/server/email/templates/confirmation.de.html b/src/lib/server/email/templates/confirmation.de.html index 98dfd68..7a15d11 100644 --- a/src/lib/server/email/templates/confirmation.de.html +++ b/src/lib/server/email/templates/confirmation.de.html @@ -1,173 +1,174 @@ + + + + Registrierung bestätigen + - + border: 2px solid { + { + tenant.primaryColor + } + } - -
-
- {{#if tenant.logo}} - {{tenant.longName}} - {{/if}} - -

Registrierung bestätigen

-
+ ; + border-radius: 8px; + padding: 20px; + text-align: center; + margin: 20px 0; + } -
-

Hallo,

+ .code { + font-family: 'Courier New', monospace; + font-size: 32px; + font-weight: bold; -

- vielen Dank für Ihre Registrierung bei {{tenant.longName}}. Um Ihre - Registrierung abzuschließen, verwenden Sie bitte den folgenden Bestätigungscode: -

+ color: { + { + tenant.primaryColor + } + } -
-
Ihr Bestätigungscode:
-
{{confirmationCode}}
-
- Geben Sie diesen Code in das Bestätigungsfeld ein + ; + letter-spacing: 4px; + margin: 10px 0; + } + + .footer { + background-color: #f8f9fa; + padding: 20px; + text-align: center; + font-size: 14px; + color: #666; + } + + .footer a { + color: { + { + tenant.primaryColor + } + } + + ; + text-decoration: none; + } + + .warning { + background-color: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 4px; + padding: 15px; + margin: 20px 0; + color: #856404; + } + + + + +
+
+ {{#if tenant.logo}} + {{tenant.longName}} + {{/if}} + +

Registrierung bestätigen

+
+ +
+

Hallo,

+ +

+ vielen Dank für Ihre Registrierung bei {{tenant.longName}}. Um Ihre + Registrierung abzuschließen, verwenden Sie bitte den folgenden Bestätigungscode: +

+ +
+
Ihr Bestätigungscode:
+
{{confirmationCode}}
+
+ Geben Sie diesen Code in das Bestätigungsfeld ein +
+ Oder hier klicken.
- {{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. +

+ +

Bei Fragen wenden Sie sich gerne an unser Support-Team.

+ +

+ Mit freundlichen Grüßen
+ Das {{tenant.longName}} Team +

-
- 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. -

- -

Bei Fragen wenden Sie sich gerne an unser Support-Team.

- -

- Mit freundlichen Grüßen
- Das {{tenant.longName}} Team -

- - -
- - - \ No newline at end of file + + diff --git a/src/lib/server/email/templates/confirmation.de.txt b/src/lib/server/email/templates/confirmation.de.txt index 084f14c..b2da80d 100644 --- a/src/lib/server/email/templates/confirmation.de.txt +++ b/src/lib/server/email/templates/confirmation.de.txt @@ -10,6 +10,8 @@ vielen Dank für Ihre Registrierung bei {{tenant.longName}}. Um Ihre Registrieru Geben Sie diesen Code in das Bestätigungsfeld ein, um Ihre Registrierung zu vervollständigen. +Alternativ können Sie direkt diese Seite besuchen: {{tenant.baseUrl}}/confirm/{{confirmationCode}} + WICHTIGER HINWEIS: Dieser Code ist nur für {{expirationMinutes}} Minuten gültig und kann nur einmal verwendet werden. Falls Sie sich nicht bei {{tenant.longName}} registriert haben, können Sie diese E-Mail ignorieren. diff --git a/src/lib/server/email/templates/confirmation.en.html b/src/lib/server/email/templates/confirmation.en.html index cadd7ce..10cd866 100644 --- a/src/lib/server/email/templates/confirmation.en.html +++ b/src/lib/server/email/templates/confirmation.en.html @@ -100,6 +100,7 @@
Enter this code in the confirmation field
+ Or simply click here.
diff --git a/src/lib/server/email/templates/confirmation.en.txt b/src/lib/server/email/templates/confirmation.en.txt index 1b575e9..ae7c750 100644 --- a/src/lib/server/email/templates/confirmation.en.txt +++ b/src/lib/server/email/templates/confirmation.en.txt @@ -10,6 +10,8 @@ Thank you for registering with {{tenant.longName}}. To complete your registratio Enter this code in the confirmation field to complete your registration. +Or simply visit this site: {{tenant.baseUrl}}/confirm/{{confirmationCode}} + IMPORTANT NOTICE: This code is valid for {{expirationMinutes}} minutes only and can be used only once. If you did not register with {{tenant.longName}}, you can safely ignore this email. diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index 99e16d5..3458c85 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -71,7 +71,7 @@ export class UserService { /** * Create a new user */ - static async createUser(userData: UserCreation) { + static async createUser(userData: UserCreation, requestUrl?: URL) { const log = logger.setContext("UserService"); log.debug("Creating new user account", { email: userData.email, @@ -138,7 +138,8 @@ export class UserService { result[0], tenant, result[0].token, - 10 // 10 minutes expiration to match tokenValidUntil + 10, // 10 minutes expiration to match tokenValidUntil + requestUrl ); log.debug("Confirmation email sent successfully", { userId: result[0].id, @@ -165,8 +166,9 @@ export class UserService { /** * Resend the confirmation email for a user * @param email - Email of the user to confirm + * @param requestUrl - Optional request URL for generating correct baseUrl */ - static async resendConfirmationEmail(email: string): Promise { + static async resendConfirmationEmail(email: string, requestUrl?: URL): Promise { const log = logger.setContext("UserService"); log.debug("Resending confirmation email", { email }); @@ -195,7 +197,8 @@ export class UserService { user, tenant, token, - 10 // 10 minutes expiration to match tokenValidUntil + 10, // 10 minutes expiration to match tokenValidUntil + requestUrl ); log.debug("Confirmation email sent successfully", { userId: user.id, diff --git a/src/routes/(pages)/setup/check-email/+page.server.ts b/src/routes/(pages)/setup/check-email/+page.server.ts index 679ae30..4f5f243 100644 --- a/src/routes/(pages)/setup/check-email/+page.server.ts +++ b/src/routes/(pages)/setup/check-email/+page.server.ts @@ -24,7 +24,7 @@ export const actions: Actions = { }); } - await UserService.resendConfirmationEmail(form.data.email); + await UserService.resendConfirmationEmail(form.data.email, event.url); log.debug("Resent confirmation e-mail", { email: form.data.email diff --git a/src/routes/(pages)/setup/create-admin-account/+page.server.ts b/src/routes/(pages)/setup/create-admin-account/+page.server.ts index 29ffbed..3cce61a 100644 --- a/src/routes/(pages)/setup/create-admin-account/+page.server.ts +++ b/src/routes/(pages)/setup/create-admin-account/+page.server.ts @@ -30,7 +30,7 @@ export const actions: Actions = { email: form.data.email, passphrase: form.data.passphrase, language: form.data.language - }); + }, event.url); const hasPasskey = false; log.debug("Admin account created successfully", { diff --git a/src/routes/api/admin/init/+server.ts b/src/routes/api/admin/init/+server.ts index cc7f832..db1f765 100644 --- a/src/routes/api/admin/init/+server.ts +++ b/src/routes/api/admin/init/+server.ts @@ -105,7 +105,7 @@ registerOpenAPIRoute("/admin/init", "POST", { } }); -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async ({ request, cookies, url }) => { const log = logger.setContext("API"); try { @@ -140,7 +140,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { 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) { diff --git a/src/routes/api/auth/register/+server.ts b/src/routes/api/auth/register/+server.ts index c983070..f06f8f3 100644 --- a/src/routes/api/auth/register/+server.ts +++ b/src/routes/api/auth/register/+server.ts @@ -123,7 +123,7 @@ registerOpenAPIRoute("/auth/register", "POST", { } }); -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async ({ request, cookies, url }) => { const log = logger.setContext("API"); try { @@ -187,7 +187,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { tenantId: finalTenantId, passphrase: body.passphrase, language: inviteUsed?.language || body.language || "de" - }); + }, url); // Add the passkey to the user account if provided if (body.passkey) { diff --git a/src/routes/api/auth/resend-confirmation/+server.ts b/src/routes/api/auth/resend-confirmation/+server.ts index 4b4b405..8d0d288 100644 --- a/src/routes/api/auth/resend-confirmation/+server.ts +++ b/src/routes/api/auth/resend-confirmation/+server.ts @@ -68,12 +68,12 @@ registerOpenAPIRoute("/auth/resend-confirmation", "POST", { } }); -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, url }) => { try { const body = await request.json(); // Resend confirmation email - await UserService.resendConfirmationEmail(body.email); + await UserService.resendConfirmationEmail(body.email, url); return json( { diff --git a/src/server-hooks/authHandle.ts b/src/server-hooks/authHandle.ts index d3123c4..b1f398c 100644 --- a/src/server-hooks/authHandle.ts +++ b/src/server-hooks/authHandle.ts @@ -104,6 +104,8 @@ export const authHandle: Handle = async ({ event, resolve }) => { sessionId: sessionData.sessionId }; + logger.debug(`Added user information for ${sessionData.user.id}`); + if (isGlobalAdminPath && !AuthorizationService.hasRole(sessionData.user, "GLOBAL_ADMIN")) { return new Response(JSON.stringify({ error: "Authentication failed" }), { status: 403, From 4c88574bbc334680b20e4a04c2bee7c5d8b9b4fe Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 22 Aug 2025 15:55:53 +0200 Subject: [PATCH 08/22] Adding setup with passkey -wip --- project.inlang/messages/de.json | 27 ++- project.inlang/messages/en.json | 33 ++- src/hooks.server.ts | 6 +- src/lib/components/ui/input/input.svelte | 4 +- src/lib/components/ui/passkey/index.ts | 5 + src/lib/components/ui/passkey/state.svelte | 45 ++++ src/lib/const/routes.ts | 6 +- src/lib/server/auth/webauthn-service.ts | 2 +- .../(pages)/confirm/[token]/+page.server.ts | 1 - src/routes/(pages)/dashboard/+page.svelte | 2 + src/routes/(pages)/login/+page.server.ts | 39 ++++ src/routes/(pages)/login/+page.svelte | 54 ++++- src/routes/(pages)/login/login-form.svelte | 86 +++++++ src/routes/(pages)/login/schema.ts | 9 + src/routes/(pages)/logout/+page.server.ts | 18 ++ src/routes/(pages)/logout/+page.svelte | 41 ++++ .../create-admin-account/+page.server.ts | 29 ++- .../create-account-form.svelte | 209 +++++++++++++++--- .../setup/create-admin-account/schema.ts | 27 ++- .../{authHandle.ts => apiAuthHandle.ts} | 26 +-- src/server-hooks/authGuard.ts | 42 ++++ src/server-hooks/utils/accessToken.ts | 24 ++ 22 files changed, 655 insertions(+), 80 deletions(-) create mode 100644 src/lib/components/ui/passkey/index.ts create mode 100644 src/lib/components/ui/passkey/state.svelte create mode 100644 src/routes/(pages)/dashboard/+page.svelte create mode 100644 src/routes/(pages)/login/+page.server.ts create mode 100644 src/routes/(pages)/login/login-form.svelte create mode 100644 src/routes/(pages)/login/schema.ts create mode 100644 src/routes/(pages)/logout/+page.server.ts create mode 100644 src/routes/(pages)/logout/+page.svelte rename src/server-hooks/{authHandle.ts => apiAuthHandle.ts} (84%) create mode 100644 src/server-hooks/authGuard.ts create mode 100644 src/server-hooks/utils/accessToken.ts diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 11d17aa..798ae78 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -42,13 +42,28 @@ "passphraseRequirements": "Mindestens 30 Zeichen.", "errors": { "email": "Ungültiges Format", - "passphrase": "Muss mindestens 30 Zeichen lang sein" - } + "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" + "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", + "passkey": { + "initial": "Warte auf Absenden des Formulars", + "success": "Passkey bereit", + "error": "Passkey konnte nicht abgerufen werden", + "user": "Warte während Passkey erstellt wird", + "loading": "Teste Passkey", + "retry": "Erneut versuchen" + } }, "confirm": { "success": { @@ -68,5 +83,11 @@ "action": "E-Mail erneut senden", "success": "E-Mail gesendet" } + }, + "logout": { + "title": "Erfolgreich abgemeldet", + "description": "Du wurdest abgemeldet.", + "success": "Erfolgreich abgemeldet", + "action": "Zur Anmeldung" } } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index fc907d7..5f3adcf 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -43,14 +43,35 @@ "passphraseRequirements": "At least 30 characters.", "errors": { "email": "Invalid format", - "passphrase": "Must be at least 30 characters long" - } + "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}" + "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" + }, + "passkey": { + "initial": "Waiting for form to be sent", + "success": "Added Passkey successfully", + "error": "Passkey could not be added", + "user": "Waiting for passkey to be created", + "loading": "Completing challenge", + "retry": "Retry" + } }, "confirm": { "success": { @@ -70,5 +91,11 @@ "action": "Resend E-Mail", "success": "E-Mail sent" } + }, + "logout": { + "title": "Logout complete", + "description": "You are logged-out.", + "success": "Logout successful", + "action": "Go to Login" } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index e98e909..8d234ca 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -5,7 +5,8 @@ import { corsHandle } from "./server-hooks/corsHandle"; import { loggingHandle } from "./server-hooks/loggingHandle"; import { rateLimitHandle } from "./server-hooks/rateLimitHandle"; import { secHeaderHandle } from "./server-hooks/secHeaderHandle"; -import { authHandle } from "./server-hooks/authHandle"; +import { apiAuthHandle } from "./server-hooks/apiAuthHandle"; +import { authGuard } from "./server-hooks/authGuard"; import { i18nHandle } from "./server-hooks/i18nHandle"; import { building } from "$app/environment"; @@ -40,5 +41,6 @@ export const handle = sequence( rateLimitHandle, corsHandle, secHeaderHandle, - authHandle + apiAuthHandle, + authGuard ); diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte index 19c6dae..29c91d8 100644 --- a/src/lib/components/ui/input/input.svelte +++ b/src/lib/components/ui/input/input.svelte @@ -24,7 +24,7 @@ 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 shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "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 @@ -39,7 +39,7 @@ 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 shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "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 diff --git a/src/lib/components/ui/passkey/index.ts b/src/lib/components/ui/passkey/index.ts new file mode 100644 index 0000000..d53af12 --- /dev/null +++ b/src/lib/components/ui/passkey/index.ts @@ -0,0 +1,5 @@ +import State from "./state.svelte"; + +const Passkey = { State }; + +export { Passkey }; diff --git a/src/lib/components/ui/passkey/state.svelte b/src/lib/components/ui/passkey/state.svelte new file mode 100644 index 0000000..3191b33 --- /dev/null +++ b/src/lib/components/ui/passkey/state.svelte @@ -0,0 +1,45 @@ + + +
+ {#if state === "initial"} + + {m["login.passkey.initial"]()} + {:else if state === "user"} + + {m["login.passkey.user"]()} + {:else if state === "loading"} + + {m["login.passkey.loading"]()} + {:else if state === "success"} + + {m["login.passkey.success"]()} + {:else if state === "error"} + + {m["login.passkey.error"]()} + {/if} +
diff --git a/src/lib/const/routes.ts b/src/lib/const/routes.ts index 1c6551a..2bbbffe 100644 --- a/src/lib/const/routes.ts +++ b/src/lib/const/routes.ts @@ -5,5 +5,9 @@ export const ROUTES = { CHECK_EMAIL: "/setup/check-email" }, RESEND_CONFIRMATION: "/confirm/resend", - LOGIN: "/login" + LOGIN: "/login", + LOGOUT: "/logout", + DASHBOARD: { + MAIN: "/dashboard" + } }; diff --git a/src/lib/server/auth/webauthn-service.ts b/src/lib/server/auth/webauthn-service.ts index ef0269d..571bbb0 100644 --- a/src/lib/server/auth/webauthn-service.ts +++ b/src/lib/server/auth/webauthn-service.ts @@ -189,7 +189,7 @@ export class WebAuthnService { * This should be stored in the session and used for verification */ static generateChallenge(): string { - return randomBytes(32).toString("base64url"); + return randomBytes(32).toString("base64"); } /** diff --git a/src/routes/(pages)/confirm/[token]/+page.server.ts b/src/routes/(pages)/confirm/[token]/+page.server.ts index f14eb9e..d1718f9 100644 --- a/src/routes/(pages)/confirm/[token]/+page.server.ts +++ b/src/routes/(pages)/confirm/[token]/+page.server.ts @@ -16,7 +16,6 @@ export const load: PageServerLoad = async (event) => { const success = resp.status < 400; try { const body = await resp.json(); - // TODO: Check if this is the first account const isSetup = body.isSetup ?? false; return { success, isSetup }; } catch (error) { diff --git a/src/routes/(pages)/dashboard/+page.svelte b/src/routes/(pages)/dashboard/+page.svelte new file mode 100644 index 0000000..f29a407 --- /dev/null +++ b/src/routes/(pages)/dashboard/+page.svelte @@ -0,0 +1,2 @@ +Hello from Dashboard
+Logout diff --git a/src/routes/(pages)/login/+page.server.ts b/src/routes/(pages)/login/+page.server.ts new file mode 100644 index 0000000..788ca12 --- /dev/null +++ b/src/routes/(pages)/login/+page.server.ts @@ -0,0 +1,39 @@ +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 + }); + } + + const resp = await event.fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ email: form.data.email, passphrase: form.data.passphrase }) + }); + + if (resp.status < 400) { + return { form }; + } else { + return fail(400, { + form + }); + } + } +}; diff --git a/src/routes/(pages)/login/+page.svelte b/src/routes/(pages)/login/+page.svelte index 76f6852..24de273 100644 --- a/src/routes/(pages)/login/+page.svelte +++ b/src/routes/(pages)/login/+page.svelte @@ -1 +1,53 @@ -Hello from login + + + + {m["login.title"]()} - OpenReception + + + + + + + {m["login.title"]()} + + + {m["login.description_passphrase"]()} + + + + + + + + {m["login.action"]()} + + + + diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte new file mode 100644 index 0000000..64a9214 --- /dev/null +++ b/src/routes/(pages)/login/login-form.svelte @@ -0,0 +1,86 @@ + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["form.passphrase"]()} + + {/snippet} + + + + {m["login.or"]()} + {#if $isUsingPasskey} + + {:else} + + {/if}. + + + diff --git a/src/routes/(pages)/login/schema.ts b/src/routes/(pages)/login/schema.ts new file mode 100644 index 0000000..53f6013 --- /dev/null +++ b/src/routes/(pages)/login/schema.ts @@ -0,0 +1,9 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + email: z.string().email(m["form.errors.email"]()), + passphrase: z.string().min(30, m["form.errors.passphrase"]()) +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/logout/+page.server.ts b/src/routes/(pages)/logout/+page.server.ts new file mode 100644 index 0000000..86ef326 --- /dev/null +++ b/src/routes/(pages)/logout/+page.server.ts @@ -0,0 +1,18 @@ +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + const success: Promise = event + .fetch("/api/auth/logout", { + method: "POST", + headers: { + "Content-Type": "application/json" + } + }) + .then(async (resp) => { + // TODO: Why is this failing? + console.log("resp", resp, await resp.json()); + return resp.status < 400; + }); + + return { streaming: { success } }; +}; diff --git a/src/routes/(pages)/logout/+page.svelte b/src/routes/(pages)/logout/+page.svelte new file mode 100644 index 0000000..27a0cdc --- /dev/null +++ b/src/routes/(pages)/logout/+page.svelte @@ -0,0 +1,41 @@ + + + + {m["logout.title"]()} - OpenReception + + + + + + {#await data.streaming.success} + + {:then} + + {/await} + + + {#await data.streaming.success} + + {:then} + + {/await} + + + diff --git a/src/routes/(pages)/setup/create-admin-account/+page.server.ts b/src/routes/(pages)/setup/create-admin-account/+page.server.ts index 3cce61a..0cf17cb 100644 --- a/src/routes/(pages)/setup/create-admin-account/+page.server.ts +++ b/src/routes/(pages)/setup/create-admin-account/+page.server.ts @@ -25,19 +25,30 @@ export const actions: Actions = { } // Create admin account - const admin = await UserService.createUser({ - name: "Admin", - email: form.data.email, - passphrase: form.data.passphrase, - language: form.data.language - }, event.url); - const hasPasskey = false; + const admin = await UserService.createUser( + { + name: "Admin", + email: form.data.email, + passphrase: form.data.passphrase, // Will be undefined if passkey is used + language: form.data.language + }, + event.url + ); + + if (form.data.passkey) { + await UserService.addPasskey(admin.id, { + id: form.data.passkey.id, + publicKey: form.data.passkey.publicKey, + counter: form.data.passkey.counter || 0, + deviceName: form.data.passkey.deviceName || "Unknown Device" + }); + } log.debug("Admin account created successfully", { adminId: admin.id, email: admin.email, - authMethod: hasPasskey ? "passkey" : "passphrase" - // passkeyId: hasPasskey ? body.passkey.id : undefined + authMethod: form.data.passkey ? "passkey" : "passphrase", + passkeyId: form.data.passkey ? form.data.passkey.id : undefined }); return { form }; diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index ef68386..19e5f3f 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -1,18 +1,23 @@ @@ -63,7 +206,7 @@ - + +
+ + + + {m["login.or"]()} + . + +
diff --git a/src/routes/(pages)/setup/create-admin-account/schema.ts b/src/routes/(pages)/setup/create-admin-account/schema.ts index 0f7271d..187786f 100644 --- a/src/routes/(pages)/setup/create-admin-account/schema.ts +++ b/src/routes/(pages)/setup/create-admin-account/schema.ts @@ -1,10 +1,27 @@ import { m } from "$i18n/messages"; import { z } from "zod"; -export const formSchema = z.object({ - email: z.string().email(m["form.errors.email"]()), - passphrase: z.string().min(30, m["form.errors.passphrase"]()), - language: z.enum(["de", "en"]) -}); +export const formSchema = z + .object({ + email: z.string().email(m["form.errors.email"]()), + passphrase: z.string().min(30, m["form.errors.passphrase"]()).optional(), + passkey: z + .object({ + id: z.string().min(10), + publicKey: z.string().min(10), + counter: z.number().min(0), + deviceName: z.string().min(1).optional() + }) + .optional(), + language: z.enum(["de", "en"]) + }) + .refine((data) => !!data.passphrase || !!data.passkey, { + message: m["form.errors.noPassAtAll"](), + path: ["passphrase"] + }) + .refine((data) => !(data.passphrase && data.passkey), { + message: m["form.errors.bothPassSet"](), + path: ["passphrase"] + }); export type FormSchema = typeof formSchema; diff --git a/src/server-hooks/authHandle.ts b/src/server-hooks/apiAuthHandle.ts similarity index 84% rename from src/server-hooks/authHandle.ts rename to src/server-hooks/apiAuthHandle.ts index b1f398c..23e4a09 100644 --- a/src/server-hooks/authHandle.ts +++ b/src/server-hooks/apiAuthHandle.ts @@ -2,12 +2,13 @@ import type { Handle } from "@sveltejs/kit"; import { SessionService } from "$lib/server/auth/session-service"; import { UniversalLogger } from "$lib/logger"; import { AuthorizationService } from "$lib/server/auth/authorization-service"; +import { getAccessToken } from "./utils/accessToken"; const logger = new UniversalLogger().setContext("AuthHandle"); -const ACCESS_TOKEN_COOKIE_NAME = "access_token"; const PROTECTED_PATHS = ["/api/admin", "/api/tenant-admin", "/api/tenants", "/api/auth/register"]; const PUBLIC_PATHS = [ + "/", "/api/auth/challenge", "/api/auth/login", "/api/auth/register", @@ -33,8 +34,8 @@ 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")) { @@ -63,22 +64,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" }), { @@ -104,8 +90,6 @@ export const authHandle: Handle = async ({ event, resolve }) => { sessionId: sessionData.sessionId }; - logger.debug(`Added user information for ${sessionData.user.id}`); - if (isGlobalAdminPath && !AuthorizationService.hasRole(sessionData.user, "GLOBAL_ADMIN")) { return new Response(JSON.stringify({ error: "Authentication failed" }), { status: 403, diff --git a/src/server-hooks/authGuard.ts b/src/server-hooks/authGuard.ts new file mode 100644 index 0000000..37bf404 --- /dev/null +++ b/src/server-hooks/authGuard.ts @@ -0,0 +1,42 @@ +import { redirect, type Handle } from "@sveltejs/kit"; +import { SessionService } from "$lib/server/auth/session-service"; +import { AuthorizationService } from "$lib/server/auth/authorization-service"; +import { getAccessToken } from "./utils/accessToken"; +import { ROUTES } from "$lib/const/routes"; + +export const authGuard: Handle = async ({ event, resolve }) => { + const { url } = event; + const path = url.pathname; + + // Do not handle api paths + if (path.startsWith("/api")) { + return resolve(event); + } + + // Only guard protected routes + const isDashboardRoute = path.startsWith(ROUTES.DASHBOARD.MAIN); + if (isDashboardRoute) { + // Check for accessToken + const accessToken: string | null = getAccessToken(event); + if (!accessToken) redirect(302, ROUTES.LOGIN); + + // Verify access token with database session check + const sessionData = await SessionService.validateTokenWithDB(accessToken); + if (!sessionData) redirect(302, ROUTES.LOGIN); + + switch (true) { + case isDashboardRoute && + AuthorizationService.hasAnyRole(sessionData.user, [ + "GLOBAL_ADMIN", + "TENANT_ADMIN", + "STAFF" + ]): + return resolve(event); + default: + // Access not granted + redirect(302, ROUTES.LOGIN); + } + } + + return resolve(event); +}; diff --git a/src/server-hooks/utils/accessToken.ts b/src/server-hooks/utils/accessToken.ts new file mode 100644 index 0000000..0fcb0d8 --- /dev/null +++ b/src/server-hooks/utils/accessToken.ts @@ -0,0 +1,24 @@ +import type { RequestEvent } from "@sveltejs/kit"; + +const ACCESS_TOKEN_COOKIE_NAME = "access_token"; +export const getAccessToken = ( + event: RequestEvent>, string | null> +): string | null => { + let accessToken: string | null = null; + + // Get access token from cookie + const accessTokenCookie = event.cookies.get(ACCESS_TOKEN_COOKIE_NAME); + if (accessTokenCookie) { + accessToken = accessTokenCookie; + } + + // Fallback: check Authorization header + if (!accessToken) { + const authHeader = event.request.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + accessToken = authHeader.substring(7); + } + } + + return accessToken; +}; From f76e9c94180dec1307d273b1c839fd30519d1c59 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Fri, 22 Aug 2025 16:05:45 +0200 Subject: [PATCH 09/22] Cleanup --- src/lib/utils/passkey.ts | 69 ++++++++++++++++ .../create-account-form.svelte | 78 +------------------ 2 files changed, 71 insertions(+), 76 deletions(-) create mode 100644 src/lib/utils/passkey.ts diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts new file mode 100644 index 0000000..4a45267 --- /dev/null +++ b/src/lib/utils/passkey.ts @@ -0,0 +1,69 @@ +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); +}; + +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 const generatePasskey = async ({ + id, + challenge, + email +}: { + id: string; + challenge: string; + email: string; +}): Promise<{ response: AuthenticatorAttestationResponse } | null> => { + 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 { + response: AuthenticatorAttestationResponse; + } | null; +}; diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index 19e5f3f..39d2fdb 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -17,7 +17,7 @@ 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 { email, unknown } from "zod/v4"; + import { arrayBufferToBase64, fetchChallenge, generatePasskey } from "$lib/utils/passkey"; let { data, @@ -43,7 +43,7 @@ if ($isUsingPasskey) { $passkeyLoading = "user"; - fetchChallenge().then((challenge) => { + fetchChallenge($formData.email).then((challenge) => { if (!challenge) { $passkeyLoading = "error"; } else { @@ -100,80 +100,6 @@ } }); - function 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); - } - - 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; - } - - const fetchChallenge = async () => { - const resp = await fetch("/api/auth/challenge", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ email: $formData.email }) - }); - - try { - const data = await resp.json(); - return { - id: data.rpId, - challenge: data.challenge - }; - } catch (error) { - return null; - } - }; - - const generatePasskey = async ({ - id, - challenge, - email - }: { - id: string; - challenge: string; - email: string; - }): Promise<{ response: AuthenticatorAttestationResponse } | null> => { - try { - 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 { - response: AuthenticatorAttestationResponse; - } | null; - } catch (error) { - throw error; - } - }; - $effect(() => { if ($isUsingPasskey) { formData.update((data) => ({ From 3c206fcba714fca088aec98b9c8955416299519a Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Fri, 22 Aug 2025 16:07:20 +0200 Subject: [PATCH 10/22] Extract Counter against replay attacks --- src/lib/server/auth/webauthn-service.ts | 24 ++++++++++++++++++++++++ src/routes/api/admin/init/+server.ts | 6 +++++- src/routes/api/auth/passkeys/+server.ts | 6 +++++- src/routes/api/auth/register/+server.ts | 6 +++++- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/lib/server/auth/webauthn-service.ts b/src/lib/server/auth/webauthn-service.ts index ef0269d..b0ed1e6 100644 --- a/src/lib/server/auth/webauthn-service.ts +++ b/src/lib/server/auth/webauthn-service.ts @@ -214,4 +214,28 @@ export class WebAuthnService { static async getUserPasskeys(userId: string) { return await centralDb.select().from(userPasskey).where(eq(userPasskey.userId, userId)); } + + /** + * Extract counter from WebAuthn credential response + * Used during registration to get the initial counter value + */ + static extractCounterFromCredential(credential: { + response: { + authenticatorData: string; + }; + }): number { + try { + // Parse authenticator data + const authenticatorDataBuffer = Buffer.from(credential.response.authenticatorData, "base64"); + + // Extract counter from authenticator data (bytes 33-36) + const counter = authenticatorDataBuffer.readUInt32BE(33); + + logger.debug("Counter extracted from credential", { counter }); + return counter; + } catch (error) { + logger.error("Failed to extract counter from credential", { error: String(error) }); + return 0; // Fallback to 0 if extraction fails + } + } } diff --git a/src/routes/api/admin/init/+server.ts b/src/routes/api/admin/init/+server.ts index db1f765..4a292a0 100644 --- a/src/routes/api/admin/init/+server.ts +++ b/src/routes/api/admin/init/+server.ts @@ -1,5 +1,6 @@ import { json } from "@sveltejs/kit"; import { UserService } from "$lib/server/services/user-service"; +import { WebAuthnService } from "$lib/server/auth/webauthn-service"; import { ValidationError } from "$lib/server/utils/errors"; import type { RequestHandler } from "./$types"; import { registerOpenAPIRoute } from "$lib/server/openapi"; @@ -154,10 +155,13 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { // 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" }); diff --git a/src/routes/api/auth/passkeys/+server.ts b/src/routes/api/auth/passkeys/+server.ts index c78dbd3..37432b6 100644 --- a/src/routes/api/auth/passkeys/+server.ts +++ b/src/routes/api/auth/passkeys/+server.ts @@ -1,5 +1,6 @@ import { json } from "@sveltejs/kit"; import { UserService } from "$lib/server/services/user-service"; +import { WebAuthnService } from "$lib/server/auth/webauthn-service"; import { NotFoundError, ValidationError } from "$lib/server/utils/errors"; import type { RequestHandler } from "./$types"; import { registerOpenAPIRoute } from "$lib/server/openapi"; @@ -115,12 +116,15 @@ export const POST: RequestHandler = async ({ request }) => { deviceName: body.passkey.deviceName }); + // Extract counter from WebAuthn credential + const counter = WebAuthnService.extractCounterFromCredential(body.passkey); + // Add the passkey using the UserService await UserService.addAdditionalPasskey(body.userId, { id: body.passkey.id, userId: body.userId, publicKey: body.passkey.publicKey, - counter: body.passkey.counter || 0, + counter, deviceName: body.passkey.deviceName || "Unknown Device" }); diff --git a/src/routes/api/auth/register/+server.ts b/src/routes/api/auth/register/+server.ts index f06f8f3..68cc352 100644 --- a/src/routes/api/auth/register/+server.ts +++ b/src/routes/api/auth/register/+server.ts @@ -1,6 +1,7 @@ import { json } from "@sveltejs/kit"; import { UserService } from "$lib/server/services/user-service"; import { InviteService } from "$lib/server/services/invite-service"; +import { WebAuthnService } from "$lib/server/auth/webauthn-service"; import { ValidationError } from "$lib/server/utils/errors"; import type { RequestHandler } from "./$types"; import { registerOpenAPIRoute } from "$lib/server/openapi"; @@ -201,10 +202,13 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { // 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, deviceName: body.passkey.deviceName || "Unknown Device" }); From 1b0ed692bde90e58e7bc0d40367e324482783555 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Fri, 22 Aug 2025 17:54:56 +0200 Subject: [PATCH 11/22] Fixed passkey structure on creation --- src/lib/utils/passkey.ts | 8 ++++- .../create-account-form.svelte | 32 +++++++++++-------- .../setup/create-admin-account/schema.ts | 4 ++- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index 4a45267..90e92a0 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -47,7 +47,11 @@ export const generatePasskey = async ({ id: string; challenge: string; email: string; -}): Promise<{ response: AuthenticatorAttestationResponse } | null> => { +}): Promise<{ + response: AuthenticatorAttestationResponse; + id: string; + getClientExtensionResults: () => { deviceName?: string }; +} | null> => { const publicKey: PublicKeyCredentialCreationOptions = { challenge: base64ToArrayBuffer(challenge), rp: { @@ -65,5 +69,7 @@ export const generatePasskey = async ({ }; return (await navigator.credentials.create({ publicKey })) as { response: AuthenticatorAttestationResponse; + id: string; + getClientExtensionResults: () => { deviceName?: string }; } | null; }; diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index 39d2fdb..89cb42b 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -66,22 +66,26 @@ const authenticatorData = passkeyResp.response.getAuthenticatorData(); console.log("authenticatorData", authenticatorData); - try { - const publicKeyBase64 = arrayBufferToBase64(publicKey); - console.log("publicKeyBase64", publicKeyBase64); - } catch (error) { - console.log("error", error); - throw error; - } + const publicKeyBase64 = arrayBufferToBase64(publicKey); + const authenticatorDataBase64 = arrayBufferToBase64(authenticatorData); + console.log("publicKeyBase64", publicKeyBase64); + console.log("authenticatorDataBase64", authenticatorDataBase64); - // $formData.passkey = { - // id: passkeyResp.id, - // publicKey, - // counter: 0, - // deviceName: passkeyResp.response.getClientExtensionResults().deviceName || "Unknown Device" - // }; + $formData.passkey = { + id: passkeyResp.id, + publicKey: publicKeyBase64, + response: { + authenticatorData: authenticatorDataBase64 + }, + deviceName: passkeyResp.getClientExtensionResults().deviceName || "Unknown Device" + }; + + // Auto-submit the form since we have all required data + $passkeyLoading = "success"; + // The form will submit automatically via the enhance action }) - .catch(() => { + .catch((reason) => { + console.error(reason); $passkeyLoading = "error"; }); } diff --git a/src/routes/(pages)/setup/create-admin-account/schema.ts b/src/routes/(pages)/setup/create-admin-account/schema.ts index 187786f..9ffa996 100644 --- a/src/routes/(pages)/setup/create-admin-account/schema.ts +++ b/src/routes/(pages)/setup/create-admin-account/schema.ts @@ -9,7 +9,9 @@ export const formSchema = z .object({ id: z.string().min(10), publicKey: z.string().min(10), - counter: z.number().min(0), + response: z.object({ + authenticatorData: z.string().min(10) + }), deviceName: z.string().min(1).optional() }) .optional(), From 9abea04803b09df655b8eb3659203400636f3466 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Fri, 22 Aug 2025 18:08:56 +0200 Subject: [PATCH 12/22] Fix user service test --- .../server/services/__tests__/user-service.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/lib/server/services/__tests__/user-service.test.ts b/src/lib/server/services/__tests__/user-service.test.ts index 33a4078..b1c4196 100644 --- a/src/lib/server/services/__tests__/user-service.test.ts +++ b/src/lib/server/services/__tests__/user-service.test.ts @@ -176,18 +176,25 @@ describe("UserService", () => { limit: vi.fn().mockResolvedValue([{ id: "user-123", recoveryPassphrase: "recovery-123" }]) }; + const mockCountSelectBuilder = { + from: vi.fn().mockResolvedValue([{ count: 1 }]) + }; + const mockUpdateBuilder = { set: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), execute: vi.fn().mockResolvedValue({ count: 1 }) }; - mockCentralDb.select.mockReturnValue(mockSelectBuilder); + // First call for user lookup, second call for count query + mockCentralDb.select + .mockReturnValueOnce(mockSelectBuilder) + .mockReturnValueOnce(mockCountSelectBuilder); mockCentralDb.update.mockReturnValue(mockUpdateBuilder); const result = await UserService.confirm(token); - expect(mockCentralDb.select).toHaveBeenCalled(); + expect(mockCentralDb.select).toHaveBeenCalledTimes(2); expect(mockCentralDb.update).toHaveBeenCalled(); expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ confirmed: true, @@ -195,6 +202,7 @@ describe("UserService", () => { recoveryPassphrase: null }); expect(result.recoveryPassphrase).toBe("recovery-123"); + expect(result.isSetup).toBe(true); }); it("should throw NotFoundError for invalid token", async () => { From fd3b6574437df3274b917792650594b0f426e426 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 26 Aug 2025 14:51:14 +0200 Subject: [PATCH 13/22] Cleanup and debugging, but no fix for form sending --- project.inlang/messages/de.json | 21 +- project.inlang/messages/en.json | 19 +- src/lib/components/ui/passkey/state.svelte | 39 ++- src/lib/utils/passkey.ts | 25 +- .../create-admin-account/+page.server.ts | 36 ++- .../create-account-form.svelte | 267 ++++++++++-------- .../setup/create-admin-account/schema.ts | 41 ++- 7 files changed, 264 insertions(+), 184 deletions(-) diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 798ae78..e155f42 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -55,15 +55,7 @@ "title": "Anmelden", "description_passphrase": "Gib Deine E-Mail-Adresse und Deinen Passphrase ein, um Dich anzumelden.", "action": "Anmelden", - "error": "Anmeldung fehlgeschlagen. Bitte erneut versuchen", - "passkey": { - "initial": "Warte auf Absenden des Formulars", - "success": "Passkey bereit", - "error": "Passkey konnte nicht abgerufen werden", - "user": "Warte während Passkey erstellt wird", - "loading": "Teste Passkey", - "retry": "Erneut versuchen" - } + "error": "Anmeldung fehlgeschlagen. Bitte erneut versuchen" }, "confirm": { "success": { @@ -89,5 +81,16 @@ "description": "Du wurdest abgemeldet.", "success": "Erfolgreich abgemeldet", "action": "Zur Anmeldung" + }, + "passkey": { + "add": { + "initial": "Warten auf E-Mail Adresse", + "click": "Klicke/Tippe um einen Passkey hinzuzufügen", + "loading": "Lade Passkey-Challenge", + "user": "Warte auf Passkey-Erstellung", + "success": "Passkey hinzugefügt", + "error": "Passkey konnte nicht hinzugefügt werden", + "retry": "Erneut versuchen" + } } } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 5f3adcf..43b650e 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -63,14 +63,6 @@ "initial": "Requesting passkey", "success": "Passkey saved", "error": "Passkey could not be retrieved" - }, - "passkey": { - "initial": "Waiting for form to be sent", - "success": "Added Passkey successfully", - "error": "Passkey could not be added", - "user": "Waiting for passkey to be created", - "loading": "Completing challenge", - "retry": "Retry" } }, "confirm": { @@ -97,5 +89,16 @@ "description": "You are logged-out.", "success": "Logout successful", "action": "Go to Login" + }, + "passkey": { + "add": { + "initial": "Waiting for E-Mail Address", + "click": "Click/Tap to add passkey", + "loading": "Completing challenge", + "user": "Waiting for creation to complete", + "success": "Added Passkey successfully", + "error": "Passkey could not be added", + "retry": "Retry" + } } } diff --git a/src/lib/components/ui/passkey/state.svelte b/src/lib/components/ui/passkey/state.svelte index 3191b33..5b48bdc 100644 --- a/src/lib/components/ui/passkey/state.svelte +++ b/src/lib/components/ui/passkey/state.svelte @@ -1,45 +1,62 @@
{#if state === "initial"} - - {m["login.passkey.initial"]()} - {:else if state === "user"} - - {m["login.passkey.user"]()} + + {m["passkey.add.initial"]()} + {:else if state === "click"} + + {m["passkey.add.click"]()} {:else if state === "loading"} - {m["login.passkey.loading"]()} + {m["passkey.add.loading"]()} + {:else if state === "user"} + + {m["passkey.add.user"]()} {:else if state === "success"} - {m["login.passkey.success"]()} + {m["passkey.add.success"]()} {:else if state === "error"} - {m["login.passkey.error"]()} + {m["passkey.add.error"]()} {/if}
diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index 90e92a0..9ce3892 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -10,7 +10,7 @@ export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { return window.btoa(binary); }; -function base64ToArrayBuffer(base64: string) { +export function base64ToArrayBuffer(base64: string) { const binaryString = atob(base64); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { @@ -39,6 +39,11 @@ export const fetchChallenge = async (email: string) => { } }; +export type GeneratePasskeyResponse = { + response: AuthenticatorAttestationResponse; + id: string; + getClientExtensionResults: () => { deviceName?: string }; +} | null; export const generatePasskey = async ({ id, challenge, @@ -47,11 +52,7 @@ export const generatePasskey = async ({ id: string; challenge: string; email: string; -}): Promise<{ - response: AuthenticatorAttestationResponse; - id: string; - getClientExtensionResults: () => { deviceName?: string }; -} | null> => { +}): Promise => { const publicKey: PublicKeyCredentialCreationOptions = { challenge: base64ToArrayBuffer(challenge), rp: { @@ -67,9 +68,11 @@ export const generatePasskey = async ({ { alg: -7, type: "public-key" } // ES256 ] }; - return (await navigator.credentials.create({ publicKey })) as { - response: AuthenticatorAttestationResponse; - id: string; - getClientExtensionResults: () => { deviceName?: string }; - } | null; + return (await navigator.credentials.create({ publicKey })) as GeneratePasskeyResponse; +}; + +export const getCounterFromAuthenticatorData = (authenticatorData: ArrayBuffer) => { + const view = new DataView(authenticatorData); + // Counter is at offset 33, 4 bytes, big-endian + return view.getUint32(33, false); // false = big-endian }; diff --git a/src/routes/(pages)/setup/create-admin-account/+page.server.ts b/src/routes/(pages)/setup/create-admin-account/+page.server.ts index 0cf17cb..65d1b79 100644 --- a/src/routes/(pages)/setup/create-admin-account/+page.server.ts +++ b/src/routes/(pages)/setup/create-admin-account/+page.server.ts @@ -5,6 +5,7 @@ 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"); @@ -16,39 +17,54 @@ export const load: PageServerLoad = async () => { export const actions: Actions = { default: async (event) => { - const form = await superValidate(event, zod(formSchema)); + console.log("--- actions/default()"); + console.log("Body already used?", event.request.bodyUsed); + const form = await superValidate(event, zod(formSchema)); + console.log("form", JSON.stringify(form.data, null, 2)); if (!form.valid) { + console.log("not valid", form.errors); return fail(400, { - form + form: { ...form, data: { ...form.data, type: "passkey" } } }); } + console.log("form is valid, proceeding"); + return { form }; + // Create admin account const admin = await UserService.createUser( { name: "Admin", email: form.data.email, - passphrase: form.data.passphrase, // Will be undefined if passkey is used + passphrase: + form.data.type === "passphrase" && form.data.passphrase + ? form.data.passphrase + : undefined, language: form.data.language }, event.url ); - if (form.data.passkey) { + if (form.data.type === "passkey") { + const publicKey = form.data.publicKeyBase64; + const authenticatorData = base64ToArrayBuffer(form.data.authenticatorDataBase64); + const counter = getCounterFromAuthenticatorData(authenticatorData); + console.log("counter", counter); + await UserService.addPasskey(admin.id, { - id: form.data.passkey.id, - publicKey: form.data.passkey.publicKey, - counter: form.data.passkey.counter || 0, - deviceName: form.data.passkey.deviceName || "Unknown Device" + 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.passkey ? "passkey" : "passphrase", - passkeyId: form.data.passkey ? form.data.passkey.id : undefined + authMethod: form.data.type === "passkey" ? "passkey" : "passphrase", + passkeyId: form.data.type === "passkey" ? form.data.id : undefined }); return { form }; diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index 89cb42b..7691e5c 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -11,9 +11,10 @@ import { onMount } from "svelte"; import { toast } from "svelte-sonner"; import { writable, type Writable } from "svelte/store"; - import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms"; + import { type Infer, type SuperValidated } from "sveltekit-superforms"; import { zodClient } from "sveltekit-superforms/adapters"; - import { formSchema, type FormSchema } from "./schema"; + 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"; @@ -28,7 +29,13 @@ const form = superForm(data.form, { validators: zodClient(formSchema), + onChange: (event) => { + if (event.paths.includes("email")) { + setProperPasskeyState(); + } + }, onResult: async (event) => { + console.log("--- onResult()", event); if (event.result.type === "success") { toast.success(m["setup.create_admin_account.success"]()); await goto(ROUTES.SETUP.CHECK_EMAIL, { @@ -38,92 +45,113 @@ onEvent({ isSubmitting: false }); }, - onSubmit: () => { - onEvent({ isSubmitting: true }); - - if ($isUsingPasskey) { - $passkeyLoading = "user"; - fetchChallenge($formData.email).then((challenge) => { - if (!challenge) { - $passkeyLoading = "error"; - } else { - generatePasskey({ ...challenge, email: $formData.email }) - .then(async (passkeyResp) => { - console.log("passkeyResp", passkeyResp); - if (!passkeyResp) { - throw "Unusable passkey response"; - } - - // Returns ArrayBuffer that has to be converted to base64 string - const publicKey = passkeyResp.response.getPublicKey(); - console.log("publicKey", publicKey); - - if (!publicKey) { - throw "Public key not found in passkey response"; - } - - // May include device name and counter - const authenticatorData = passkeyResp.response.getAuthenticatorData(); - console.log("authenticatorData", authenticatorData); - - const publicKeyBase64 = arrayBufferToBase64(publicKey); - const authenticatorDataBase64 = arrayBufferToBase64(authenticatorData); - console.log("publicKeyBase64", publicKeyBase64); - console.log("authenticatorDataBase64", authenticatorDataBase64); - - $formData.passkey = { - id: passkeyResp.id, - publicKey: publicKeyBase64, - response: { - authenticatorData: authenticatorDataBase64 - }, - deviceName: passkeyResp.getClientExtensionResults().deviceName || "Unknown Device" - }; - - // Auto-submit the form since we have all required data - $passkeyLoading = "success"; - // The form will submit automatically via the enhance action - }) - .catch((reason) => { - console.error(reason); - $passkeyLoading = "error"; - }); - } - }); - } - } + onSubmit: () => onEvent({ isSubmitting: true }) }); - const { form: formData, enhance, ...rest } = form; - const isUsingPasskey = writable(true); + const { form: formData, enhance } = form; const passkeyLoading: Writable = writable("initial"); onMount(() => { + $formData.type = "passkey"; if (!$formData.language) { $formData.language = getLocale() ?? "en"; } }); - $effect(() => { - if ($isUsingPasskey) { - formData.update((data) => ({ - ...data, - passphrase: "" - })); + const setProperPasskeyState = () => { + const isOk = baseSchema.shape.email.safeParse($formData.email).success; + if (isOk) { + $passkeyLoading = "click"; } else { - formData.update((data) => ({ - ...data, - passkey: undefined - })); + $passkeyLoading = "initial"; } - }); + }; + + const onToggle = () => { + if ($formData.type === "passkey") { + const { id, publicKeyBase64, authenticatorDataBase64, type, ...rest } = $formData; + $formData = { + ...rest, + type: "passphrase", + passphrase: "" + }; + } else { + const { passphrase, type, ...rest } = $formData; + $formData = { + ...rest, + 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( + (reason) => { + console.error(reason); + $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); + + const { type, ...rest } = $formData; + $formData = { + ...rest, + type: "passkey", + id: passkeyResp.id, + publicKeyBase64, + authenticatorDataBase64 + }; + + // Update UI to show passkey is ready + $passkeyLoading = "success"; + + const validation = await form.validateForm(); + console.log("final validation", validation); + } + }; + + + {#snippet children({ props })} + + {/snippet} + + @@ -136,46 +164,61 @@ - -
- - - - {m["login.or"]()} - . - -
+ {#if $formData.type === "passphrase"} + + + {#snippet children({ props })} + {m["form.passphrase"]()} + + + {/snippet} + + + + {m["form.passphraseRequirements"]()} + {m["login.or"]()} + . + + + {:else if $formData.type === "passkey"} +
+ + + {#snippet children({ props })} + + {/snippet} + + + + + {#snippet children({ props })} + + {/snippet} + + + + + {#snippet children({ props })} + + {/snippet} + + + + + + {m["login.or"]()} + . + +
+ {/if}
diff --git a/src/routes/(pages)/setup/create-admin-account/schema.ts b/src/routes/(pages)/setup/create-admin-account/schema.ts index 9ffa996..729d127 100644 --- a/src/routes/(pages)/setup/create-admin-account/schema.ts +++ b/src/routes/(pages)/setup/create-admin-account/schema.ts @@ -1,29 +1,24 @@ 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 = z.object({ + type: z.literal("passkey"), + id: z.string().min(3), + publicKeyBase64: z.string().base64(), + authenticatorDataBase64: z.string().base64() +}); +const passphraseSchema = z.object({ + type: z.literal("passphrase"), + passphrase: z.string().min(30, m["form.errors.passphrase"]()) +}); + export const formSchema = z - .object({ - email: z.string().email(m["form.errors.email"]()), - passphrase: z.string().min(30, m["form.errors.passphrase"]()).optional(), - passkey: z - .object({ - id: z.string().min(10), - publicKey: z.string().min(10), - response: z.object({ - authenticatorData: z.string().min(10) - }), - deviceName: z.string().min(1).optional() - }) - .optional(), - language: z.enum(["de", "en"]) - }) - .refine((data) => !!data.passphrase || !!data.passkey, { - message: m["form.errors.noPassAtAll"](), - path: ["passphrase"] - }) - .refine((data) => !(data.passphrase && data.passkey), { - message: m["form.errors.bothPassSet"](), - path: ["passphrase"] - }); + .discriminatedUnion("type", [passkeySchema, passphraseSchema]) + .and(baseSchema); export type FormSchema = typeof formSchema; From 7b28ae82493c81b6aba7f0cac118cc40f34b305f Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 26 Aug 2025 15:07:38 +0200 Subject: [PATCH 14/22] Fix form submission --- src/routes/(pages)/setup/create-admin-account/schema.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/routes/(pages)/setup/create-admin-account/schema.ts b/src/routes/(pages)/setup/create-admin-account/schema.ts index 729d127..efc0bef 100644 --- a/src/routes/(pages)/setup/create-admin-account/schema.ts +++ b/src/routes/(pages)/setup/create-admin-account/schema.ts @@ -6,19 +6,17 @@ export const baseSchema = z.object({ language: z.enum(["de", "en"]) }); -const passkeySchema = z.object({ +const passkeySchema = baseSchema.extend({ type: z.literal("passkey"), id: z.string().min(3), publicKeyBase64: z.string().base64(), authenticatorDataBase64: z.string().base64() }); -const passphraseSchema = z.object({ +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]) - .and(baseSchema); +export const formSchema = z.discriminatedUnion("type", [passkeySchema, passphraseSchema]); export type FormSchema = typeof formSchema; From 94c9f591527df7e1cf1b47af2d26161b0dbfa4d5 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 26 Aug 2025 15:08:09 +0200 Subject: [PATCH 15/22] Cleanup --- .../(pages)/setup/create-admin-account/+page.server.ts | 9 --------- .../create-admin-account/create-account-form.svelte | 7 +------ 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/routes/(pages)/setup/create-admin-account/+page.server.ts b/src/routes/(pages)/setup/create-admin-account/+page.server.ts index 65d1b79..82a6def 100644 --- a/src/routes/(pages)/setup/create-admin-account/+page.server.ts +++ b/src/routes/(pages)/setup/create-admin-account/+page.server.ts @@ -17,21 +17,13 @@ export const load: PageServerLoad = async () => { export const actions: Actions = { default: async (event) => { - console.log("--- actions/default()"); - console.log("Body already used?", event.request.bodyUsed); - const form = await superValidate(event, zod(formSchema)); - console.log("form", JSON.stringify(form.data, null, 2)); if (!form.valid) { - console.log("not valid", form.errors); return fail(400, { form: { ...form, data: { ...form.data, type: "passkey" } } }); } - console.log("form is valid, proceeding"); - return { form }; - // Create admin account const admin = await UserService.createUser( { @@ -50,7 +42,6 @@ export const actions: Actions = { const publicKey = form.data.publicKeyBase64; const authenticatorData = base64ToArrayBuffer(form.data.authenticatorDataBase64); const counter = getCounterFromAuthenticatorData(authenticatorData); - console.log("counter", counter); await UserService.addPasskey(admin.id, { id: form.data.id, diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index 7691e5c..cc1846a 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -35,7 +35,6 @@ } }, onResult: async (event) => { - console.log("--- onResult()", event); if (event.result.type === "success") { toast.success(m["setup.create_admin_account.success"]()); await goto(ROUTES.SETUP.CHECK_EMAIL, { @@ -97,8 +96,7 @@ } else { $passkeyLoading = "user"; const passkeyResp = await generatePasskey({ ...challenge, email: $formData.email }).catch( - (reason) => { - console.error(reason); + () => { $passkeyLoading = "error"; } ); @@ -133,9 +131,6 @@ // Update UI to show passkey is ready $passkeyLoading = "success"; - - const validation = await form.validateForm(); - console.log("final validation", validation); } }; From 93d6b00d94c8dc47ca00aa3e0de8ad58bb3449e8 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 26 Aug 2025 21:04:36 +0200 Subject: [PATCH 16/22] Added login with passkey --- src/lib/utils/passkey.ts | 31 +++ src/routes/(pages)/login/+page.server.ts | 32 ++- src/routes/(pages)/login/login-form.svelte | 233 +++++++++++++++--- src/routes/(pages)/login/schema.ts | 17 +- .../create-account-form.svelte | 34 +-- 5 files changed, 288 insertions(+), 59 deletions(-) diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index 9ce3892..f40258b 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -71,6 +71,37 @@ export const generatePasskey = async ({ 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 diff --git a/src/routes/(pages)/login/+page.server.ts b/src/routes/(pages)/login/+page.server.ts index 788ca12..b2699d0 100644 --- a/src/routes/(pages)/login/+page.server.ts +++ b/src/routes/(pages)/login/+page.server.ts @@ -3,6 +3,7 @@ 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 { @@ -15,9 +16,32 @@ export const actions: Actions = { const form = await superValidate(event, zod(formSchema)); if (!form.valid) { - return fail(400, { - form - }); + 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", { @@ -25,7 +49,7 @@ export const actions: Actions = { headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: form.data.email, passphrase: form.data.passphrase }) + body: JSON.stringify(body) }); if (resp.status < 400) { diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte index 64a9214..1f9ba03 100644 --- a/src/routes/(pages)/login/login-form.svelte +++ b/src/routes/(pages)/login/login-form.svelte @@ -7,10 +7,16 @@ import { Input } from "$lib/components/ui/input"; import { ROUTES } from "$lib/const/routes"; import { toast } from "svelte-sonner"; - import { writable } from "svelte/store"; + import { writable, type Writable } from "svelte/store"; import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms"; import { zodClient } from "sveltekit-superforms/adapters"; - import { formSchema, type FormSchema } from "./schema"; + 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, @@ -21,6 +27,11 @@ 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); @@ -32,11 +43,110 @@ 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); + const { type, ...rest } = $formData; + $formData = { + ...rest, + type: "passkey", + id: credentialResp.id, + authenticatorDataBase64, + clientDataBase64, + signatureBase64 + }; + + // Update UI to show passkey is ready + $passkeyLoading = "success"; + } + }; + const { form: formData, enhance } = form; - const isUsingPasskey = writable(false); + const passkeyLoading: Writable = writable("initial"); + + // Fixes TypeScript checks in svelte if-blocks + const formDataPassphrase = $derived.by(() => { + if ($formData.type === "passphrase") { + return $formData as Extract; + } + return null; + }); + const formDataPasskey = $derived.by(() => { + if ($formData.type === "passkey") { + return $formData as Extract; + } + return null; + }); + + type FormDataPassphrase = Extract; + type FormDataPasskey = Extract; + {#snippet children({ props })} @@ -46,41 +156,88 @@ - - - {#snippet children({ props })} - {m["form.passphrase"]()} - - {/snippet} - - - - {m["login.or"]()} - {#if $isUsingPasskey} - - {:else} - - {/if}. - - + + + {/if} + {#if $formData.type === "passkey"} +
+ + + + + + + + {m["login.or"]()} + . + +
+ {/if}
diff --git a/src/routes/(pages)/login/schema.ts b/src/routes/(pages)/login/schema.ts index 53f6013..0d4ded3 100644 --- a/src/routes/(pages)/login/schema.ts +++ b/src/routes/(pages)/login/schema.ts @@ -1,9 +1,22 @@ import { m } from "$i18n/messages"; import { z } from "zod"; -export const formSchema = z.object({ - email: z.string().email(m["form.errors.email"]()), +export const baseSchema = z.object({ + email: z.string().email(m["form.errors.email"]()) +}); + +const passkeySchema = baseSchema.extend({ + type: z.literal("passkey"), + id: z.string().min(3), + authenticatorDataBase64: z.string().base64(), + clientDataBase64: z.string().base64(), + signatureBase64: z.string().base64() +}); +const passphraseSchema = baseSchema.extend({ + type: z.literal("passphrase"), passphrase: z.string().min(30, m["form.errors.passphrase"]()) }); +export const formSchema = z.discriminatedUnion("type", [passkeySchema, passphraseSchema]); + export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index cc1846a..4ff43e6 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -68,16 +68,14 @@ const onToggle = () => { if ($formData.type === "passkey") { - const { id, publicKeyBase64, authenticatorDataBase64, type, ...rest } = $formData; $formData = { - ...rest, + ...$formData, type: "passphrase", passphrase: "" }; } else { - const { passphrase, type, ...rest } = $formData; $formData = { - ...rest, + ...$formData, type: "passkey", id: "", publicKeyBase64: "", @@ -119,7 +117,6 @@ // Update form data with passkey info const publicKeyBase64 = arrayBufferToBase64(publicKey); const authenticatorDataBase64 = arrayBufferToBase64(authenticatorData); - const { type, ...rest } = $formData; $formData = { ...rest, @@ -133,10 +130,13 @@ $passkeyLoading = "success"; } }; + + type FormDataPassphrase = Extract; + type FormDataPasskey = Extract; - + - {:else if $formData.type === "passkey"} + {/if} + {#if $formData.type === "passkey"}
- + - + - + From 5c1b365265e0faccc11ac5fae31e16941b09ae8b Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Tue, 26 Aug 2025 21:11:44 +0200 Subject: [PATCH 17/22] Made Passkey.State a Button to improve usability --- src/lib/components/ui/passkey/state.svelte | 31 +++++++++------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/lib/components/ui/passkey/state.svelte b/src/lib/components/ui/passkey/state.svelte index 5b48bdc..09fcb6c 100644 --- a/src/lib/components/ui/passkey/state.svelte +++ b/src/lib/components/ui/passkey/state.svelte @@ -1,12 +1,13 @@ -
{#if state === "initial"} @@ -59,4 +54,4 @@ {m["passkey.add.error"]()} {/if} -
+ From 821b6cc5d23ceb5f378ee5a93059cb358a66ce57 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Wed, 27 Aug 2025 09:11:18 +0200 Subject: [PATCH 18/22] Type corrections in unit test --- .../email/__tests__/generate-base-url.test.ts | 84 +++++++++++-------- 1 file changed, 49 insertions(+), 35 deletions(-) diff --git a/src/lib/server/email/__tests__/generate-base-url.test.ts b/src/lib/server/email/__tests__/generate-base-url.test.ts index 83c1799..d76f5ad 100644 --- a/src/lib/server/email/__tests__/generate-base-url.test.ts +++ b/src/lib/server/email/__tests__/generate-base-url.test.ts @@ -26,11 +26,12 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", logo: null, createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), + description: "", + databaseUrl: "", + setupState: "NEW" }; const result = generateBaseUrl(requestUrl, tenant); @@ -39,7 +40,7 @@ describe("generateBaseUrl", () => { 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"); }); @@ -50,8 +51,9 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "test", longName: "Test Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -67,8 +69,9 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -84,8 +87,9 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -103,14 +107,14 @@ describe("generateBaseUrl", () => { 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"); }); @@ -121,8 +125,9 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -138,8 +143,9 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -155,8 +161,9 @@ describe("generateBaseUrl", () => { id: "tenant-2", shortName: "new-tenant", longName: "New Tenant Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -172,8 +179,9 @@ describe("generateBaseUrl", () => { id: "tenant-2", shortName: "new-tenant", longName: "New Tenant Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -189,8 +197,9 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "tenant", longName: "Tenant Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -206,8 +215,9 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -223,8 +233,9 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "", // Empty shortName longName: "ACME Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -243,8 +254,9 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -261,8 +273,9 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -279,8 +292,9 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - primaryColor: "#000000", - backgroundColor: "#ffffff", + description: "", + databaseUrl: "", + setupState: "NEW", logo: null, createdAt: new Date(), updatedAt: new Date() @@ -293,7 +307,7 @@ describe("generateBaseUrl", () => { 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 @@ -303,11 +317,11 @@ describe("generateBaseUrl", () => { 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"); }); }); -}); \ No newline at end of file +}); From 7f71d5d83f4d1d3a1de7428ee2684671e1574040 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Wed, 27 Aug 2025 10:16:11 +0200 Subject: [PATCH 19/22] Fixed challenge string (needs to be base64url) Fixed logout (needs credentials) Fixed passkey counter (does not neccesarly work for browser/os based passkeys) --- src/lib/server/auth/webauthn-service.ts | 193 ++++++++++++++++++++-- src/routes/(pages)/logout/+page.server.ts | 5 +- src/routes/api/auth/challenge/+server.ts | 32 ++-- src/routes/api/auth/register/+server.ts | 36 ++-- src/server-hooks/apiAuthHandle.ts | 8 +- 5 files changed, 227 insertions(+), 47 deletions(-) diff --git a/src/lib/server/auth/webauthn-service.ts b/src/lib/server/auth/webauthn-service.ts index 27cf439..97aa2a8 100644 --- a/src/lib/server/auth/webauthn-service.ts +++ b/src/lib/server/auth/webauthn-service.ts @@ -1,4 +1,4 @@ -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 { randomBytes, createHash } from "node:crypto"; @@ -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,14 +249,14 @@ export class WebAuthnService { * This should be stored in the session and used for verification */ static generateChallenge(): string { - return randomBytes(32).toString("base64"); + return randomBytes(32).toString("base64url"); } /** * Update the counter for a passkey after successful authentication */ static async updatePasskeyCounter(passkeyId: string, newCounter: number): Promise { - await centralDb + await db .update(userPasskey) .set({ counter: newCounter, @@ -212,7 +272,104 @@ 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 + }; } /** @@ -227,10 +384,10 @@ export class WebAuthnService { 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) { diff --git a/src/routes/(pages)/logout/+page.server.ts b/src/routes/(pages)/logout/+page.server.ts index 86ef326..9a447df 100644 --- a/src/routes/(pages)/logout/+page.server.ts +++ b/src/routes/(pages)/logout/+page.server.ts @@ -6,11 +6,10 @@ export const load: PageServerLoad = async (event) => { method: "POST", headers: { "Content-Type": "application/json" - } + }, + credentials: "same-origin" }) .then(async (resp) => { - // TODO: Why is this failing? - console.log("resp", resp, await resp.json()); return resp.status < 400; }); diff --git a/src/routes/api/auth/challenge/+server.ts b/src/routes/api/auth/challenge/+server.ts index 90966ea..03fee91 100644 --- a/src/routes/api/auth/challenge/+server.ts +++ b/src/routes/api/auth/challenge/+server.ts @@ -11,7 +11,8 @@ const logger = new UniversalLogger().setContext("AuthChallengeAPI"); registerOpenAPIRoute("/auth/challenge", "POST", { summary: "Generate WebAuthn authentication challenge", - description: "Generate a challenge for WebAuthn authentication (login) or registration and return registered passkeys if user exists", + 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", @@ -93,16 +94,16 @@ 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('.'); - + 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 parts.slice(-2).join("."); } return hostname; } - + // Development: use localhost return "localhost"; } @@ -116,14 +117,16 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { // 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) { // User doesn't exist yet - this is a registration flow isRegistration = true; - logger.debug("User not found - generating challenge for registration", { email: body.email }); + logger.debug("User not found - generating challenge for registration", { + email: body.email + }); } else { throw error; } @@ -157,11 +160,11 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { type: "public-key"; transports: string[]; }> = []; - + if (user) { // Get user's registered passkeys for login const passkeys = await WebAuthnService.getUserPasskeys(user.id); - + // Format passkeys for WebAuthn API allowCredentials = passkeys.map((passkey) => ({ id: passkey.id, @@ -183,7 +186,16 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { } const rpId = getRpId(url); - + + logger.debug("Returning challenge data", { + challenge, + allowCredentials, + timeout: 60000, // 60 seconds + rpId, + userVerification: "preferred", + isRegistration + }); + return json({ challenge, allowCredentials, diff --git a/src/routes/api/auth/register/+server.ts b/src/routes/api/auth/register/+server.ts index 68cc352..89beabc 100644 --- a/src/routes/api/auth/register/+server.ts +++ b/src/routes/api/auth/register/+server.ts @@ -181,37 +181,43 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { }); // 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" - }, url); + 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 }); + 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, + 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 diff --git a/src/server-hooks/apiAuthHandle.ts b/src/server-hooks/apiAuthHandle.ts index 23e4a09..3a65f9a 100644 --- a/src/server-hooks/apiAuthHandle.ts +++ b/src/server-hooks/apiAuthHandle.ts @@ -49,7 +49,13 @@ export const apiAuthHandle: 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); } From 8cd59ad9b53a6992077ab39d609bfdc54d7ef428 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Wed, 27 Aug 2025 11:16:01 +0200 Subject: [PATCH 20/22] Linting and formatting errors --- src/lib/components/ui/form/form-field.svelte | 7 +---- src/lib/components/ui/form/form-legend.svelte | 2 +- src/lib/components/ui/input/index.ts | 2 +- src/lib/components/ui/label/index.ts | 2 +- src/lib/components/ui/label/label.svelte | 2 +- src/lib/components/ui/skeleton/index.ts | 2 +- src/lib/server/email/email-service.ts | 18 +++++------ src/routes/api/admin/init/+server.ts | 30 +++++++++++-------- src/routes/api/auth/passkeys/+server.ts | 2 +- 9 files changed, 34 insertions(+), 33 deletions(-) diff --git a/src/lib/components/ui/form/form-field.svelte b/src/lib/components/ui/form/form-field.svelte index 7481fda..1398993 100644 --- a/src/lib/components/ui/form/form-field.svelte +++ b/src/lib/components/ui/form/form-field.svelte @@ -17,12 +17,7 @@ {#snippet children({ constraints, errors, tainted, value })} -
+
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
{/snippet} diff --git a/src/lib/components/ui/form/form-legend.svelte b/src/lib/components/ui/form/form-legend.svelte index 9d52f6a..b3c9043 100644 --- a/src/lib/components/ui/form/form-legend.svelte +++ b/src/lib/components/ui/form/form-legend.svelte @@ -11,6 +11,6 @@ diff --git a/src/lib/components/ui/input/index.ts b/src/lib/components/ui/input/index.ts index f47b6d3..c9ffe28 100644 --- a/src/lib/components/ui/input/index.ts +++ b/src/lib/components/ui/input/index.ts @@ -3,5 +3,5 @@ import Root from "./input.svelte"; export { Root, // - Root as Input, + Root as Input }; diff --git a/src/lib/components/ui/label/index.ts b/src/lib/components/ui/label/index.ts index 8bfca0b..2c3128c 100644 --- a/src/lib/components/ui/label/index.ts +++ b/src/lib/components/ui/label/index.ts @@ -3,5 +3,5 @@ import Root from "./label.svelte"; export { Root, // - Root as Label, + Root as Label }; diff --git a/src/lib/components/ui/label/label.svelte b/src/lib/components/ui/label/label.svelte index d0afda3..d71afbc 100644 --- a/src/lib/components/ui/label/label.svelte +++ b/src/lib/components/ui/label/label.svelte @@ -13,7 +13,7 @@ bind:ref data-slot="label" class={cn( - "flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50", + "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} diff --git a/src/lib/components/ui/skeleton/index.ts b/src/lib/components/ui/skeleton/index.ts index 186db21..2be5c50 100644 --- a/src/lib/components/ui/skeleton/index.ts +++ b/src/lib/components/ui/skeleton/index.ts @@ -3,5 +3,5 @@ import Root from "./skeleton.svelte"; export { Root, // - Root as Skeleton, + Root as Skeleton }; diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index 2d5e2c3..643492f 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -195,28 +195,28 @@ export async function sendAppointmentUpdatedEmail( */ export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): string { const protocol = requestUrl.protocol; - const port = requestUrl.port ? `:${requestUrl.port}` : ''; + 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.')) { + 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('.'); - + 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('.'); + 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}`; } @@ -243,7 +243,7 @@ export async function sendConfirmationEmail( const subject = language === "en" ? "Confirm Your Registration" : "Registrierung bestätigen"; // Generate appropriate base URL if request URL is provided - const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : 'http://localhost:5173'; + const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : "http://localhost:5173"; // Create enhanced tenant object with baseUrl const tenantWithBaseUrl = { diff --git a/src/routes/api/admin/init/+server.ts b/src/routes/api/admin/init/+server.ts index 4a292a0..13ef8fa 100644 --- a/src/routes/api/admin/init/+server.ts +++ b/src/routes/api/admin/init/+server.ts @@ -136,35 +136,41 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => { }); // 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" - }, url); + 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 }); + 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, deviceName: body.passkey.deviceName || "Unknown Device" }); - + log.debug("Passkey added to admin account", { adminId: admin.id, passkeyId: body.passkey.id diff --git a/src/routes/api/auth/passkeys/+server.ts b/src/routes/api/auth/passkeys/+server.ts index 37432b6..80206a5 100644 --- a/src/routes/api/auth/passkeys/+server.ts +++ b/src/routes/api/auth/passkeys/+server.ts @@ -118,7 +118,7 @@ export const POST: RequestHandler = async ({ request }) => { // 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, From 16dd7205eff5a9e698cc8503367b921831bbfddb Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 27 Aug 2025 12:02:57 +0200 Subject: [PATCH 21/22] Fix lint --- .../layouts/centered-card/main.svelte | 3 ++- .../components/layouts/empty-layout/main.svelte | 9 ++++++++- src/lib/components/ui/form/form-root.svelte | 2 +- src/lib/components/ui/passkey/state.svelte | 12 +++++------- src/routes/(pages)/login/login-form.svelte | 17 +---------------- .../create-account-form.svelte | 3 +-- 6 files changed, 18 insertions(+), 28 deletions(-) diff --git a/src/lib/components/layouts/centered-card/main.svelte b/src/lib/components/layouts/centered-card/main.svelte index 0838849..925987c 100644 --- a/src/lib/components/layouts/centered-card/main.svelte +++ b/src/lib/components/layouts/centered-card/main.svelte @@ -1,9 +1,10 @@ -
+
{@render children?.()}
diff --git a/src/lib/components/layouts/empty-layout/main.svelte b/src/lib/components/layouts/empty-layout/main.svelte index ec9757b..e8a5e3a 100644 --- a/src/lib/components/layouts/empty-layout/main.svelte +++ b/src/lib/components/layouts/empty-layout/main.svelte @@ -1,9 +1,16 @@ -
+
{@render children?.()}
diff --git a/src/lib/components/ui/form/form-root.svelte b/src/lib/components/ui/form/form-root.svelte index cbe14a9..5462175 100644 --- a/src/lib/components/ui/form/form-root.svelte +++ b/src/lib/components/ui/form/form-root.svelte @@ -1,10 +1,10 @@ diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index 4ff43e6..431b79c 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -117,9 +117,8 @@ // Update form data with passkey info const publicKeyBase64 = arrayBufferToBase64(publicKey); const authenticatorDataBase64 = arrayBufferToBase64(authenticatorData); - const { type, ...rest } = $formData; $formData = { - ...rest, + ...$formData, type: "passkey", id: passkeyResp.id, publicKeyBase64, From 6d41808fec940e4b233729450be65fb68883dd4b Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 27 Aug 2025 12:08:56 +0200 Subject: [PATCH 22/22] Fix unit tests --- src/routes/(pages)/+page.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/(pages)/+page.svelte b/src/routes/(pages)/+page.svelte index daf147a..e16f841 100644 --- a/src/routes/(pages)/+page.svelte +++ b/src/routes/(pages)/+page.svelte @@ -1,6 +1,6 @@ @@ -12,6 +12,7 @@
+ Hello World Environment configuration is {#await data.streamed.isEnvOk}