diff --git a/.changeset/calm-clocks-travel.md b/.changeset/calm-clocks-travel.md new file mode 100644 index 00000000000..dd89264dee9 --- /dev/null +++ b/.changeset/calm-clocks-travel.md @@ -0,0 +1,8 @@ +--- +'@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/.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. | 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/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 3840cf8113d..a421c0f931c 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": "124KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "322KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "124.5KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" }, { "path": "./dist/clerk.native.js", "maxSize": "80KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 8f3e8ab0414..ebbaebeae07 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -88,6 +88,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, @@ -127,6 +128,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. @@ -200,6 +202,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__ && @@ -697,6 +706,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 }); @@ -758,6 +768,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, @@ -851,6 +862,10 @@ class SignInFuture implements SignInFutureResource { return this.#resource.identifier; } + get timezone() { + return this.#resource.timezone; + } + get createdSessionId() { return this.#resource.createdSessionId; } @@ -1076,6 +1091,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, @@ -1083,6 +1099,7 @@ class SignInFuture implements SignInFutureResource { captchaWidgetType, captchaError, locale: getBrowserLocale() || undefined, + ...(timezone !== null ? { timezone } : {}), }; await this.#resource.__internal_basePost({ @@ -1107,12 +1124,14 @@ class SignInFuture implements SignInFutureResource { const identifier = params.identifier || params.emailAddress || params.phoneNumber; const previousIdentifier = this.#resource.identifier; const locale = getBrowserLocale(); + const timezone = params.timezone ?? this.#resource.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 f12541eeeef..ac8c1d0b5c9 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -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, @@ -101,6 +102,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 +170,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 +559,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 +602,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 +836,10 @@ class SignUpFuture implements SignUpFutureResource { return this.#resource.locale; } + get timezone() { + return this.#resource.timezone; + } + get unverifiedFields() { return this.#resource.unverifiedFields; } @@ -914,6 +929,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 +939,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 +978,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 = getBrowserTimezone(); + if (timezone !== null) { + body.timezone = timezone; + } await this.#resource.__internal_basePost({ path: this.#resource.pathRoot, body }); } }); @@ -1104,9 +1125,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 }); }; 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 374b11163a1..2af05b64bbd 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(); @@ -569,6 +587,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); @@ -669,6 +767,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 = { @@ -811,6 +915,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, @@ -1055,6 +1174,42 @@ describe('SignIn', () => { }); }); + 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' }) }), + }); + 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).toHaveProperty('timezone', 'America/New_York'); + }); + 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 f06145bb1b8..dd2a5617b47 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('__experimental_getEnterpriseConnections', () => { afterEach(() => { vi.clearAllMocks(); @@ -267,6 +285,53 @@ 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.each([ { strategy: 'email_code', label: 'email_code' }, { strategy: 'email_link', label: 'email_link' }, @@ -429,6 +494,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, @@ -1011,7 +1109,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' }); @@ -1044,7 +1142,8 @@ describe('SignUp', () => { strategy: 'oauth_google', redirectUrl: '/complete', redirectCallbackUrl: '/sso-callback', - }); + timezone: 'Europe/Paris', + } as any); expect(mockFetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -1055,6 +1154,7 @@ describe('SignUp', () => { }), }), ); + expect(mockFetch.mock.calls[0][0].body).not.toHaveProperty('timezone'); }); it('continues an existing sign up via the resource URL', async () => { @@ -1614,7 +1714,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' }, @@ -1622,7 +1725,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({ @@ -1634,29 +1740,7 @@ describe('SignUp', () => { }), }), ); - }); - - it('updates existing signup when already created', 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' }); - - // 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).toHaveProperty('timezone', 'America/New_York'); }); 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 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/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/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/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', []); }, diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 4d2191fa723..2128d95f8e2 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; } @@ -331,6 +332,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..844376484f2 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -169,6 +169,10 @@ 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; }; export type ResetPasswordParams = { diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index fa6bb6089d5..b8186f111ba 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 to the new sign-in attempt. If omitted, reuses the current sign-in's timezone, then defaults to the browser's timezone. */ + timezone?: string; } & ( | { /** @@ -395,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'`. */ diff --git a/packages/shared/src/types/signUp.ts b/packages/shared/src/types/signUp.ts index 28a744e44db..3659ff78b94 100644 --- a/packages/shared/src/types/signUp.ts +++ b/packages/shared/src/types/signUp.ts @@ -71,11 +71,16 @@ export interface SignUpResource extends ClerkResource { abandonAt: number | null; legalAcceptedAt: number | null; locale: string | null; + timezone: string | null; create: (params: SignUpCreateParams) => Promise; 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; diff --git a/packages/shared/src/types/signUpCommon.ts b/packages/shared/src/types/signUpCommon.ts index 699ec380869..da860fe5533 100644 --- a/packages/shared/src/types/signUpCommon.ts +++ b/packages/shared/src/types/signUpCommon.ts @@ -136,10 +136,14 @@ 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'> >; -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 8a912861d48..31a341eb795 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 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.