improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments
This commit is contained in:
Emir Karabeg
2026-01-02 19:45:10 -08:00
committed by GitHub
parent c3adcf315b
commit 8d15219c12
4 changed files with 359 additions and 164 deletions

View File

@@ -9,7 +9,7 @@ globs: ["apps/sim/**/*.tsx", "apps/sim/**/*.css"]
1. **No inline styles** - Use Tailwind classes 1. **No inline styles** - Use Tailwind classes
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode 2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
3. **Exact values** - `text-[14px]`, `h-[25px]` 3. **Exact values** - `text-[14px]`, `h-[26px]`
4. **Transitions** - `transition-colors` for interactive states 4. **Transitions** - `transition-colors` for interactive states
## Conditional Classes ## Conditional Classes

View File

@@ -8,35 +8,156 @@ import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
const logger = createLogger('InviteById') const logger = createLogger('InviteById')
function getErrorMessage(reason: string): string { /** Error codes that can occur during invitation processing */
switch (reason) { type InviteErrorCode =
case 'missing-token': | 'missing-token'
return 'The invitation link is invalid or missing a required parameter.' | 'invalid-token'
case 'invalid-token': | 'expired'
return 'The invitation link is invalid or has already been used.' | 'already-processed'
case 'expired': | 'email-mismatch'
return 'This invitation has expired. Please ask for a new invitation.' | 'workspace-not-found'
case 'already-processed': | 'user-not-found'
return 'This invitation has already been accepted or declined.' | 'already-member'
case 'email-mismatch': | 'already-in-organization'
return 'This invitation was sent to a different email address. Please log in with the correct account.' | 'invalid-invitation'
case 'workspace-not-found': | 'missing-invitation-id'
return 'The workspace associated with this invitation could not be found.' | 'server-error'
case 'user-not-found': | 'unauthorized'
return 'Your user account could not be found. Please try logging out and logging back in.' | 'forbidden'
case 'already-member': | 'network-error'
return 'You are already a member of this organization or workspace.' | 'unknown'
case 'already-in-organization':
return 'You are already a member of an organization. Leave your current organization before accepting a new invitation.' interface InviteError {
case 'invalid-invitation': code: InviteErrorCode
return 'This invitation is invalid or no longer exists.' message: string
case 'missing-invitation-id': requiresAuth?: boolean
return 'The invitation link is missing required information. Please use the original invitation link.' canRetry?: boolean
case 'server-error': }
return 'An unexpected error occurred while processing your invitation. Please try again later.'
default: /**
return 'An unknown error occurred while processing your invitation.' * Maps error codes to user-friendly error objects with contextual information
*/
function getInviteError(reason: string): InviteError {
const errorMap: Record<string, InviteError> = {
'missing-token': {
code: 'missing-token',
message: 'The invitation link is invalid or missing a required parameter.',
},
'invalid-token': {
code: 'invalid-token',
message: 'The invitation link is invalid or has already been used.',
},
expired: {
code: 'expired',
message: 'This invitation has expired. Please ask for a new invitation.',
},
'already-processed': {
code: 'already-processed',
message: 'This invitation has already been accepted or declined.',
},
'email-mismatch': {
code: 'email-mismatch',
message:
'This invitation was sent to a different email address. Please sign in with the correct account.',
requiresAuth: true,
},
'workspace-not-found': {
code: 'workspace-not-found',
message: 'The workspace associated with this invitation could not be found.',
},
'user-not-found': {
code: 'user-not-found',
message: 'Your user account could not be found. Please try signing out and signing back in.',
requiresAuth: true,
},
'already-member': {
code: 'already-member',
message: 'You are already a member of this organization or workspace.',
},
'already-in-organization': {
code: 'already-in-organization',
message:
'You are already a member of an organization. Leave your current organization before accepting a new invitation.',
},
'invalid-invitation': {
code: 'invalid-invitation',
message: 'This invitation is invalid or no longer exists.',
},
'missing-invitation-id': {
code: 'missing-invitation-id',
message:
'The invitation link is missing required information. Please use the original invitation link.',
},
'server-error': {
code: 'server-error',
message:
'An unexpected error occurred while processing your invitation. Please try again later.',
canRetry: true,
},
unauthorized: {
code: 'unauthorized',
message: 'You need to sign in to accept this invitation.',
requiresAuth: true,
},
forbidden: {
code: 'forbidden',
message:
'You do not have permission to accept this invitation. Please check you are signed in with the correct account.',
requiresAuth: true,
},
'network-error': {
code: 'network-error',
message:
'Unable to connect to the server. Please check your internet connection and try again.',
canRetry: true,
},
} }
return (
errorMap[reason] || {
code: 'unknown',
message:
'An unexpected error occurred while processing your invitation. Please try again or contact support.',
canRetry: true,
}
)
}
/**
* Parses API error responses and extracts a standardized error code
*/
function parseApiError(error: unknown, statusCode?: number): InviteErrorCode {
// Handle network/fetch errors
if (error instanceof TypeError && error.message.includes('fetch')) {
return 'network-error'
}
// Handle error message patterns first (more specific matching)
const errorMessage =
typeof error === 'string' ? error.toLowerCase() : (error as Error)?.message?.toLowerCase() || ''
// Check specific patterns before falling back to status codes
// Order matters: more specific patterns must come first
if (errorMessage.includes('already a member of an organization')) return 'already-in-organization'
if (errorMessage.includes('already a member')) return 'already-member'
if (errorMessage.includes('email mismatch') || errorMessage.includes('different email'))
return 'email-mismatch'
if (errorMessage.includes('already processed')) return 'already-processed'
if (errorMessage.includes('unauthorized')) return 'unauthorized'
if (errorMessage.includes('forbidden') || errorMessage.includes('permission')) return 'forbidden'
if (errorMessage.includes('not found') || errorMessage.includes('expired'))
return 'invalid-invitation'
// Handle HTTP status codes as fallback
if (statusCode) {
if (statusCode === 401) return 'unauthorized'
if (statusCode === 403) return 'forbidden'
if (statusCode === 404) return 'invalid-invitation'
if (statusCode === 409) return 'already-in-organization'
if (statusCode >= 500) return 'server-error'
}
return 'unknown'
} }
export default function Invite() { export default function Invite() {
@@ -47,7 +168,7 @@ export default function Invite() {
const { data: session, isPending } = useSession() const { data: session, isPending } = useSession()
const [invitationDetails, setInvitationDetails] = useState<any>(null) const [invitationDetails, setInvitationDetails] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<InviteError | null>(null)
const [isAccepting, setIsAccepting] = useState(false) const [isAccepting, setIsAccepting] = useState(false)
const [accepted, setAccepted] = useState(false) const [accepted, setAccepted] = useState(false)
const [isNewUser, setIsNewUser] = useState(false) const [isNewUser, setIsNewUser] = useState(false)
@@ -59,7 +180,7 @@ export default function Invite() {
const errorReason = searchParams.get('error') const errorReason = searchParams.get('error')
if (errorReason) { if (errorReason) {
setError(getErrorMessage(errorReason)) setError(getInviteError(errorReason))
setIsLoading(false) setIsLoading(false)
return return
} }
@@ -99,11 +220,37 @@ export default function Invite() {
return return
} }
// Handle workspace invitation errors with specific status codes
if (!workspaceInviteResponse.ok && workspaceInviteResponse.status !== 404) {
const errorCode = parseApiError(null, workspaceInviteResponse.status)
const errorData = await workspaceInviteResponse.json().catch(() => ({}))
logger.error('Workspace invitation fetch failed:', {
status: workspaceInviteResponse.status,
error: errorData,
})
// Refine error code based on response body if available
if (errorData.error) {
const refinedCode = parseApiError(errorData.error, workspaceInviteResponse.status)
setError(getInviteError(refinedCode))
} else {
setError(getInviteError(errorCode))
}
setIsLoading(false)
return
}
try { try {
const { data } = await client.organization.getInvitation({ const { data, error: orgError } = await client.organization.getInvitation({
query: { id: inviteId }, query: { id: inviteId },
}) })
if (orgError) {
logger.error('Organization invitation fetch error:', orgError)
const errorCode = parseApiError(orgError.message || orgError)
throw { code: errorCode, original: orgError }
}
if (data) { if (data) {
setInvitationType('organization') setInvitationType('organization')
@@ -115,7 +262,7 @@ export default function Invite() {
if (activeOrgResponse?.data) { if (activeOrgResponse?.data) {
// User is already in an organization // User is already in an organization
setCurrentOrgName(activeOrgResponse.data.name) setCurrentOrgName(activeOrgResponse.data.name)
setError('already-in-organization') setError(getInviteError('already-in-organization'))
setIsLoading(false) setIsLoading(false)
return return
} }
@@ -139,14 +286,19 @@ export default function Invite() {
} }
} }
} else { } else {
throw new Error('Invitation not found or has expired') throw { code: 'invalid-invitation' }
} }
} catch (_err) { } catch (orgErr: any) {
throw new Error('Invitation not found or has expired') // If this is our structured error, use it directly
if (orgErr.code) {
throw orgErr
}
throw { code: parseApiError(orgErr) }
} }
} catch (err: any) { } catch (err: any) {
logger.error('Error fetching invitation:', err) logger.error('Error fetching invitation:', err)
setError(err.message || 'Failed to load invitation details') const errorCode = err.code || parseApiError(err)
setError(getInviteError(errorCode))
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -168,7 +320,9 @@ export default function Invite() {
const orgId = invitationDetails?.data?.organizationId const orgId = invitationDetails?.data?.organizationId
if (!orgId) { if (!orgId) {
throw new Error('Organization ID not found') setError(getInviteError('invalid-invitation'))
setIsAccepting(false)
return
} }
// Use our custom API endpoint that handles Pro usage snapshot // Use our custom API endpoint that handles Pro usage snapshot
@@ -182,8 +336,15 @@ export default function Invite() {
}) })
if (!response.ok) { if (!response.ok) {
const data = await response.json().catch(() => ({ error: 'Failed to accept invitation' })) const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to accept invitation') const errorCode = parseApiError(data.error || '', response.status)
logger.error('Failed to accept organization invitation:', {
status: response.status,
error: data,
})
setError(getInviteError(errorCode))
setIsAccepting(false)
return
} }
// Set the organization as active // Set the organization as active
@@ -202,13 +363,8 @@ export default function Invite() {
// Reset accepted state on error // Reset accepted state on error
setAccepted(false) setAccepted(false)
// Check if it's a 409 conflict (already in an organization) const errorCode = parseApiError(err)
if (err.status === 409 || err.message?.includes('already a member of an organization')) { setError(getInviteError(errorCode))
setError('already-in-organization')
} else {
setError(err.message || 'Failed to accept invitation')
}
setIsAccepting(false) setIsAccepting(false)
} }
} }
@@ -279,12 +435,10 @@ export default function Invite() {
} }
if (error) { if (error) {
const errorReason = searchParams.get('error') const callbackUrl = encodeURIComponent(getCallbackUrl())
const isExpiredError = errorReason === 'expired'
const isAlreadyInOrg = error === 'already-in-organization'
// Special handling for already in organization // Special handling for already in organization
if (isAlreadyInOrg) { if (error.code === 'already-in-organization') {
return ( return (
<InviteLayout> <InviteLayout>
<InviteStatusCard <InviteStatusCard
@@ -293,7 +447,7 @@ export default function Invite() {
description={ description={
currentOrgName currentOrgName
? `You are currently a member of "${currentOrgName}". You must leave your current organization before accepting a new invitation.` ? `You are currently a member of "${currentOrgName}". You must leave your current organization before accepting a new invitation.`
: 'You are already a member of an organization. Leave your current organization before accepting a new invitation.' : error.message
} }
icon='users' icon='users'
actions={[ actions={[
@@ -313,24 +467,96 @@ export default function Invite() {
) )
} }
// Use getErrorMessage for consistent error messages // Handle email mismatch - user needs to sign in with a different account
const errorMessage = error.startsWith('You are already') ? error : getErrorMessage(error) if (error.code === 'email-mismatch') {
return (
<InviteLayout>
<InviteStatusCard
type='warning'
title='Wrong Account'
description={error.message}
icon='userPlus'
actions={[
{
label: 'Sign in with a different account',
onClick: async () => {
await client.signOut()
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)
},
variant: 'default' as const,
},
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'ghost' as const,
},
]}
/>
</InviteLayout>
)
}
// Handle auth-related errors - prompt user to sign in
if (error.requiresAuth) {
return (
<InviteLayout>
<InviteStatusCard
type='warning'
title='Authentication Required'
description={error.message}
icon='userPlus'
actions={[
{
label: 'Sign in to continue',
onClick: () => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
variant: 'default' as const,
},
{
label: 'Create an account',
onClick: () => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
variant: 'outline' as const,
},
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'ghost' as const,
},
]}
/>
</InviteLayout>
)
}
// Handle retryable errors
const actions: Array<{
label: string
onClick: () => void
variant?: 'default' | 'outline' | 'ghost'
}> = []
if (error.canRetry) {
actions.push({
label: 'Try Again',
onClick: () => window.location.reload(),
variant: 'default' as const,
})
}
actions.push({
label: 'Return to Home',
onClick: () => router.push('/'),
variant: error.canRetry ? ('ghost' as const) : ('default' as const),
})
return ( return (
<InviteLayout> <InviteLayout>
<InviteStatusCard <InviteStatusCard
type='error' type='error'
title='Invitation Error' title='Invitation Error'
description={errorMessage} description={error.message}
icon='error' icon='error'
isExpiredError={isExpiredError} isExpiredError={error.code === 'expired'}
actions={[ actions={actions}
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'default' as const,
},
]}
/> />
</InviteLayout> </InviteLayout>
) )

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import AuthBackground from '@/app/(auth)/components/auth-background'
import Nav from '@/app/(landing)/components/nav/nav' import Nav from '@/app/(landing)/components/nav/nav'
interface InviteLayoutProps { interface InviteLayoutProps {
@@ -8,12 +9,13 @@ interface InviteLayoutProps {
export default function InviteLayout({ children }: InviteLayoutProps) { export default function InviteLayout({ children }: InviteLayoutProps) {
return ( return (
<div className='relative min-h-screen'> <AuthBackground>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' /> <main className='relative flex min-h-screen flex-col text-foreground'>
<Nav variant='auth' /> <Nav hideAuthButtons={true} variant='auth' />
<div className='flex min-h-[calc(100vh-120px)] items-center justify-center px-4'> <div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
<div className='w-full max-w-[410px]'>{children}</div> <div className='w-full max-w-lg px-4'>{children}</div>
</div>
</div> </div>
</main>
</AuthBackground>
) )
} }

View File

@@ -1,16 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useState } from 'react'
import { import { ArrowRight, ChevronRight, Loader2, RotateCcw } from 'lucide-react'
AlertCircle,
CheckCircle2,
Loader2,
Mail,
RotateCcw,
ShieldX,
UserPlus,
Users2,
} from 'lucide-react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useBrandConfig } from '@/lib/branding/branding' import { useBrandConfig } from '@/lib/branding/branding'
@@ -32,104 +23,53 @@ interface InviteStatusCardProps {
isExpiredError?: boolean isExpiredError?: boolean
} }
const iconMap = {
userPlus: UserPlus,
mail: Mail,
users: Users2,
error: ShieldX,
success: CheckCircle2,
warning: AlertCircle,
}
const iconColorMap = {
userPlus: 'text-[var(--brand-primary-hex)]',
mail: 'text-[var(--brand-primary-hex)]',
users: 'text-[var(--brand-primary-hex)]',
error: 'text-red-500 dark:text-red-400',
success: 'text-green-500 dark:text-green-400',
warning: 'text-yellow-600 dark:text-yellow-500',
}
const iconBgMap = {
userPlus: 'bg-[var(--brand-primary-hex)]/10',
mail: 'bg-[var(--brand-primary-hex)]/10',
users: 'bg-[var(--brand-primary-hex)]/10',
error: 'bg-red-50 dark:bg-red-950/20',
success: 'bg-green-50 dark:bg-green-950/20',
warning: 'bg-yellow-50 dark:bg-yellow-950/20',
}
export function InviteStatusCard({ export function InviteStatusCard({
type, type,
title, title,
description, description,
icon, icon: _icon,
actions = [], actions = [],
isExpiredError = false, isExpiredError = false,
}: InviteStatusCardProps) { }: InviteStatusCardProps) {
const router = useRouter() const router = useRouter()
const [buttonClass, setButtonClass] = useState('auth-button-gradient') const [hoveredButtonIndex, setHoveredButtonIndex] = useState<number | null>(null)
const brandConfig = useBrandConfig() const brandConfig = useBrandConfig()
useEffect(() => {
const checkCustomBrand = () => {
const computedStyle = getComputedStyle(document.documentElement)
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
if (brandAccent && brandAccent !== '#6f3dfa') {
setButtonClass('auth-button-custom')
} else {
setButtonClass('auth-button-gradient')
}
}
checkCustomBrand()
window.addEventListener('resize', checkCustomBrand)
const observer = new MutationObserver(checkCustomBrand)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['style', 'class'],
})
return () => {
window.removeEventListener('resize', checkCustomBrand)
observer.disconnect()
}
}, [])
if (type === 'loading') { if (type === 'loading') {
return ( return (
<div className={`${soehne.className} space-y-6`}> <>
<div className='space-y-1 text-center'> <div className='space-y-1 text-center'>
<h1 className='font-medium text-[32px] text-black tracking-tight'>Loading</h1> <h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Loading
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}> <p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{description} {description}
</p> </p>
</div> </div>
<div className='flex w-full items-center justify-center py-8'> <div className={`${inter.className} mt-8 flex w-full items-center justify-center py-8`}>
<Loader2 className='h-8 w-8 animate-spin text-[var(--brand-primary-hex)]' /> <Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
</div> </div>
<div <div
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`} className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
> >
Need help?{' '} Need help?{' '}
<a <a
href='mailto:help@sim.ai' href={`mailto:${brandConfig.supportEmail}`}
className='auth-link underline-offset-4 transition hover:underline' className='auth-link underline-offset-4 transition hover:underline'
> >
Contact support Contact support
</a> </a>
</div> </div>
</div> </>
) )
} }
const IconComponent = icon ? iconMap[icon] : null
const iconColor = icon ? iconColorMap[icon] : ''
const iconBg = icon ? iconBgMap[icon] : ''
return ( return (
<div className={`${soehne.className} space-y-6`}> <>
<div className='space-y-1 text-center'> <div className='space-y-1 text-center'>
<h1 className='font-medium text-[32px] text-black tracking-tight'>{title}</h1> <h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
{title}
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}> <p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{description} {description}
</p> </p>
@@ -148,14 +88,40 @@ export function InviteStatusCard({
</Button> </Button>
)} )}
{actions.map((action, index) => ( {actions.map((action, index) => {
const isPrimary = (action.variant || 'default') === 'default'
const isHovered = hoveredButtonIndex === index
if (isPrimary) {
return (
<Button <Button
key={index} key={index}
variant={action.variant || 'default'} onMouseEnter={() => setHoveredButtonIndex(index)}
onMouseLeave={() => setHoveredButtonIndex(null)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
onClick={action.onClick}
disabled={action.disabled || action.loading}
>
<span className='flex items-center gap-1'>
{action.loading ? `${action.label}...` : action.label}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
)
}
return (
<Button
key={index}
variant={action.variant}
className={ className={
(action.variant || 'default') === 'default' action.variant === 'outline'
? `${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`
: action.variant === 'outline'
? 'w-full rounded-[10px] border-[var(--brand-primary-hex)] font-medium text-[15px] text-[var(--brand-primary-hex)] transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white' ? 'w-full rounded-[10px] border-[var(--brand-primary-hex)] font-medium text-[15px] text-[var(--brand-primary-hex)] transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
: 'w-full rounded-[10px] text-muted-foreground hover:bg-secondary hover:text-foreground' : 'w-full rounded-[10px] text-muted-foreground hover:bg-secondary hover:text-foreground'
} }
@@ -164,12 +130,13 @@ export function InviteStatusCard({
> >
{action.loading ? `${action.label}...` : action.label} {action.loading ? `${action.label}...` : action.label}
</Button> </Button>
))} )
})}
</div> </div>
</div> </div>
<div <div
className={`${inter.className} auth-text-muted fixed right-0 bottom-0 left-0 z-50 pb-8 text-center font-[340] text-[13px] leading-relaxed`} className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
> >
Need help?{' '} Need help?{' '}
<a <a
@@ -179,6 +146,6 @@ export function InviteStatusCard({
Contact support Contact support
</a> </a>
</div> </div>
</div> </>
) )
} }