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({