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 72328cc3ed8..872fe143cfa 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 e1ff98e2ee1..f00c87893a0 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -706,6 +706,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 c2de93a5030..313be02906e 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, @@ -125,6 +125,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. @@ -198,6 +199,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__ && @@ -653,6 +661,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 }); @@ -713,6 +722,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, @@ -806,6 +816,10 @@ class SignInFuture implements SignInFutureResource { return this.#resource.identifier; } + get timezone() { + return this.#resource.timezone; + } + get createdSessionId() { return this.#resource.createdSessionId; } @@ -1027,6 +1041,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, @@ -1034,6 +1049,7 @@ class SignInFuture implements SignInFutureResource { captchaWidgetType, captchaError, locale: getBrowserLocale() || undefined, + ...(timezone !== null ? { timezone } : {}), }; await this.#resource.__internal_basePost({ @@ -1058,12 +1074,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..355a0f65070 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, @@ -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 = '/client/sign_ups'; @@ -101,6 +107,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 +175,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' }); @@ -186,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, }); @@ -195,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', }); }; @@ -502,7 +516,7 @@ export class SignUp extends BaseResource implements SignUpResource { update = (params: SignUpUpdateParams): Promise => { return this._basePatch({ - body: normalizeUnsafeMetadata(params), + body: normalizeUnsafeMetadata(withoutTimezone(params)), }); }; @@ -550,6 +564,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 +607,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 +841,10 @@ class SignUpFuture implements SignUpFutureResource { return this.#resource.locale; } + get timezone() { + return this.#resource.timezone; + } + get unverifiedFields() { return this.#resource.unverifiedFields; } @@ -914,6 +934,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 +944,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 }); @@ -937,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, }; @@ -954,16 +976,20 @@ class SignUpFuture implements SignUpFutureResource { captchaToken, captchaWidgetType, captchaError, - ...params, + ...withoutTimezone(params), unsafeMetadata: params.unsafeMetadata ? normalizeUnsafeMetadata(params.unsafeMetadata) : undefined, }; 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 = getBrowserTimezone(); + if (timezone !== null) { + body.timezone = timezone; + } await this.#resource.__internal_basePost({ path: this.#resource.pathRoot, body }); } }); @@ -1104,9 +1130,13 @@ class SignUpFuture implements SignUpFutureResource { 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 = getBrowserTimezone(); + if (browserTimezone !== null) { + body.timezone = browserTimezone; + } return this.#resource.__internal_basePost({ path: this.#resource.pathRoot, body }); }; @@ -1218,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/User.ts b/packages/clerk-js/src/core/resources/User.ts index f3643bd1f13..8197e58c335 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; @@ -456,6 +457,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); @@ -504,6 +506,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 df1d5a34891..80e8a1fafbb 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -31,12 +31,30 @@ 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); 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(); @@ -386,6 +404,86 @@ 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.mock.calls[0][0].body).not.toHaveProperty('timezone'); + }); + + 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.mock.calls[0][0].body).not.toHaveProperty('timezone'); + }); + it('includes captcha params when signUpIfMissing is true', async () => { vi.stubGlobal('__BUILD_DISABLE_RHC__', false); @@ -486,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 = { @@ -628,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, @@ -872,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 0bcff445b5f..aee6bdaab94 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -32,12 +32,30 @@ 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); 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(); @@ -63,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, @@ -236,6 +269,68 @@ 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.mock.calls[0][0].body).not.toHaveProperty('timezone'); + }); + + 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 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' }, @@ -398,6 +493,39 @@ 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('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, @@ -575,6 +703,22 @@ 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', () => { @@ -980,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' }); @@ -1013,7 +1157,8 @@ describe('SignUp', () => { strategy: 'oauth_google', redirectUrl: '/complete', redirectCallbackUrl: '/sso-callback', - }); + timezone: 'Europe/Paris', + } as any); expect(mockFetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -1024,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 () => { @@ -1583,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' }, @@ -1591,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({ @@ -1603,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' }, @@ -1613,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( @@ -1626,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/clerk-js/src/core/resources/__tests__/User.test.ts b/packages/clerk-js/src/core/resources/__tests__/User.test.ts index ae380cce8aa..0ad3f1c4b0c 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/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 9078b4accdb..bebf3c61d58 100644 --- a/packages/shared/src/types/signIn.ts +++ b/packages/shared/src/types/signIn.ts @@ -65,6 +65,7 @@ export interface SignInResource extends ClerkResource { * upgrading the SDK alone does not enable it. */ protectCheck: ProtectCheckResource | null; + timezone: string | null; create: (params: SignInCreateParams) => Promise; @@ -134,4 +135,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 59941ee856a..0d0c853faf9 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. */ @@ -50,6 +52,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; } & ( | { /** @@ -382,6 +386,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'`. */ 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..9bf9a127275 100644 --- a/packages/shared/src/types/signUpCommon.ts +++ b/packages/shared/src/types/signUpCommon.ts @@ -136,10 +136,11 @@ export type SignUpCreateParams = Partial< oidcLoginHint: string; channel: PhoneCodeChannel; locale?: string; + timezone?: string; } & 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 eb2075bfe8e..605a28a66f7 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -45,6 +45,8 @@ export interface SignUpFutureAdditionalParams { /** @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: *
    @@ -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/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(); +}); diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts index d606a01d480..604e16ddd5b 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.