mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
80544b73cb
commit
46f2cfb9e7
3 files changed
+39
-3
No files matched your search
@@ -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" } });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user