diff --git a/apps/sim/ee/sso/components/sso-form.test.tsx b/apps/sim/ee/sso/components/sso-form.test.tsx index 7a6f2dc0af7..88a010706d6 100644 --- a/apps/sim/ee/sso/components/sso-form.test.tsx +++ b/apps/sim/ee/sso/components/sso-form.test.tsx @@ -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(), })) @@ -21,19 +23,35 @@ vi.mock('next/link', () => ({ })) vi.mock('@sim/emcn', () => ({ - Button: ({ children }: { children?: ReactNode }) => , - Input: () => , + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + + ), + Input: (props: InputHTMLAttributes) => , Label: ({ children }: { children?: ReactNode }) => {children}, 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 }) => ( - + AuthSubmitButton: ({ + children, + disabled = false, + loading = false, + loadingLabel, + }: { + children?: ReactNode + disabled?: boolean + loading?: boolean + loadingLabel: string + }) => ( + ), })) @@ -49,6 +67,22 @@ function renderFirstFrame(search: string, registrationDisabled = false): string return renderToString() } +let container: HTMLDivElement +let root: Root + +function renderInteractive(search = '') { + mockUseSearchParams.mockReturnValue(new URLSearchParams(search)) + act(() => root.render()) +} + +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 @@ -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('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('button[type="submit"]') + expect(submitButton?.disabled).toBe(false) + expect(submitButton).toHaveTextContent('Continue with SSO') + }) +}) diff --git a/apps/sim/ee/sso/components/sso-form.tsx b/apps/sim/ee/sso/components/sso-form.tsx index f2c3cd5f4c6..ca20d1db5e4 100644 --- a/apps/sim/ee/sso/components/sso-form.tsx +++ b/apps/sim/ee/sso/components/sso-form.tsx @@ -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[] = [] @@ -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) } }