diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json
index 1adba90..5b4cf5b 100644
--- a/project.inlang/messages/de.json
+++ b/project.inlang/messages/de.json
@@ -131,7 +131,7 @@
"logout": {
"title": "Abmelden",
"success": {
- "title": "Du wurdest abgemeldet.",
+ "title": "Bis bald",
"description": "Du wurdest abgemeldet."
},
"error": {
@@ -757,7 +757,15 @@
"general": {
"navItem": "Einstellungen",
"title": "Kontoeinstellungen",
- "description": "Ändere Deinen Namen oder die Sprache für E-Mails."
+ "description": "Ändere Deinen Namen oder die Sprache für E-Mails.",
+ "language": {
+ "title": "E-Mail Sprache",
+ "placeholder": "Wähle eine Sprache",
+ "hint": "OpenReception sendet E-Mails in dieser Sprache an Dich"
+ },
+ "action": "Änderungen speichern",
+ "success": "Konto-Einstellungen gespeichert",
+ "error": "Konto-Einstellungen konnten nicht gespeichert werden"
},
"change-email": {
"navItem": "E-Mail Adresse",
diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json
index cf9035a..21139e4 100644
--- a/project.inlang/messages/en.json
+++ b/project.inlang/messages/en.json
@@ -139,8 +139,8 @@
"logout": {
"title": "Logout",
"success": {
- "title": "You are logged out.",
- "description": "You are logged-out."
+ "title": "Have a good day",
+ "description": "You are now logged out."
},
"error": {
"title": "Logout failed",
@@ -765,7 +765,15 @@
"general": {
"navItem": "Settings",
"title": "Account Settings",
- "description": "Change your name and e-mail language."
+ "description": "Change your name and e-mail language.",
+ "language": {
+ "title": "E-Mail Language",
+ "placeholder": "Select a language",
+ "hint": "OpenReception will send e-mails in this language to you"
+ },
+ "action": "Save changes",
+ "success": "Account Settings saved",
+ "error": "Account Settings could not be saved"
},
"change-email": {
"navItem": "E-Mail Address",
diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts
index 128ffb1..db5e82d 100644
--- a/src/lib/stores/auth.ts
+++ b/src/lib/stores/auth.ts
@@ -1,4 +1,5 @@
import { browser } from "$app/environment";
+import type { SupportedLocale } from "$lib/const/locales";
import type { UserRole } from "$lib/server/auth/authorization-service";
import { writable } from "svelte/store";
@@ -17,6 +18,7 @@ export interface AuthState {
email: string;
name: string;
role: UserRole;
+ language: SupportedLocale;
// The currently selected tenant
tenantId?: string | null;
};
diff --git a/src/routes/(pages)/dashboard/+layout.server.ts b/src/routes/(pages)/dashboard/+layout.server.ts
index 635d6ad..73f2b4e 100644
--- a/src/routes/(pages)/dashboard/+layout.server.ts
+++ b/src/routes/(pages)/dashboard/+layout.server.ts
@@ -7,6 +7,7 @@ import {
import type { TTenant } from "$lib/types/tenant";
import type { LayoutServerLoad } from "./$types";
import logger from "$lib/logger";
+import type { SupportedLocale } from "$lib/const/locales";
const log = logger.setContext("/dashboard/+layout.server.ts");
@@ -25,6 +26,7 @@ export const load: LayoutServerLoad = async (event) => {
name: event.locals.user.name,
email: event.locals.user.email,
role: event.locals.user.role,
+ language: event.locals.user.language as SupportedLocale,
tenantId: event.locals.user.tenantId,
};
auth.setUser(user);
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
new file mode 100644
index 0000000..f9f4bb4
--- /dev/null
+++ b/src/routes/(pages)/dashboard/account/general/(components)/account-settings-form/account-settings-form.svelte
@@ -0,0 +1,102 @@
+
+
+{#if account}
+
+
+
+ {#snippet children({ props })}
+ {m["form.name"]()}
+
+ {/snippet}
+
+
+
+
+
+ {#snippet children({ props })}
+ {m["account.general.language.title"]()}
+ ($formData.language = v)}
+ >
+
+ {$formData.language
+ ? translatedLocales[$formData.language as keyof typeof translatedLocales]
+ : m["account.general.language.placeholder"]()}
+
+
+ {#each supportedLocales as locale (locale)}
+
+ {translatedLocales[locale as keyof typeof translatedLocales]}
+
+ {/each}
+
+
+
+ {m["account.general.language.hint"]()}
+
+ {/snippet}
+
+
+
+
+
+ {m["account.general.action"]()}
+
+
+
+{/if}
diff --git a/src/routes/(pages)/dashboard/account/general/(components)/account-settings-form/index.ts b/src/routes/(pages)/dashboard/account/general/(components)/account-settings-form/index.ts
new file mode 100644
index 0000000..768ec05
--- /dev/null
+++ b/src/routes/(pages)/dashboard/account/general/(components)/account-settings-form/index.ts
@@ -0,0 +1,5 @@
+import AccountSettingsForm from "./account-settings-form.svelte";
+
+export { AccountSettingsForm };
+export { formSchema } from "./schema";
+export type { FormSchema } from "./schema";
diff --git a/src/routes/(pages)/dashboard/account/general/(components)/account-settings-form/schema.ts b/src/routes/(pages)/dashboard/account/general/(components)/account-settings-form/schema.ts
new file mode 100644
index 0000000..46691e2
--- /dev/null
+++ b/src/routes/(pages)/dashboard/account/general/(components)/account-settings-form/schema.ts
@@ -0,0 +1,9 @@
+import { m } from "$i18n/messages";
+import { z } from "zod";
+
+export const formSchema = z.object({
+ name: z.string().min(2, m["form.errors.name"]()).max(50, m["form.errors.name"]()),
+ language: z.string().default("en"),
+});
+
+export type FormSchema = typeof formSchema;
diff --git a/src/routes/(pages)/dashboard/account/general/+page.server.ts b/src/routes/(pages)/dashboard/account/general/+page.server.ts
new file mode 100644
index 0000000..1ed615f
--- /dev/null
+++ b/src/routes/(pages)/dashboard/account/general/+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)/account-settings-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`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "same-origin",
+ body: JSON.stringify({
+ name: form.data.name,
+ language: form.data.language,
+ }),
+ });
+
+ 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/general/+page.svelte b/src/routes/(pages)/dashboard/account/general/+page.svelte
index 304fb00..03b5abe 100644
--- a/src/routes/(pages)/dashboard/account/general/+page.svelte
+++ b/src/routes/(pages)/dashboard/account/general/+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 { AccountSettingsForm } from "./(components)/account-settings-form";
{m["account.general.title"]()}
+
diff --git a/src/routes/api/auth/session/+server.ts b/src/routes/api/auth/session/+server.ts
index 160fb3c..249c2c5 100644
--- a/src/routes/api/auth/session/+server.ts
+++ b/src/routes/api/auth/session/+server.ts
@@ -26,8 +26,9 @@ registerOpenAPIRoute("/auth/session", "GET", {
name: { type: "string", description: "User name" },
role: { type: "string", enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] },
tenantId: { type: "string", description: "Tenant ID (if applicable)" },
+ language: { type: "string", description: "Language for E-Mails" },
},
- required: ["id", "email", "name", "role"],
+ required: ["id", "email", "name", "role", "language"],
},
expiresAt: {
type: "string",
@@ -83,6 +84,7 @@ export const GET: RequestHandler = async ({ locals }) => {
name: locals.user.name,
role: locals.user.role,
tenantId: locals.user.tenantId,
+ language: locals.user.language,
},
expiresAt: new Date(locals.user.session.exp ?? 0).toISOString(),
});
diff --git a/src/routes/api/me/+server.ts b/src/routes/api/me/+server.ts
new file mode 100644
index 0000000..65e3624
--- /dev/null
+++ b/src/routes/api/me/+server.ts
@@ -0,0 +1,137 @@
+/**
+ * API Route: Dashboard User manages their own account
+ */
+
+import { logger } from "$lib/logger";
+import { registerOpenAPIRoute } from "$lib/server/openapi";
+import { UserService } from "$lib/server/services/user-service";
+import {
+ AuthenticationError,
+ BackendError,
+ InternalError,
+ logError,
+ ValidationError,
+} from "$lib/server/utils/errors";
+import type { RequestHandler } from "@sveltejs/kit";
+import { json } from "@sveltejs/kit";
+import { z } from "zod";
+
+const requestSchema = z.object({
+ name: z.string().min(2).max(50).optional(),
+ language: z.enum(["de", "en"]).optional(),
+});
+
+// Register OpenAPI documentation for DELETE
+registerOpenAPIRoute("/me", "PUT", {
+ summary: "Update current account",
+ description: "Allows a staff member to update their current account settings.",
+ tags: ["Staff", "Account"],
+ requestBody: {
+ description: "Account data",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ name: {
+ type: "string",
+ description: "Name of the staff member",
+ },
+ language: {
+ type: "string",
+ description: "ISO 639-1 Code",
+ },
+ },
+ },
+ },
+ },
+ },
+ responses: {
+ "200": {
+ description: "Account settings changed",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ name: {
+ type: "string",
+ example: "John Doe",
+ },
+ language: {
+ type: "string",
+ example: "en",
+ },
+ },
+ },
+ },
+ },
+ },
+ "400": {
+ description: "Invalid request",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/Error" },
+ },
+ },
+ },
+ "401": {
+ description: "Unauthorized",
+ 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");
+
+ try {
+ if (!locals.user) {
+ throw new AuthenticationError("Unauthorized");
+ }
+
+ const body = await request.json();
+ const updateData = requestSchema.parse(body);
+
+ log.debug("Staff member updating their account", {
+ userId: locals.user.id,
+ updateData,
+ });
+
+ // Delete appointment with authentication verification
+ const user = await UserService.updateUser(locals.user.id, updateData);
+
+ log.debug("User updated", {
+ userId: locals.user.id,
+ });
+
+ return json({
+ name: user.name,
+ language: user.language,
+ });
+ } catch (error) {
+ logError(log)("Error updating user", error);
+
+ if (error instanceof BackendError) {
+ return error.toJson();
+ }
+
+ if (error instanceof z.ZodError) {
+ return new ValidationError("Invalid request data").toJson();
+ }
+
+ return new InternalError().toJson();
+ }
+};
diff --git a/src/routes/api/me/server.test.ts b/src/routes/api/me/server.test.ts
new file mode 100644
index 0000000..fbeddbe
--- /dev/null
+++ b/src/routes/api/me/server.test.ts
@@ -0,0 +1,95 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { RequestEvent } from "@sveltejs/kit";
+
+vi.mock("$lib/server/services/user-service", () => ({
+ UserService: {
+ updateUser: vi.fn(),
+ },
+}));
+
+vi.mock("$lib/server/openapi", () => ({
+ registerOpenAPIRoute: vi.fn(),
+}));
+
+vi.mock("$lib/logger", () => ({
+ logger: {
+ setContext: vi.fn(() => ({
+ debug: vi.fn(),
+ error: vi.fn(),
+ })),
+ },
+}));
+
+import { PUT } from "./+server";
+import { UserService } from "$lib/server/services/user-service";
+
+const mockUser = {
+ id: "user-123",
+ name: "Old Name",
+ language: "en",
+};
+
+const createRequestEvent = (body: any, user = mockUser): RequestEvent =>
+ ({
+ request: {
+ json: () => Promise.resolve(body),
+ } as any,
+ locals: {
+ user,
+ },
+ }) as RequestEvent;
+
+describe("/api/me", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("updates the authenticated user's account and returns the new values", async () => {
+ const updatedUser = { ...mockUser, name: "New Name", language: "de" };
+ vi.mocked(UserService.updateUser).mockResolvedValue(updatedUser as any);
+
+ const event = createRequestEvent({ name: "New Name", language: "de" });
+ const response = await PUT(event);
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body).toEqual({ name: "New Name", language: "de" });
+ expect(UserService.updateUser).toHaveBeenCalledWith(mockUser.id, {
+ name: "New Name",
+ language: "de",
+ });
+ });
+
+ it("returns 401 when the request is unauthenticated", async () => {
+ const event = createRequestEvent({ name: "New Name" }, null as any);
+ const response = await PUT(event);
+ const body = await response.json();
+
+ expect(response.status).toBe(401);
+ expect(body.error).toBe("Unauthorized");
+ expect(UserService.updateUser).not.toHaveBeenCalled();
+ });
+
+ it("returns 422 when the request body is invalid", async () => {
+ const event = createRequestEvent({ name: "X", language: "fr" });
+
+ const response = await PUT(event);
+ const body = await response.json();
+
+ expect(response.status).toBe(422);
+ expect(body.error).toBe("Invalid request data");
+ expect(UserService.updateUser).not.toHaveBeenCalled();
+ });
+
+ it("returns 500 when an unexpected error is thrown by the update service", async () => {
+ vi.mocked(UserService.updateUser).mockRejectedValue(new Error("database outage"));
+
+ const event = createRequestEvent({ name: "New Name" });
+ const response = await PUT(event);
+ const body = await response.json();
+
+ expect(response.status).toBe(500);
+ expect(body.error).toBe("Internal server error");
+ });
+});