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 && } +
+ ) +}