IPv4SidePanel.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { useParams } from 'common'
  3. import { useEffect, useState } from 'react'
  4. import { toast } from 'sonner'
  5. import { cn, RadioGroup, RadioGroupLargeItem, SidePanel } from 'ui'
  6. import { Admonition } from 'ui-patterns'
  7. import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
  8. import { DocsButton } from '@/components/ui/DocsButton'
  9. import { InlineLink } from '@/components/ui/InlineLink'
  10. import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
  11. import { useProjectAddonRemoveMutation } from '@/data/subscriptions/project-addon-remove-mutation'
  12. import { useProjectAddonUpdateMutation } from '@/data/subscriptions/project-addon-update-mutation'
  13. import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
  14. import type { AddonVariantId } from '@/data/subscriptions/types'
  15. import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
  16. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  17. import { useIsAwsCloudProvider } from '@/hooks/misc/useSelectedProject'
  18. import { DOCS_URL } from '@/lib/constants'
  19. import { formatCurrency } from '@/lib/helpers'
  20. import { useAddonsPagePanel } from '@/state/addons-page'
  21. const IPv4SidePanel = () => {
  22. const isAws = useIsAwsCloudProvider()
  23. const { ref: projectRef } = useParams()
  24. const [selectedOption, setSelectedOption] = useState<string>('ipv4_none')
  25. const { can: canUpdateIPv4 } = useAsyncCheckPermissions(
  26. PermissionAction.BILLING_WRITE,
  27. 'stripe.subscriptions'
  28. )
  29. const { panel, closePanel } = useAddonsPagePanel()
  30. const visible = panel === 'ipv4'
  31. const { data: addons, isPending: isLoading } = useProjectAddonsQuery({ projectRef })
  32. const { mutate: updateAddon, isPending: isUpdating } = useProjectAddonUpdateMutation({
  33. onSuccess: () => {
  34. toast.success(`Successfully enabled IPv4`)
  35. closePanel()
  36. },
  37. onError: (error) => {
  38. toast.error(`Unable to enable IPv4: ${error.message}`)
  39. },
  40. })
  41. const { mutate: removeAddon, isPending: isRemoving } = useProjectAddonRemoveMutation({
  42. onSuccess: () => {
  43. toast.success(`Successfully disabled IPv4.`)
  44. closePanel()
  45. },
  46. onError: (error) => {
  47. toast.error(`Unable to disable IPv4: ${error.message}`)
  48. },
  49. })
  50. const isSubmitting = isUpdating || isRemoving
  51. const subscriptionIpV4Option = (addons?.selected_addons ?? []).find(
  52. (addon) => addon.type === 'ipv4'
  53. )
  54. const availableOptions =
  55. (addons?.available_addons ?? []).find((addon) => addon.type === 'ipv4')?.variants ?? []
  56. const { hasAccess: hasAccessToIPv4, isLoading: isLoadingEntitlement } =
  57. useCheckEntitlements('ipv4')
  58. const hasChanges = selectedOption !== (subscriptionIpV4Option?.variant.identifier ?? 'ipv4_none')
  59. const selectedIPv4 = availableOptions.find((option) => option.identifier === selectedOption)
  60. const ipv4Options = [
  61. {
  62. value: 'ipv4_none',
  63. id: 'ipv4_none',
  64. title: 'No IPv4 address',
  65. description: 'Use shared pooler or IPv6 for database connections.',
  66. priceContent: (
  67. <>
  68. <p className="text-foreground text-sm">$0</p>
  69. <p className="text-foreground-light translate-y-px text-sm">/ month</p>
  70. </>
  71. ),
  72. priceRowClassName: 'mt-2',
  73. },
  74. ...availableOptions.map((option) => ({
  75. value: option.identifier,
  76. id: option.identifier,
  77. title: 'Dedicated IPv4 address',
  78. description: 'Allow database connections from IPv4 networks.',
  79. priceContent: (
  80. <>
  81. <p className="text-sm" translate="no">
  82. {formatCurrency(option.price)}
  83. </p>
  84. <p className="text-foreground-light translate-y-[0.5px]">/ month / database</p>
  85. </>
  86. ),
  87. priceRowClassName: 'mt-3',
  88. })),
  89. ]
  90. useEffect(() => {
  91. if (visible) {
  92. if (subscriptionIpV4Option !== undefined) {
  93. setSelectedOption(subscriptionIpV4Option.variant.identifier)
  94. } else {
  95. setSelectedOption('ipv4_none')
  96. }
  97. }
  98. }, [visible, isLoading])
  99. const onConfirm = async () => {
  100. if (!projectRef) return console.error('Project ref is required')
  101. if (selectedOption === 'ipv4_none' && subscriptionIpV4Option !== undefined) {
  102. removeAddon({ projectRef, variant: subscriptionIpV4Option.variant.identifier })
  103. } else {
  104. updateAddon({ projectRef, type: 'ipv4', variant: selectedOption as AddonVariantId })
  105. }
  106. }
  107. return (
  108. <SidePanel
  109. size="large"
  110. visible={visible}
  111. onCancel={closePanel}
  112. onConfirm={onConfirm}
  113. loading={isLoading || isSubmitting || isLoadingEntitlement}
  114. disabled={
  115. !hasAccessToIPv4 ||
  116. isLoadingEntitlement ||
  117. isLoading ||
  118. !hasChanges ||
  119. isSubmitting ||
  120. !canUpdateIPv4 ||
  121. !isAws
  122. }
  123. tooltip={
  124. !hasAccessToIPv4
  125. ? 'Unable to enable IPv4 on a Free Plan'
  126. : !canUpdateIPv4
  127. ? 'You do not have permission to update IPv4'
  128. : undefined
  129. }
  130. header={
  131. <div className="flex w-full items-center justify-between">
  132. <h4>Dedicated IPv4 address</h4>
  133. <DocsButton href={`${DOCS_URL}/guides/platform/ipv4-address`} />
  134. </div>
  135. }
  136. >
  137. <SidePanel.Content>
  138. <div className="py-6 space-y-4">
  139. <p className="text-sm">
  140. Your project’s direct connection endpoint and dedicated pooler are IPv6-only by default.
  141. Enable the dedicated IPv4 address add-on to connect from IPv4-only networks.
  142. </p>
  143. <p className="text-sm">
  144. The shared pooler endpoint accepts IPv4 connections by default and does not require this
  145. add-on.
  146. </p>
  147. {!isAws && (
  148. <Admonition
  149. type="default"
  150. description="Dedicated IPv4 address is only available for AWS projects."
  151. />
  152. )}
  153. {isAws && (
  154. <div className={cn('mt-8! pb-4', !hasAccessToIPv4 && 'opacity-75')}>
  155. <RadioGroup
  156. name="ipv4"
  157. value={selectedOption}
  158. onValueChange={setSelectedOption}
  159. className="grid grid-cols-1 md:grid-cols-2 gap-4"
  160. >
  161. {ipv4Options.map((option) => (
  162. <RadioGroupLargeItem
  163. key={option.id}
  164. value={option.value}
  165. label=""
  166. showIndicator={false}
  167. className={cn(
  168. 'w-full gap-0 p-0 shadow-none bg-transparent cursor-pointer text-left',
  169. 'border-default hover:border-control hover:bg-transparent'
  170. )}
  171. >
  172. <div className="px-4 py-3">
  173. <p className="text-sm font-medium">{option.title}</p>
  174. <p className="text-foreground-light text-sm mt-1">{option.description}</p>
  175. <div
  176. className={cn(
  177. 'flex items-center space-x-1 text-sm',
  178. option.priceRowClassName
  179. )}
  180. >
  181. {option.priceContent}
  182. </div>
  183. </div>
  184. </RadioGroupLargeItem>
  185. ))}
  186. </RadioGroup>
  187. <TaxDisclaimer className="mt-3" />
  188. </div>
  189. )}
  190. {hasChanges && (
  191. <>
  192. <Admonition
  193. type="note"
  194. title="Potential downtime"
  195. description="There might be some downtime when enabling the add-on since some DNS clients might
  196. have cached the old DNS entry. Generally, this should be less than a minute."
  197. />
  198. {selectedOption !== 'ipv4_none' && (
  199. <p className="text-sm text-foreground-light">
  200. By default, this is only applied to the primary database for your project. If{' '}
  201. <InlineLink href={`${DOCS_URL}/guides/platform/read-replicas`} target="_blank">
  202. read replicas
  203. </InlineLink>{' '}
  204. are used, each replica also gets its own IPv4 address, with a corresponding{' '}
  205. <span className="text-foreground">{formatCurrency(selectedIPv4?.price)}</span>{' '}
  206. charge.
  207. </p>
  208. )}
  209. <p className="text-sm text-foreground-light">
  210. There are no immediate charges. The add-on is billed at the end of your billing
  211. cycle based on your usage and prorated to the hour.
  212. </p>
  213. </>
  214. )}
  215. {!hasAccessToIPv4 && (
  216. <UpgradeToPro
  217. addon="ipv4"
  218. source="ipv4SidePanel"
  219. featureProposition="connect from IPv4-only networks"
  220. primaryText="Dedicated IPv4 address is a Pro Plan add-on"
  221. secondaryText="Enable the add-on to connect to your project from IPv4-only networks."
  222. />
  223. )}
  224. </div>
  225. </SidePanel.Content>
  226. </SidePanel>
  227. )
  228. }
  229. export default IPv4SidePanel