diff --git a/.changeset/lockout-expires-in-seconds.md b/.changeset/lockout-expires-in-seconds.md new file mode 100644 index 00000000000..ed69893a139 --- /dev/null +++ b/.changeset/lockout-expires-in-seconds.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Keep `meta.lockout_expires_in_seconds` on `user_locked` ClerkAPIError objects so apps can show when the lockout ends. diff --git a/packages/shared/src/__tests__/lockoutExpiresMeta.spec.ts b/packages/shared/src/__tests__/lockoutExpiresMeta.spec.ts new file mode 100644 index 00000000000..89afd48dd67 --- /dev/null +++ b/packages/shared/src/__tests__/lockoutExpiresMeta.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { ClerkAPIError } from '../errors/clerkApiError'; +import { errorToJSON } from '../errors/parseError'; +import type { ClerkAPIErrorJSON } from '../types/errors'; + +describe('user_locked error meta', () => { + const json: ClerkAPIErrorJSON = { + code: 'user_locked', + message: 'Account locked', + long_message: 'Too many failed attempts. Try again later.', + meta: { + lockout_expires_in_seconds: 3599, + }, + }; + + it('keeps lockoutExpiresInSeconds from the wire payload', () => { + const error = new ClerkAPIError(json); + expect(error.meta.lockoutExpiresInSeconds).toBe(3599); + }); + + it('survives a snapshot round trip', () => { + const roundTripped = new ClerkAPIError(errorToJSON(new ClerkAPIError(json))); + expect(roundTripped.meta.lockoutExpiresInSeconds).toBe(3599); + expect(errorToJSON(roundTripped).meta?.lockout_expires_in_seconds).toBe(3599); + }); + + it('leaves an error without lockout duration alone', () => { + const error = new ClerkAPIError({ + code: 'form_code_incorrect', + message: 'Incorrect code', + meta: { param_name: 'code' }, + }); + expect(error.meta.lockoutExpiresInSeconds).toBeUndefined(); + expect(errorToJSON(error).meta?.lockout_expires_in_seconds).toBeUndefined(); + }); +}); diff --git a/packages/shared/src/errors/clerkApiError.ts b/packages/shared/src/errors/clerkApiError.ts index adaeb3e5b9d..5b5b6d5ee37 100644 --- a/packages/shared/src/errors/clerkApiError.ts +++ b/packages/shared/src/errors/clerkApiError.ts @@ -35,6 +35,7 @@ export class ClerkAPIError implements Cler linkUrl: json.meta?.link_url, linkText: json.meta?.link_text, data: json.meta?.data, + lockoutExpiresInSeconds: json.meta?.lockout_expires_in_seconds, } as unknown as Meta, }; this.code = parsedError.code; diff --git a/packages/shared/src/errors/parseError.ts b/packages/shared/src/errors/parseError.ts index a13945d8f10..dd12c6d0db3 100644 --- a/packages/shared/src/errors/parseError.ts +++ b/packages/shared/src/errors/parseError.ts @@ -48,6 +48,7 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { link_url: error?.meta?.linkUrl, link_text: error?.meta?.linkText, data: error?.meta?.data, + lockout_expires_in_seconds: error?.meta?.lockoutExpiresInSeconds, }, }; } diff --git a/packages/shared/src/types/errors.ts b/packages/shared/src/types/errors.ts index f54e6982ea0..0f49364a7dd 100644 --- a/packages/shared/src/types/errors.ts +++ b/packages/shared/src/types/errors.ts @@ -30,6 +30,7 @@ export interface ClerkAPIErrorJSON { link_url?: string; link_text?: string; data?: Record; + lockout_expires_in_seconds?: number; }; } @@ -122,6 +123,11 @@ export interface ClerkAPIError { * sensitive. */ data?: Record; + /** + * Seconds until a `user_locked` account can try again. Taken from + * `meta.lockout_expires_in_seconds` on the API error. + */ + lockoutExpiresInSeconds?: number; }; }