From edd938d5449f1a3cb7a05228db6c7c660ebe7ef7 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 29 Jul 2026 10:02:50 +0200 Subject: [PATCH] Added passphrase change for global admins --- project.inlang/messages/de.json | 13 +- project.inlang/messages/en.json | 13 +- src/lib/stores/auth.ts | 20 +-- .../change-passphrase-form.svelte | 104 ++++++++++++ .../change-passphrase-form/index.ts | 5 + .../change-passphrase-form/schema.ts | 21 +++ .../account/change-passphrase/+page.server.ts | 49 ++++++ .../account/change-passphrase/+page.svelte | 2 + .../account-settings-form.svelte | 43 ++--- src/routes/api/me/+server.ts | 1 - src/routes/api/me/passphrase/+server.ts | 151 ++++++++++++++++++ 11 files changed, 392 insertions(+), 30 deletions(-) create mode 100644 src/routes/(pages)/dashboard/account/change-passphrase/(components)/change-passphrase-form/change-passphrase-form.svelte create mode 100644 src/routes/(pages)/dashboard/account/change-passphrase/(components)/change-passphrase-form/index.ts create mode 100644 src/routes/(pages)/dashboard/account/change-passphrase/(components)/change-passphrase-form/schema.ts create mode 100644 src/routes/(pages)/dashboard/account/change-passphrase/+page.server.ts create mode 100644 src/routes/api/me/passphrase/+server.ts diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 5b4cf5b..922bccd 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -49,6 +49,7 @@ "errors": { "email": "Ungültiges Format", "passphrase": "Muss mindestens 30 Zeichen lang sein", + "passphraseMismatch": "Ist nicht mit dem Passphrase identisch", "noPassAtAll": "Either passphrase or passkey is required", "bothPassSet": "Nur Passphrase oder Passkey darf gesetzt sein", "name": "Muss mindestens 2 Zeichen lang sein", @@ -813,7 +814,17 @@ "change-passphrase": { "navItem": "Passphrase", "title": "Change Passphrase", - "description": "Globale Admins können einen Passphrase als Backup-Methode verwenden." + "description": "Globale Admins können einen Passphrase als Backup-Methode verwenden.", + "currentPassphrase": "Aktueller Passphrase", + "newPassphrase": "Neuer Passphrase", + "repeatedPassphrase": "Neuer Passphrase (Wiederholung)", + "action": "Passphrase ändern", + "success": "Passphrase geändert", + "errors": { + "notAGlobalAdmin": "Du darfst keinen Passphrase einrichten", + "passphraseIncorrect": "Aktueller Passphrase falsch", + "unknown": "Passphrase konnte nicht geändert werden" + } } }, "staff": { diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 21139e4..9a7405f 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -64,6 +64,7 @@ "errors": { "email": "Invalid format", "passphrase": "Must be at least 30 characters long", + "passphraseMismatch": "Does not match the new passphrase", "noPassAtAll": "Either passphrase or passkey is required", "bothPassSet": "Only one of passphrase or passkey should be provided", "name": "Must be at least 2 characters", @@ -821,7 +822,17 @@ "change-passphrase": { "navItem": "Passphrase", "title": "Change Passphrase", - "description": "Global Admins can set strong passphrases as a backup login method." + "description": "Global Admins can set strong passphrases as a backup login method.", + "currentPassphrase": "Current Passphrase", + "newPassphrase": "New Passphrase", + "repeatedPassphrase": "New Passphrase (repeat)", + "action": "Change Passphrase", + "success": "Passphrase changed", + "errors": { + "notAGlobalAdmin": "You are now allowed to add a passphrase", + "passphraseIncorrect": "Current Passphrase is incorrect", + "unknown": "Passphrase could not be updated" + } } }, "staff": { diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index db5e82d..cffbb43 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -13,18 +13,20 @@ export interface PasskeyAuthData { export interface AuthState { isAuthenticated: boolean; refreshPromise: Promise | null; - user?: { - id: string; - email: string; - name: string; - role: UserRole; - language: SupportedLocale; - // The currently selected tenant - tenantId?: string | null; - }; + user?: AuthStateUser; passkeyAuthData?: PasskeyAuthData; } +export type AuthStateUser = { + id: string; + email: string; + name: string; + role: UserRole; + language: SupportedLocale; + // The currently selected tenant + tenantId?: string | null; +}; + function createAuthStore() { const store = writable({ isAuthenticated: false, diff --git a/src/routes/(pages)/dashboard/account/change-passphrase/(components)/change-passphrase-form/change-passphrase-form.svelte b/src/routes/(pages)/dashboard/account/change-passphrase/(components)/change-passphrase-form/change-passphrase-form.svelte new file mode 100644 index 0000000..2546ae6 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/change-passphrase/(components)/change-passphrase-form/change-passphrase-form.svelte @@ -0,0 +1,104 @@ + + +{#if account} + + + + {#snippet children({ props })} + {m["account.change-passphrase.currentPassphrase"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["account.change-passphrase.newPassphrase"]()} + + {/snippet} + + + + {m["form.passphraseRequirements"]()} + + + + + {#snippet children({ props })} + {m["account.change-passphrase.repeatedPassphrase"]()} + + {/snippet} + + + +
+ + {m["account.change-passphrase.action"]()} + +
+
+{/if} diff --git a/src/routes/(pages)/dashboard/account/change-passphrase/(components)/change-passphrase-form/index.ts b/src/routes/(pages)/dashboard/account/change-passphrase/(components)/change-passphrase-form/index.ts new file mode 100644 index 0000000..476f529 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/change-passphrase/(components)/change-passphrase-form/index.ts @@ -0,0 +1,5 @@ +import ChangePassphraseForm from "./change-passphrase-form.svelte"; + +export { ChangePassphraseForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/account/change-passphrase/(components)/change-passphrase-form/schema.ts b/src/routes/(pages)/dashboard/account/change-passphrase/(components)/change-passphrase-form/schema.ts new file mode 100644 index 0000000..4e36028 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/change-passphrase/(components)/change-passphrase-form/schema.ts @@ -0,0 +1,21 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z + .object({ + passphrase: z.string().min(30, m["form.errors.passphrase"]()), + newPassphrase: z.string().min(30, m["form.errors.passphrase"]()), + repeatedPassphrase: z.string().min(30, m["form.errors.passphrase"]()), + }) + .check((ctx) => { + if (ctx.value.newPassphrase !== ctx.value.repeatedPassphrase) { + ctx.issues.push({ + code: "custom", + message: m["form.errors.passphraseMismatch"](), + path: ["repeatedPassphrase"], + input: ctx.value.repeatedPassphrase, + }); + } + }); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/account/change-passphrase/+page.server.ts b/src/routes/(pages)/dashboard/account/change-passphrase/+page.server.ts new file mode 100644 index 0000000..76f0642 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/change-passphrase/+page.server.ts @@ -0,0 +1,49 @@ +import logger from "$lib/logger"; +import { fail, type Actions } from "@sveltejs/kit"; +import { superValidate } from "sveltekit-superforms"; +import { zod4 as zod } from "sveltekit-superforms/adapters"; +import { formSchema as editFormSchema } from "./(components)/change-passphrase-form"; + +const log = logger.setContext(import.meta.filename); + +export const actions: Actions = { + edit: async (event) => { + const form = await superValidate(event, zod(editFormSchema)); + + if (!form.valid) { + log.error("Edit account settings form is not valid", { errors: form.errors }); + return fail(400, { + form: { ...form, data: { ...form.data } }, + error: "Form is not valid", + }); + } + + const resp = await event.fetch(`/api/me/passphrase`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + body: JSON.stringify({ + passphrase: form.data.passphrase, + newPassphrase: form.data.newPassphrase, + }), + }); + + if (resp.status < 400) { + return { form }; + } else { + let error = "Unknown error"; + try { + const body = await resp.json(); + error = body.error; + } catch (e) { + log.error("Failed to parse edit account settings error response", { error: e }); + } + return fail(resp.status, { + form: { ...form, data: { ...form.data } }, + error, + }); + } + }, +}; diff --git a/src/routes/(pages)/dashboard/account/change-passphrase/+page.svelte b/src/routes/(pages)/dashboard/account/change-passphrase/+page.svelte index b3ebcd0..a0a86b3 100644 --- a/src/routes/(pages)/dashboard/account/change-passphrase/+page.svelte +++ b/src/routes/(pages)/dashboard/account/change-passphrase/+page.svelte @@ -4,6 +4,7 @@ import { SidebarLayout } from "$lib/components/layouts/sidebar-layout"; import { Headline } from "$lib/components/ui/typography"; import { ROUTES } from "$lib/const/routes"; + import { ChangePassphraseForm } from "./(components)/change-passphrase-form"; {m["account.change-passphrase.title"]()} + diff --git a/src/routes/(pages)/dashboard/account/general/(components)/account-settings-form/account-settings-form.svelte b/src/routes/(pages)/dashboard/account/general/(components)/account-settings-form/account-settings-form.svelte index f9f4bb4..c86235b 100644 --- a/src/routes/(pages)/dashboard/account/general/(components)/account-settings-form/account-settings-form.svelte +++ b/src/routes/(pages)/dashboard/account/general/(components)/account-settings-form/account-settings-form.svelte @@ -3,7 +3,7 @@ import * as Form from "$lib/components/ui/form"; import { Input } from "$lib/components/ui/input"; import * as Select from "$lib/components/ui/select"; - import { supportedLocales, translatedLocales } from "$lib/const/locales"; + import { supportedLocales, translatedLocales, type SupportedLocale } from "$lib/const/locales"; import { auth } from "$lib/stores/auth"; import { untrack } from "svelte"; import { toast } from "svelte-sonner"; @@ -15,41 +15,48 @@ const form = superForm( { name: untrack(() => account?.name || ""), - language: untrack(() => account?.language) as string, + language: untrack(() => account?.language as string), }, { dataType: "json", validators: zodClient(formSchema), onResult: async (event) => { if (event.result.type === "success") { - if (account) { - auth.setUser({ - ...account, - name: event.result.data?.form.data.name, - language: event.result.data?.form.data.language, - }); - } toast.success(m["account.general.success"]()); } else if (event.result.type === "failure") { toast.error(m["account.general.error"]()); } isSubmitting = false; }, + onUpdated: ({ form }) => { + if (account) { + auth.setUser({ + ...account, + name: form.data.name, + language: form.data.language as SupportedLocale, + }); + } + }, onSubmit: () => (isSubmitting = true), }, ); - $effect(() => { - // Fixes hard reload of page resulting in empty form - if (!$formData.name && account?.name) { - $formData.name = account.name; - $formData.language = account.language; - } - }); - let isSubmitting = $state(false); - const { form: formData, enhance } = form; + const { form: formData, enhance, reset } = form; + + $effect(() => { + if (account) { + untrack(() => + reset({ + data: { + name: account.name, + language: account.language, + }, + }), + ); + } + }); {#if account} diff --git a/src/routes/api/me/+server.ts b/src/routes/api/me/+server.ts index 65e3624..f1f77e8 100644 --- a/src/routes/api/me/+server.ts +++ b/src/routes/api/me/+server.ts @@ -110,7 +110,6 @@ export const PUT: RequestHandler = async ({ request, locals }) => { updateData, }); - // Delete appointment with authentication verification const user = await UserService.updateUser(locals.user.id, updateData); log.debug("User updated", { diff --git a/src/routes/api/me/passphrase/+server.ts b/src/routes/api/me/passphrase/+server.ts new file mode 100644 index 0000000..256829e --- /dev/null +++ b/src/routes/api/me/passphrase/+server.ts @@ -0,0 +1,151 @@ +/** + * API Route: Dashboard User manages their own account + */ + +import { logger } from "$lib/logger"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; +import { UserService } from "$lib/server/services/user-service"; +import { + AuthenticationError, + AuthorizationError, + BackendError, + InternalError, + logError, + ValidationError, +} from "$lib/server/utils/errors"; +import { hashPassphrase, verifyPassphrase } from "$lib/server/utils/passphrase"; +import type { RequestHandler } from "@sveltejs/kit"; +import { json } from "@sveltejs/kit"; +import { z } from "zod"; + +const requestSchema = z.object({ + passphrase: z.string().min(30).max(100).optional(), + newPassphrase: z.string().min(30).max(100), +}); + +// Register OpenAPI documentation for DELETE +registerOpenAPIRoute("/me/passphrase", "PUT", { + summary: "Update current account passphrase", + description: "Allows a global admins to update their passphrase.", + tags: ["Staff", "Account"], + requestBody: { + description: "Account data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + passphrase: { + type: "string", + description: "Current passphrase", + }, + newPassphrase: { + type: "string", + description: "New passphrase", + }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "Passphrase changed", + content: { + "application/json": { + schema: { + type: "object", + properties: {}, + }, + }, + }, + }, + "400": { + description: "Invalid request", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "401": { + description: "Unauthorized", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Forbidden. This account cannot set a passphrase", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +export const PUT: RequestHandler = async ({ request, locals }) => { + const log = logger.setContext("API.Me.Passphrase"); + + try { + if (!locals.user) { + throw new AuthenticationError("Unauthorized"); + } + + if (locals.user.role !== "GLOBAL_ADMIN") { + throw new AuthorizationError("This user cannot set a passphrase"); + } + + const body = await request.json(); + const updateData = requestSchema.parse(body); + + const user = await UserService.getUserByEmail(locals.user.email); + // Check passphrase, if user currently has a passphrase set + if (user.passphraseHash) { + const isPassphraseValid = await verifyPassphrase(user.passphraseHash || "", body.passphrase); + if (!isPassphraseValid) { + await challengeThrottleService.recordFailedAttempt(user.email, "passphrase"); + return json({ error: "Invalid passphrase" }, { status: 401 }); + } + // Clear throttle on successful passphrase check + await challengeThrottleService.clearThrottle(user.email, "passphrase"); + } + + log.debug("User setting new passphrase", { + userId: locals.user.id, + }); + + const passphraseHash = await hashPassphrase(updateData.newPassphrase); + await UserService.updateUser(locals.user.id, { passphraseHash }); + + log.debug("User passphrase updated", { + userId: locals.user.id, + }); + + return json({}); + } catch (error) { + logError(log)("Error updating user passphrase", error); + + if (error instanceof BackendError) { + return error.toJson(); + } + + if (error instanceof z.ZodError) { + return new ValidationError("Invalid request data").toJson(); + } + + return new InternalError().toJson(); + } +};