From e4dcb1c6925b4a96d8cf605cfdb9b5c27b44d1de Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Wed, 29 Apr 2026 17:50:48 -0700
Subject: [PATCH 1/3] add: safe email verification resend recovery flow
---
src/__test__/integration/auth.test.ts | 89 +++++++++++++
src/app/(auth)/sign-in/page.tsx | 19 +++
src/app/(auth)/sign-up/page.tsx | 13 ++
src/configs/keys.ts | 4 +
src/configs/user-messages.ts | 5 +
src/core/server/actions/auth-actions.ts | 102 ++++++++++++++
src/core/server/functions/auth/auth.types.ts | 6 +
.../auth/resend-verification/constants.ts | 3 +
.../auth/resend-verification/hooks.ts | 42 ++++++
.../auth/resend-verification/index.tsx | 124 ++++++++++++++++++
10 files changed, 407 insertions(+)
create mode 100644 src/features/auth/resend-verification/constants.ts
create mode 100644 src/features/auth/resend-verification/hooks.ts
create mode 100644 src/features/auth/resend-verification/index.tsx
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..4034cd7ea 100644
--- a/src/app/(auth)/sign-in/page.tsx
+++ b/src/app/(auth)/sign-in/page.tsx
@@ -11,6 +11,7 @@ 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 { ResendVerificationForm } from '@/features/auth/resend-verification'
import { Button } from '@/ui/primitives/button'
import {
Form,
@@ -58,6 +59,18 @@ 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') || ''
useEffect(() => {
form.setValue('returnTo', returnTo)
@@ -167,6 +180,12 @@ export default function Login() {
{message && }
+ {shouldShowResendVerification && (
+
+ )}
)
}
diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx
index 3604c688a..f24fda4cd 100644
--- a/src/app/(auth)/sign-up/page.tsx
+++ b/src/app/(auth)/sign-up/page.tsx
@@ -15,6 +15,7 @@ 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 { ResendVerificationForm } from '@/features/auth/resend-verification'
import { TurnstileWidget } from '@/features/auth/turnstile-widget'
import { useTurnstile } from '@/features/auth/use-turnstile'
import { Button } from '@/ui/primitives/button'
@@ -68,6 +69,12 @@ export default function SignUp() {
const turnstile = useTurnstile(form)
turnstileResetRef.current = turnstile.reset
+ const shouldShowResendVerification =
+ !!message &&
+ 'success' in message &&
+ message.success === USER_MESSAGES.signUpVerification.message
+ const resendInitialEmail =
+ form.watch('email') || searchParams.get('email') || ''
useEffect(() => {
form.setValue('returnTo', returnTo)
@@ -209,6 +216,12 @@ export default function SignUp() {
{message && }
+ {shouldShowResendVerification && (
+
+ )}
)
}
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..3357f32cc 100644
--- a/src/core/server/actions/auth-actions.ts
+++ b/src/core/server/actions/auth-actions.ts
@@ -1,8 +1,10 @@
'use server'
+import { createHash } from 'node:crypto'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { returnValidationErrors } from 'next-safe-action'
+import { KV_KEYS } from '@/configs/keys'
import { z } from 'zod'
import { CAPTCHA_REQUIRED_SERVER } from '@/configs/flags'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
@@ -11,6 +13,7 @@ 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,95 @@ 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/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..70d2e0dae
--- /dev/null
+++ b/src/features/auth/resend-verification/index.tsx
@@ -0,0 +1,124 @@
+'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 {
+ getTimeoutMsFromUserMessage,
+ 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 {
+ 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
+}
+
+export function ResendVerificationForm({
+ initialEmail,
+ returnTo,
+ className,
+}: 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])
+
+ useEffect(() => {
+ if (
+ message &&
+ (('success' in message && message.success) ||
+ ('error' in message && message.error))
+ ) {
+ const content =
+ 'success' in message
+ ? message.success || ''
+ : 'error' in message
+ ? message.error || ''
+ : ''
+ const timeoutMs = getTimeoutMsFromUserMessage(content) || 5000
+ const timeout = setTimeout(() => setMessage(undefined), timeoutMs)
+ return () => clearTimeout(timeout)
+ }
+ }, [message])
+
+ return (
+
+
+ Didn't get the verification email?
+
+
+
+
+ {message &&
}
+
+ )
+}
From a198dfa05079b623fbd57fb4d01d61d65702e3c8 Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Wed, 29 Apr 2026 17:56:56 -0700
Subject: [PATCH 2/3] chore: format
---
src/core/server/actions/auth-actions.ts | 6 ++++--
src/features/auth/resend-verification/index.tsx | 8 ++++++--
2 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts
index 3357f32cc..607230f78 100644
--- a/src/core/server/actions/auth-actions.ts
+++ b/src/core/server/actions/auth-actions.ts
@@ -4,9 +4,9 @@ import { createHash } from 'node:crypto'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { returnValidationErrors } from 'next-safe-action'
-import { KV_KEYS } from '@/configs/keys'
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'
@@ -361,7 +361,9 @@ export const resendSignupVerificationAction = actionClient
const requesterUserAgent = headerStore.get('user-agent') ?? 'unknown-agent'
const emailHash = hashCooldownPart(normalizedEmail)
- const requesterHash = hashCooldownPart(`${requesterIp}:${requesterUserAgent}`)
+ const requesterHash = hashCooldownPart(
+ `${requesterIp}:${requesterUserAgent}`
+ )
const cooldownKey = KV_KEYS.AUTH_RESEND_SIGNUP_VERIFICATION_COOLDOWN(
emailHash,
requesterHash
diff --git a/src/features/auth/resend-verification/index.tsx b/src/features/auth/resend-verification/index.tsx
index 70d2e0dae..bba582dc9 100644
--- a/src/features/auth/resend-verification/index.tsx
+++ b/src/features/auth/resend-verification/index.tsx
@@ -45,7 +45,9 @@ export function ResendVerificationForm({
{
actionProps: {
onSuccess: () => {
- setMessage({ success: USER_MESSAGES.signUpVerificationResend.message })
+ setMessage({
+ success: USER_MESSAGES.signUpVerificationResend.message,
+ })
startCooldown()
},
onError: ({ error }) => {
@@ -86,7 +88,9 @@ export function ResendVerificationForm({
}, [message])
return (
-
+
Didn't get the verification email?
From a5ac297536e371a23bd51ab939f35412ad81cfe8 Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Wed, 29 Apr 2026 18:40:24 -0700
Subject: [PATCH 3/3] refactor: improve ux around email verification
---
src/app/(auth)/sign-in/page.tsx | 22 +++++++----
src/app/(auth)/sign-up/page.tsx | 37 ++++++++++++------
src/features/auth/recovery-view/index.tsx | 39 +++++++++++++++++++
src/features/auth/recovery-view/utils.ts | 11 ++++++
.../auth/resend-verification/index.tsx | 31 +++++----------
5 files changed, 99 insertions(+), 41 deletions(-)
create mode 100644 src/features/auth/recovery-view/index.tsx
create mode 100644 src/features/auth/recovery-view/utils.ts
diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx
index 4034cd7ea..696da67a2 100644
--- a/src/app/(auth)/sign-in/page.tsx
+++ b/src/app/(auth)/sign-in/page.tsx
@@ -11,7 +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 { ResendVerificationForm } from '@/features/auth/resend-verification'
+import { RecoveryView } from '@/features/auth/recovery-view'
+import { buildSignInHrefWithEmail } from '@/features/auth/recovery-view/utils'
import { Button } from '@/ui/primitives/button'
import {
Form,
@@ -71,6 +72,7 @@ export default function Login() {
hasUnconfirmedEmailMessage || hasVerificationLinkError
const resendInitialEmail =
form.watch('email') || searchParams.get('email') || ''
+ const backToSignInHref = buildSignInHrefWithEmail(resendInitialEmail)
useEffect(() => {
form.setValue('returnTo', returnTo)
@@ -97,6 +99,18 @@ export default function Login() {
window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}`
}
+ if (shouldShowResendVerification) {
+ return (
+
+ )
+ }
+
return (
Sign in
@@ -180,12 +194,6 @@ export default function Login() {
{message &&
}
- {shouldShowResendVerification && (
-
- )}
)
}
diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx
index f24fda4cd..8f9d6028b 100644
--- a/src/app/(auth)/sign-up/page.tsx
+++ b/src/app/(auth)/sign-up/page.tsx
@@ -15,7 +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 { ResendVerificationForm } from '@/features/auth/resend-verification'
+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'
@@ -34,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')
@@ -42,6 +44,9 @@ export default function SignUp() {
return undefined
})
+ const [showResendVerification, setShowResendVerification] = useState(
+ initialSuccess === USER_MESSAGES.signUpVerification.message
+ )
const turnstileResetRef = useRef<() => void>(() => {})
@@ -56,6 +61,7 @@ export default function SignUp() {
onSuccess: () => {
turnstileResetRef.current()
setMessage({ success: USER_MESSAGES.signUpVerification.message })
+ setShowResendVerification(true)
},
onError: ({ error }) => {
turnstileResetRef.current()
@@ -69,12 +75,9 @@ export default function SignUp() {
const turnstile = useTurnstile(form)
turnstileResetRef.current = turnstile.reset
- const shouldShowResendVerification =
- !!message &&
- 'success' in message &&
- message.success === USER_MESSAGES.signUpVerification.message
const resendInitialEmail =
form.watch('email') || searchParams.get('email') || ''
+ const backToSignInHref = buildSignInHrefWithEmail(resendInitialEmail)
useEffect(() => {
form.setValue('returnTo', returnTo)
@@ -92,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),
@@ -99,7 +106,19 @@ export default function SignUp() {
)
return () => clearTimeout(timer)
}
- }, [message])
+ }, [message, showResendVerification])
+
+ if (showResendVerification) {
+ return (
+
+ )
+ }
return (
@@ -216,12 +235,6 @@ export default function SignUp() {
{message &&
}
- {shouldShowResendVerification && (
-
- )}
)
}
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/index.tsx b/src/features/auth/resend-verification/index.tsx
index bba582dc9..258a266b2 100644
--- a/src/features/auth/resend-verification/index.tsx
+++ b/src/features/auth/resend-verification/index.tsx
@@ -3,16 +3,14 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks'
import { useEffect, useState } from 'react'
-import {
- getTimeoutMsFromUserMessage,
- USER_MESSAGES,
-} from '@/configs/user-messages'
+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,
@@ -24,12 +22,14 @@ 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 } =
@@ -69,28 +69,15 @@ export function ResendVerificationForm({
form.setValue('returnTo', returnTo)
}, [returnTo, form])
- useEffect(() => {
- if (
- message &&
- (('success' in message && message.success) ||
- ('error' in message && message.error))
- ) {
- const content =
- 'success' in message
- ? message.success || ''
- : 'error' in message
- ? message.error || ''
- : ''
- const timeoutMs = getTimeoutMsFromUserMessage(content) || 5000
- const timeout = setTimeout(() => setMessage(undefined), timeoutMs)
- return () => clearTimeout(timeout)
- }
- }, [message])
-
return (
+ {showDivider && (
+
+
+
+ )}
Didn't get the verification email?