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
102 changes: 94 additions & 8 deletions apps/sim/ee/sso/components/sso-form.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
/**
* @vitest-environment jsdom
*/
import type { ReactNode } from 'react'
import { act, type ButtonHTMLAttributes, type InputHTMLAttributes, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockUseSearchParams } = vi.hoisted(() => ({
const { mockSsoSignIn, mockUseSearchParams } = vi.hoisted(() => ({
mockSsoSignIn: vi.fn(),
mockUseSearchParams: vi.fn(),
}))

Expand All @@ -21,19 +23,35 @@ vi.mock('next/link', () => ({
}))

vi.mock('@sim/emcn', () => ({
Button: ({ children }: { children?: ReactNode }) => <button type='button'>{children}</button>,
Input: () => <input />,
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type='button' {...props}>
{children}
</button>
),
Input: (props: InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
Label: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
}))

vi.mock('@/lib/auth/auth-client', () => ({
client: { signIn: { sso: vi.fn() } },
client: { signIn: { sso: mockSsoSignIn } },
}))

vi.mock('@/app/(auth)/components', () => ({
AuthSubmitButton: ({ children }: { children?: ReactNode }) => (
<button type='submit'>{children}</button>
AuthSubmitButton: ({
children,
disabled = false,
loading = false,
loadingLabel,
}: {
children?: ReactNode
disabled?: boolean
loading?: boolean
loadingLabel: string
}) => (
<button type='submit' disabled={disabled || loading}>
{loading ? loadingLabel : children}
</button>
),
}))

Expand All @@ -49,6 +67,22 @@ function renderFirstFrame(search: string, registrationDisabled = false): string
return renderToString(<SSOForm registrationDisabled={registrationDisabled} />)
}

let container: HTMLDivElement
let root: Root

function renderInteractive(search = '') {
mockUseSearchParams.mockReturnValue(new URLSearchParams(search))
act(() => root.render(<SSOForm registrationDisabled={false} />))
}

async function submitForm() {
const form = container.querySelector('form')
expect(form).not.toBeNull()
await act(async () => {
form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
})
}

/**
* `renderToString` produces the markup of the first frame with no effects run,
* which is exactly the window in which a callback URL seeded from an effect is
Expand Down Expand Up @@ -99,3 +133,55 @@ describe('SSOForm signup cross-link', () => {
expect(html).not.toContain('/signup')
})
})

describe('SSOForm sign-in errors', () => {
beforeEach(() => {
mockSsoSignIn.mockReset()
mockUseSearchParams.mockReset()
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

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

it('shows a generic retryable error when Better Auth resolves with a 404', async () => {
mockSsoSignIn.mockResolvedValue({
data: null,
error: {
message: 'No provider found for the issuer',
status: 404,
statusText: 'Not Found',
},
})
renderInteractive('email=user%40example.com')

await submitForm()

expect(container).toHaveTextContent('Unable to start SSO. Check your email and try again.')
expect(container).not.toHaveTextContent('No provider found for the issuer')
const submitButton = container.querySelector<HTMLButtonElement>('button[type="submit"]')
expect(submitButton?.disabled).toBe(false)
expect(submitButton).toHaveTextContent('Continue with SSO')

await submitForm()
expect(mockSsoSignIn).toHaveBeenCalledTimes(2)
})

it('does not expose the message from a rejected sign-in request', async () => {
mockSsoSignIn.mockRejectedValue(new Error('INVALID_EMAIL_DOMAIN'))
renderInteractive('email=user%40example.com')

await submitForm()

expect(container).toHaveTextContent('Unable to start SSO. Check your email and try again.')
expect(container).not.toHaveTextContent('INVALID_EMAIL_DOMAIN')
const submitButton = container.querySelector<HTMLButtonElement>('button[type="submit"]')
expect(submitButton?.disabled).toBe(false)
expect(submitButton).toHaveTextContent('Continue with SSO')
})
})
30 changes: 10 additions & 20 deletions apps/sim/ee/sso/components/sso-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { AuthSubmitButton } from '@/app/(auth)/components'

const logger = createLogger('SSOForm')
const SSO_SIGN_IN_ERROR = 'Unable to start SSO. Check your email and try again.'

const validateEmailField = (emailValue: string): string[] => {
const errors: string[] = []
Expand Down Expand Up @@ -110,33 +111,22 @@ export default function SSOForm({ registrationDisabled }: SSOFormProps) {
try {
const safeCallbackUrl = callbackUrl

await client.signIn.sso({
const result = await client.signIn.sso({
email: emailValue,
callbackURL: safeCallbackUrl,
errorCallbackURL: `/sso?error=sso_failed&callbackUrl=${encodeURIComponent(safeCallbackUrl)}`,
})
} catch (err) {
logger.error('SSO sign-in failed', { error: err, email: emailValue })

let errorMessage = 'SSO sign-in failed. Please try again.'
if (err instanceof Error) {
if (err.message.includes('NO_PROVIDER_FOUND')) {
errorMessage = 'SSO provider not found. Please check your configuration.'
} else if (err.message.includes('INVALID_EMAIL_DOMAIN')) {
errorMessage = 'Email domain not configured for SSO. Please contact your administrator.'
} else if (err.message.includes('network')) {
errorMessage = 'Network error. Please check your connection and try again.'
} else if (err.message.includes('rate limit')) {
errorMessage = 'Too many requests. Please wait a moment before trying again.'
} else if (err.message.includes('SSO_DISABLED')) {
errorMessage = 'SSO authentication is disabled. Please use another sign-in method.'
} else {
errorMessage = err.message
}
if (!result || result.error) {
logger.error('SSO sign-in failed', { error: result?.error, email: emailValue })
setEmailErrors([SSO_SIGN_IN_ERROR])
setShowEmailValidationError(true)
}

setEmailErrors([errorMessage])
} catch (err) {
logger.error('SSO sign-in failed', { error: err, email: emailValue })
setEmailErrors([SSO_SIGN_IN_ERROR])
setShowEmailValidationError(true)
} finally {
setIsLoading(false)
}
}
Expand Down
Loading