d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
'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<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.',
|
|
},
|
|
'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<InvitationDetails | null>(null)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [error, setError] = useState<InviteError | null>(null)
|
|
const [isAccepting, setIsAccepting] = useState(false)
|
|
const [accepted, setAccepted] = useState(false)
|
|
const [isNewUser, setIsNewUser] = useState(false)
|
|
const [token, setToken] = useState<string | null>(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 (
|
|
<InviteLayout>
|
|
<InviteStatusCard
|
|
type='login'
|
|
title="You've been invited!"
|
|
description={
|
|
isNewUser
|
|
? 'Create an account to join this workspace on Sim'
|
|
: 'Sign in to your account to accept this invitation'
|
|
}
|
|
icon='userPlus'
|
|
actions={[
|
|
...(isNewUser
|
|
? [
|
|
{
|
|
label: 'Create an account',
|
|
onClick: () =>
|
|
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('/'),
|
|
},
|
|
]}
|
|
/>
|
|
</InviteLayout>
|
|
)
|
|
}
|
|
|
|
if (isLoading || isPending) {
|
|
return (
|
|
<InviteLayout>
|
|
<InviteStatusCard type='loading' title='' description='Loading invitation...' />
|
|
</InviteLayout>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
const callbackUrl = encodeURIComponent(getCallbackUrl())
|
|
|
|
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`)
|
|
},
|
|
},
|
|
{ label: 'Return to Home', onClick: () => router.push('/') },
|
|
]}
|
|
/>
|
|
</InviteLayout>
|
|
)
|
|
}
|
|
|
|
if (error.code === 'already-in-organization') {
|
|
return (
|
|
<InviteLayout>
|
|
<InviteStatusCard
|
|
type='warning'
|
|
title='Already Part of a Team'
|
|
description={error.message}
|
|
icon='users'
|
|
actions={[
|
|
{ label: 'Manage Team Settings', onClick: () => router.push('/workspace') },
|
|
{ label: 'Return to Home', onClick: () => router.push('/') },
|
|
]}
|
|
/>
|
|
</InviteLayout>
|
|
)
|
|
}
|
|
|
|
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`),
|
|
},
|
|
{
|
|
label: 'Create an account',
|
|
onClick: () => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
|
|
},
|
|
{ label: 'Return to Home', onClick: () => router.push('/') },
|
|
]}
|
|
/>
|
|
</InviteLayout>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<InviteLayout>
|
|
<InviteStatusCard
|
|
type='error'
|
|
title='Invitation Error'
|
|
description={error.message}
|
|
icon='error'
|
|
isExpiredError={error.code === 'expired'}
|
|
actions={actions}
|
|
/>
|
|
</InviteLayout>
|
|
)
|
|
}
|
|
|
|
const displayName =
|
|
invitation?.kind === 'workspace'
|
|
? invitation.grants[0]?.workspaceName || 'a workspace'
|
|
: invitation?.organizationName || 'an organization'
|
|
|
|
if (accepted) {
|
|
return (
|
|
<InviteLayout>
|
|
<InviteStatusCard
|
|
type='success'
|
|
title='Welcome!'
|
|
description={`You have successfully joined ${displayName}. Redirecting...`}
|
|
icon='success'
|
|
actions={[{ label: 'Return to Home', onClick: () => router.push('/') }]}
|
|
/>
|
|
</InviteLayout>
|
|
)
|
|
}
|
|
|
|
const isOrg = invitation?.kind === 'organization'
|
|
|
|
return (
|
|
<InviteLayout>
|
|
<InviteStatusCard
|
|
type='invitation'
|
|
title={isOrg ? 'Organization Invitation' : 'Workspace Invitation'}
|
|
description={`You've been invited to join ${displayName}. Click accept below to join.`}
|
|
icon={isOrg ? 'users' : 'mail'}
|
|
actions={[
|
|
{
|
|
label: 'Accept Invitation',
|
|
onClick: handleAcceptInvitation,
|
|
disabled: isAccepting,
|
|
loading: isAccepting,
|
|
},
|
|
{ label: 'Return to Home', onClick: () => router.push('/') },
|
|
]}
|
|
/>
|
|
</InviteLayout>
|
|
)
|
|
}
|