Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions apps/sim/app/(auth)/verify/otp-error-source.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/** @vitest-environment jsdom */
import { act, type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
chatRequest: vi.fn(),
chatVerify: vi.fn(),
fileRequest: vi.fn(),
fileVerify: vi.fn(),
refresh: vi.fn(),
}))

vi.mock('@sim/emcn', () => ({
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
ChipInput: ({
error: _error,
size: _size,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> & {
error?: boolean
size?: string
}) => <input {...props} />,
Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
<label htmlFor={htmlFor}>{children}</label>
),
InputOTP: ({
children,
value,
onChange,
'aria-invalid': invalid,
}: {
children: ReactNode
value: string
onChange: (value: string) => void
'aria-invalid'?: boolean
}) => (
<div>
<input
data-testid='otp-code'
aria-invalid={invalid}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
{children}
</div>
),
InputOTPGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
InputOTPSlot: ({ invalid, index }: { invalid?: boolean; index: number }) => (
<span data-otp-slot={index} data-invalid={invalid} />
),
}))
vi.mock('@/lib/messaging/email/validation', () => ({
quickValidateEmail: () => ({ isValid: true }),
}))
vi.mock('@/app/(auth)/components', () => ({
AuthSubmitButton: ({
children,
loading: _loading,
loadingLabel: _loadingLabel,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
loading?: boolean
loadingLabel?: string
}) => (
<button type='submit' {...props}>
{children}
</button>
),
}))
vi.mock('@/app/(auth)/components/auth-button-classes', () => ({ AUTH_TEXT_LINK: '' }))
vi.mock('@/components/auth/public-auth-header', () => ({
PublicAuthHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
}))
vi.mock('@/app/f/[token]/public-file-auth-shell', () => ({
PublicFileAuthShell: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: mocks.refresh }) }))
vi.mock('@/hooks/queries/chats', () => ({
useChatEmailOtpRequest: () => ({ mutateAsync: mocks.chatRequest, isPending: false }),
useChatEmailOtpVerify: () => ({ mutateAsync: mocks.chatVerify, isPending: false }),
}))
vi.mock('@/hooks/queries/public-shares', () => ({
usePublicFileOtpRequest: () => ({ mutateAsync: mocks.fileRequest, isPending: false }),
usePublicFileOtpVerify: () => ({ mutateAsync: mocks.fileVerify, isPending: false }),
}))

import EmailAuth from '@/app/(interfaces)/chat/components/auth/email/email-auth'
import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth'

let root: Root
let container: HTMLDivElement

function changeInput(input: HTMLInputElement, value: string) {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
}

function button(label: string) {
const found = Array.from(container.querySelectorAll('button')).find(
(candidate) => candidate.textContent?.trim() === label
)
if (!found) throw new Error(`Missing button: ${label}`)
return found
}

function expectOtpInvalid(invalid: boolean) {
expect(container.querySelector('[data-testid="otp-code"]')?.getAttribute('aria-invalid')).toBe(
String(invalid)
)
const slots = container.querySelectorAll('[data-otp-slot]')
expect(slots).toHaveLength(6)
for (const slot of slots) expect(slot.getAttribute('data-invalid')).toBe(String(invalid))
}

beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
mocks.chatRequest.mockResolvedValue({})
mocks.chatVerify.mockResolvedValue({})
mocks.fileRequest.mockResolvedValue({})
mocks.fileVerify.mockResolvedValue({})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
container.remove()
vi.unstubAllGlobals()
})

