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