diff --git a/apps/sim/app/(auth)/verify/otp-error-source.test.tsx b/apps/sim/app/(auth)/verify/otp-error-source.test.tsx
new file mode 100644
index 00000000000..f81caafe77f
--- /dev/null
+++ b/apps/sim/app/(auth)/verify/otp-error-source.test.tsx
@@ -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) => values.filter(Boolean).join(' '),
+ ChipInput: ({
+ error: _error,
+ size: _size,
+ ...props
+ }: Omit, 'size'> & {
+ error?: boolean
+ size?: string
+ }) => ,
+ Label: ({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) => (
+
+ ),
+ InputOTP: ({
+ children,
+ value,
+ onChange,
+ 'aria-invalid': invalid,
+ }: {
+ children: ReactNode
+ value: string
+ onChange: (value: string) => void
+ 'aria-invalid'?: boolean
+ }) => (
+
+ onChange(event.target.value)}
+ />
+ {children}
+
+ ),
+ InputOTPGroup: ({ children }: { children: ReactNode }) => {children}
,
+ InputOTPSlot: ({ invalid, index }: { invalid?: boolean; index: number }) => (
+
+ ),
+}))
+vi.mock('@/lib/messaging/email/validation', () => ({
+ quickValidateEmail: () => ({ isValid: true }),
+}))
+vi.mock('@/app/(auth)/components', () => ({
+ AuthSubmitButton: ({
+ children,
+ loading: _loading,
+ loadingLabel: _loadingLabel,
+ ...props
+ }: ButtonHTMLAttributes & {
+ loading?: boolean
+ loadingLabel?: string
+ }) => (
+
+ ),
+}))
+vi.mock('@/app/(auth)/components/auth-button-classes', () => ({ AUTH_TEXT_LINK: '' }))
+vi.mock('@/components/auth/public-auth-header', () => ({
+ PublicAuthHeader: ({ title }: { title: string }) => {title}
,
+}))
+vi.mock('@/app/f/[token]/public-file-auth-shell', () => ({
+ PublicFileAuthShell: ({ children }: { children: ReactNode }) => {children}
,
+}))
+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())
+ act(() =>
+ changeInput(container.querySelector('#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('[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())
+ act(() =>
+ changeInput(container.querySelector('#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('[data-testid="otp-code"]')!, '123456')
+ )
+ expect(container.textContent).toContain('Incorrect code')
+ expectOtpInvalid(true)
+ })
+})
diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx
index 88d1c2e9d67..4e13de53044 100644
--- a/apps/sim/app/(auth)/verify/verify-content.tsx
+++ b/apps/sim/app/(auth)/verify/verify-content.tsx
@@ -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,
@@ -84,14 +84,16 @@ function VerificationForm({
-
+
{OTP_SLOTS.map((index) => (
-
+
))}
diff --git a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx
index 2781d70779b..190dfbaf917 100644
--- a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx
+++ b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx
@@ -34,13 +34,17 @@ const validateEmailField = (emailValue: string): string[] => {
export default function EmailAuth({ identifier }: EmailAuthProps) {
const [email, setEmail] = useState('')
- const [authError, setAuthError] = useState(null)
+ const [authError, setAuthError] = useState<{
+ kind: 'verification' | 'request'
+ message: string
+ } | null>(null)
const [emailErrors, setEmailErrors] = useState([])
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)
@@ -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',
+ })
}
}
@@ -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)
}
}
@@ -181,15 +191,12 @@ 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}
>
{[0, 1, 2, 3, 4, 5].map((index) => (
-
+
))}
@@ -197,7 +204,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) {
{authError && (
-
{authError}
+
{authError.message}
)}
diff --git a/apps/sim/app/f/[token]/public-file-email-auth.tsx b/apps/sim/app/f/[token]/public-file-email-auth.tsx
index a674db4ffcb..198fea32ae5 100644
--- a/apps/sim/app/f/[token]/public-file-email-auth.tsx
+++ b/apps/sim/app/f/[token]/public-file-email-auth.tsx
@@ -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
(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
@@ -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)
@@ -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'),
+ })
}
}
@@ -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') })
}
}
@@ -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'),
+ })
}
}
@@ -107,7 +117,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
size='lg'
error={Boolean(error)}
/>
- {error ? {error}
: null}
+ {error ? {error.message}
: null}
{[0, 1, 2, 3, 4, 5].map((i) => (
-
+
))}
- {error ? {error}
: null}
+ {error ? (
+ {error.message}
+ ) : null}
{
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ )
+ host = document.createElement('div')
+ document.body.appendChild(host)
+ root = createRoot(host)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ host.remove()
+ vi.unstubAllGlobals()
+})
+
+function renderOtp(invalid: boolean) {
+ act(() => {
+ root.render(
+
+
+
+
+
+
+ )
+ })
+}
+
+describe('InputOTPSlot invalid state', () => {
+ it('keeps the standard border when valid', () => {
+ renderOtp(false)
+ const slot = host.querySelector('[data-testid="first-slot"]')
+ expect(slot?.classList.contains('border-[var(--border-1)]')).toBe(true)
+ expect(slot?.classList.contains('border-[var(--text-error)]')).toBe(false)
+ expect(host.querySelector('input')?.getAttribute('aria-invalid')).toBe('false')
+ })
+
+ it('keeps the error border while the active slot has a focus ring', () => {
+ renderOtp(true)
+ const input = host.querySelector('input')
+ expect(input?.getAttribute('aria-invalid')).toBe('true')
+
+ act(() => input?.focus())
+ const slot = host.querySelector('[data-testid="first-slot"]')
+ expect(slot?.classList.contains('border-[var(--text-error)]')).toBe(true)
+ expect(slot?.classList.contains('ring-1')).toBe(true)
+ expect(slot?.classList.contains('border-[var(--text-muted)]')).toBe(false)
+ })
+})
diff --git a/packages/emcn/src/components/input-otp/input-otp.tsx b/packages/emcn/src/components/input-otp/input-otp.tsx
index a6c5ea82fff..2c3ff3a83b7 100644
--- a/packages/emcn/src/components/input-otp/input-otp.tsx
+++ b/packages/emcn/src/components/input-otp/input-otp.tsx
@@ -66,32 +66,39 @@ InputOTPGroup.displayName = 'InputOTPGroup'
*
* Uses emcn design tokens for consistent styling with the Input component.
*/
-const InputOTPSlot = React.forwardRef<
- React.ElementRef<'div'>,
- React.ComponentPropsWithoutRef<'div'> & { index: number }
->(({ index, className, ...props }, ref) => {
- const inputOTPContext = React.useContext(OTPInputContext)
- const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
+export interface InputOTPSlotProps extends React.ComponentPropsWithoutRef<'div'> {
+ /** Zero-based position of this slot in the OTP input. */
+ index: number
+ /** Keep the error border visible even while this slot is active. */
+ invalid?: boolean
+}
+
+const InputOTPSlot = React.forwardRef, InputOTPSlotProps>(
+ ({ index, invalid = false, className, ...props }, ref) => {
+ const inputOTPContext = React.useContext(OTPInputContext)
+ const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
- return (
-
- {char}
- {hasFakeCaret && (
-
- )}
-
- )
-})
+ return (
+
+ {char}
+ {hasFakeCaret && (
+
+ )}
+
+ )
+ }
+)
InputOTPSlot.displayName = 'InputOTPSlot'
/**