Skip to content
Merged
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
269 changes: 133 additions & 136 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
"@storybook/react": "^8.5.2",
"@storybook/test": "^8.5.2",
"@storybook/theming": "^8.5.2",
"@tailwindcss/postcss": "^4.0.6",
"@tailwindcss/postcss": "^4.0.15",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/bun": "^1.2.5",
Expand All @@ -148,8 +148,7 @@
"server-cli-only": "^0.3.2",
"storybook": "^8.5.2",
"storybook-dark-mode": "^4.0.2",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^4.0.6",
"tailwindcss": "^4.0.15",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.2",
"typescript": "5.7.3",
Expand Down
20 changes: 20 additions & 0 deletions src/__test__/integration/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import { redirect } from 'next/navigation'
import { encodedRedirect } from '@/lib/utils/auth'

// Create hoisted mock functions that can be used throughout the file
const { validateEmail, shouldWarnAboutAlternateEmail } = vi.hoisted(() => ({
validateEmail: vi.fn(),
shouldWarnAboutAlternateEmail: vi.fn(),
}))

// Mock console.error to prevent output during tests
const originalConsoleError = console.error
console.error = vi.fn()
Expand Down Expand Up @@ -59,6 +65,12 @@ vi.mock('@/lib/utils/auth', () => ({
})),
}))

// Use the hoisted mock functions in the module mock
vi.mock('@/server/auth/validate-email', () => ({
validateEmail,
shouldWarnAboutAlternateEmail,
}))

