feat(auth): signup captures browser-detected timezone

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 12:09:16 +02:00
1 parent 80544b73cb
commit 46f2cfb9e7
3 files changed
+39 -3

No files matched your search

+21 -2
View File
@@ -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" } });