OrganizationInvite.tsx188 lines · main
1import { useIsLoggedIn, useParams } from 'common'
2import Link from 'next/link'
3import { useRouter } from 'next/router'
4import type { ReactNode } from 'react'
5import { toast } from 'sonner'
6import { Button, Card, CardContent } from 'ui'
7import { Admonition, ShimmeringLoader } from 'ui-patterns'
8
9import {
10 getOrganizationInviteContent,
11 getOrganizationInviteStatus,
12} from './OrganizationInvite.utils'
13import { OrganizationInviteError } from './OrganizationInviteError'
14import {
15 InterstitialAccountRow,
16 InterstitialLayout,
17 BrivenLogo,
18} from '@/components/layouts/InterstitialLayout'
19import { useOrganizationAcceptInvitationMutation } from '@/data/organization-members/organization-invitation-accept-mutation'
20import { useOrganizationInvitationTokenQuery } from '@/data/organization-members/organization-invitation-token-query'
21import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
22import { useProfile, useProfileNameAndPicture } from '@/lib/profile'
23
24export const OrganizationInvite = () => {
25 const router = useRouter()
26 const isLoggedIn = useIsLoggedIn()
27 const { profile, isLoading: isLoadingProfile } = useProfile()
28 const { username, avatarUrl, primaryEmail } = useProfileNameAndPicture()
29 const { slug, token } = useParams()
30
31 const isSignUpEnabled = useIsFeatureEnabled('dashboard_auth:sign_up')
32
33 const {
34 data,
35 error,
36 isSuccess: isSuccessInvitation,
37 isError: isErrorInvitation,
38 isPending: isLoadingInvitation,
39 } = useOrganizationInvitationTokenQuery(
40 { slug, token },
41 {
42 retry: false,
43 refetchOnWindowFocus: false,
44 enabled: !!profile && !!slug && !!token,
45 }
46 )
47 const inviteStatus = getOrganizationInviteStatus({
48 data,
49 error,
50 isErrorInvitation,
51 isLoadingInvitation,
52 isLoadingProfile,
53 isLoggedIn,
54 isRouterReady: router.isReady,
55 isSuccessInvitation,
56 profileExists: !!profile,
57 })
58 const isSignedOut = inviteStatus === 'signed-out'
59 const isInvitationLoading = inviteStatus === 'loading'
60 const inviteContent = getOrganizationInviteContent({
61 data,
62 isSignUpEnabled,
63 status: inviteStatus,
64 })
65 const hasError = ['wrong-account', 'expired', 'invalid', 'error'].includes(inviteStatus)
66 const loginRedirectLink = `/sign-in?returnTo=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}`
67 const signupRedirectLink = `/sign-up?returnTo=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}`
68
69 const { mutate: joinOrganization, isPending: isJoining } =
70 useOrganizationAcceptInvitationMutation({
71 onSuccess: () => {
72 router.push('/organizations')
73 },
74 onError: (error) => {
75 toast.error(`Failed to join organization: ${error.message}`)
76 },
77 })
78
79 async function handleJoinOrganization() {
80 if (!slug) return console.error('Slug is required')
81 if (!token) return console.error('Token is required')
82 joinOrganization({ slug, token })
83 }
84
85 const withLayout = (children: ReactNode) => (
86 <InterstitialLayout
87 logo={<BrivenLogo />}
88 title={
89 isInvitationLoading ? (
90 <ShimmeringLoader className="mx-auto h-7 w-36 max-w-full py-0" />
91 ) : inviteContent.title ? (
92 inviteContent.title
93 ) : undefined
94 }
95 description={
96 isInvitationLoading ? (
97 <ShimmeringLoader className="mx-auto h-4 w-48 max-w-full py-0" />
98 ) : inviteContent.description ? (
99 inviteContent.description
100 ) : undefined
101 }
102 titleClassName="text-xl"
103 >
104 <div className="px-6 pb-6">{children}</div>
105 </InterstitialLayout>
106 )
107
108 if (isSignedOut) {
109 return withLayout(
110 <div className="flex flex-col gap-2">
111 <Button asChild type="primary" block>
112 <Link href={loginRedirectLink}>Sign in</Link>
113 </Button>
114 {isSignUpEnabled && (
115 <Button asChild type="default" block>
116 <Link href={signupRedirectLink}>Create an account</Link>
117 </Button>
118 )}
119 </div>
120 )
121 }
122
123 if (isInvitationLoading) {
124 return withLayout(
125 <div className="flex flex-col gap-6">
126 <Card className="shadow-none">
127 <CardContent className="flex items-center gap-3 border-none px-4 py-3">
128 <ShimmeringLoader className="size-8 flex-shrink-0 rounded-full py-0" />
129 <div className="min-w-0 flex-1 space-y-2">
130 <ShimmeringLoader className="h-3 w-20 py-0" />
131 <ShimmeringLoader className="h-4 w-40 max-w-full py-0" />
132 </div>
133 </CardContent>
134 </Card>
135 <div className="flex flex-col gap-2">
136 <ShimmeringLoader className="h-10 w-full py-0" />
137 <ShimmeringLoader className="h-10 w-full py-0" />
138 </div>
139 </div>
140 )
141 }
142
143 if (inviteStatus === 'no-longer-valid') {
144 return withLayout(
145 <div className="flex flex-col gap-3">
146 <Admonition
147 type="warning"
148 description="This invite has already been accepted or declined."
149 />
150 <Button type="default" block asChild>
151 <Link href="/">Back to dashboard</Link>
152 </Button>
153 </div>
154 )
155 }
156
157 if (hasError) {
158 return withLayout(
159 <OrganizationInviteError
160 data={data}
161 error={error}
162 isError={isErrorInvitation}
163 isInvalidInvite={inviteStatus === 'invalid'}
164 />
165 )
166 }
167
168 return withLayout(
169 <div className="flex flex-col gap-6">
170 <InterstitialAccountRow avatarUrl={avatarUrl} displayName={primaryEmail ?? username ?? ''} />
171
172 <div className="flex flex-col gap-2">
173 <Button
174 type="primary"
175 block
176 loading={isJoining}
177 disabled={isJoining}
178 onClick={handleJoinOrganization}
179 >
180 Accept invite
181 </Button>
182 <Button asChild type="text" block>
183 <Link href="/projects">Decline</Link>
184 </Button>
185 </div>
186 </div>
187 )
188}