fix(security): strengthen email invite validation logic, fix invite page UI (#1162)

* fix(security): strengthen email ivnite validation logic, fix invite page UI

* ui
This commit is contained in:
Waleed
2025-08-28 00:03:03 -07:00
committed by GitHub
parent 104d34cc9e
commit bda8ee772a
7 changed files with 364 additions and 105 deletions
@@ -190,6 +190,11 @@ export async function PUT(
)
}
// Prevent admins from changing other admins' roles - only owners can modify admin roles
if (targetMember[0].role === 'admin' && userMember[0].role !== 'owner') {
return NextResponse.json({ error: 'Only owners can change admin roles' }, { status: 403 })
}
// Update member role
const updatedMember = await db
.update(member)
@@ -5,7 +5,7 @@ import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { invitation, member, permissions, workspaceInvitation } from '@/db/schema'
import { invitation, member, permissions, user, workspaceInvitation } from '@/db/schema'
const logger = createLogger('OrganizationInvitationAcceptanceAPI')
@@ -70,11 +70,33 @@ export async function GET(req: NextRequest) {
)
}
// Get user data to check email verification status
const userData = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (userData.length === 0) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=user-not-found',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Check if user's email is verified
if (!userData[0].emailVerified) {
return NextResponse.redirect(
new URL(
`/invite/invite-error?reason=email-not-verified&details=${encodeURIComponent(`You must verify your email address (${userData[0].email}) before accepting invitations.`)}`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Verify the email matches the current user
if (orgInvitation.email !== session.user.email) {
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=email-mismatch',
`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${orgInvitation.email}, but you're logged in as ${userData[0].email}`)}`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
@@ -235,6 +257,24 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 })
}
// Get user data to check email verification status
const userData = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (userData.length === 0) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Check if user's email is verified
if (!userData[0].emailVerified) {
return NextResponse.json(
{
error: 'Email not verified',
message: `You must verify your email address (${userData[0].email}) before accepting invitations.`,
},
{ status: 403 }
)
}
if (orgInvitation.email !== session.user.email) {
return NextResponse.json({ error: 'Email mismatch' }, { status: 403 })
}
@@ -11,7 +11,6 @@ export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get('token')
if (!token) {
// Redirect to a page explaining the error
return NextResponse.redirect(
new URL(
'/invite/invite-error?reason=missing-token',
@@ -68,40 +67,39 @@ export async function GET(req: NextRequest) {
const userEmail = session.user.email.toLowerCase()
const invitationEmail = invitation.email.toLowerCase()
// Check if the logged-in user's email matches the invitation
// We'll use exact matching as the primary check
const isExactMatch = userEmail === invitationEmail
// For SSO or company email variants, check domain and normalized username
// This handles cases like john.doe@company.com vs john@company.com
const normalizeUsername = (email: string): string => {
return email
.split('@')[0]
.replace(/[^a-zA-Z0-9]/g, '')
.toLowerCase()
}
const isSameDomain = userEmail.split('@')[1] === invitationEmail.split('@')[1]
const normalizedUserEmail = normalizeUsername(userEmail)
const normalizedInvitationEmail = normalizeUsername(invitationEmail)
const isSimilarUsername =
normalizedUserEmail === normalizedInvitationEmail ||
normalizedUserEmail.includes(normalizedInvitationEmail) ||
normalizedInvitationEmail.includes(normalizedUserEmail)
const isValidMatch = isExactMatch || (isSameDomain && isSimilarUsername)
if (!isValidMatch) {
// Get user info to include in the error message
const userData = await db
.select()
.from(user)
.where(eq(user.id, session.user.id))
.then((rows) => rows[0])
// Get user data to check email verification status and for error messages
const userData = await db
.select()
.from(user)
.where(eq(user.id, session.user.id))
.then((rows) => rows[0])
if (!userData) {
return NextResponse.redirect(
new URL(
`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${invitation.email}, but you're logged in as ${userData?.email || session.user.email}`)}`,
'/invite/invite-error?reason=user-not-found',
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Check if user's email is verified
if (!userData.emailVerified) {
return NextResponse.redirect(
new URL(
`/invite/invite-error?reason=email-not-verified&details=${encodeURIComponent(`You must verify your email address (${userData.email}) before accepting invitations.`)}`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
}
// Check if the logged-in user's email matches the invitation
const isValidMatch = userEmail === invitationEmail
if (!isValidMatch) {
return NextResponse.redirect(
new URL(
`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${invitation.email}, but you're logged in as ${userData.email}`)}`,
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
)
)
+2 -2
View File
@@ -122,8 +122,8 @@
--popover-foreground: 0 0% 98%;
/* Primary Colors */
--primary: 0 0% 98%;
--primary-foreground: 0 0% 11.2%;
--primary: 0 0% 11.2%;
--primary-foreground: 0 0% 98%;
/* Secondary Colors */
--secondary: 0 0% 12.0%;
+202 -50
View File
@@ -1,12 +1,13 @@
'use client'
import { useEffect, useState } from 'react'
import { BotIcon, CheckCircle } from 'lucide-react'
import { AlertCircle, CheckCircle2, Mail, UserPlus, Users2 } from 'lucide-react'
import Image from 'next/image'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { LoadingAgent } from '@/components/ui/loading-agent'
import { client, useSession } from '@/lib/auth-client'
import { useBrandConfig } from '@/lib/branding/branding'
export default function Invite() {
const router = useRouter()
@@ -14,6 +15,7 @@ export default function Invite() {
const inviteId = params.id as string
const searchParams = useSearchParams()
const { data: session, isPending } = useSession()
const brandConfig = useBrandConfig()
const [invitationDetails, setInvitationDetails] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -174,28 +176,46 @@ export default function Invite() {
const callbackUrl = encodeURIComponent(getCallbackUrl())
return (
<div className='flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4'>
<Card className='w-full max-w-md p-6'>
<CardHeader className='px-0 pt-0 text-center'>
<CardTitle>You've been invited to join a workspace</CardTitle>
<CardDescription>
{isNewUser
? 'Create an account to join this workspace on Sim'
: 'Sign in to your account to accept this invitation'}
</CardDescription>
</CardHeader>
<CardFooter className='flex flex-col space-y-2 px-0'>
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
<div className='mb-8'>
<Image
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
alt='Sim Logo'
width={120}
height={67}
className='dark:invert'
priority
/>
</div>
<div className='flex w-full max-w-md flex-col items-center text-center'>
<div className='mb-6 rounded-full bg-blue-50 p-3 dark:bg-blue-950/20'>
<UserPlus className='h-8 w-8 text-blue-500 dark:text-blue-400' />
</div>
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>
You've been invited!
</h1>
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
{isNewUser
? 'Create an account to join this workspace on Sim'
: 'Sign in to your account to accept this invitation'}
</p>
<div className='flex w-full flex-col gap-3'>
{isNewUser ? (
<>
<Button
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
onClick={() => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`)}
>
Create an account
</Button>
<Button
variant='outline'
className='w-full'
className='w-full border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white'
onClick={() => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)}
>
I already have an account
@@ -205,13 +225,14 @@ export default function Invite() {
<>
<Button
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
onClick={() => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)}
>
Sign in
</Button>
<Button
variant='outline'
className='w-full'
className='w-full border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white'
onClick={() =>
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`)
}
@@ -220,8 +241,23 @@ export default function Invite() {
</Button>
</>
)}
</CardFooter>
</Card>
<Button
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
onClick={() => router.push('/')}
>
Return to Home
</Button>
</div>
</div>
<footer className='mt-8 text-center text-gray-500 text-xs'>
Need help?{' '}
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
Contact support
</a>
</footer>
</div>
)
}
@@ -229,9 +265,26 @@ export default function Invite() {
// Show loading state
if (isLoading || isPending) {
return (
<div className='flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4'>
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
<div className='mb-8'>
<Image
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
alt='Sim Logo'
width={120}
height={67}
className='dark:invert'
priority
/>
</div>
<LoadingAgent size='lg' />
<p className='mt-4 text-muted-foreground text-sm'>Loading invitation...</p>
<p className='mt-4 text-gray-400 text-sm'>Loading invitation...</p>
<footer className='mt-8 text-center text-gray-500 text-xs'>
Need help?{' '}
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
Contact support
</a>
</footer>
</div>
)
}
@@ -239,14 +292,41 @@ export default function Invite() {
// Show error state
if (error) {
return (
<div className='flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4'>
<Card className='max-w-md space-y-2 p-6 text-center'>
<div className='flex justify-center'>
<BotIcon className='h-16 w-16 text-muted-foreground' />
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
<div className='mb-8'>
<Image
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
alt='Sim Logo'
width={120}
height={67}
className='dark:invert'
priority
/>
</div>
<div className='flex w-full max-w-md flex-col items-center text-center'>
<div className='mb-6 rounded-full bg-red-50 p-3 dark:bg-red-950/20'>
<AlertCircle className='h-8 w-8 text-red-500 dark:text-red-400' />
</div>
<h3 className='font-semibold text-lg'>Invitation Error</h3>
<p className='text-muted-foreground'>{error}</p>
</Card>
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>
Invitation Error
</h1>
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>{error}</p>
<Button
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
onClick={() => router.push('/')}
>
Return to Home
</Button>
</div>
<footer className='mt-8 text-center text-gray-500 text-xs'>
Need help?{' '}
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
Contact support
</a>
</footer>
</div>
)
}
@@ -254,41 +334,113 @@ export default function Invite() {
// Show success state
if (accepted) {
return (
<div className='flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4'>
<Card className='max-w-md space-y-2 p-6 text-center'>
<div className='flex justify-center'>
<CheckCircle className='h-16 w-16 text-green-500' />
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
<div className='mb-8'>
<Image
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
alt='Sim Logo'
width={120}
height={67}
className='dark:invert'
priority
/>
</div>
<div className='flex w-full max-w-md flex-col items-center text-center'>
<div className='mb-6 rounded-full bg-green-50 p-3 dark:bg-green-950/20'>
<CheckCircle2 className='h-8 w-8 text-green-500 dark:text-green-400' />
</div>
<h3 className='font-semibold text-lg'>Invitation Accepted</h3>
<p className='text-muted-foreground'>
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>Welcome!</h1>
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
You have successfully joined {invitationDetails?.name || 'the workspace'}. Redirecting
to your workspace...
</p>
</Card>
<Button
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
onClick={() => router.push('/')}
>
Return to Home
</Button>
</div>
<footer className='mt-8 text-center text-gray-500 text-xs'>
Need help?{' '}
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
Contact support
</a>
</footer>
</div>
)
}
// Show invitation details
return (
<div className='flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4'>
<Card className='w-full max-w-md'>
<CardHeader className='text-center'>
<CardTitle className='mb-1'>Workspace Invitation</CardTitle>
<CardDescription className='text-md'>
You've been invited to join{' '}
<span className='font-medium'>{invitationDetails?.name || 'a workspace'}</span>
</CardDescription>
<p className='mt-2 text-md text-muted-foreground'>
Click the accept below to join the workspace.
</p>
</CardHeader>
<CardFooter className='flex justify-center'>
<Button onClick={handleAcceptInvitation} disabled={isAccepting} className='w-full'>
<span className='ml-2'>{isAccepting ? '' : ''}Accept Invitation</span>
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
<div className='mb-8'>
<Image
src='/logo/b&w/medium.png'
alt='Sim Logo'
width={120}
height={67}
className='dark:invert'
priority
/>
</div>
<div className='flex w-full max-w-md flex-col items-center text-center'>
<div className='mb-6 rounded-full bg-blue-50 p-3 dark:bg-blue-950/20'>
{invitationType === 'organization' ? (
<Users2 className='h-8 w-8 text-blue-500 dark:text-blue-400' />
) : (
<Mail className='h-8 w-8 text-blue-500 dark:text-blue-400' />
)}
</div>
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>
{invitationType === 'organization' ? 'Organization Invitation' : 'Workspace Invitation'}
</h1>
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
You've been invited to join{' '}
<span className='font-medium text-black dark:text-white'>
{invitationDetails?.name || `a ${invitationType}`}
</span>
. Click accept below to join.
</p>
<div className='flex w-full flex-col gap-3'>
<Button
onClick={handleAcceptInvitation}
disabled={isAccepting}
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
>
{isAccepting ? (
<>
<LoadingAgent size='sm' />
Accepting...
</>
) : (
'Accept Invitation'
)}
</Button>
</CardFooter>
</Card>
<Button
variant='ghost'
className='w-full text-gray-600 hover:bg-gray-200 hover:text-black dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white'
onClick={() => router.push('/')}
>
Return to Home
</Button>
</div>
</div>
<footer className='mt-8 text-center text-gray-500 text-xs'>
Need help?{' '}
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
Contact support
</a>
</footer>
</div>
)
}
@@ -1,10 +1,12 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { Mail, RotateCcw, ShieldX } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { useBrandConfig } from '@/lib/branding/branding'
function getErrorMessage(reason: string, details?: string): string {
switch (reason) {
@@ -22,6 +24,18 @@ function getErrorMessage(reason: string, details?: string): string {
: 'This invitation was sent to a different email address than the one you are logged in with.'
case 'workspace-not-found':
return 'The workspace associated with this invitation could not be found.'
case 'user-not-found':
return 'Your user account could not be found. Please try logging out and logging back in.'
case 'email-not-verified':
return details
? details
: 'You must verify your email address before accepting invitations. Please check your email for a verification link.'
case 'already-member':
return 'You are already a member of this organization or workspace.'
case 'invalid-invitation':
return 'This invitation is invalid or no longer exists.'
case 'missing-invitation-id':
return 'The invitation link is missing required information. Please use the original invitation link.'
case 'server-error':
return 'An unexpected error occurred while processing your invitation. Please try again later.'
default:
@@ -34,6 +48,7 @@ export default function InviteError() {
const reason = searchParams?.get('reason') || 'unknown'
const details = searchParams?.get('details')
const [errorMessage, setErrorMessage] = useState('')
const brandConfig = useBrandConfig()
useEffect(() => {
// Only set the error message on the client side
@@ -43,31 +58,79 @@ export default function InviteError() {
// Provide a fallback message for SSR
const displayMessage = errorMessage || 'Loading error details...'
const isEmailVerificationError = reason === 'email-not-verified'
const isExpiredError = reason === 'expired'
return (
<div className='flex min-h-screen flex-col items-center justify-center'>
<div className='mx-auto max-w-md rounded-lg border bg-card px-6 py-12'>
<div className='flex flex-col items-center text-center'>
<AlertTriangle className='mb-4 h-12 w-12 text-amber-500' />
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
{/* Logo */}
<div className='mb-8'>
<Image
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
alt='Sim Logo'
width={120}
height={67}
className='dark:invert'
priority
/>
</div>
<h1 className='mb-2 font-bold text-2xl tracking-tight'>Invitation Error</h1>
<div className='flex w-full max-w-md flex-col items-center text-center'>
<div className='mb-6 rounded-full bg-red-50 p-3 dark:bg-red-950/20'>
<ShieldX className='h-8 w-8 text-red-500 dark:text-red-400' />
</div>
<p className='mb-6 text-muted-foreground'>{displayMessage}</p>
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>Invitation Error</h1>
<div className='flex w-full flex-col gap-4'>
<Link href='/workspace' passHref>
<Button variant='default' className='w-full'>
Go to Dashboard
</Button>
</Link>
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
{displayMessage}
</p>
<Link href='/' passHref>
<Button variant='outline' className='w-full'>
Return to Home
</Button>
</Link>
</div>
<div className='flex w-full flex-col gap-3'>
{isEmailVerificationError && (
<Button
variant='default'
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
asChild
>
<Link href='/verify'>
<Mail className='mr-2 h-4 w-4' />
Verify Email
</Link>
</Button>
)}
{isExpiredError && (
<Button
variant='outline'
className='w-full border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white'
asChild
>
<Link href='/'>
<RotateCcw className='mr-2 h-4 w-4' />
Request New Invitation
</Link>
</Button>
)}
<Button
className='w-full'
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
asChild
>
<Link href='/'>Return to Home</Link>
</Button>
</div>
</div>
<footer className='mt-8 text-center text-gray-500 text-xs'>
Need help?{' '}
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
Contact support
</a>
</footer>
</div>
)
}
+1
View File
@@ -43,6 +43,7 @@ export const buildTimeCSPDirectives: CSPDirectives = {
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
'img-src': [
'https://agentics.epiqglobal.com',
"'self'",
'data:',
'blob:',