Skip to content

Commit 36e09d2

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(ui): distinguish OTP verification from resend errors
1 parent 112a8b4 commit 36e09d2

3 files changed

Lines changed: 210 additions & 17 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/** @vitest-environment jsdom */
2+
import { act, type InputHTMLAttributes, type ReactNode } from 'react'
3+
import { createRoot, type Root } from 'react-dom/client'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
chatRequest: vi.fn(),
8+
chatVerify: vi.fn(),
9+
fileRequest: vi.fn(),
10+
fileVerify: vi.fn(),
11+
refresh: vi.fn(),
12+
}))
13+
14+
vi.mock('@sim/emcn', () => ({
15+
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
16+
ChipInput: ({
17+
error: _error,
18+
size: _size,
19+
...props
20+
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> & {
21+
error?: boolean
22+
size?: string
23+
}) => <input {...props} />,
24+
Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
25+
<label htmlFor={htmlFor}>{children}</label>
26+
),
27+
InputOTP: ({
28+
children,
29+
value,
30+
onChange,
31+
'aria-invalid': invalid,
32+
}: {
33+
children: ReactNode
34+
value: string
35+
onChange: (value: string) => void
36+
'aria-invalid'?: boolean
37+
}) => (
38+
<div>
39+
<input
40+
data-testid='otp-code'
41+
aria-invalid={invalid}
42+
value={value}
43+
onChange={(event) => onChange(event.target.value)}
44+
/>
45+
{children}
46+
</div>
47+
),
48+
InputOTPGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
49+
InputOTPSlot: ({ invalid, index }: { invalid?: boolean; index: number }) => (
50+
<span data-otp-slot={index} data-invalid={invalid} />
51+
),
52+
}))
53+
vi.mock('@sim/logger', () => ({ createLogger: () => ({ error: vi.fn() }) }))
54+
vi.mock('@/lib/messaging/email/validation', () => ({
55+
quickValidateEmail: () => ({ isValid: true }),
56+
}))
57+
vi.mock('@/app/(auth)/components', () => ({
58+
AuthSubmitButton: ({
59+
children,
60+
loading: _loading,
61+
loadingLabel: _loadingLabel,
62+
...props
63+
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
64+
loading?: boolean
65+
loadingLabel?: string
66+
}) => (
67+
<button type='submit' {...props}>
68+
{children}
69+
</button>
70+
),
71+
}))
72+
vi.mock('@/app/(auth)/components/auth-button-classes', () => ({ AUTH_TEXT_LINK: '' }))
73+
vi.mock('@/components/auth/public-auth-header', () => ({
74+
PublicAuthHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
75+
}))
76+
vi.mock('@/app/f/[token]/public-file-auth-shell', () => ({
77+
PublicFileAuthShell: ({ children }: { children: ReactNode }) => <div>{children}</div>,
78+
}))
79+
vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: mocks.refresh }) }))
80+
vi.mock('@/hooks/queries/chats', () => ({
81+
useChatEmailOtpRequest: () => ({ mutateAsync: mocks.chatRequest, isPending: false }),
82+
useChatEmailOtpVerify: () => ({ mutateAsync: mocks.chatVerify, isPending: false }),
83+
}))
84+
vi.mock('@/hooks/queries/public-shares', () => ({
85+
usePublicFileOtpRequest: () => ({ mutateAsync: mocks.fileRequest, isPending: false }),
86+
usePublicFileOtpVerify: () => ({ mutateAsync: mocks.fileVerify, isPending: false }),
87+
}))
88+
89+
import EmailAuth from '@/app/(interfaces)/chat/components/auth/email/email-auth'
90+
import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth'
91+
92+
let root: Root
93+
let container: HTMLDivElement
94+
95+
function changeInput(input: HTMLInputElement, value: string) {
96+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
97+
input.dispatchEvent(new Event('input', { bubbles: true }))
98+
}
99+
100+
function button(label: string) {
101+
const found = Array.from(container.querySelectorAll('button')).find(
102+
(candidate) => candidate.textContent?.trim() === label
103+
)
104+
if (!found) throw new Error(`Missing button: ${label}`)
105+
return found
106+
}
107+
108+
function expectOtpInvalid(invalid: boolean) {
109+
expect(container.querySelector('[data-testid="otp-code"]')?.getAttribute('aria-invalid')).toBe(
110+
String(invalid)
111+
)
112+
const slots = container.querySelectorAll('[data-otp-slot]')
113+
expect(slots).toHaveLength(6)
114+
for (const slot of slots) expect(slot.getAttribute('data-invalid')).toBe(String(invalid))
115+
}
116+
117+
beforeEach(() => {
118+
vi.clearAllMocks()
119+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
120+
mocks.chatRequest.mockResolvedValue({})
121+
mocks.chatVerify.mockResolvedValue({})
122+
mocks.fileRequest.mockResolvedValue({})
123+
mocks.fileVerify.mockResolvedValue({})
124+
container = document.createElement('div')
125+
document.body.appendChild(container)
126+
root = createRoot(container)
127+
})
128+
129+
afterEach(() => {
130+
act(() => root.unmount())
131+
container.remove()
132+
vi.unstubAllGlobals()
133+
})
134+
135+
describe('OTP error provenance', () => {
136+
it('keeps the chat code valid on resend failure and marks only a failed verification invalid', async () => {
137+
act(() => root.render(<EmailAuth identifier='chat-1' />))
138+
act(() => changeInput(container.querySelector('#email')!, 'member@example.com'))
139+
await act(async () => button('Continue').click())
140+
141+
mocks.chatRequest.mockRejectedValueOnce(new Error('Delivery failed'))
142+
await act(async () => button('Resend').click())
143+
expect(container.textContent).toContain('Delivery failed')
144+
expectOtpInvalid(false)
145+
146+
mocks.chatVerify.mockRejectedValueOnce(new Error('Incorrect code'))
147+
await act(async () =>
148+
changeInput(container.querySelector('[data-testid="otp-code"]')!, '123456')
149+
)
150+
expect(container.textContent).toContain('Incorrect code')
151+
expectOtpInvalid(true)
152+
})
153+
154+
it('keeps the public-file code valid on resend failure and marks only a failed verification invalid', async () => {
155+
act(() => root.render(<PublicFileEmailAuth token='share-1' />))
156+
act(() => changeInput(container.querySelector('#email')!, 'member@example.com'))
157+
await act(async () => button('Continue').click())
158+
159+
mocks.fileRequest.mockRejectedValueOnce(new Error('Delivery failed'))
160+
await act(async () => button('Resend').click())
161+
expect(container.textContent).toContain('Delivery failed')
162+
expectOtpInvalid(false)
163+
164+
mocks.fileVerify.mockRejectedValueOnce(new Error('Incorrect code'))
165+
await act(async () =>
166+
changeInput(container.querySelector('[data-testid="otp-code"]')!, '123456')
167+
)
168+
expect(container.textContent).toContain('Incorrect code')
169+
expectOtpInvalid(true)
170+
})
171+
})