describe('Auth Actions - Integration Tests', () => {
beforeEach(() => {
vi.resetAllMocks()
Expand Down Expand Up @@ -149,6 +161,14 @@ describe('Auth Actions - Integration Tests', () => {
* shows success message
*/
it('should show success message on valid sign-up', async () => {
// Set up mock implementations for this specific test
validateEmail.mockResolvedValue({
valid: true,
data: { status: 'valid', address: '[email protected]' },
})

shouldWarnAboutAlternateEmail.mockResolvedValue(false)

// Setup: Mock Supabase client to return successful sign-up
mockSupabaseClient.auth.signUp.mockResolvedValue({
data: { user: { id: 'new-user-123' } },
Expand Down
1 change: 1 addition & 0 deletions src/configs/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ export const KV_KEYS = {
`user-team-access:${userId}:${teamIdOrSlug}`,
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}`,
}
1 change: 1 addition & 0 deletions src/configs/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const ERROR_CODES = {
URL_REWRITE: 'URL_REWRITE_ERROR',
INFRA: 'INFRA_ERROR',
GUARD: 'GUARD_ERROR',
EMAIL_VALIDATION: 'EMAIL_VALIDATION_ERROR',
} as const

export const INFO_CODES = {
Expand Down
6 changes: 3 additions & 3 deletions src/features/auth/form-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,23 @@ export function AuthFormMessage({
>
{'success' in message && (
<Alert variant="contrast1">
<CheckCircle2 className="h-4 w-4" />
<CheckCircle2 className="text-contrast-1 h-4 w-4" />
<AlertDescription>
{decodeURIComponent(message.success!)}
</AlertDescription>
</Alert>
)}
{'error' in message && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertCircle className="text-error h-4 w-4" />
<AlertDescription>
{decodeURIComponent(message.error!)}
</AlertDescription>
</Alert>
)}
{'message' in message && (
<Alert variant="contrast2">
<Info className="h-4 w-4" />
<Info className="text-contrast-2 h-4 w-4" />
<AlertDescription>
{decodeURIComponent(message.message!)}
</AlertDescription>
Expand Down
25 changes: 25 additions & 0 deletions src/server/auth/auth-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { actionClient } from '@/lib/clients/action'
import { returnServerError } from '@/lib/utils/action'
import { z } from 'zod'
import { zfd } from 'zod-form-data'
import {
shouldWarnAboutAlternateEmail,
validateEmail,
} from '@/server/auth/validate-email'

export const signInWithOAuthAction = actionClient
.schema(
Expand Down Expand Up @@ -78,11 +82,32 @@ export const signUpAction = actionClient
const supabase = await createClient()
const origin = (await headers()).get('origin') || ''

const validationResult = await validateEmail(email)

if (validationResult?.data) {
if (!validationResult.valid) {
return returnServerError(
'Please use a valid email address - your company email works best'
)
}

if (await shouldWarnAboutAlternateEmail(validationResult.data)) {
return returnServerError(
'Is this a secondary email? Use your primary email for fast access'
)
}
}

const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${origin}${AUTH_URLS.CALLBACK}${returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''}`,
data: validationResult?.data
? {
email_validation: validationResult?.data,
}
: undefined,
},
})

Expand Down
114 changes: 114 additions & 0 deletions src/server/auth/validate-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { KV_KEYS } from '@/configs/keys'
import { ERROR_CODES } from '@/configs/logs'
import { kv } from '@vercel/kv'

/**
* Response type from the ZeroBounce email validation API
*/
export type EmailValidationResponse = {
address: string
status: string
sub_status: string
free_email: boolean
account: string
domain: string
mx_found: boolean
did_you_mean: string | null
domain_age_days: string | null
active_in_days: string | null
smtp_provider: string | null
mx_record: string | null
firstname: string | null
lastname: string | null
gender: string | null
country: string | null
region: string | null
city: string | null
zipcode: string | null
processed_at: string
}

/**
* Validates an email address using the ZeroBounce API
*
* This function checks if an email is deliverable and safe to use by querying
* the ZeroBounce validation service. It handles various email statuses including
* invalid addresses, spam traps, and abusive accounts.
*
* @param email - The email address to validate
* @returns An object containing validation result and response data, or null
* - Object with `{ valid: boolean, data: EmailValidationResponse }` when validation succeeds
* - `null` if validation couldn't be performed (API key missing or error occurred)
* This allows for graceful degradation when email validation is unavailable
*
* @example
* const result = await validateEmail("[email protected]");
* if (result === null) {
* // Validation service unavailable
* } else if (result.valid) {
* // Email is valid
* } else {
* // Email is invalid
* }
*/
export async function validateEmail(
email: string
): Promise<{ valid: boolean; data: EmailValidationResponse } | null> {
if (!process.env.ZEROBOUNCE_API_KEY) {
return null
}

try {
const response = await fetch(
`https://api.zerobounce.net/v2/validate?api_key=${process.env.ZEROBOUNCE_API_KEY}&email=${email}&ip_address=`
)
// Parse the JSON response from the ZeroBounce API
const responseData = await response.json()

// Convert the mx_found string value to a boolean if it's 'true' or 'false'
// Otherwise keep the original value (could be null or another value)
const data = {
...responseData,
mx_found:
responseData.mx_found === 'true'
? true
: responseData.mx_found === 'false'
? false
: responseData.mx_found,
} as EmailValidationResponse

switch (data.status) {
case 'invalid':
case 'spamtrap':
case 'abuse':
case 'do_not_mail':
return { valid: false, data }
default:
return { valid: true, data }
}
} catch (error) {
console.error(ERROR_CODES.EMAIL_VALIDATION, error)
return null
}
}

export const shouldWarnAboutAlternateEmail = async (
validationResult: EmailValidationResponse
): Promise<boolean> => {
if (validationResult.sub_status === 'alternate') {
const warnedAlternateEmail = await kv.get(
KV_KEYS.WARNED_ALTERNATE_EMAIL(validationResult.address)
)

if (!warnedAlternateEmail) {
await kv.set(
KV_KEYS.WARNED_ALTERNATE_EMAIL(validationResult.address),
true
)

return true
}
}

return false
}
2 changes: 1 addition & 1 deletion src/ui/primitives/alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const AlertDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-fg-500 text-sm [&_p]:leading-relaxed', className)}
className={cn('text-fg-300 text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
))
Expand Down