OrganizationInvite.tsx 5.8 KB

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