‎apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,17 @@ const validateEmailField = (emailValue: string): string[] => {
3434

3535
export default function EmailAuth({ identifier }: EmailAuthProps) {
3636
const [email, setEmail] = useState('')
37-
const [authError, setAuthError] = useState<string | null>(null)
37+
const [authError, setAuthError] = useState<{
38+
kind: 'verification' | 'request'
39+
message: string
40+
} | null>(null)
3841
const [emailErrors, setEmailErrors] = useState<string[]>([])
3942
const hasEmailError = emailErrors.length > 0
4043

4144
const [showOtpVerification, setShowOtpVerification] = useState(false)
4245
const [otpValue, setOtpValue] = useState('')
4346
const [countdown, setCountdown] = useState(0)
47+
const isInvalidOtp = authError?.kind === 'verification'
4448

4549
const requestOtp = useChatEmailOtpRequest(identifier)
4650
const verifyOtp = useChatEmailOtpVerify(identifier)
@@ -89,7 +93,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
8993
await verifyOtp.mutateAsync({ email, otp: codeToVerify })
9094
} catch (error) {
9195
logger.error('Error verifying OTP:', error)
92-
setAuthError(toError(error).message || 'Invalid verification code')
96+
setAuthError({
97+
kind: 'verification',
98+
message: toError(error).message || 'Invalid verification code',
99+
})
93100
}
94101
}
95102

