From 46f2cfb9e722fa035f7f6a95e10cccfbf2faaa63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 12:09:16 +0200 Subject: [PATCH] feat(auth): signup captures browser-detected timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.4.25 W7 phase 6 — proposal §4 Option C as default-value layer. The registration form now reads Intl.DateTimeFormat().resolvedOptions().timeZone and sends it with the signup payload. The server: 1. Validates the value against Intl.supportedValuesOf via isValidTimezone(). 2. On any invalid value (or absence), resolves the admin- configured server default (AppSettings.defaultUserTimezone) and uses that. 3. On no admin default, the resolver falls back to the 'Europe/Berlin' string, which is also the User.timezone column default — so the worst case still produces the legacy pre-v1.4.25 behaviour. The Zod registerSchema gains an optional 'timezone' string field capped at 64 chars (matches the longest IANA canonical zone identifier plus headroom). Audit-log entry now carries the resolved zone so an operator can spot a Tokyo user mis-attributed to Berlin if browser detection fails. No new tests — the existing register-route integration coverage is thin, and the v1.4.25 W7 phase 7 integration test in Pacific/Auckland covers the full signup → measurement → export round-trip end-to-end. --- src/app/api/auth/register/route.ts | 23 +++++++++++++++++++++-- src/app/auth/register/page.tsx | 9 ++++++++- src/lib/validations/auth.ts | 10 ++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 8847952e8..a4d35ef90 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -15,6 +15,10 @@ import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { + isValidTimezone, + resolveServerDefaultTimezone, +} from "@/lib/tz/resolver"; export const POST = apiHandler(async (request: NextRequest) => { const ip = getClientIp(request) ?? "unknown"; @@ -59,7 +63,21 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError(parsed.error.issues[0].message, 422); } - const { email, username, password } = parsed.data; + const { email, username, password, timezone: timezoneInput } = parsed.data; + + // v1.4.25 W7 — accept a browser-detected timezone from the + // registration form. Validate against the runtime IANA list; on + // any invalid value (or absence), fall back to the admin- + // configured server default. The `User.timezone` column has a + // hard-coded "Europe/Berlin" default at the schema layer, so the + // worst-case chain still produces a usable string. + let timezone: string; + const trimmedTimezone = timezoneInput?.trim() ?? ""; + if (trimmedTimezone && isValidTimezone(trimmedTimezone)) { + timezone = trimmedTimezone; + } else { + timezone = await resolveServerDefaultTimezone(); + } // Check if email or username already taken (unified message to prevent enumeration) const [existingEmail, existingUsername] = await Promise.all([ @@ -91,6 +109,7 @@ export const POST = apiHandler(async (request: NextRequest) => { username, passwordHash, role, + timezone, }, }); @@ -104,7 +123,7 @@ export const POST = apiHandler(async (request: NextRequest) => { await auditLog("auth.register", { userId: user.id, ipAddress: ip, - details: { method: "password" }, + details: { method: "password", timezone }, }); annotate({ action: { name: "auth.register" } }); diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 7f185ece0..d1355582e 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordStrength } from "@/components/ui/password-strength"; import { useTranslations } from "@/lib/i18n/context"; +import { detectBrowserTimezone } from "@/lib/tz/resolver"; export default function RegisterPage() { const router = useRouter(); @@ -28,10 +29,16 @@ export default function RegisterPage() { setLoading(true); try { + // v1.4.25 W7 — ship the browser-detected timezone with the + // signup payload. Server validates against the IANA list and + // falls back to the admin server-default if the value is + // bogus, so a tampered or proxied request can never poison + // the user's stored zone. + const timezone = detectBrowserTimezone(); const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, username, password }), + body: JSON.stringify({ email, username, password, timezone }), }); const json = await res.json(); diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts index b16e16882..fab433cda 100644 --- a/src/lib/validations/auth.ts +++ b/src/lib/validations/auth.ts @@ -16,6 +16,16 @@ export const registerSchema = z.object({ PASSWORD_MIN_LENGTH, `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, ), + /** + * v1.4.25 W7 — browser-detected timezone. The signup form pulls + * `Intl.DateTimeFormat().resolvedOptions().timeZone` and sends it + * along; the server validates against the runtime IANA list and + * falls back to the admin-configured server default + * (`AppSettings.defaultUserTimezone`) when the value is invalid + * or missing. Optional so legacy clients that predate v1.4.25 + * keep working. + */ + timezone: z.string().max(64).optional(), }); export const loginPasswordSchema = z.object({