diff --git a/src/__test__/integration/auth.test.ts b/src/__test__/integration/auth.test.ts
index 429963f98..7098a9717 100644
--- a/src/__test__/integration/auth.test.ts
+++ b/src/__test__/integration/auth.test.ts
@@ -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,
@@ -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()
@@ -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(),
@@ -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()
@@ -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(() => {
@@ -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
diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx
index 7faa4bf36..696da67a2 100644
--- a/src/app/(auth)/sign-in/page.tsx
+++ b/src/app/(auth)/sign-in/page.tsx
@@ -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,
@@ -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)
@@ -84,6 +99,18 @@ export default function Login() {
window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}`
}
+ if (shouldShowResendVerification) {
+ return (
+
+ )
+ }
+
return (
Sign in
diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx
index 3604c688a..8f9d6028b 100644
--- a/src/app/(auth)/sign-up/page.tsx
+++ b/src/app/(auth)/sign-up/page.tsx
@@ -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'
@@ -33,6 +35,7 @@ export default function SignUp() {
'use no memo'
const searchParams = useSearchParams()
+ const initialSuccess = decodeURIComponent(searchParams.get('success') || '')
const [message, setMessage] = useState
(() => {
const error = searchParams.get('error')
const success = searchParams.get('success')
@@ -41,6 +44,9 @@ export default function SignUp() {
return undefined
})
+ const [showResendVerification, setShowResendVerification] = useState(
+ initialSuccess === USER_MESSAGES.signUpVerification.message
+ )
const turnstileResetRef = useRef<() => void>(() => {})
@@ -55,6 +61,7 @@ export default function SignUp() {
onSuccess: () => {
turnstileResetRef.current()
setMessage({ success: USER_MESSAGES.signUpVerification.message })
+ setShowResendVerification(true)
},
onError: ({ error }) => {
turnstileResetRef.current()
@@ -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)
@@ -85,6 +95,10 @@ export default function SignUp() {
}, [searchParams, form])
useEffect(() => {
+ if (showResendVerification) {
+ return
+ }
+
if (message && 'success' in message && message.success) {
const timer = setTimeout(
() => setMessage(undefined),
@@ -92,7 +106,19 @@ export default function SignUp() {
)
return () => clearTimeout(timer)
}
- }, [message])
+ }, [message, showResendVerification])
+
+ if (showResendVerification) {
+ return (
+
+ )
+ }
return (
diff --git a/src/configs/keys.ts b/src/configs/keys.ts
index ab622ea60..57bf3ad1a 100644
--- a/src/configs/keys.ts
+++ b/src/configs/keys.ts
@@ -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}`,
}
/*
diff --git a/src/configs/user-messages.ts b/src/configs/user-messages.ts
index 3f51a1b55..67b2bc068 100644
--- a/src/configs/user-messages.ts
+++ b/src/configs/user-messages.ts
@@ -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,
diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts
index 88753eb36..607230f78 100644
--- a/src/core/server/actions/auth-actions.ts
+++ b/src/core/server/actions/auth-actions.ts
@@ -1,16 +1,19 @@
'use server'
+import { createHash } from 'node:crypto'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { returnValidationErrors } from 'next-safe-action'
import { z } from 'zod'
import { CAPTCHA_REQUIRED_SERVER } from '@/configs/flags'
+import { KV_KEYS } from '@/configs/keys'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import { USER_MESSAGES } from '@/configs/user-messages'
import { actionClient } from '@/core/server/actions/client'
import { returnServerError } from '@/core/server/actions/utils'
import {
forgotPasswordSchema,
+ resendSignupVerificationSchema,
signInSchema,
signUpSchema,
} from '@/core/server/functions/auth/auth.types'
@@ -18,6 +21,7 @@ import {
shouldWarnAboutAlternateEmail,
validateEmail,
} from '@/core/server/functions/auth/validate-email'
+import { kv } from '@/core/shared/clients/kv'
import { l } from '@/core/shared/clients/logger/logger'
import { supabaseAdmin } from '@/core/shared/clients/supabase/admin'
import { createClient } from '@/core/shared/clients/supabase/server'
@@ -63,6 +67,15 @@ async function checkAuthProviderHealth(): Promise
{
const AUTH_PROVIDER_ERROR_MESSAGE =
'Our authentication provider is experiencing issues. Please try again later.'
+const RESEND_SIGNUP_VERIFICATION_COOLDOWN_SECONDS = 60
+const RESEND_SIGNUP_VERIFICATION_HASH_PREFIX_LENGTH = 24
+
+function hashCooldownPart(value: string): string {
+ return createHash('sha256')
+ .update(value)
+ .digest('hex')
+ .slice(0, RESEND_SIGNUP_VERIFICATION_HASH_PREFIX_LENGTH)
+}
const SignInWithOAuthInputSchema = z.object({
provider: z.union([z.literal('github'), z.literal('google')]),
@@ -331,6 +344,97 @@ export const forgotPasswordAction = actionClient
}
})
+export const resendSignupVerificationAction = actionClient
+ .schema(resendSignupVerificationSchema)
+ .metadata({ actionName: 'resendSignupVerification' })
+ .action(async ({ parsedInput: { email, returnTo = '' } }) => {
+ const headerStore = await headers()
+ const origin = headerStore.get('origin')
+
+ if (!origin) {
+ throw new Error('Origin not found')
+ }
+
+ const normalizedEmail = email.trim().toLowerCase()
+ const requesterIp =
+ headerStore.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown-ip'
+ const requesterUserAgent = headerStore.get('user-agent') ?? 'unknown-agent'
+
+ const emailHash = hashCooldownPart(normalizedEmail)
+ const requesterHash = hashCooldownPart(
+ `${requesterIp}:${requesterUserAgent}`
+ )
+ const cooldownKey = KV_KEYS.AUTH_RESEND_SIGNUP_VERIFICATION_COOLDOWN(
+ emailHash,
+ requesterHash
+ )
+
+ try {
+ const cooldownKeyExists = await kv.get(cooldownKey)
+
+ if (cooldownKeyExists) {
+ return
+ }
+
+ await kv.set(cooldownKey, true, {
+ ex: RESEND_SIGNUP_VERIFICATION_COOLDOWN_SECONDS,
+ })
+ } catch (kvError) {
+ l.warn(
+ {
+ key: 'resend_signup_verification_action:kv_error',
+ error: kvError,
+ context: {
+ email_hash: emailHash,
+ requester_hash: requesterHash,
+ },
+ },
+ 'failed to access resend verification cooldown key'
+ )
+ }
+
+ const callbackUrl = `${origin}${AUTH_URLS.CALLBACK}${returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''}`
+
+ try {
+ const supabase = await createClient()
+ const { error } = await supabase.auth.resend({
+ type: 'signup',
+ email: normalizedEmail,
+ options: {
+ emailRedirectTo: callbackUrl,
+ },
+ })
+
+ if (error) {
+ l.warn(
+ {
+ key: 'resend_signup_verification_action:supabase_error',
+ error,
+ context: {
+ email_hash: emailHash,
+ requester_hash: requesterHash,
+ has_return_to: returnTo.length > 0,
+ },
+ },
+ `failed to resend signup verification email: ${error.message}`
+ )
+ }
+ } catch (error) {
+ l.warn(
+ {
+ key: 'resend_signup_verification_action:unexpected_error',
+ error,
+ context: {
+ email_hash: emailHash,
+ requester_hash: requesterHash,
+ has_return_to: returnTo.length > 0,
+ },
+ },
+ 'unexpected error while resending signup verification email'
+ )
+ }
+ })
+
export async function signOutAction(returnTo?: string) {
const supabase = await createClient()
diff --git a/src/core/server/functions/auth/auth.types.ts b/src/core/server/functions/auth/auth.types.ts
index 42786c86b..2b24e3e22 100644
--- a/src/core/server/functions/auth/auth.types.ts
+++ b/src/core/server/functions/auth/auth.types.ts
@@ -36,3 +36,9 @@ export const forgotPasswordSchema = z.object({
email: emailSchema,
callbackUrl: z.string().optional(),
})
+
+export const resendSignupVerificationSchema = z.object({
+ email: emailSchema,
+ returnTo: relativeUrlSchema.optional(),
+ captchaToken: z.string().optional(),
+})
diff --git a/src/features/auth/recovery-view/index.tsx b/src/features/auth/recovery-view/index.tsx
new file mode 100644
index 000000000..8b400d109
--- /dev/null
+++ b/src/features/auth/recovery-view/index.tsx
@@ -0,0 +1,39 @@
+import Link from 'next/link'
+import type { AuthMessage } from '@/features/auth/form-message'
+import { AuthFormMessage } from '@/features/auth/form-message'
+import { ResendVerificationForm } from '@/features/auth/resend-verification'
+
+type RecoveryViewProps = {
+ title: string
+ message?: AuthMessage
+ initialEmail: string
+ returnTo?: string
+ backToSignInHref: string
+}
+
+export function RecoveryView({
+ title,
+ message,
+ initialEmail,
+ returnTo,
+ backToSignInHref,
+}: RecoveryViewProps) {
+ return (
+
+
{title}
+ {message &&
}
+
+
+
+ Back to sign in
+
+ .
+
+
+ )
+}
diff --git a/src/features/auth/recovery-view/utils.ts b/src/features/auth/recovery-view/utils.ts
new file mode 100644
index 000000000..fd929318c
--- /dev/null
+++ b/src/features/auth/recovery-view/utils.ts
@@ -0,0 +1,11 @@
+import { AUTH_URLS } from '@/configs/urls'
+
+export function buildSignInHrefWithEmail(email: string): string {
+ const normalizedEmail = email.trim()
+ if (!normalizedEmail) {
+ return AUTH_URLS.SIGN_IN
+ }
+
+ const params = new URLSearchParams({ email: normalizedEmail })
+ return `${AUTH_URLS.SIGN_IN}?${params.toString()}`
+}
diff --git a/src/features/auth/resend-verification/constants.ts b/src/features/auth/resend-verification/constants.ts
new file mode 100644
index 000000000..505de16e4
--- /dev/null
+++ b/src/features/auth/resend-verification/constants.ts
@@ -0,0 +1,3 @@
+export const RESEND_VERIFICATION_COOLDOWN_SECONDS = 60
+export const RESEND_VERIFICATION_BUTTON_LABEL = 'Resend verification e-mail'
+export const RESEND_VERIFICATION_LOADING_LABEL = 'Sending...'
diff --git a/src/features/auth/resend-verification/hooks.ts b/src/features/auth/resend-verification/hooks.ts
new file mode 100644
index 000000000..8476ecf80
--- /dev/null
+++ b/src/features/auth/resend-verification/hooks.ts
@@ -0,0 +1,42 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+
+export function useResendVerificationCooldown(cooldownSeconds: number) {
+ const [cooldownEndsAt, setCooldownEndsAt] = useState(null)
+ const [secondsLeft, setSecondsLeft] = useState(0)
+
+ useEffect(() => {
+ if (!cooldownEndsAt) {
+ return
+ }
+
+ const updateSecondsLeft = () => {
+ const nextSecondsLeft = Math.ceil((cooldownEndsAt - Date.now()) / 1000)
+
+ if (nextSecondsLeft <= 0) {
+ setCooldownEndsAt(null)
+ setSecondsLeft(0)
+ return
+ }
+
+ setSecondsLeft(nextSecondsLeft)
+ }
+
+ updateSecondsLeft()
+ const intervalId = window.setInterval(updateSecondsLeft, 1000)
+
+ return () => window.clearInterval(intervalId)
+ }, [cooldownEndsAt])
+
+ const startCooldown = useCallback(() => {
+ setCooldownEndsAt(Date.now() + cooldownSeconds * 1000)
+ setSecondsLeft(cooldownSeconds)
+ }, [cooldownSeconds])
+
+ return {
+ secondsLeft,
+ isCoolingDown: secondsLeft > 0,
+ startCooldown,
+ }
+}
diff --git a/src/features/auth/resend-verification/index.tsx b/src/features/auth/resend-verification/index.tsx
new file mode 100644
index 000000000..258a266b2
--- /dev/null
+++ b/src/features/auth/resend-verification/index.tsx
@@ -0,0 +1,115 @@
+'use client'
+
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks'
+import { useEffect, useState } from 'react'
+import { USER_MESSAGES } from '@/configs/user-messages'
+import { resendSignupVerificationAction } from '@/core/server/actions/auth-actions'
+import { resendSignupVerificationSchema } from '@/core/server/functions/auth/auth.types'
+import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message'
+import { Button } from '@/ui/primitives/button'
+import { Input } from '@/ui/primitives/input'
+import { Label } from '@/ui/primitives/label'
+import { Separator } from '@/ui/primitives/separator'
+import {
+ RESEND_VERIFICATION_BUTTON_LABEL,
+ RESEND_VERIFICATION_COOLDOWN_SECONDS,
+ RESEND_VERIFICATION_LOADING_LABEL,
+} from './constants'
+import { useResendVerificationCooldown } from './hooks'
+
+type ResendVerificationProps = {
+ initialEmail?: string
+ returnTo?: string
+ className?: string
+ showDivider?: boolean
+}
+
+export function ResendVerificationForm({
+ initialEmail,
+ returnTo,
+ className,
+ showDivider = true,
+}: ResendVerificationProps) {
+ const [message, setMessage] = useState()
+ const { isCoolingDown, secondsLeft, startCooldown } =
+ useResendVerificationCooldown(RESEND_VERIFICATION_COOLDOWN_SECONDS)
+
+ const {
+ form,
+ handleSubmitWithAction,
+ action: { isExecuting },
+ } = useHookFormAction(
+ resendSignupVerificationAction,
+ zodResolver(resendSignupVerificationSchema),
+ {
+ actionProps: {
+ onSuccess: () => {
+ setMessage({
+ success: USER_MESSAGES.signUpVerificationResend.message,
+ })
+ startCooldown()
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ setMessage({ error: error.serverError })
+ }
+ },
+ },
+ }
+ )
+
+ useEffect(() => {
+ if (initialEmail && !form.getValues('email')) {
+ form.setValue('email', initialEmail)
+ }
+ }, [initialEmail, form])
+
+ useEffect(() => {
+ form.setValue('returnTo', returnTo)
+ }, [returnTo, form])
+
+ return (
+
+ {showDivider && (
+
+
+
+ )}
+
+ Didn't get the verification email?
+
+
+
+
+ {message &&
}
+
+ )
+}