@@ -102,7 +109,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
102109
setOtpValue('')
103110
} catch (error) {
104111
logger.error('Error resending OTP:', error)
105-
setAuthError(toError(error).message || 'Failed to resend verification code')
112+
setAuthError({
113+
kind: 'request',
114+
message: toError(error).message || 'Failed to resend verification code',
115+
})
106116
setCountdown(0)
107117
}
108118
}
@@ -181,20 +191,20 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
181191
}
182192
}}
183193
disabled={verifyOtp.isPending}
184-
className={cn('gap-2', authError && 'otp-error')}
185-
aria-invalid={Boolean(authError)}
194+
className={cn('gap-2', isInvalidOtp && 'otp-error')}
195+
aria-invalid={isInvalidOtp}
186196
>
187197
<InputOTPGroup>
188198
{[0, 1, 2, 3, 4, 5].map((index) => (
189-
<InputOTPSlot key={index} index={index} invalid={Boolean(authError)} />
199+
<InputOTPSlot key={index} index={index} invalid={isInvalidOtp} />
190200
))}
191201
</InputOTPGroup>
192202
</InputOTP>
193203
</div>
194204

195205
{authError && (
196206
<div className='mt-1 space-y-1 text-center text-[var(--text-error)] text-xs'>
197-
<p>{authError}</p>
207+
<p>{authError.message}</p>
198208
</div>
199209
)}
200210

‎apps/sim/app/f/[token]/public-file-email-auth.tsx‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
2828
const [email, setEmail] = useState('')
2929
const [otp, setOtp] = useState('')
3030
const [sent, setSent] = useState(false)
31-
const [error, setError] = useState<string | null>(null)
31+
const [error, setError] = useState<{
32+
kind: 'verification' | 'request'
33+
message: string
34+
} | null>(null)
3235
const [countdown, setCountdown] = useState(0)
36+
const isInvalidOtp = error?.kind === 'verification'
3337

3438
useEffect(() => {
3539
if (countdown <= 0) return
@@ -39,7 +43,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
3943

4044
const sendCode = async () => {
4145
if (!quickValidateEmail(normalizeEmail(email)).isValid) {
42-
setError('Please enter a valid email address.')
46+
setError({ kind: 'request', message: 'Please enter a valid email address.' })
4347
return
4448
}
4549
setError(null)
@@ -48,7 +52,10 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
4852
setSent(true)
4953
setOtp('')
5054
} catch (err) {
51-
setError(getErrorMessage(err, 'Failed to send verification code'))
55+
setError({
56+
kind: 'request',
57+
message: getErrorMessage(err, 'Failed to send verification code'),
58+
})
5259
}
5360
}
5461

@@ -59,7 +66,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
5966
await verifyOtp.mutateAsync({ email: normalizeEmail(email), otp: code })
6067
router.refresh()
6168
} catch (err) {
62-
setError(getErrorMessage(err, 'Invalid verification code'))
69+
setError({ kind: 'verification', message: getErrorMessage(err, 'Invalid verification code') })
6370
}
6471
}
6572

@@ -71,7 +78,10 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
7178
setError(null)
7279
} catch (err) {
7380
setCountdown(0)
74-
setError(getErrorMessage(err, 'Failed to resend verification code'))
81+
setError({
82+
kind: 'request',
83+
message: getErrorMessage(err, 'Failed to resend verification code'),
84+
})
7585
}
7686
}
7787

@@ -107,7 +117,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
107117
size='lg'
108118
error={Boolean(error)}
109119
/>
110-
{error ? <p className='text-[var(--text-error)] text-xs'>{error}</p> : null}
120+
{error ? <p className='text-[var(--text-error)] text-xs'>{error.message}</p> : null}
111121
</div>
112122

113123
<AuthSubmitButton
@@ -143,18 +153,20 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
143153
if (value.length === 6) verifyCode(value)
144154
}}
145155
disabled={verifyOtp.isPending}
146-
className={cn('gap-2', error && 'otp-error')}
147-
aria-invalid={Boolean(error)}
156+
className={cn('gap-2', isInvalidOtp && 'otp-error')}
157+
aria-invalid={isInvalidOtp}
148158
>
149159
<InputOTPGroup>
150160
{[0, 1, 2, 3, 4, 5].map((i) => (
151-
<InputOTPSlot key={i} index={i} invalid={Boolean(error)} />
161+
<InputOTPSlot key={i} index={i} invalid={isInvalidOtp} />
152162
))}
153163
</InputOTPGroup>
154164
</InputOTP>
155165
</div>
156166

157-
{error ? <p className='text-center text-[var(--text-error)] text-xs'>{error}</p> : null}
167+
{error ? (
168+
<p className='text-center text-[var(--text-error)] text-xs'>{error.message}</p>
169+
) : null}
158170

159171
<AuthSubmitButton
160172
type='button'

0 commit comments

Comments
 (0)