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
2 changes: 1 addition & 1 deletion apps/sim/app/(auth)/components/auth-nav-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function AuthNavPrompt({ prompt, href, linkLabel, onNavigate }: AuthNavPr
return (
<div className='flex items-center justify-center gap-1 text-sm'>
{prompt && <span className='text-[var(--text-muted)]'>{prompt}</span>}
<ChipLink href={href} onClick={onNavigate} className='border border-[var(--border-1)]'>
<ChipLink href={href} onClick={onNavigate} variant='outline'>
{linkLabel}
</ChipLink>
</div>
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/(auth)/components/auth-submit-button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { ReactNode } from 'react'
import { Chip, Loader } from '@sim/emcn'
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'

interface AuthSubmitButtonProps {
children: ReactNode
Expand Down Expand Up @@ -32,7 +31,8 @@ export function AuthSubmitButton({
onClick={onClick}
disabled={disabled || loading}
fullWidth
className={AUTH_BUTTON_CLASS}
size='lg'
align='center'
>
{loading ? (
<span className='flex items-center gap-2'>
Expand Down
17 changes: 0 additions & 17 deletions apps/sim/app/(auth)/components/constants.ts

This file was deleted.

8 changes: 4 additions & 4 deletions apps/sim/app/(auth)/components/password-input.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
'use client'

import { useState } from 'react'
import { ChipInput, type ChipInputProps, cn } from '@sim/emcn'
import { ChipInput, type ChipInputProps } from '@sim/emcn'
import { Eye, EyeOff } from '@sim/emcn/icons'
import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants'

type PasswordInputProps = Omit<ChipInputProps, 'type' | 'icon' | 'endAdornment'>
type PasswordInputProps = Omit<ChipInputProps, 'type' | 'icon' | 'endAdornment' | 'size'>

/**
* A {@link ChipInput} that owns the password reveal toggle — the eye button is
Expand All @@ -19,7 +18,8 @@ export function PasswordInput({ error, className, ...props }: PasswordInputProps
return (
<ChipInput
{...props}
className={cn(AUTH_CONTROL_HEIGHT, className)}
size='lg'
className={className}
type={visible ? 'text' : 'password'}
error={error}
endAdornment={
Expand Down
15 changes: 10 additions & 5 deletions apps/sim/app/(auth)/components/social-login-buttons.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
'use client'

import { type ReactNode, useState } from 'react'
import { Chip, cn } from '@sim/emcn'
import { Chip } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
import { client } from '@/lib/auth/auth-client'
import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect'
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'

const logger = createLogger('SocialLoginButtons')

Expand Down Expand Up @@ -73,7 +72,9 @@ export function SocialLoginButtons({
<Chip
fullWidth
leftIcon={GithubIcon}
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
variant='outline'
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
size='lg'
align='center'
disabled={!githubAvailable || isGithubLoading}
onClick={signInWithGithub}
>
Expand All @@ -85,7 +86,9 @@ export function SocialLoginButtons({
<Chip
fullWidth
leftIcon={GoogleIcon}
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
variant='outline'
size='lg'
align='center'
disabled={!googleAvailable || isGoogleLoading}
onClick={signInWithGoogle}
>
Expand All @@ -97,7 +100,9 @@ export function SocialLoginButtons({
<Chip
fullWidth
leftIcon={MicrosoftIcon}
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
variant='outline'
size='lg'
align='center'
disabled={!microsoftAvailable || isMicrosoftLoading}
onClick={signInWithMicrosoft}
>
Expand Down
13 changes: 5 additions & 8 deletions apps/sim/app/(auth)/components/sso-login-button.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
'use client'
import { Chip, cn } from '@sim/emcn'
import { Chip } from '@sim/emcn'
import { useRouter } from 'next/navigation'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'

interface SSOLoginButtonProps {
callbackURL?: string
Expand All @@ -28,14 +27,12 @@ export function SSOLoginButton({

return (
<Chip
variant={variant === 'primary' ? 'primary' : undefined}
variant={variant}
fullWidth
onClick={handleSSOClick}
className={cn(
AUTH_BUTTON_CLASS,
variant === 'outline' && 'border border-[var(--border-1)]',
className
)}
size='lg'
align='center'
className={className}
>
Sign in with SSO
</Chip>
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/(auth)/oauth/consent/consent-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
AuthSubmitButton,
AuthTextLink,
} from '@/app/(auth)/components'
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
import { OAuthConsentLoading } from '@/app/(auth)/oauth/consent/loading'
import {
useOAuthConsent,
Expand Down Expand Up @@ -166,7 +165,8 @@ export function OAuthConsentView({
variant='border'
fullWidth
disabled={isPending}
className={AUTH_BUTTON_CLASS}
size='lg'
align='center'
onClick={() => decide(false)}
>
{consent.isPending && consent.variables === false ? 'Declining…' : 'Deny'}
Expand Down
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
Loading
Loading