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"); + }); +});