Skip to content

Commit 7eef55c

Browse files
refactor: improve OTP error handling (#369)
1 parent c9f49cc commit 7eef55c

5 files changed

Lines changed: 97 additions & 10 deletions

File tree

src/components/magic-link-verify.test.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,19 @@ describe('MagicLinkVerify', () => {
190190
await waitForStateChange()
191191

192192
expect(screen.getByText('Verification Failed')).toBeInTheDocument()
193-
expect(screen.getByText('The link has expired or is invalid. Please request a new one.')).toBeInTheDocument()
193+
expect(screen.getByText('This link is invalid. Please request a new one.')).toBeInTheDocument()
194+
})
195+
196+
it('shows specific error for OTP_EXPIRED', async () => {
197+
mockSignInEmailOtp.mockResolvedValue({
198+
error: { code: 'OTP_EXPIRED', message: 'OTP expired' },
199+
})
200+
201+
renderComponent('user@example.com', '123456')
202+
await waitForStateChange()
203+
204+
expect(screen.getByText('Verification Failed')).toBeInTheDocument()
205+
expect(screen.getByText('This link has expired. Please request a new one.')).toBeInTheDocument()
194206
})
195207

196208
it('shows error when signIn throws', async () => {

src/components/magic-link-verify.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useNavigate, useSearchParams } from 'react-router'
77
import { Button } from '@/components/ui/button'
88
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
99
import { useAuth } from '@/contexts'
10+
import { getOtpErrorMessage } from '@/lib/otp-error-messages'
1011
import { useSettings } from '@/hooks/use-settings'
1112

1213
type VerifyState = { status: 'verifying' } | { status: 'success' } | { status: 'error'; message: string }
@@ -47,14 +48,7 @@ export const MagicLinkVerify = () => {
4748
})
4849

4950
if (result.error) {
50-
// Handle specific error codes from Better Auth
51-
if (result.error.code === 'TOO_MANY_ATTEMPTS') {
52-
setState({ status: 'error', message: 'Too many attempts. Please request a new code.' })
53-
} else if (result.error.code === 'INVALID_OTP') {
54-
setState({ status: 'error', message: 'The link has expired or is invalid. Please request a new one.' })
55-
} else {
56-
setState({ status: 'error', message: result.error.message || 'Verification failed. Please try again.' })
57-
}
51+
setState({ status: 'error', message: getOtpErrorMessage(result.error, 'link') })
5852
return
5953
}
6054

src/components/sign-in/use-sign-in-form-state.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AuthClient } from '@/contexts'
2+
import { getOtpErrorMessage } from '@/lib/otp-error-messages'
23
import { setAuthToken } from '@/lib/auth-token'
34
import { isValidEmailFormat } from '@/lib/utils'
45
import { useReducer, type FormEvent } from 'react'
@@ -135,7 +136,7 @@ export const useSignInFormState = ({
135136
})
136137

137138
if (result.error) {
138-
dispatch({ type: 'VERIFY_ERROR', payload: result.error.message || 'Invalid code. Please try again.' })
139+
dispatch({ type: 'VERIFY_ERROR', payload: getOtpErrorMessage(result.error, 'code') })
139140
return
140141
}
141142

src/lib/otp-error-messages.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { getOtpErrorMessage } from './otp-error-messages'
3+
4+
describe('getOtpErrorMessage', () => {
5+
describe('link context', () => {
6+
it('returns link-specific message for OTP_EXPIRED', () => {
7+
expect(getOtpErrorMessage({ code: 'OTP_EXPIRED', message: 'OTP expired' }, 'link')).toBe(
8+
'This link has expired. Please request a new one.',
9+
)
10+
})
11+
12+
it('returns link-specific message for INVALID_OTP', () => {
13+
expect(getOtpErrorMessage({ code: 'INVALID_OTP', message: 'Invalid OTP' }, 'link')).toBe(
14+
'This link is invalid. Please request a new one.',
15+
)
16+
})
17+
18+
it('returns message for TOO_MANY_ATTEMPTS', () => {
19+
expect(getOtpErrorMessage({ code: 'TOO_MANY_ATTEMPTS', message: 'Too many attempts' }, 'link')).toBe(
20+
'Too many attempts. Please request a new code.',
21+
)
22+
})
23+
})
24+
25+
describe('code context', () => {
26+
it('returns code-specific message for OTP_EXPIRED', () => {
27+
expect(getOtpErrorMessage({ code: 'OTP_EXPIRED', message: 'OTP expired' }, 'code')).toBe(
28+
'This code has expired. Please request a new one.',
29+
)
30+
})
31+
32+
it('returns code-specific message for INVALID_OTP', () => {
33+
expect(getOtpErrorMessage({ code: 'INVALID_OTP', message: 'Invalid OTP' }, 'code')).toBe(
34+
'Invalid code. Please try again.',
35+
)
36+
})
37+
38+
it('returns message for TOO_MANY_ATTEMPTS', () => {
39+
expect(getOtpErrorMessage({ code: 'TOO_MANY_ATTEMPTS', message: 'Too many attempts' }, 'code')).toBe(
40+
'Too many attempts. Please request a new code.',
41+
)
42+
})
43+
})
44+
45+
describe('fallbacks', () => {
46+
it('uses error.message when code is unknown', () => {
47+
expect(getOtpErrorMessage({ code: 'UNKNOWN', message: 'Custom message' }, 'link')).toBe('Custom message')
48+
})
49+
50+
it('uses fallback when error has no code or message', () => {
51+
expect(getOtpErrorMessage({}, 'link')).toBe('Verification failed. Please try again.')
52+
})
53+
})
54+
})

src/lib/otp-error-messages.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
type OtpError = { code?: string; message?: string }
2+
type OtpErrorContext = 'link' | 'code'
3+
4+
const messages: Record<OtpErrorContext, Record<string, string>> = {
5+
link: {
6+
OTP_EXPIRED: 'This link has expired. Please request a new one.',
7+
INVALID_OTP: 'This link is invalid. Please request a new one.',
8+
TOO_MANY_ATTEMPTS: 'Too many attempts. Please request a new code.',
9+
},
10+
code: {
11+
OTP_EXPIRED: 'This code has expired. Please request a new one.',
12+
INVALID_OTP: 'Invalid code. Please try again.',
13+
TOO_MANY_ATTEMPTS: 'Too many attempts. Please request a new code.',
14+
},
15+
}
16+
17+
const fallback = 'Verification failed. Please try again.'
18+
19+
/**
20+
* Returns a user-friendly message for OTP verification errors.
21+
* Handles OTP_EXPIRED, INVALID_OTP, and TOO_MANY_ATTEMPTS from Better Auth.
22+
*/
23+
export const getOtpErrorMessage = (error: OtpError, context: OtpErrorContext): string => {
24+
const message = error?.code ? messages[context][error.code] : undefined
25+
return message ?? error?.message ?? fallback
26+
}

0 commit comments

Comments
 (0)