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" } });
+8 -1
View File
@@ -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();
+10
View File
@@ -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({