mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-31 01:11:53 +08:00
fix(auth): recover from SSO provider lookup errors (#6966)
Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
This commit is contained in:
@@ -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 }) => <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>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)}`,
|
||||
})
|
||||
|
||||
if (!result || result.error) {
|
||||
logger.error('SSO sign-in failed', { error: result?.error, email: emailValue })
|
||||
setEmailErrors([SSO_SIGN_IN_ERROR])
|
||||
setShowEmailValidationError(true)
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
setEmailErrors([errorMessage])
|
||||
setEmailErrors([SSO_SIGN_IN_ERROR])
|
||||
setShowEmailValidationError(true)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user