Skip to content
Draft
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
89 changes: 89 additions & 0 deletions src/__test__/integration/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import {
forgotPasswordAction,
resendSignupVerificationAction,
signInAction,
signInWithOAuthAction,
signOutAction,
Expand All @@ -20,6 +21,11 @@ const { verifyTurnstileToken } = vi.hoisted(() => ({
verifyTurnstileToken: vi.fn(),
}))

const { kvGetMock, kvSetMock } = vi.hoisted(() => ({
kvGetMock: vi.fn(),
kvSetMock: vi.fn(),
}))

// Mock console.error to prevent output during tests
const originalConsoleError = console.error
console.error = vi.fn()
Expand All @@ -36,6 +42,7 @@ const mockSupabaseClient = {
auth: {
signInWithPassword: vi.fn(),
signUp: vi.fn(),
resend: vi.fn(),
resetPasswordForEmail: vi.fn(),
updateUser: vi.fn(),
signInWithOAuth: vi.fn(),
Expand Down Expand Up @@ -86,6 +93,13 @@ vi.mock('@/lib/captcha/turnstile', () => ({
verifyTurnstileToken,
}))

vi.mock('@/core/shared/clients/kv', () => ({
kv: {
get: kvGetMock,
set: kvSetMock,
},
}))

describe('Auth Actions - Integration Tests', () => {
beforeEach(() => {
vi.resetAllMocks()
Expand All @@ -96,6 +110,8 @@ describe('Auth Actions - Integration Tests', () => {
})
global.fetch = fetchMock as unknown as typeof fetch
verifyTurnstileToken.mockResolvedValue(true)
kvGetMock.mockResolvedValue(null)
kvSetMock.mockResolvedValue('OK')
})

afterEach(() => {
Expand Down Expand Up @@ -383,6 +399,79 @@ describe('Auth Actions - Integration Tests', () => {
}) */
})

describe('Resend Signup Verification Flow', () => {
it('should resend signup verification with callback returnTo', async () => {
mockSupabaseClient.auth.resend.mockResolvedValue({
data: {},
error: null,
})

const result = await resendSignupVerificationAction({
email: 'NewUser@Example.com',
returnTo: '/dashboard/team-123/sandboxes',
})

expect(result).toBeDefined()
expect(result).not.toHaveProperty('serverError')
expect(result).not.toHaveProperty('validationErrors')
expect(mockSupabaseClient.auth.resend).toHaveBeenCalledWith({
type: 'signup',
email: 'newuser@example.com',
options: {
emailRedirectTo:
'https://app.e2b.dev/api/auth/callback?returnTo=%2Fdashboard%2Fteam-123%2Fsandboxes',
},
})
expect(kvGetMock).toHaveBeenCalledTimes(1)
expect(kvSetMock).toHaveBeenCalledWith(
expect.stringContaining('auth:resend-signup-verification:'),
true,
{
ex: 60,
}
)
})

it('should short-circuit resend when cooldown key exists', async () => {
kvGetMock.mockResolvedValue(true)

const result = await resendSignupVerificationAction({
email: 'user@example.com',
})

expect(result).toBeDefined()
expect(result).not.toHaveProperty('serverError')
expect(result).not.toHaveProperty('validationErrors')
expect(mockSupabaseClient.auth.resend).not.toHaveBeenCalled()
expect(kvSetMock).not.toHaveBeenCalled()
})

it('should not fail when provider returns resend error', async () => {
mockSupabaseClient.auth.resend.mockResolvedValue({
data: {},
error: { message: 'security purposes' },
})

const result = await resendSignupVerificationAction({
email: 'user@example.com',
})

expect(result).toBeDefined()
expect(result).not.toHaveProperty('serverError')
expect(result).not.toHaveProperty('validationErrors')
expect(kvSetMock).toHaveBeenCalledTimes(1)
})

it('should return validation errors when email is invalid', async () => {
const result = await resendSignupVerificationAction({
email: 'invalid-email',
})

expect(result).toBeDefined()
expect(result).toHaveProperty('validationErrors')
})
})

describe('OAuth Authentication', () => {
/**
* AUTHENTICATION TEST: Verifies that OAuth sign-in redirects to provider
Expand Down
27 changes: 27 additions & 0 deletions src/app/(auth)/sign-in/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { signInAction } from '@/core/server/actions/auth-actions'
import { signInSchema } from '@/core/server/functions/auth/auth.types'
import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message'
import { OAuthProviders } from '@/features/auth/oauth-provider-buttons'
import { RecoveryView } from '@/features/auth/recovery-view'
import { buildSignInHrefWithEmail } from '@/features/auth/recovery-view/utils'
import { Button } from '@/ui/primitives/button'
import {
Form,
Expand Down Expand Up @@ -58,6 +60,19 @@ export default function Login() {
})

const returnTo = searchParams.get('returnTo') || undefined
const decodedQueryError = decodeURIComponent(searchParams.get('error') || '')
const hasUnconfirmedEmailMessage =
!!message &&
'success' in message &&
message.success === USER_MESSAGES.signInEmailNotConfirmed.message
const hasVerificationLinkError =
decodedQueryError.toLowerCase().includes('verification link') ||
decodedQueryError.toLowerCase().includes('email link has expired')
const shouldShowResendVerification =
hasUnconfirmedEmailMessage || hasVerificationLinkError
const resendInitialEmail =
form.watch('email') || searchParams.get('email') || ''
const backToSignInHref = buildSignInHrefWithEmail(resendInitialEmail)

useEffect(() => {
form.setValue('returnTo', returnTo)
Expand All @@ -84,6 +99,18 @@ export default function Login() {
window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}`
}

if (shouldShowResendVerification) {
return (
<RecoveryView
title="Verify your e-mail"
message={message}
initialEmail={resendInitialEmail}
returnTo={returnTo}
backToSignInHref={backToSignInHref}
/>
)
}

return (
<div className="flex w-full flex-col">
<h1>Sign in</h1>
Expand Down
28 changes: 27 additions & 1 deletion src/app/(auth)/sign-up/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { signUpAction } from '@/core/server/actions/auth-actions'
import { signUpSchema } from '@/core/server/functions/auth/auth.types'
import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message'
import { OAuthProviders } from '@/features/auth/oauth-provider-buttons'
import { RecoveryView } from '@/features/auth/recovery-view'
import { buildSignInHrefWithEmail } from '@/features/auth/recovery-view/utils'
import { TurnstileWidget } from '@/features/auth/turnstile-widget'
import { useTurnstile } from '@/features/auth/use-turnstile'
import { Button } from '@/ui/primitives/button'
Expand All @@ -33,6 +35,7 @@ export default function SignUp() {
'use no memo'

const searchParams = useSearchParams()
const initialSuccess = decodeURIComponent(searchParams.get('success') || '')
const [message, setMessage] = useState<AuthMessage | undefined>(() => {
const error = searchParams.get('error')
const success = searchParams.get('success')
Expand All @@ -41,6 +44,9 @@ export default function SignUp() {

return undefined
})
const [showResendVerification, setShowResendVerification] = useState(
initialSuccess === USER_MESSAGES.signUpVerification.message
)

const turnstileResetRef = useRef<() => void>(() => {})

Expand All @@ -55,6 +61,7 @@ export default function SignUp() {
onSuccess: () => {
turnstileResetRef.current()
setMessage({ success: USER_MESSAGES.signUpVerification.message })
setShowResendVerification(true)
},
onError: ({ error }) => {
turnstileResetRef.current()
Expand All @@ -68,6 +75,9 @@ export default function SignUp() {

const turnstile = useTurnstile(form)
turnstileResetRef.current = turnstile.reset
const resendInitialEmail =
form.watch('email') || searchParams.get('email') || ''
const backToSignInHref = buildSignInHrefWithEmail(resendInitialEmail)

useEffect(() => {
form.setValue('returnTo', returnTo)
Expand All @@ -85,14 +95,30 @@ export default function SignUp() {
}, [searchParams, form])

useEffect(() => {
if (showResendVerification) {
return
}

if (message && 'success' in message && message.success) {
const timer = setTimeout(
() => setMessage(undefined),
getTimeoutMsFromUserMessage(message.success) || 5000
)
return () => clearTimeout(timer)
}
}, [message])
}, [message, showResendVerification])

if (showResendVerification) {
return (
<RecoveryView
title="Verify your e-mail"
message={message}
initialEmail={resendInitialEmail}
returnTo={returnTo}
backToSignInHref={backToSignInHref}
/>
)
}

return (
<div className="flex w-full flex-col">
Expand Down
4 changes: 4 additions & 0 deletions src/configs/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export const KV_KEYS = {
TEAM_SLUG_TO_ID: (slug: string) => `team-slug:${slug}:id`,
TEAM_ID_TO_SLUG: (teamId: string) => `team-id:${teamId}:slug`,
WARNED_ALTERNATE_EMAIL: (email: string) => `warned-alternate-email:${email}`,
AUTH_RESEND_SIGNUP_VERIFICATION_COOLDOWN: (
emailHash: string,
requesterHash: string
) => `auth:resend-signup-verification:${emailHash}:${requesterHash}`,
}

/*
Expand Down
5 changes: 5 additions & 0 deletions src/configs/user-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export const USER_MESSAGES = {
message: 'Check your e-mail for a verification link.',
timeoutMs: 30000,
},
signUpVerificationResend: {
message:
'If an account exists and is awaiting confirmation, we sent a new verification link.',
timeoutMs: 30000,
},
passwordReset: {
message: 'Check your e-mail for a reset link.',
timeoutMs: 30000,
Expand Down
Loading
Loading