'use client' import { useEffect, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { acceptInvitationContract, getInvitationContract, type InvitationDetails, } from '@/lib/api/contracts/invitations' import { client, useSession } from '@/lib/auth/auth-client' import { InviteLayout, InviteStatusCard } from '@/app/invite/components' import { organizationKeys } from '@/hooks/queries/organization' import { subscriptionKeys } from '@/hooks/queries/subscription' const logger = createLogger('InviteById') type InviteErrorCode = | 'missing-token' | 'invalid-token' | 'expired' | 'already-processed' | 'email-mismatch' | 'workspace-not-found' | 'user-not-found' | 'already-member' | 'already-in-organization' | 'no-seats-available' | 'upgrade-required' | 'invalid-invitation' | 'missing-invitation-id' | 'server-error' | 'unauthorized' | 'forbidden' | 'network-error' | 'unknown' interface InviteError { code: InviteErrorCode message: string requiresAuth?: boolean canRetry?: boolean } function getInviteError(code: string): InviteError { const errorMap: Record = { '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.', }, 'no-seats-available': { code: 'no-seats-available', message: 'This organization has reached its seat limit. Ask an admin to contact support to add seats, then try again.', canRetry: true, }, 'upgrade-required': { code: 'upgrade-required', message: 'The workspace owner needs an active paid plan with billing set up before you can join. Ask them to update their plan, then try again.', canRetry: true, }, 'invalid-invitation': { code: 'invalid-invitation', message: 'This invitation is invalid or no longer exists.', }, 'not-found': { code: 'invalid-invitation', message: 'This invitation is invalid or no longer exists.', }, '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[code] || { code: 'unknown', message: 'An unexpected error occurred while processing your invitation. Please try again or contact support.', canRetry: true, } ) } function codeFromStatus(status: number): InviteErrorCode { if (status === 401) return 'unauthorized' if (status === 403) return 'forbidden' if (status === 404) return 'invalid-invitation' if (status === 409) return 'already-in-organization' if (status >= 500) return 'server-error' return 'unknown' } function codeFromApiClientError(error: ApiClientError): string { if (error.body && typeof error.body === 'object') { const code = (error.body as { error?: unknown }).error if (typeof code === 'string' && code.length > 0) return code } return codeFromStatus(error.status) } export default function Invite() { const router = useRouter() const params = useParams() const inviteId = params.id as string const inviteTokenStorageKey = `inviteToken:${inviteId}` const searchParams = useSearchParams() const { data: session, isPending } = useSession() const queryClient = useQueryClient() const [invitation, setInvitation] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [isAccepting, setIsAccepting] = useState(false) const [accepted, setAccepted] = useState(false) const [isNewUser, setIsNewUser] = useState(false) const [token, setToken] = useState(null) useEffect(() => { const errorReason = searchParams.get('error') const isNew = searchParams.get('new') === 'true' setIsNewUser(isNew) const tokenFromQuery = searchParams.get('token') if (tokenFromQuery) { setToken(tokenFromQuery) sessionStorage.setItem(inviteTokenStorageKey, tokenFromQuery) } else { const storedToken = sessionStorage.getItem(inviteTokenStorageKey) if (storedToken) { setToken(storedToken) } } if (errorReason) { setError(getInviteError(errorReason)) setIsLoading(false) } }, [searchParams, inviteId, inviteTokenStorageKey]) useEffect(() => { if (!session?.user) return async function fetchInvitation() { setIsLoading(true) try { const data = await requestJson(getInvitationContract, { params: { id: inviteId }, query: { token: token ?? undefined }, }) setInvitation(data.invitation) setError(null) } catch (fetchError) { logger.error('Error fetching invitation:', fetchError) const code = fetchError instanceof ApiClientError ? codeFromApiClientError(fetchError) : 'network-error' setError(getInviteError(code)) } finally { setIsLoading(false) } } fetchInvitation() }, [session?.user, inviteId, token]) const handleAcceptInvitation = async () => { if (!session?.user || !invitation) return setIsAccepting(true) try { const data = await requestJson(acceptInvitationContract, { params: { id: inviteId }, body: { token: token ?? undefined }, }) if (invitation.organizationId) { try { await client.organization.setActive({ organizationId: invitation.organizationId }) } catch (setActiveError) { logger.warn('Failed to set active organization after accept', setActiveError) } } await Promise.all([ queryClient.invalidateQueries({ queryKey: subscriptionKeys.all }), queryClient.invalidateQueries({ queryKey: organizationKeys.all }), ]) setAccepted(true) setIsAccepting(false) setTimeout(() => router.push(data.redirectPath), 1200) } catch (acceptError) { logger.error('Error accepting invitation:', acceptError) const code = acceptError instanceof ApiClientError ? codeFromApiClientError(acceptError) : 'network-error' setError(getInviteError(code)) setIsAccepting(false) } } const getCallbackUrl = () => { const effectiveToken = token || sessionStorage.getItem(inviteTokenStorageKey) || searchParams.get('token') return `/invite/${inviteId}${effectiveToken ? `?token=${effectiveToken}` : ''}` } if (!session?.user && !isPending) { const callbackUrl = encodeURIComponent(getCallbackUrl()) return ( router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`), }, { label: 'I already have an account', onClick: () => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`), }, ] : [ { label: 'Sign in', onClick: () => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`), }, { label: 'Create an account', onClick: () => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`), }, ]), { label: 'Return to Home', onClick: () => router.push('/'), }, ]} /> ) } if (isLoading || isPending) { return ( ) } if (error) { const callbackUrl = encodeURIComponent(getCallbackUrl()) if (error.code === 'email-mismatch') { return ( { await client.signOut() router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`) }, }, { label: 'Return to Home', onClick: () => router.push('/') }, ]} /> ) } if (error.code === 'already-in-organization') { return ( router.push('/workspace') }, { label: 'Return to Home', onClick: () => router.push('/') }, ]} /> ) } if (error.requiresAuth) { return ( router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`), }, { label: 'Create an account', onClick: () => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`), }, { label: 'Return to Home', onClick: () => router.push('/') }, ]} /> ) } const actions: Array<{ label: string; onClick: () => void }> = [] if (error.canRetry) { actions.push({ label: 'Try Again', onClick: () => window.location.reload() }) } actions.push({ label: 'Return to Home', onClick: () => router.push('/') }) return ( ) } const displayName = invitation?.kind === 'workspace' ? invitation.grants[0]?.workspaceName || 'a workspace' : invitation?.organizationName || 'an organization' if (accepted) { return ( router.push('/') }]} /> ) } const isOrg = invitation?.kind === 'organization' return ( router.push('/') }, ]} /> ) }