Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code e2c015ed76 Invalid timezone "ETC/UNKNOWN" in workspace member crashes date rendering
https://sonarly.com/issue/14644?type=bug

The `TimeZoneAbbreviation` component passes the workspace member's `timeZone` value directly to `Temporal.Instant.toZonedDateTimeISO()` without validation. When the database contains the invalid value `"ETC/UNKNOWN"`, the Temporal polyfill throws a `RangeError` because it cannot construct a `DateTimeFormat` with that timezone.

Fix: Added timezone validation in `useUserTimezone` to prevent invalid IANA timezone strings from propagating to `Temporal.Instant.toZonedDateTimeISO()` and crashing the app.

**Changes:**

1. **New utility `isValidTimeZone`** (`packages/twenty-front/src/modules/localization/utils/isValidTimeZone.ts`): Uses `Intl.DateTimeFormat(undefined, { timeZone })` to validate timezone strings — the same validation mechanism the Temporal polyfill uses internally. Invalid timezones cause `DateTimeFormat` to throw a `RangeError`, which we catch and return `false`.

2. **Updated `useUserTimezone` hook** (`packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useUserTimezone.ts`): Before using the workspace member's timezone, we now validate it with `isValidTimeZone()`. If the stored timezone is invalid (like `"ETC/UNKNOWN"`), the hook falls back to the browser's system timezone instead of passing the bad value downstream to all 30+ consumers.

3. **Tests**: Added a test case to the existing `useUserTimezone.test.tsx` for the invalid timezone scenario, and a new test file for the `isValidTimeZone` utility covering both valid IANA timezones and invalid strings.

**Note:** The deeper issue is that the server accepts arbitrary strings for the `timeZone` field with no IANA validation. A server-side fix (adding validation on the workspace member `timeZone` field against the `IANA_TIME_ZONES` constant from `twenty-shared`) would prevent bad data from being written in the first place, but this frontend fix provides immediate protection for all date rendering paths.
2026-03-14 04:22:37 +00:00
4 changed files with 79 additions and 2 deletions
@@ -0,0 +1,17 @@
import { isValidTimeZone } from '@/localization/utils/isValidTimeZone';
describe('isValidTimeZone', () => {
it('should return true for valid IANA timezones', () => {
expect(isValidTimeZone('America/New_York')).toBe(true);
expect(isValidTimeZone('Europe/Paris')).toBe(true);
expect(isValidTimeZone('UTC')).toBe(true);
expect(isValidTimeZone('Asia/Tokyo')).toBe(true);
});
it('should return false for invalid timezones', () => {
expect(isValidTimeZone('ETC/UNKNOWN')).toBe(false);
expect(isValidTimeZone('Invalid/Timezone')).toBe(false);
expect(isValidTimeZone('Not_A_Timezone')).toBe(false);
expect(isValidTimeZone('')).toBe(false);
});
});
@@ -0,0 +1,9 @@
export const isValidTimeZone = (timeZone: string): boolean => {
try {
Intl.DateTimeFormat(undefined, { timeZone });
return true;
} catch {
return false;
}
};
@@ -99,4 +99,49 @@ describe('useUserTimezone', () => {
expect(result.current.userTimezone).toBe(userTimezone);
expect(result.current.isSystemTimezone).toBe(false);
});
it('should fall back to system timezone when currentWorkspaceMember.timeZone is invalid', () => {
// Restore real Intl so isValidTimeZone can properly reject invalid timezones
global.Intl = originalIntl;
jotaiStore.set(currentWorkspaceMemberState.atom, {
id: 'workspace-member-id',
name: {
firstName: 'John',
lastName: 'Doe',
},
colorScheme: 'Light',
locale: 'en-US',
userEmail: 'john@example.com',
timeZone: 'ETC/UNKNOWN',
dateFormat: null,
timeFormat: null,
numberFormat: null,
calendarStartDay: null,
});
const WrapperWithInvalidTimezone = ({
children,
}: {
children: React.ReactNode;
}) => <JotaiProvider store={jotaiStore}>{children}</JotaiProvider>;
const { result } = renderHook(() => useUserTimezone(), {
wrapper: WrapperWithInvalidTimezone,
});
const realSystemTimeZone =
Intl.DateTimeFormat().resolvedOptions().timeZone;
expect(result.current.userTimezone).toBe(realSystemTimeZone);
expect(result.current.isSystemTimezone).toBe(true);
// Re-apply mock for other tests
global.Intl = {
...originalIntl,
DateTimeFormat: jest.fn().mockImplementation(() => ({
resolvedOptions: () => ({ timeZone: mockSystemTimezone }),
})),
} as any;
});
});
@@ -1,13 +1,19 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { isValidTimeZone } from '@/localization/utils/isValidTimeZone';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useUserTimezone = () => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const memberTimeZone = currentWorkspaceMember?.timeZone;
const userTimezone =
currentWorkspaceMember?.timeZone !== 'system'
? (currentWorkspaceMember?.timeZone ?? systemTimeZone)
memberTimeZone !== 'system' &&
memberTimeZone !== undefined &&
memberTimeZone !== null &&
isValidTimeZone(memberTimeZone)
? memberTimeZone
: systemTimeZone;
const isSystemTimezone = userTimezone === systemTimeZone;