From fab4ceba9ae5249f8f2ac9e327cc1cacc3320cb8 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 24 Aug 2026 16:08:48 -0400 Subject: [PATCH 01/11] feat(js): report browser timezone --- .changeset/calm-clocks-travel.md | 7 ++ packages/backend/src/api/endpoints/UserApi.ts | 4 + packages/backend/src/api/resources/JSON.ts | 4 + packages/backend/src/api/resources/User.ts | 3 + .../clerk-js/src/core/resources/SignIn.ts | 16 +++- .../clerk-js/src/core/resources/SignUp.ts | 40 +++++++-- packages/clerk-js/src/core/resources/User.ts | 3 + .../core/resources/__tests__/SignIn.test.ts | 88 +++++++++++++++++++ .../core/resources/__tests__/SignUp.test.ts | 70 +++++++++++++++ .../src/utils/__tests__/timezone.test.ts | 49 +++++++++++ packages/clerk-js/src/utils/index.ts | 1 + packages/clerk-js/src/utils/timezone.ts | 13 +++ packages/shared/src/types/json.ts | 2 + packages/shared/src/types/signIn.ts | 2 + packages/shared/src/types/signInCommon.ts | 1 + packages/shared/src/types/signInFuture.ts | 4 + packages/shared/src/types/signUp.ts | 1 + packages/shared/src/types/signUpCommon.ts | 1 + packages/shared/src/types/signUpFuture.ts | 3 + packages/shared/src/types/user.ts | 2 + 20 files changed, 306 insertions(+), 8 deletions(-) create mode 100644 .changeset/calm-clocks-travel.md create mode 100644 packages/clerk-js/src/utils/__tests__/timezone.test.ts create mode 100644 packages/clerk-js/src/utils/timezone.ts diff --git a/.changeset/calm-clocks-travel.md b/.changeset/calm-clocks-travel.md new file mode 100644 index 00000000000..01620618a66 --- /dev/null +++ b/.changeset/calm-clocks-travel.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/backend': patch +--- + +Capture authentication timezones so Clerk emails can display timestamps in a stored user timezone. diff --git a/packages/backend/src/api/endpoints/UserApi.ts b/packages/backend/src/api/endpoints/UserApi.ts index 4c9a73766a3..4e7256c525d 100644 --- a/packages/backend/src/api/endpoints/UserApi.ts +++ b/packages/backend/src/api/endpoints/UserApi.ts @@ -251,6 +251,8 @@ export type CreateUserParams = { lastName?: string; /** The locale of the user in BCP-47 format (e.g., `'en-US'`, `'fr-FR'`). */ locale?: string; + /** The timezone of the user. */ + timezone?: string; /** When set to `true`, all password checks are skipped. It is recommended to use this method only when migrating plaintext passwords to Clerk. Upon migration the user base should be prompted to pick stronger password. */ skipPasswordChecks?: boolean; /** When set to `true`, password is not required anymore when creating the user and can be omitted. This is useful when you are trying to create a user that doesn't have a password, in an instance that is using passwords. **You cannot use this flag if password is the only way for a user to sign into your instance.** */ @@ -324,6 +326,8 @@ export type UpdateUserParams = { legalAcceptedAt?: Date; /** The locale of the user in BCP-47 format (e.g., `'en-US'`). */ locale?: string; + /** The timezone of the user. */ + timezone?: string; /** If `true`, the user can delete themselves with the Frontend API. */ deleteSelfEnabled?: boolean; /** If `true`, the user can create Organizations with the Frontend API. */ diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 157313685b9..ca744df6376 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -707,6 +707,10 @@ export interface UserJSON extends ClerkResourceJSON { * The locale of the user in BCP-47 format. */ locale: string | null; + /** + * The timezone of the user. + */ + timezone: string | null; } export interface VerificationJSON extends ClerkResourceJSON { diff --git a/packages/backend/src/api/resources/User.ts b/packages/backend/src/api/resources/User.ts index acfcff22858..6a3159dd9d8 100644 --- a/packages/backend/src/api/resources/User.ts +++ b/packages/backend/src/api/resources/User.ts @@ -83,6 +83,8 @@ export class User { /** The locale of the user in BCP-47 format. */ readonly locale: string | null, + /** The timezone of the user. */ + readonly timezone: string | null = null, ) {} static fromJSON(data: UserJSON): User { @@ -120,6 +122,7 @@ export class User { data.delete_self_enabled, data.legal_accepted_at, data.locale, + data.timezone, ); res._raw = data; return res; diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index d6369a138a8..44830d32555 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -78,7 +78,7 @@ import { import { debugLogger } from '@/utils/debug'; -import { getBrowserLocale, web3 } from '../../utils'; +import { getBrowserLocale, getBrowserTimezone, web3 } from '../../utils'; import { _authenticateWithPopup, _futureAuthenticateWithPopup, @@ -126,6 +126,7 @@ export class SignIn extends BaseResource implements SignInResource { userData: UserData = new UserData(null); clientTrustState?: ClientTrustState; protectCheck: ProtectCheckResource | null = null; + timezone: string | null = null; /** * The current status of the sign-in process. @@ -199,6 +200,13 @@ export class SignIn extends BaseResource implements SignInResource { body.locale = browserLocale; } + if (body.timezone === undefined) { + const browserTimezone = getBrowserTimezone(); + if (browserTimezone) { + body.timezone = browserTimezone; + } + } + if ( this.shouldRequireCaptcha(params) && !__BUILD_DISABLE_RHC__ && @@ -657,6 +665,7 @@ export class SignIn extends BaseResource implements SignInResource { uiHints: data.protect_check.ui_hints, } : null; + this.timezone = data.timezone ?? null; } eventBus.emit('resource:update', { resource: this }); @@ -718,6 +727,7 @@ export class SignIn extends BaseResource implements SignInResource { identifier: this.identifier, created_session_id: this.createdSessionId, user_data: this.userData.__internal_toSnapshot(), + timezone: this.timezone, protect_check: this.protectCheck ? { status: this.protectCheck.status, @@ -1036,6 +1046,7 @@ class SignInFuture implements SignInFutureResource { private async _create(params: SignInFutureCreateParams): Promise { const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(params); + const timezone = params.timezone ?? getBrowserTimezone(); const body: Record = { ...params, @@ -1043,6 +1054,7 @@ class SignInFuture implements SignInFutureResource { captchaWidgetType, captchaError, locale: getBrowserLocale() || undefined, + ...(timezone !== null ? { timezone } : {}), }; await this.#resource.__internal_basePost({ @@ -1067,12 +1079,14 @@ class SignInFuture implements SignInFutureResource { const identifier = params.identifier || params.emailAddress || params.phoneNumber; const previousIdentifier = this.#resource.identifier; const locale = getBrowserLocale(); + const timezone = this.#resource.id ? null : (params.timezone ?? getBrowserTimezone()); await this.#resource.__internal_basePost({ path: this.#resource.pathRoot, body: { identifier: identifier || previousIdentifier, password: params.password, ...(locale ? { locale } : {}), + ...(timezone !== null ? { timezone } : {}), }, }); }); diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 32f8e625239..12b8e50a621 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -50,7 +50,7 @@ import type { import { debugLogger } from '@/utils/debug'; -import { getBrowserLocale, getClerkQueryParam, web3 } from '../../utils'; +import { getBrowserLocale, getBrowserTimezone, getClerkQueryParam, web3 } from '../../utils'; import { _authenticateWithPopup, _futureAuthenticateWithPopup, @@ -101,6 +101,7 @@ export class SignUp extends BaseResource implements SignUpResource { abandonAt: number | null = null; legalAcceptedAt: number | null = null; locale: string | null = null; + timezone: string | null = null; /** * The current status of the sign-up process. @@ -168,6 +169,13 @@ export class SignUp extends BaseResource implements SignUpResource { } } + if (finalParams.timezone === undefined) { + const browserTimezone = getBrowserTimezone(); + if (browserTimezone) { + finalParams.timezone = browserTimezone; + } + } + if (!__BUILD_DISABLE_RHC__ && !this.clientBypass() && !this.shouldBypassCaptchaForAttempt(params)) { const captchaChallenge = new CaptchaChallenge(SignUp.clerk); const captchaParams = await captchaChallenge.managedOrInvisible({ action: 'signup' }); @@ -550,6 +558,7 @@ export class SignUp extends BaseResource implements SignUpResource { this.web3wallet = data.web3_wallet; this.legalAcceptedAt = data.legal_accepted_at; this.locale = data.locale; + this.timezone = data.timezone ?? null; } eventBus.emit('resource:update', { resource: this }); @@ -592,6 +601,7 @@ export class SignUp extends BaseResource implements SignUpResource { web3_wallet: this.web3wallet, legal_accepted_at: this.legalAcceptedAt, locale: this.locale, + timezone: this.timezone, external_account: this.externalAccount, external_account_strategy: this.externalAccount?.strategy, }; @@ -825,6 +835,10 @@ class SignUpFuture implements SignUpFutureResource { return this.#resource.locale; } + get timezone() { + return this.#resource.timezone; + } + get unverifiedFields() { return this.#resource.unverifiedFields; } @@ -914,6 +928,7 @@ class SignUpFuture implements SignUpFutureResource { private async _create(params: SignUpFutureCreateParams): Promise { const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken(params); + const timezone = params.timezone ?? getBrowserTimezone(); const body: Record = { transfer: params.transfer, @@ -923,6 +938,7 @@ class SignUpFuture implements SignUpFutureResource { ...params, unsafeMetadata: params.unsafeMetadata ? normalizeUnsafeMetadata(params.unsafeMetadata) : undefined, locale: params.locale ?? getBrowserLocale(), + ...(timezone !== null ? { timezone } : {}), }; await this.#resource.__internal_basePost({ path: this.#resource.pathRoot, body }); @@ -961,9 +977,13 @@ class SignUpFuture implements SignUpFutureResource { if (this.#resource.id) { await this.#resource.__internal_basePatch({ body }); } else { - // Inject browser locale only when creating the sign-up, so an existing - // sign-up's locale is not overwritten on update. + // Inject browser locale and timezone only when creating the sign-up, so an existing + // sign-up's values are not overwritten on update. body.locale = params.locale ?? getBrowserLocale(); + const timezone = params.timezone ?? getBrowserTimezone(); + if (timezone !== null) { + body.timezone = timezone; + } await this.#resource.__internal_basePost({ path: this.#resource.pathRoot, body }); } }); @@ -1063,6 +1083,7 @@ class SignUpFuture implements SignUpFutureResource { emailAddress, popup, locale, + timezone, } = params; return runAsyncResourceTask(this.#resource, async () => { const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken({ strategy }); @@ -1100,13 +1121,18 @@ class SignUpFuture implements SignUpFutureResource { captchaWidgetType, captchaError, locale, + ...(timezone !== undefined ? { timezone } : {}), }; if (this.#resource.id) { return this.#resource.__internal_basePatch({ body }); } - // Inject browser locale only when creating the sign-up, so an existing - // sign-up's locale is not overwritten on update. + // Inject browser locale and timezone only when creating the sign-up, so an existing + // sign-up's values are not overwritten on update. body.locale = locale ?? getBrowserLocale(); + const browserTimezone = timezone ?? getBrowserTimezone(); + if (browserTimezone !== null) { + body.timezone = browserTimezone; + } return this.#resource.__internal_basePost({ path: this.#resource.pathRoot, body }); }; @@ -1134,7 +1160,7 @@ class SignUpFuture implements SignUpFutureResource { } async web3(params: SignUpFutureWeb3Params): Promise<{ error: ClerkError | null }> { - const { strategy, unsafeMetadata, legalAccepted, firstName, lastName, locale } = params; + const { strategy, unsafeMetadata, legalAccepted, firstName, lastName, locale, timezone } = params; const provider = strategy.replace('web3_', '').replace('_signature', '') as Web3Provider; return runAsyncResourceTask(this.#resource, async () => { @@ -1163,7 +1189,7 @@ class SignUpFuture implements SignUpFutureResource { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const web3Wallet = identifier || this.#resource.web3wallet!; - await this._create({ web3Wallet, unsafeMetadata, legalAccepted, firstName, lastName, locale }); + await this._create({ web3Wallet, unsafeMetadata, legalAccepted, firstName, lastName, locale, timezone }); await this.#resource.__internal_basePost({ body: { strategy }, action: 'prepare_verification', diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index ee4da919e6f..77af8b62a29 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -99,6 +99,7 @@ export class User extends BaseResource implements UserResource { legalAcceptedAt: Date | null = null; updatedAt: Date | null = null; createdAt: Date | null = null; + timezone: string | null = null; private cachedSessionsWithActivities: SessionWithActivities[] | null = null; @@ -458,6 +459,7 @@ export class User extends BaseResource implements UserResource { this.createOrganizationEnabled = data.create_organization_enabled || false; this.createOrganizationsLimit = data.create_organizations_limit || null; this.deleteSelfEnabled = data.delete_self_enabled || false; + this.timezone = data.timezone ?? null; if (data.last_sign_in_at) { this.lastSignInAt = unixEpochToDate(data.last_sign_in_at); @@ -506,6 +508,7 @@ export class User extends BaseResource implements UserResource { legal_accepted_at: this.legalAcceptedAt?.getTime() || null, updated_at: this.updatedAt?.getTime() || null, created_at: this.createdAt?.getTime() || null, + timezone: this.timezone, }; } } diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 120aafdc753..5fdb6097e41 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -31,6 +31,10 @@ vi.mock('../../../utils/captcha/CaptchaChallenge', () => ({ })); describe('SignIn', () => { + beforeEach(() => { + vi.stubGlobal('Intl', undefined); + }); + it('can be serialized with JSON.stringify', () => { const signIn = new SignIn(); const snapshot = JSON.stringify(signIn); @@ -386,6 +390,90 @@ describe('SignIn', () => { ); }); + it('includes the detected timezone when creating a sign-in', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + const signIn = new SignIn(); + SignIn.clerk = { + client: { captchaBypass: false }, + __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, + } as any; + + await signIn.create({ identifier: 'user@example.com' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ timezone: 'America/New_York' }), + }), + ); + }); + + it('omits timezone when browser detection is unavailable', async () => { + vi.stubGlobal('Intl', undefined); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + const signIn = new SignIn(); + SignIn.clerk = { + client: { captchaBypass: false }, + __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, + } as any; + + await signIn.create({ identifier: 'user@example.com' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.not.objectContaining({ timezone: expect.anything() }) }), + ); + }); + + it('preserves an explicitly supplied timezone when creating a sign-in', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + const signIn = new SignIn(); + SignIn.clerk = { + client: { captchaBypass: false }, + __internal_environment: { displayConfig: { captchaOauthBypass: [] } }, + } as any; + + await signIn.create({ identifier: 'user@example.com', timezone: 'Europe/Paris' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ timezone: 'Europe/Paris' }) }), + ); + }); + + it('does not inject timezone when continuing an existing sign-in', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + const signIn = new SignIn({ id: 'signin_123' } as any); + + await signIn.prepareFirstFactor({ strategy: 'email_code', emailAddressId: 'email_123' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.not.objectContaining({ timezone: expect.anything() }) }), + ); + }); + it('includes captcha params when signUpIfMissing is true', async () => { vi.stubGlobal('__BUILD_DISABLE_RHC__', false); diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts index 0bcff445b5f..914126988d8 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -32,6 +32,10 @@ vi.mock('../../../utils/captcha/CaptchaChallenge', () => ({ })); describe('SignUp', () => { + beforeEach(() => { + vi.stubGlobal('Intl', undefined); + }); + it('can be serialized with JSON.stringify', () => { const signUp = new SignUp(); const snapshot = JSON.stringify(signUp); @@ -236,6 +240,72 @@ describe('SignUp', () => { SignUp.clerk = {} as any; }); + it('includes the detected timezone when creating a sign-up', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + + await new SignUp().create({ emailAddress: 'user@example.com' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ timezone: 'America/New_York' }) }), + ); + }); + + it('omits timezone when browser detection is unavailable', async () => { + vi.stubGlobal('Intl', undefined); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + + await new SignUp().create({ emailAddress: 'user@example.com' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.not.objectContaining({ timezone: expect.anything() }) }), + ); + }); + + it('preserves an explicitly supplied timezone when creating a sign-up', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + + await new SignUp().create({ emailAddress: 'user@example.com', timezone: 'Europe/Paris' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ timezone: 'Europe/Paris' }) }), + ); + }); + + it('does not inject timezone when updating an existing sign-up', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + + await new SignUp({ id: 'signup_123' } as any).update({ firstName: 'Ada' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.not.objectContaining({ timezone: expect.anything() }) }), + ); + }); + it.each([ { strategy: 'email_code', label: 'email_code' }, { strategy: 'email_link', label: 'email_link' }, diff --git a/packages/clerk-js/src/utils/__tests__/timezone.test.ts b/packages/clerk-js/src/utils/__tests__/timezone.test.ts new file mode 100644 index 00000000000..5177f438821 --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/timezone.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getBrowserTimezone } from '../timezone'; + +describe('getBrowserTimezone()', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns the browser timezone when available', () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + + expect(getBrowserTimezone()).toBe('America/New_York'); + }); + + it('returns null when the browser timezone is empty', () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: '' }) }), + }); + + expect(getBrowserTimezone()).toBeNull(); + }); + + it('returns null when Intl is unavailable', () => { + vi.stubGlobal('Intl', undefined); + + expect(getBrowserTimezone()).toBeNull(); + }); + + it('returns null when Intl.DateTimeFormat is unavailable', () => { + vi.stubGlobal('Intl', {}); + + expect(getBrowserTimezone()).toBeNull(); + }); + + it('returns null when resolvedOptions throws', () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ + resolvedOptions: () => { + throw new Error('timezone unavailable'); + }, + }), + }); + + expect(getBrowserTimezone()).toBeNull(); + }); +}); diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts index db9d7631927..0b1fe1d81d7 100644 --- a/packages/clerk-js/src/utils/index.ts +++ b/packages/clerk-js/src/utils/index.ts @@ -19,6 +19,7 @@ export * from '@clerk/shared/internal/clerk-js/queryStateParams'; export * from '@clerk/shared/internal/clerk-js/querystring'; export * from '@clerk/shared/internal/clerk-js/runtime'; export * from './tokenId'; +export * from './timezone'; export * from '@clerk/shared/internal/clerk-js/url'; export * from './web3'; export * from '@clerk/shared/internal/clerk-js/windowNavigate'; diff --git a/packages/clerk-js/src/utils/timezone.ts b/packages/clerk-js/src/utils/timezone.ts new file mode 100644 index 00000000000..b9f9159fba8 --- /dev/null +++ b/packages/clerk-js/src/utils/timezone.ts @@ -0,0 +1,13 @@ +import { inBrowser } from '@clerk/shared/browser'; + +export function getBrowserTimezone(): string | null { + if (!inBrowser()) { + return null; + } + try { + const timezone = Intl?.DateTimeFormat?.().resolvedOptions().timeZone; + return typeof timezone === 'string' && timezone.trim() ? timezone : null; + } catch { + return null; + } +} diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 0cb5392230a..4ee9445f620 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -147,6 +147,7 @@ export interface SignUpJSON extends ClerkResourceJSON { abandon_at: number | null; legal_accepted_at: number | null; locale: string | null; + timezone: string | null; verifications: SignUpVerificationsJSON | null; protect_check?: ProtectCheckJSON | null; } @@ -329,6 +330,7 @@ export interface UserJSON extends ClerkResourceJSON { create_organizations_limit: number | null; delete_self_enabled: boolean; legal_accepted_at: number | null; + timezone: string | null; updated_at: number; created_at: number; } diff --git a/packages/shared/src/types/signIn.ts b/packages/shared/src/types/signIn.ts index a3c77c01469..7b96b766b67 100644 --- a/packages/shared/src/types/signIn.ts +++ b/packages/shared/src/types/signIn.ts @@ -66,6 +66,7 @@ export interface SignInResource extends ClerkResource { * upgrading the SDK alone does not enable it. */ protectCheck: ProtectCheckResource | null; + timezone: string | null; create: (params: SignInCreateParams) => Promise; @@ -136,4 +137,5 @@ export interface SignInJSON extends ClerkResourceJSON { second_factor_verification: VerificationJSON | null; created_session_id: string | null; protect_check?: ProtectCheckJSON | null; + timezone: string | null; } diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 8e1fb480c29..eb985c4eb76 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -169,6 +169,7 @@ export type SignInCreateParams = ( ) & { transfer?: boolean; signUpIfMissing?: boolean; + timezone?: string; }; export type ResetPasswordParams = { diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index fa6bb6089d5..afc44f3b8c7 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -9,6 +9,8 @@ import type { Web3Provider } from './web3'; /** @generateWithEmptyComment */ export interface SignInFutureCreateParams { + /** The timezone to assign to the user. If omitted, defaults to the browser's timezone. */ + timezone?: string; /** * The authentication identifier for the sign-in. This can be the value of the user's email address, phone number, username, or Web3 wallet address. */ @@ -58,6 +60,8 @@ export type SignInFuturePasswordParams = { * [password](https://clerk.com/docs/guides/configure/auth-strategies/sign-up-sign-in-options#password) is enabled. */ password: string; + /** The timezone to assign when this starts a new sign-in. If omitted, defaults to the browser's timezone. */ + timezone?: string; } & ( | { /** diff --git a/packages/shared/src/types/signUp.ts b/packages/shared/src/types/signUp.ts index 92d4bd31c1e..753bc99c3a8 100644 --- a/packages/shared/src/types/signUp.ts +++ b/packages/shared/src/types/signUp.ts @@ -71,6 +71,7 @@ export interface SignUpResource extends ClerkResource { abandonAt: number | null; legalAcceptedAt: number | null; locale: string | null; + timezone: string | null; create: (params: SignUpCreateParams) => Promise; diff --git a/packages/shared/src/types/signUpCommon.ts b/packages/shared/src/types/signUpCommon.ts index 699ec380869..02054314ac7 100644 --- a/packages/shared/src/types/signUpCommon.ts +++ b/packages/shared/src/types/signUpCommon.ts @@ -136,6 +136,7 @@ export type SignUpCreateParams = Partial< oidcLoginHint: string; channel: PhoneCodeChannel; locale?: string; + timezone?: string; } & Omit>, 'legalAccepted'> >; diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index 8a912861d48..e7e36c7c6ad 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -41,6 +41,8 @@ export interface SignUpFutureAdditionalParams { * The locale to assign to the user in [BCP 47](https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag) format (e.g., "en-US", "fr-FR"). If omitted, defaults to the browser's locale. */ locale?: string; + /** The timezone to assign to the user. If omitted, defaults to the browser's timezone. */ + timezone?: string; } /** @generateWithEmptyComment */ @@ -474,6 +476,7 @@ export interface SignUpFutureResource { * The locale of the user in [BCP 47](https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag) format (e.g., "en-US", "fr-FR"), or `null` if not set. */ readonly locale: string | null; + readonly timezone: string | null; /** * The current protect check challenge, if one is pending. Only populated when Protect mid-flow diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts index 1a25132bf65..3bd639571d3 100644 --- a/packages/shared/src/types/user.ts +++ b/packages/shared/src/types/user.ts @@ -200,6 +200,8 @@ export interface UserResource extends ClerkResource, BillingPayerMethods { * The date and time when the user was created. */ createdAt: Date | null; + /** The user's timezone. */ + timezone: string | null; /** * Updates the user's attributes. Use this method to save information you collected about the user. From 6123e0c4377f07bcb791ea1e22ff29ff3962404f Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 24 Aug 2026 16:19:41 -0400 Subject: [PATCH 02/11] fix(js): expose sign-in future timezone --- .../clerk-js/src/core/resources/SignIn.ts | 4 ++ .../core/resources/__tests__/SignIn.test.ts | 59 +++++++++++++++++-- .../core/resources/__tests__/SignUp.test.ts | 53 +++++++++++++++-- .../src/core/resources/__tests__/User.test.ts | 14 +++++ packages/shared/src/types/signInFuture.ts | 5 ++ 5 files changed, 123 insertions(+), 12 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 44830d32555..64af8be0d8a 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -821,6 +821,10 @@ class SignInFuture implements SignInFutureResource { return this.#resource.identifier; } + get timezone() { + return this.#resource.timezone; + } + get createdSessionId() { return this.#resource.createdSessionId; } diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 5fdb6097e41..c09988130f2 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -41,6 +41,20 @@ describe('SignIn', () => { expect(snapshot).toBeDefined(); }); + it('keeps a null timezone across JSON, resource, and snapshot representations', () => { + const signIn = new SignIn({ timezone: null } as any); + + expect(signIn.timezone).toBeNull(); + expect(signIn.__internal_toSnapshot().timezone).toBeNull(); + }); + + it('defaults a missing timezone from an older snapshot to null', () => { + const signIn = new SignIn({ id: 'signin_123' } as any); + + expect(signIn.timezone).toBeNull(); + expect(signIn.__internal_toSnapshot().timezone).toBeNull(); + }); + describe('prepareSecondFactor', () => { afterEach(() => { vi.clearAllMocks(); @@ -429,9 +443,7 @@ describe('SignIn', () => { await signIn.create({ identifier: 'user@example.com' }); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ body: expect.not.objectContaining({ timezone: expect.anything() }) }), - ); + expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); }); it('preserves an explicitly supplied timezone when creating a sign-in', async () => { @@ -469,9 +481,7 @@ describe('SignIn', () => { await signIn.prepareFirstFactor({ strategy: 'email_code', emailAddressId: 'email_123' }); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ body: expect.not.objectContaining({ timezone: expect.anything() }) }), - ); + expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); }); it('includes captcha params when signUpIfMissing is true', async () => { @@ -574,6 +584,12 @@ describe('SignIn', () => { expect(snapshot).toBeDefined(); }); + it('exposes the sign-in timezone', () => { + const signIn = new SignIn({ timezone: 'America/New_York' } as any); + + expect(signIn.__internal_future.timezone).toBe('America/New_York'); + }); + describe('selectFirstFactor', () => { beforeAll(() => { const signInCreatedJSON = { @@ -716,6 +732,21 @@ describe('SignIn', () => { }); }); + it('includes the detected timezone when creating a sign-in', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor' }, + }); + BaseResource._fetch = mockFetch; + + await new SignIn().__internal_future.create({ identifier: 'user@example.com' }); + + expect(mockFetch.mock.calls[0][0].body).toHaveProperty('timezone', 'America/New_York'); + }); + it('returns error property on success', async () => { const mockFetch = vi.fn().mockResolvedValue({ client: null, @@ -960,6 +991,22 @@ describe('SignIn', () => { }); }); + it('omits timezone when continuing an existing sign-in', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor', identifier: 'user@example.com' }, + }); + BaseResource._fetch = mockFetch; + const signIn = new SignIn({ id: 'signin_123', identifier: 'user@example.com' } as any); + + await signIn.__internal_future.password({ password: 'password123' }); + + expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); + }); + it('uses previous identifier when no identifier parameter is provided', async () => { const mockFetch = vi.fn().mockResolvedValue({ client: null, diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts index 914126988d8..aad0753af70 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -42,6 +42,20 @@ describe('SignUp', () => { expect(snapshot).toBeDefined(); }); + it('keeps a null timezone across JSON, resource, and snapshot representations', () => { + const signUp = new SignUp({ timezone: null } as any); + + expect(signUp.timezone).toBeNull(); + expect(signUp.__internal_toSnapshot().timezone).toBeNull(); + }); + + it('defaults a missing timezone from an older snapshot to null', () => { + const signUp = new SignUp({ id: 'signup_123' } as any); + + expect(signUp.timezone).toBeNull(); + expect(signUp.__internal_toSnapshot().timezone).toBeNull(); + }); + describe('prepareVerification', () => { afterEach(() => { vi.clearAllMocks(); @@ -267,9 +281,7 @@ describe('SignUp', () => { await new SignUp().create({ emailAddress: 'user@example.com' }); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ body: expect.not.objectContaining({ timezone: expect.anything() }) }), - ); + expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); }); it('preserves an explicitly supplied timezone when creating a sign-up', async () => { @@ -301,9 +313,7 @@ describe('SignUp', () => { await new SignUp({ id: 'signup_123' } as any).update({ firstName: 'Ada' }); - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ body: expect.not.objectContaining({ timezone: expect.anything() }) }), - ); + expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); }); it.each([ @@ -468,6 +478,21 @@ describe('SignUp', () => { ); }); + it('includes the detected timezone when creating a sign-up', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + + await new SignUp().__internal_future.create({ emailAddress: 'user@example.com' }); + + expect(mockFetch.mock.calls[0][0].body).toHaveProperty('timezone', 'America/New_York'); + }); + it('returns error property on success', async () => { const mockFetch = vi.fn().mockResolvedValue({ client: null, @@ -645,6 +670,22 @@ describe('SignUp', () => { }), ); }); + + it('omits timezone when updating an existing sign-up', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', first_name: 'Ada' }, + }); + BaseResource._fetch = mockFetch; + const signUp = new SignUp({ id: 'signup_123' } as any); + + await signUp.__internal_future.update({ firstName: 'Ada' }); + + expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); + }); }); describe('sendPhoneCode', () => { diff --git a/packages/clerk-js/src/core/resources/__tests__/User.test.ts b/packages/clerk-js/src/core/resources/__tests__/User.test.ts index a86d25d2e15..1b1ce59248a 100644 --- a/packages/clerk-js/src/core/resources/__tests__/User.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/User.test.ts @@ -5,6 +5,20 @@ import { BaseResource } from '../internal'; import { User } from '../User'; describe('User', () => { + it('keeps a null timezone across JSON, resource, and snapshot representations', () => { + const user = new User({ timezone: null } as unknown as UserJSON); + + expect(user.timezone).toBeNull(); + expect(user.__internal_toSnapshot().timezone).toBeNull(); + }); + + it('defaults a missing timezone from an older snapshot to null', () => { + const user = new User({} as unknown as UserJSON); + + expect(user.timezone).toBeNull(); + expect(user.__internal_toSnapshot().timezone).toBeNull(); + }); + it('creates an external account', async () => { const externalAccountJSON = { object: 'external_account', diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index afc44f3b8c7..6727e5cdf99 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -399,6 +399,11 @@ export interface SignInFutureResource { */ readonly identifier: string | null; + /** + * The timezone associated with the current sign-in, or `null` if not set. + */ + readonly timezone: string | null; + /** * The ID of the session that was created upon completion of the current sign-in. The value of this property is `null` if the sign-in status is not `'complete'`. */ From d1a7118a00771cba51f3d37bb85fbb60507d95ed Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 24 Aug 2026 17:40:55 -0400 Subject: [PATCH 03/11] fix(js): keep signup timezone creation-only --- .../clerk-js/src/core/resources/SignUp.ts | 28 ++++---- .../core/resources/__tests__/SignUp.test.ts | 66 ++++++++++++++++--- packages/shared/src/types/signUpCommon.ts | 2 +- packages/shared/src/types/signUpFuture.ts | 4 +- .../src/types/signUpTimezone.type.test.ts | 22 +++++++ 5 files changed, 97 insertions(+), 25 deletions(-) create mode 100644 packages/shared/src/types/signUpTimezone.type.test.ts diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 12b8e50a621..355a0f65070 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -76,6 +76,12 @@ declare global { } } +const withoutTimezone = (params: T): Omit => { + const body = { ...params } as T & { timezone?: unknown }; + delete body.timezone; + return body; +}; + export class SignUp extends BaseResource implements SignUpResource { pathRoot = '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/client/sign_ups'; @@ -194,7 +200,7 @@ export class SignUp extends BaseResource implements SignUpResource { prepareVerification = (params: PrepareVerificationParams): Promise => { debugLogger.debug('SignUp.prepareVerification', { id: this.id, strategy: params.strategy }); return this._basePost({ - body: params, + body: withoutTimezone(params), action: 'prepare_verification', coalesce: true, }); @@ -203,7 +209,7 @@ export class SignUp extends BaseResource implements SignUpResource { attemptVerification = (params: AttemptVerificationParams): Promise => { debugLogger.debug('SignUp.attemptVerification', { id: this.id, strategy: params.strategy }); return this._basePost({ - body: params, + body: withoutTimezone(params), action: 'attempt_verification', }); }; @@ -510,7 +516,7 @@ export class SignUp extends BaseResource implements SignUpResource { update = (params: SignUpUpdateParams): Promise => { return this._basePatch({ - body: normalizeUnsafeMetadata(params), + body: normalizeUnsafeMetadata(withoutTimezone(params)), }); }; @@ -953,7 +959,7 @@ class SignUpFuture implements SignUpFutureResource { async update(params: SignUpFutureUpdateParams): Promise<{ error: ClerkError | null }> { return runAsyncResourceTask(this.#resource, async () => { const body: Record = { - ...params, + ...withoutTimezone(params), unsafeMetadata: params.unsafeMetadata ? normalizeUnsafeMetadata(params.unsafeMetadata) : undefined, }; @@ -970,7 +976,7 @@ class SignUpFuture implements SignUpFutureResource { captchaToken, captchaWidgetType, captchaError, - ...params, + ...withoutTimezone(params), unsafeMetadata: params.unsafeMetadata ? normalizeUnsafeMetadata(params.unsafeMetadata) : undefined, }; @@ -980,7 +986,7 @@ class SignUpFuture implements SignUpFutureResource { // Inject browser locale and timezone only when creating the sign-up, so an existing // sign-up's values are not overwritten on update. body.locale = params.locale ?? getBrowserLocale(); - const timezone = params.timezone ?? getBrowserTimezone(); + const timezone = getBrowserTimezone(); if (timezone !== null) { body.timezone = timezone; } @@ -1083,7 +1089,6 @@ class SignUpFuture implements SignUpFutureResource { emailAddress, popup, locale, - timezone, } = params; return runAsyncResourceTask(this.#resource, async () => { const { captchaToken, captchaWidgetType, captchaError } = await this.getCaptchaToken({ strategy }); @@ -1121,7 +1126,6 @@ class SignUpFuture implements SignUpFutureResource { captchaWidgetType, captchaError, locale, - ...(timezone !== undefined ? { timezone } : {}), }; if (this.#resource.id) { return this.#resource.__internal_basePatch({ body }); @@ -1129,7 +1133,7 @@ class SignUpFuture implements SignUpFutureResource { // Inject browser locale and timezone only when creating the sign-up, so an existing // sign-up's values are not overwritten on update. body.locale = locale ?? getBrowserLocale(); - const browserTimezone = timezone ?? getBrowserTimezone(); + const browserTimezone = getBrowserTimezone(); if (browserTimezone !== null) { body.timezone = browserTimezone; } @@ -1160,7 +1164,7 @@ class SignUpFuture implements SignUpFutureResource { } async web3(params: SignUpFutureWeb3Params): Promise<{ error: ClerkError | null }> { - const { strategy, unsafeMetadata, legalAccepted, firstName, lastName, locale, timezone } = params; + const { strategy, unsafeMetadata, legalAccepted, firstName, lastName, locale } = params; const provider = strategy.replace('web3_', '').replace('_signature', '') as Web3Provider; return runAsyncResourceTask(this.#resource, async () => { @@ -1189,7 +1193,7 @@ class SignUpFuture implements SignUpFutureResource { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const web3Wallet = identifier || this.#resource.web3wallet!; - await this._create({ web3Wallet, unsafeMetadata, legalAccepted, firstName, lastName, locale, timezone }); + await this._create({ web3Wallet, unsafeMetadata, legalAccepted, firstName, lastName, locale }); await this.#resource.__internal_basePost({ body: { strategy }, action: 'prepare_verification', @@ -1244,7 +1248,7 @@ class SignUpFuture implements SignUpFutureResource { async ticket(params?: SignUpFutureTicketParams): Promise<{ error: ClerkError | null }> { const ticket = params?.ticket ?? getClerkQueryParam('__clerk_ticket'); - return this.create({ ...params, strategy: 'ticket', ticket: ticket ?? undefined }); + return this.create({ ...withoutTimezone(params ?? {}), strategy: 'ticket', ticket: ticket ?? undefined }); } async finalize(params?: SignUpFutureFinalizeParams): Promise<{ error: ClerkError | null }> { diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts index aad0753af70..aee6bdaab94 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -81,6 +81,21 @@ describe('SignUp', () => { await Promise.all([first, second]); }); + it('does not forward timezone during legacy verification continuation', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123' }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + await signUp.prepareVerification({ strategy: 'email_code', timezone: 'Europe/Paris' } as any); + await signUp.attemptVerification({ strategy: 'email_code', code: '123456', timezone: 'Europe/Paris' } as any); + + expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); + expect(mockFetch.mock.calls[1][0].body).not.toHaveProperty('timezone'); + }); + it('does not coalesce preparations for different verifications', async () => { const mockFetch = vi.fn().mockResolvedValue({ client: null, @@ -301,7 +316,7 @@ describe('SignUp', () => { ); }); - it('does not inject timezone when updating an existing sign-up', async () => { + it('does not forward an explicitly supplied timezone when updating an existing sign-up', async () => { vi.stubGlobal('Intl', { DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), }); @@ -311,7 +326,7 @@ describe('SignUp', () => { }); BaseResource._fetch = mockFetch; - await new SignUp({ id: 'signup_123' } as any).update({ firstName: 'Ada' }); + await new SignUp({ id: 'signup_123' } as any).update({ firstName: 'Ada', timezone: 'Europe/Paris' } as any); expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); }); @@ -493,6 +508,24 @@ describe('SignUp', () => { expect(mockFetch.mock.calls[0][0].body).toHaveProperty('timezone', 'America/New_York'); }); + it('preserves an explicitly supplied timezone when creating a sign-up', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123', status: 'missing_requirements' }, + }); + BaseResource._fetch = mockFetch; + + await new SignUp().__internal_future.create({ + emailAddress: 'user@example.com', + timezone: 'Europe/Paris', + }); + + expect(mockFetch.mock.calls[0][0].body).toHaveProperty('timezone', 'Europe/Paris'); + }); + it('returns error property on success', async () => { const mockFetch = vi.fn().mockResolvedValue({ client: null, @@ -671,7 +704,7 @@ describe('SignUp', () => { ); }); - it('omits timezone when updating an existing sign-up', async () => { + it('does not forward an explicitly supplied timezone when updating an existing sign-up', async () => { vi.stubGlobal('Intl', { DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), }); @@ -682,7 +715,7 @@ describe('SignUp', () => { BaseResource._fetch = mockFetch; const signUp = new SignUp({ id: 'signup_123' } as any); - await signUp.__internal_future.update({ firstName: 'Ada' }); + await signUp.__internal_future.update({ firstName: 'Ada', timezone: 'Europe/Paris' } as any); expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); }); @@ -1091,7 +1124,7 @@ describe('SignUp', () => { ); }); - it('does not inject browser locale when continuing an existing signup', async () => { + it('does not forward locale defaults or an explicit timezone when continuing an existing signup', async () => { vi.stubGlobal('window', { location: { origin: 'https://example.com' } }); vi.stubGlobal('navigator', { language: 'fr-FR' }); @@ -1124,7 +1157,8 @@ describe('SignUp', () => { strategy: 'oauth_google', redirectUrl: '/complete', redirectCallbackUrl: '/sso-callback', - }); + timezone: 'Europe/Paris', + } as any); expect(mockFetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -1135,6 +1169,7 @@ describe('SignUp', () => { }), }), ); + expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); }); it('continues an existing sign up via the resource URL', async () => { @@ -1694,7 +1729,10 @@ describe('SignUp', () => { vi.unstubAllGlobals(); }); - it('creates signup with password when no existing signup', async () => { + it('ignores an explicit timezone and detects the browser timezone when creating with a password', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); const mockFetch = vi.fn().mockResolvedValue({ client: null, response: { id: 'signup_123', status: 'missing_requirements' }, @@ -1702,7 +1740,10 @@ describe('SignUp', () => { BaseResource._fetch = mockFetch; const signUp = new SignUp(); - await signUp.__internal_future.password({ password: 'test-password-123' }); + await signUp.__internal_future.password({ + password: 'test-password-123', + timezone: 'Europe/Paris', + } as any); expect(mockFetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -1714,9 +1755,10 @@ describe('SignUp', () => { }), }), ); + expect(mockFetch.mock.calls[0][0].body).toHaveProperty('timezone', 'America/New_York'); }); - it('updates existing signup when already created', async () => { + it('does not forward an explicitly supplied timezone when updating an existing signup with a password', async () => { const mockFetch = vi.fn().mockResolvedValue({ client: null, response: { id: 'signup_123', status: 'missing_requirements' }, @@ -1724,7 +1766,10 @@ describe('SignUp', () => { BaseResource._fetch = mockFetch; const signUp = new SignUp({ id: 'signup_123' } as any); - await signUp.__internal_future.password({ password: 'test-password-123' }); + await signUp.__internal_future.password({ + password: 'test-password-123', + timezone: 'Europe/Paris', + } as any); // Should use PATCH to update existing signup, not POST to create a new one expect(mockFetch).toHaveBeenCalledWith( @@ -1737,6 +1782,7 @@ describe('SignUp', () => { }), }), ); + expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); }); it('returns error property on success', async () => { diff --git a/packages/shared/src/types/signUpCommon.ts b/packages/shared/src/types/signUpCommon.ts index 02054314ac7..9bf9a127275 100644 --- a/packages/shared/src/types/signUpCommon.ts +++ b/packages/shared/src/types/signUpCommon.ts @@ -140,7 +140,7 @@ export type SignUpCreateParams = Partial< } & Omit>, 'legalAccepted'> >; -export type SignUpUpdateParams = SignUpCreateParams; +export type SignUpUpdateParams = Omit; /** * @deprecated Use `SignUpAuthenticateWithWeb3Params` instead. diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index e7e36c7c6ad..31a341eb795 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -41,12 +41,12 @@ export interface SignUpFutureAdditionalParams { * The locale to assign to the user in [BCP 47](https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag) format (e.g., "en-US", "fr-FR"). If omitted, defaults to the browser's locale. */ locale?: string; - /** The timezone to assign to the user. If omitted, defaults to the browser's timezone. */ - timezone?: string; } /** @generateWithEmptyComment */ export interface SignUpFutureCreateParams extends SignUpFutureAdditionalParams { + /** The timezone to assign to the user. If omitted, defaults to the browser's timezone. */ + timezone?: string; /** * The strategy to use for the sign-up. The following strategies are supported: *
    diff --git a/packages/shared/src/types/signUpTimezone.type.test.ts b/packages/shared/src/types/signUpTimezone.type.test.ts new file mode 100644 index 00000000000..fb30007a6fd --- /dev/null +++ b/packages/shared/src/types/signUpTimezone.type.test.ts @@ -0,0 +1,22 @@ +import { expectTypeOf, test } from 'vitest'; + +import type { SignUpCreateParams, SignUpUpdateParams } from './signUpCommon'; +import type { + SignUpFuturePasswordParams, + SignUpFutureSSOParams, + SignUpFutureTicketParams, + SignUpFutureUpdateParams, + SignUpFutureWeb3Params, +} from './signUpFuture'; + +type HasTimezone = 'timezone' extends keyof T ? true : false; + +test('timezone is available only on explicit sign-up creation params', () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); +}); From bf26610f7620a2d24af09e01258bf0ab0f520cd3 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 21 Sep 2026 16:40:53 -0400 Subject: [PATCH 04/11] fix(react): expose authentication timezones through state proxies --- .changeset/calm-clocks-travel.md | 1 + .../clerk-js/src/core/resources/SignIn.ts | 3 ++- .../clerk-js/src/core/resources/SignUp.ts | 3 ++- packages/clerk-js/src/utils/index.ts | 1 - .../react/src/__tests__/stateProxy.test.ts | 21 +++++++++++++++++++ packages/react/src/stateProxy.ts | 6 ++++++ 6 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.changeset/calm-clocks-travel.md b/.changeset/calm-clocks-travel.md index 01620618a66..dd89264dee9 100644 --- a/.changeset/calm-clocks-travel.md +++ b/.changeset/calm-clocks-travel.md @@ -2,6 +2,7 @@ '@clerk/clerk-js': patch '@clerk/shared': patch '@clerk/backend': patch +'@clerk/react': patch --- Capture authentication timezones so Clerk emails can display timestamps in a stored user timezone. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 64af8be0d8a..9a49c7f7bd7 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -78,7 +78,7 @@ import { import { debugLogger } from '@/utils/debug'; -import { getBrowserLocale, getBrowserTimezone, web3 } from '../../utils'; +import { getBrowserLocale, web3 } from '../../utils'; import { _authenticateWithPopup, _futureAuthenticateWithPopup, @@ -87,6 +87,7 @@ import { import { _authenticateWithTransport } from '../../utils/authenticateWithTransport'; import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge'; import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask'; +import { getBrowserTimezone } from '../../utils/timezone'; import { loadZxcvbn } from '../../utils/zxcvbn'; import { clerkInvalidFAPIResponse, diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 355a0f65070..d690a9743e1 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -50,7 +50,7 @@ import type { import { debugLogger } from '@/utils/debug'; -import { getBrowserLocale, getBrowserTimezone, getClerkQueryParam, web3 } from '../../utils'; +import { getBrowserLocale, getClerkQueryParam, web3 } from '../../utils'; import { _authenticateWithPopup, _futureAuthenticateWithPopup, @@ -60,6 +60,7 @@ import { _authenticateWithTransport } from '../../utils/authenticateWithTranspor import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge'; import { normalizeUnsafeMetadata } from '../../utils/resourceParams'; import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask'; +import { getBrowserTimezone } from '../../utils/timezone'; import { loadZxcvbn } from '../../utils/zxcvbn'; import { clerkInvalidFAPIResponse, diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts index 0b1fe1d81d7..db9d7631927 100644 --- a/packages/clerk-js/src/utils/index.ts +++ b/packages/clerk-js/src/utils/index.ts @@ -19,7 +19,6 @@ export * from '@clerk/shared/internal/clerk-js/queryStateParams'; export * from '@clerk/shared/internal/clerk-js/querystring'; export * from '@clerk/shared/internal/clerk-js/runtime'; export * from './tokenId'; -export * from './timezone'; export * from '@clerk/shared/internal/clerk-js/url'; export * from './web3'; export * from '@clerk/shared/internal/clerk-js/windowNavigate'; diff --git a/packages/react/src/__tests__/stateProxy.test.ts b/packages/react/src/__tests__/stateProxy.test.ts index 28e3f366619..e1a8586a0d9 100644 --- a/packages/react/src/__tests__/stateProxy.test.ts +++ b/packages/react/src/__tests__/stateProxy.test.ts @@ -4,6 +4,27 @@ import { describe, expect, it, vi } from 'vitest'; import { StateProxy } from '../stateProxy'; describe('StateProxy', () => { + it.each(['signIn', 'signUp'] as const)( + 'exposes %s timezone after loading and follows the active attempt', + resource => { + const clientAttempt = { timezone: 'America/New_York' }; + let stateAttempt: { timezone: string } | null = { timezone: 'Europe/Paris' }; + const isomorphicClerk = { + loaded: false, + client: { [resource]: { __internal_future: clientAttempt } }, + __internal_state: { [`${resource}Signal`]: () => ({ [resource]: stateAttempt }) }, + }; + const proxy = new StateProxy(isomorphicClerk as any); + const attempt = resource === 'signIn' ? proxy.signInSignal().signIn : proxy.signUpSignal().signUp; + + expect(attempt.timezone).toBeNull(); + isomorphicClerk.loaded = true; + expect(attempt.timezone).toBe('Europe/Paris'); + stateAttempt = null; + expect(attempt.timezone).toBe('America/New_York'); + }, + ); + it('preserves a completed sign-in across chained calls when the client clears its sign-in attempt', async () => { const emptySignIn = { status: 'needs_identifier', diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 2cd53f6c9a4..d8bb23c6423 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -150,6 +150,9 @@ export class StateProxy implements State { get id() { return gateProperty(target, 'id', undefined); }, + get timezone() { + return gateProperty(target, 'timezone', null); + }, get supportedFirstFactors() { return gateProperty(target, 'supportedFirstFactors', []); }, @@ -269,6 +272,9 @@ export class StateProxy implements State { get id() { return gateProperty(target, 'id', undefined); }, + get timezone() { + return gateProperty(target, 'timezone', null); + }, get requiredFields() { return gateProperty(target, 'requiredFields', []); }, From f371086c91967bd16901b38764c04f310a75e978 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 21 Sep 2026 16:47:30 -0400 Subject: [PATCH 05/11] chore(clerk-js): adjust timezone bundle budgets --- packages/clerk-js/bundlewatch.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 3b53161aab2..c268208c2d0 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -2,8 +2,8 @@ "files": [ { "path": "./dist/clerk.js", "maxSize": "554KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "81KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "122.5KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "320KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "123KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "320.25KB" }, { "path": "./dist/clerk.native.js", "maxSize": "80KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, From 4a8542f8b0fb946f98959dfd4305987dd9c70603 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 21 Sep 2026 17:02:12 -0400 Subject: [PATCH 06/11] test(shared): update user timezone documentation snapshot --- .typedoc/__tests__/__snapshots__/user-resource-properties.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/.typedoc/__tests__/__snapshots__/user-resource-properties.mdx b/.typedoc/__tests__/__snapshots__/user-resource-properties.mdx index 92cba6df7d8..f6524cf2521 100644 --- a/.typedoc/__tests__/__snapshots__/user-resource-properties.mdx +++ b/.typedoc/__tests__/__snapshots__/user-resource-properties.mdx @@ -28,6 +28,7 @@ | `primaryWeb3Wallet` | null \| [Web3WalletResource](/docs/reference/types/web3-wallet) | The user's primary Web3 wallet. | | `primaryWeb3WalletId` | null \| string | The ID of the user's primary Web3 wallet. | | `publicMetadata` | [UserPublicMetadata](/docs/reference/types/metadata#userpublicmetadata) | Metadata that can be read from the Frontend API and Backend API and can be set only from the Backend API. | +| `timezone` | null \| string | The user's timezone. | | `totpEnabled` | `boolean` | Indicates whether the user has enabled TOTP. | | `twoFactorEnabled` | `boolean` | Indicates whether the user has enabled two-factor authentication. | | `unsafeMetadata` | [UserUnsafeMetadata](/docs/reference/types/metadata#userunsafemetadata) | Metadata that can be read and set from the Frontend API. It's considered unsafe because it can be modified from the frontend. There is also an `unsafeMetadata` attribute in the [`SignUp`](/docs/reference/objects/sign-up-future) object. The value of that field will be automatically copied to the user's unsafe metadata once the sign-up is complete. | From e9910c7b8602b0d4b4603af96b95c6b403243a96 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 21 Sep 2026 17:15:12 -0400 Subject: [PATCH 07/11] docs(shared): document browser timezone defaults for auth creation --- packages/shared/src/types/signInCommon.ts | 3 +++ packages/shared/src/types/signUpCommon.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index eb985c4eb76..844376484f2 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -169,6 +169,9 @@ export type SignInCreateParams = ( ) & { transfer?: boolean; signUpIfMissing?: boolean; + /** + * An IANA timezone for this sign-in. Defaults to the browser's timezone when omitted, if available. + */ timezone?: string; }; diff --git a/packages/shared/src/types/signUpCommon.ts b/packages/shared/src/types/signUpCommon.ts index 9bf9a127275..da860fe5533 100644 --- a/packages/shared/src/types/signUpCommon.ts +++ b/packages/shared/src/types/signUpCommon.ts @@ -136,6 +136,9 @@ export type SignUpCreateParams = Partial< oidcLoginHint: string; channel: PhoneCodeChannel; locale?: string; + /** + * An IANA timezone for this sign-up. Defaults to the browser's timezone when omitted, if available. + */ timezone?: string; } & Omit>, 'legalAccepted'> >; From 8ebac9d421ef2c7baf6b150c58cfffce37b07d6d Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 23 Sep 2026 11:18:40 -0400 Subject: [PATCH 08/11] fix(js): keep sign-in timezone on password attempts --- .../clerk-js/src/core/resources/SignIn.ts | 2 +- .../core/resources/__tests__/SignIn.test.ts | 24 +++++++++++++++++-- packages/shared/src/types/signInFuture.ts | 2 +- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 9a49c7f7bd7..b0a08a978a9 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -1084,7 +1084,7 @@ class SignInFuture implements SignInFutureResource { const identifier = params.identifier || params.emailAddress || params.phoneNumber; const previousIdentifier = this.#resource.identifier; const locale = getBrowserLocale(); - const timezone = this.#resource.id ? null : (params.timezone ?? getBrowserTimezone()); + const timezone = params.timezone ?? this.#resource.timezone ?? getBrowserTimezone(); await this.#resource.__internal_basePost({ path: this.#resource.pathRoot, body: { diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index c09988130f2..a33c40c4afb 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -991,7 +991,27 @@ describe('SignIn', () => { }); }); - it('omits timezone when continuing an existing sign-in', async () => { + it('reuses the timezone captured by an existing sign-in', async () => { + vi.stubGlobal('Intl', { + DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), + }); + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signin_123', status: 'needs_first_factor', identifier: 'user@example.com' }, + }); + BaseResource._fetch = mockFetch; + const signIn = new SignIn({ + id: 'signin_123', + identifier: 'user@example.com', + timezone: 'Europe/Paris', + } as any); + + await signIn.__internal_future.password({ password: 'password123' }); + + expect(mockFetch.mock.calls[0][0].body).toHaveProperty('timezone', 'Europe/Paris'); + }); + + it('falls back to the browser timezone when an existing sign-in has none', async () => { vi.stubGlobal('Intl', { DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), }); @@ -1004,7 +1024,7 @@ describe('SignIn', () => { await signIn.__internal_future.password({ password: 'password123' }); - expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); + expect(mockFetch.mock.calls[0][0].body).toHaveProperty('timezone', 'America/New_York'); }); it('uses previous identifier when no identifier parameter is provided', async () => { diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 6727e5cdf99..b8186f111ba 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -60,7 +60,7 @@ export type SignInFuturePasswordParams = { * [password](https://clerk.com/docs/guides/configure/auth-strategies/sign-up-sign-in-options#password) is enabled. */ password: string; - /** The timezone to assign when this starts a new sign-in. If omitted, defaults to the browser's timezone. */ + /** The timezone to assign to the new sign-in attempt. If omitted, reuses the current sign-in's timezone, then defaults to the browser's timezone. */ timezone?: string; } & ( | { From 94ef062300f3ef287bd55c51f33a6c1126639097 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 23 Sep 2026 14:01:13 -0400 Subject: [PATCH 09/11] refactor(js): only strip sign-up timezone on update paths Prepare, attempt, and ticket params are already typed without timezone, matching SignIn. Keep the guard where create params can reach a PATCH (e.g. upsert) and document why. --- packages/clerk-js/src/core/resources/SignUp.ts | 8 +++++--- .../src/core/resources/__tests__/SignUp.test.ts | 15 --------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index d690a9743e1..aa77d5b12df 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -77,6 +77,8 @@ declare global { } } +// `timezone` is create-only and FAPI ignores it on PATCH. Strip it from update bodies built from +// reused create params (e.g. `upsert`) so it's clear an update can't change it. const withoutTimezone = (params: T): Omit => { const body = { ...params } as T & { timezone?: unknown }; delete body.timezone; @@ -201,7 +203,7 @@ export class SignUp extends BaseResource implements SignUpResource { prepareVerification = (params: PrepareVerificationParams): Promise => { debugLogger.debug('SignUp.prepareVerification', { id: this.id, strategy: params.strategy }); return this._basePost({ - body: withoutTimezone(params), + body: params, action: 'prepare_verification', coalesce: true, }); @@ -210,7 +212,7 @@ export class SignUp extends BaseResource implements SignUpResource { attemptVerification = (params: AttemptVerificationParams): Promise => { debugLogger.debug('SignUp.attemptVerification', { id: this.id, strategy: params.strategy }); return this._basePost({ - body: withoutTimezone(params), + body: params, action: 'attempt_verification', }); }; @@ -1249,7 +1251,7 @@ class SignUpFuture implements SignUpFutureResource { async ticket(params?: SignUpFutureTicketParams): Promise<{ error: ClerkError | null }> { const ticket = params?.ticket ?? getClerkQueryParam('__clerk_ticket'); - return this.create({ ...withoutTimezone(params ?? {}), strategy: 'ticket', ticket: ticket ?? undefined }); + return this.create({ ...params, strategy: 'ticket', ticket: ticket ?? undefined }); } async finalize(params?: SignUpFutureFinalizeParams): Promise<{ error: ClerkError | null }> { diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts index aee6bdaab94..e758bf7050d 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -81,21 +81,6 @@ describe('SignUp', () => { await Promise.all([first, second]); }); - it('does not forward timezone during legacy verification continuation', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - client: null, - response: { id: 'signup_123' }, - }); - BaseResource._fetch = mockFetch; - - const signUp = new SignUp({ id: 'signup_123' } as any); - await signUp.prepareVerification({ strategy: 'email_code', timezone: 'Europe/Paris' } as any); - await signUp.attemptVerification({ strategy: 'email_code', code: '123456', timezone: 'Europe/Paris' } as any); - - expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); - expect(mockFetch.mock.calls[1][0].body).not.toHaveProperty('timezone'); - }); - it('does not coalesce preparations for different verifications', async () => { const mockFetch = vi.fn().mockResolvedValue({ client: null, From 1c7d99ed1859753dbeeccdc2775d14cb850417c2 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 23 Sep 2026 14:12:01 -0400 Subject: [PATCH 10/11] refactor(js): drop sign-up timezone stripping on update FAPI ignores timezone on sign-up PATCH (no unknown-param check, value is create-only), so stripping it client-side guards against nothing. --- .../clerk-js/src/core/resources/SignUp.ts | 14 +---- .../core/resources/__tests__/SignUp.test.ts | 58 ------------------- 2 files changed, 3 insertions(+), 69 deletions(-) diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index aa77d5b12df..85f3ab77bcc 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -77,14 +77,6 @@ declare global { } } -// `timezone` is create-only and FAPI ignores it on PATCH. Strip it from update bodies built from -// reused create params (e.g. `upsert`) so it's clear an update can't change it. -const withoutTimezone = (params: T): Omit => { - const body = { ...params } as T & { timezone?: unknown }; - delete body.timezone; - return body; -}; - export class SignUp extends BaseResource implements SignUpResource { pathRoot = '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/client/sign_ups'; @@ -519,7 +511,7 @@ export class SignUp extends BaseResource implements SignUpResource { update = (params: SignUpUpdateParams): Promise => { return this._basePatch({ - body: normalizeUnsafeMetadata(withoutTimezone(params)), + body: normalizeUnsafeMetadata(params), }); }; @@ -962,7 +954,7 @@ class SignUpFuture implements SignUpFutureResource { async update(params: SignUpFutureUpdateParams): Promise<{ error: ClerkError | null }> { return runAsyncResourceTask(this.#resource, async () => { const body: Record = { - ...withoutTimezone(params), + ...params, unsafeMetadata: params.unsafeMetadata ? normalizeUnsafeMetadata(params.unsafeMetadata) : undefined, }; @@ -979,7 +971,7 @@ class SignUpFuture implements SignUpFutureResource { captchaToken, captchaWidgetType, captchaError, - ...withoutTimezone(params), + ...params, unsafeMetadata: params.unsafeMetadata ? normalizeUnsafeMetadata(params.unsafeMetadata) : undefined, }; diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts index e758bf7050d..40417c4765f 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -301,21 +301,6 @@ describe('SignUp', () => { ); }); - it('does not forward an explicitly supplied timezone when updating an existing sign-up', async () => { - vi.stubGlobal('Intl', { - DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), - }); - const mockFetch = vi.fn().mockResolvedValue({ - client: null, - response: { id: 'signup_123', status: 'missing_requirements' }, - }); - BaseResource._fetch = mockFetch; - - await new SignUp({ id: 'signup_123' } as any).update({ firstName: 'Ada', timezone: 'Europe/Paris' } as any); - - expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); - }); - it.each([ { strategy: 'email_code', label: 'email_code' }, { strategy: 'email_link', label: 'email_link' }, @@ -688,22 +673,6 @@ describe('SignUp', () => { }), ); }); - - it('does not forward an explicitly supplied timezone when updating an existing sign-up', async () => { - vi.stubGlobal('Intl', { - DateTimeFormat: () => ({ resolvedOptions: () => ({ timeZone: 'America/New_York' }) }), - }); - const mockFetch = vi.fn().mockResolvedValue({ - client: null, - response: { id: 'signup_123', first_name: 'Ada' }, - }); - BaseResource._fetch = mockFetch; - const signUp = new SignUp({ id: 'signup_123' } as any); - - await signUp.__internal_future.update({ firstName: 'Ada', timezone: 'Europe/Paris' } as any); - - expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); - }); }); describe('sendPhoneCode', () => { @@ -1743,33 +1712,6 @@ describe('SignUp', () => { expect(mockFetch.mock.calls[0][0].body).toHaveProperty('timezone', 'America/New_York'); }); - it('does not forward an explicitly supplied timezone when updating an existing signup with a password', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - client: null, - response: { id: 'signup_123', status: 'missing_requirements' }, - }); - BaseResource._fetch = mockFetch; - - const signUp = new SignUp({ id: 'signup_123' } as any); - await signUp.__internal_future.password({ - password: 'test-password-123', - timezone: 'Europe/Paris', - } as any); - - // Should use PATCH to update existing signup, not POST to create a new one - expect(mockFetch).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'PATCH', - path: '/client/sign_ups/signup_123', - body: expect.objectContaining({ - strategy: 'password', - password: 'test-password-123', - }), - }), - ); - expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); - }); - it('returns error property on success', async () => { const mockFetch = vi.fn().mockResolvedValue({ client: null, From 7175ec3c1fbb8fd109f78c331077df68f466d9c8 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Wed, 23 Sep 2026 14:20:22 -0400 Subject: [PATCH 11/11] docs(shared): note upsert only applies timezone on create --- packages/shared/src/types/signUp.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/shared/src/types/signUp.ts b/packages/shared/src/types/signUp.ts index 753bc99c3a8..9f0d6b06563 100644 --- a/packages/shared/src/types/signUp.ts +++ b/packages/shared/src/types/signUp.ts @@ -77,6 +77,10 @@ export interface SignUpResource extends ClerkResource { update: (params: SignUpUpdateParams) => Promise; + /** + * Updates the current sign-up if it exists, otherwise creates one. + * `timezone` only applies when a sign-up is created and is ignored when updating an existing one. + */ upsert: (params: SignUpCreateParams | SignUpUpdateParams) => Promise; prepareVerification: (params: PrepareVerificationParams) => Promise;