describe('OTP error provenance', () => {
it('keeps the chat code valid on resend failure and marks only a failed verification invalid', async () => {
act(() => root.render(<EmailAuth identifier='chat-1' />))
act(() =>
changeInput(container.querySelector<HTMLInputElement>('#email')!, 'member@example.com')
)
await act(async () => button('Continue').click())

mocks.chatRequest.mockRejectedValueOnce(new Error('Delivery failed'))
await act(async () => button('Resend').click())
expect(container.textContent).toContain('Delivery failed')
expectOtpInvalid(false)

mocks.chatVerify.mockRejectedValueOnce(new Error('Incorrect code'))
await act(async () =>
changeInput(container.querySelector<HTMLInputElement>('[data-testid="otp-code"]')!, '123456')
)
expect(container.textContent).toContain('Incorrect code')
expectOtpInvalid(true)
})

it('keeps the public-file code valid on resend failure and marks only a failed verification invalid', async () => {
act(() => root.render(<PublicFileEmailAuth token='share-1' />))
act(() =>
changeInput(container.querySelector<HTMLInputElement>('#email')!, 'member@example.com')
)
await act(async () => button('Continue').click())

mocks.fileRequest.mockRejectedValueOnce(new Error('Delivery failed'))
await act(async () => button('Resend').click())
expect(container.textContent).toContain('Delivery failed')
expectOtpInvalid(false)

mocks.fileVerify.mockRejectedValueOnce(new Error('Incorrect code'))
await act(async () =>
changeInput(container.querySelector<HTMLInputElement>('[data-testid="otp-code"]')!, '123456')
)
expect(container.textContent).toContain('Incorrect code')
expectOtpInvalid(true)
})
})
16 changes: 9 additions & 7 deletions apps/sim/app/(auth)/verify/verify-content.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { Suspense, useEffect, useState } from 'react'
import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn'
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn'
import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'
import {
AuthFormMessage,
Expand Down Expand Up @@ -84,14 +84,16 @@ function VerificationForm({
</p>

<div className='flex justify-center'>
<InputOTP maxLength={6} value={otp} onChange={handleOtpChange} disabled={isLoading}>
<InputOTP
maxLength={6}
value={otp}
onChange={handleOtpChange}
disabled={isLoading}
aria-invalid={isInvalidOtp}
>
<InputOTPGroup>
{OTP_SLOTS.map((index) => (
<InputOTPSlot
key={index}
index={index}
className={cn(isInvalidOtp && 'border-[var(--text-error)]')}
/>
<InputOTPSlot key={index} index={index} invalid={isInvalidOtp} />
))}
</InputOTPGroup>
</InputOTP>
Expand Down
27 changes: 17 additions & 10 deletions apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,17 @@ const validateEmailField = (emailValue: string): string[] => {

export default function EmailAuth({ identifier }: EmailAuthProps) {
const [email, setEmail] = useState('')
const [authError, setAuthError] = useState<string | null>(null)
const [authError, setAuthError] = useState<{
kind: 'verification' | 'request'
message: string
} | null>(null)
const [emailErrors, setEmailErrors] = useState<string[]>([])
const hasEmailError = emailErrors.length > 0

const [showOtpVerification, setShowOtpVerification] = useState(false)
const [otpValue, setOtpValue] = useState('')
const [countdown, setCountdown] = useState(0)
const isInvalidOtp = authError?.kind === 'verification'

const requestOtp = useChatEmailOtpRequest(identifier)
const verifyOtp = useChatEmailOtpVerify(identifier)
Expand Down Expand Up @@ -89,7 +93,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
await verifyOtp.mutateAsync({ email, otp: codeToVerify })
} catch (error) {
logger.error('Error verifying OTP:', error)
setAuthError(toError(error).message || 'Invalid verification code')
setAuthError({
kind: 'verification',
message: toError(error).message || 'Invalid verification code',
})
}
}

Expand All @@ -102,7 +109,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
setOtpValue('')
} catch (error) {
logger.error('Error resending OTP:', error)
setAuthError(toError(error).message || 'Failed to resend verification code')
setAuthError({
kind: 'request',
message: toError(error).message || 'Failed to resend verification code',
})
setCountdown(0)
}
}
Expand Down Expand Up @@ -181,23 +191,20 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
}
}}
disabled={verifyOtp.isPending}
className={cn('gap-2', authError && 'otp-error')}
className={cn('gap-2', isInvalidOtp && 'otp-error')}
aria-invalid={isInvalidOtp}
>
<InputOTPGroup>
{[0, 1, 2, 3, 4, 5].map((index) => (
<InputOTPSlot
key={index}
index={index}
className={cn(authError && 'border-[var(--text-error)]')}
/>
<InputOTPSlot key={index} index={index} invalid={isInvalidOtp} />
))}
</InputOTPGroup>
</InputOTP>
</div>

{authError && (
<div className='mt-1 space-y-1 text-center text-[var(--text-error)] text-xs'>
<p>{authError}</p>
<p>{authError.message}</p>
</div>
)}

Expand Down
35 changes: 22 additions & 13 deletions apps/sim/app/f/[token]/public-file-email-auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
const [email, setEmail] = useState('')
const [otp, setOtp] = useState('')
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
const [error, setError] = useState<{
kind: 'verification' | 'request'
message: string
} | null>(null)
const [countdown, setCountdown] = useState(0)
const isInvalidOtp = error?.kind === 'verification'

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

const sendCode = async () => {
if (!quickValidateEmail(normalizeEmail(email)).isValid) {
setError('Please enter a valid email address.')
setError({ kind: 'request', message: 'Please enter a valid email address.' })
return
}
setError(null)
Expand All @@ -48,7 +52,10 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
setSent(true)
setOtp('')
} catch (err) {
setError(getErrorMessage(err, 'Failed to send verification code'))
setError({
kind: 'request',
message: getErrorMessage(err, 'Failed to send verification code'),
})
}
}

Expand All @@ -59,7 +66,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
await verifyOtp.mutateAsync({ email: normalizeEmail(email), otp: code })
router.refresh()
} catch (err) {
setError(getErrorMessage(err, 'Invalid verification code'))
setError({ kind: 'verification', message: getErrorMessage(err, 'Invalid verification code') })
}
}

Expand All @@ -71,7 +78,10 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
setError(null)
} catch (err) {
setCountdown(0)
setError(getErrorMessage(err, 'Failed to resend verification code'))
setError({
kind: 'request',
message: getErrorMessage(err, 'Failed to resend verification code'),
})
}
}

Expand Down Expand Up @@ -107,7 +117,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
size='lg'
error={Boolean(error)}
/>
{error ? <p className='text-[var(--text-error)] text-xs'>{error}</p> : null}
{error ? <p className='text-[var(--text-error)] text-xs'>{error.message}</p> : null}
</div>

<AuthSubmitButton
Expand Down Expand Up @@ -143,21 +153,20 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
if (value.length === 6) verifyCode(value)
}}
disabled={verifyOtp.isPending}
className={cn('gap-2', error && 'otp-error')}
className={cn('gap-2', isInvalidOtp && 'otp-error')}
aria-invalid={isInvalidOtp}
>
<InputOTPGroup>
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot
key={i}
index={i}
className={cn(error && 'border-[var(--text-error)]')}
/>
<InputOTPSlot key={i} index={i} invalid={isInvalidOtp} />
))}
</InputOTPGroup>
</InputOTP>
</div>

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

<AuthSubmitButton
type='button'
Expand Down
8 changes: 7 additions & 1 deletion packages/emcn/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,13 @@ export {
type InfoCardProps,
} from './info-card/info-card'
export { Input, type InputProps } from './input/input'
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp/input-otp'
export {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
type InputOTPSlotProps,
} from './input-otp/input-otp'
export { Label } from './label/label'
export { Lightbox, type LightboxProps } from './lightbox/lightbox'
export { focusFirstTextInput, focusFirstTextInputIn } from './modal/auto-focus'
Expand Down
Loading
Loading