diff --git a/messages/de.json b/messages/de.json index 3bffcae98..2aa59ba92 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1320,6 +1320,10 @@ "genderMale": "Männlich", "genderFemale": "Weiblich", "genderHint": "Wird für geschlechtsspezifische Zielwerte verwendet.", + "timezone": "Zeitzone", + "timezoneHint": "Wird für Diagrammachsen, Erinnerungszeiten, Export-Zeitstempel und den AI-Coach-Kontext verwendet. Gespeicherte Daten bleiben unverändert.", + "timezoneDetect": "Browser-Zeitzone übernehmen ({tz})", + "timezoneInvalid": "Keine gültige IANA-Zeitzone.", "profileSaved": "Profil gespeichert", "changePassword": "Passwort ändern", "changePasswordDescription": "Ersetze dein aktuelles Passwort durch ein neues.", diff --git a/messages/en.json b/messages/en.json index 155ab44fe..e7dfb2149 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1320,6 +1320,10 @@ "genderMale": "Male", "genderFemale": "Female", "genderHint": "Used for gender-specific target values.", + "timezone": "Timezone", + "timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and AI Coach context. Stored data is unchanged.", + "timezoneDetect": "Use browser timezone ({tz})", + "timezoneInvalid": "Not a valid IANA timezone.", "profileSaved": "Profile saved", "changePassword": "Change password", "changePasswordDescription": "Replace your current password with a new one.", diff --git a/src/app/api/auth/me/timezone/__tests__/route.test.ts b/src/app/api/auth/me/timezone/__tests__/route.test.ts new file mode 100644 index 000000000..68501467f --- /dev/null +++ b/src/app/api/auth/me/timezone/__tests__/route.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + appSettings: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const }, +}; + +function mkReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/auth/me/timezone", { + method: "PUT", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("PUT /api/auth/me/timezone", () => { + it("rejects unauthenticated requests with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await PUT(mkReq({ timezone: "Europe/Berlin" })); + expect(res.status).toBe(401); + }); + + it("accepts a valid IANA zone and writes it to the user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({ + timezone: "Pacific/Auckland", + } as never); + + const res = await PUT(mkReq({ timezone: "Pacific/Auckland" })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { timezone: "Pacific/Auckland" }, + }); + }); + + it("rejects an invalid timezone with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await PUT(mkReq({ timezone: "Mars/Olympus" })); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects an empty timezone with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await PUT(mkReq({ timezone: "" })); + expect(res.status).toBe(422); + }); + + it("rejects a missing timezone field with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await PUT(mkReq({})); + expect(res.status).toBe(422); + }); + + it("trims whitespace before validating", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({ + timezone: "Asia/Tokyo", + } as never); + const res = await PUT(mkReq({ timezone: " Asia/Tokyo " })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { timezone: "Asia/Tokyo" }, + }); + }); +}); diff --git a/src/app/api/auth/me/timezone/route.ts b/src/app/api/auth/me/timezone/route.ts new file mode 100644 index 000000000..596458dd2 --- /dev/null +++ b/src/app/api/auth/me/timezone/route.ts @@ -0,0 +1,73 @@ +/** + * PUT /api/auth/me/timezone + * + * v1.4.25 W7 — per-user timezone setter. Lifts the timezone write + * out of `PUT /api/auth/profile` so the picker UI can post the + * single field without round-tripping email/heightCm/etc, and so + * the change pipeline (validate → write → invalidate-cache) is + * isolated from the heavier profile patch. + * + * Auth: cookie session OR Bearer token. + * Audit: `user.timezone.update` with the old + new zone. + * + * Validation: the body is `{ "timezone": "Europe/Berlin" }`. We + * accept any string the runtime's `Intl.DateTimeFormat` constructor + * accepts — i.e. every IANA zone the active Node engine knows about. + * Rejecting unknown zones at the API surface stops a malformed + * client from poisoning the column with a value the resolver + * would later have to defend against. + */ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver"; + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "user.timezone.update" } }); + + const { data: body, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const tz = + body && typeof (body as { timezone?: unknown }).timezone === "string" + ? ((body as { timezone: string }).timezone || "").trim() + : ""; + + if (!isValidTimezone(tz)) { + return apiError("Not a valid IANA timezone.", 422); + } + + const before = await prisma.user.findUnique({ + where: { id: user.id }, + select: { timezone: true }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { timezone: tz }, + }); + + invalidateUserTimezone(user.id); + + await auditLog("user.timezone.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + previous: before?.timezone ?? null, + next: tz, + }, + }); + + annotate({ + meta: { + timezone_previous: before?.timezone ?? null, + timezone_next: tz, + }, + }); + + return apiSuccess({ timezone: tz }); +}); diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index dcf206fb1..37ab2484b 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -43,6 +43,7 @@ import { formatDate } from "@/lib/format"; import { locales, localeLabels, type Locale } from "@/lib/i18n/config"; import { useTranslations } from "@/lib/i18n/context"; import { describePasskeyError } from "@/lib/passkey-errors"; +import { TimezonePicker } from "@/components/settings/timezone-picker"; interface PasskeyInfo { id: string; @@ -71,6 +72,7 @@ export function AccountSection() { const [heightCm, setHeightCm] = useState(""); const [dateOfBirth, setDateOfBirth] = useState(""); const [gender, setGender] = useState(""); + const [timezone, setTimezone] = useState("Europe/Berlin"); const [saving, setSaving] = useState(false); const [saveMsg, setSaveMsg] = useState(null); const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( @@ -132,6 +134,7 @@ export function AccountSection() { : "", ); setGender(user.gender ?? ""); + setTimezone(user.timezone || "Europe/Berlin"); } async function handleSaveProfile(e: React.FormEvent) { @@ -140,23 +143,48 @@ export function AccountSection() { setSaveMsg(null); setSaveMsgType(null); - const res = await fetch("/api/auth/profile", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: email.trim() || null, - heightCm: heightCm ? parseFloat(heightCm) : null, - dateOfBirth: dateOfBirth || null, - gender: gender || null, + // The timezone is owned by a dedicated route (v1.4.25 W7) so the + // resolver cache can be invalidated without piping the flag + // through the bigger profile patch path. Run the two PUTs in + // parallel — they're independent. + const [profileRes, tzRes] = await Promise.all([ + fetch("/api/auth/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: email.trim() || null, + heightCm: heightCm ? parseFloat(heightCm) : null, + dateOfBirth: dateOfBirth || null, + gender: gender || null, + }), }), - }); + user && timezone && timezone !== user.timezone + ? fetch("/api/auth/me/timezone", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ timezone }), + }) + : Promise.resolve({ ok: true } as Response), + ]); - if (res.ok) { + if (profileRes.ok && tzRes.ok) { setSaveMsg(t("settings.profileSaved")); setSaveMsgType("success"); await refetch(); + } else if (!tzRes.ok) { + // The dedicated tz endpoint owns the IANA validation error + // text. Surface its message verbatim so the user sees + // "Not a valid IANA timezone." instead of the generic save + // failure copy. + try { + const json = (await (tzRes as Response).json()) as { error?: string }; + setSaveMsg(json.error || t("settings.timezoneInvalid")); + } catch { + setSaveMsg(t("settings.timezoneInvalid")); + } + setSaveMsgType("error"); } else { - const json = await res.json(); + const json = await profileRes.json(); setSaveMsg(json.error || t("settings.savingError")); setSaveMsgType("error"); } @@ -396,6 +424,8 @@ export function AccountSection() { + +
onChange(e.target.value)} + placeholder="Europe/Berlin" + autoComplete="off" + /> +

{labelHint}

+ +
+ ); + } + + return ( +
+ +
+ + +
+

{labelHint}

+
+ ); +}