IPv4SidePanel.tsx247 lines · main
| 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 | |
| 8 | import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' |
| 9 | import { DocsButton } from '@/components/ui/DocsButton' |
| 10 | import { InlineLink } from '@/components/ui/InlineLink' |
| 11 | import { UpgradeToPro } from '@/components/ui/UpgradeToPro' |
| 12 | import { useProjectAddonRemoveMutation } from '@/data/subscriptions/project-addon-remove-mutation' |
| 13 | import { useProjectAddonUpdateMutation } from '@/data/subscriptions/project-addon-update-mutation' |
| 14 | import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' |
| 15 | import type { AddonVariantId } from '@/data/subscriptions/types' |
| 16 | import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' |
| 17 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 18 | import { useIsAwsCloudProvider } from '@/hooks/misc/useSelectedProject' |
| 19 | import { DOCS_URL } from '@/lib/constants' |
| 20 | import { formatCurrency } from '@/lib/helpers' |
| 21 | import { useAddonsPagePanel } from '@/state/addons-page' |
| 22 | |
| 23 | const 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 | |
| 247 | export default IPv4SidePanel |