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;
};
}