PITRSidePanel.tsx336 lines · main
| 1 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 2 | import { useParams } from 'common' |
| 3 | import { useTheme } from 'next-themes' |
| 4 | import { useEffect, useState } from 'react' |
| 5 | import { toast } from 'sonner' |
| 6 | import { |
| 7 | Alert, |
| 8 | AlertDescription, |
| 9 | AlertTitle, |
| 10 | Button, |
| 11 | cn, |
| 12 | CriticalIcon, |
| 13 | RadioGroupCard, |
| 14 | RadioGroupCardItem, |
| 15 | SidePanel, |
| 16 | } from 'ui' |
| 17 | |
| 18 | import { subscriptionHasHipaaAddon } from '@/components/interfaces/Billing/Subscription/Subscription.utils' |
| 19 | import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer' |
| 20 | import { SupportLink } from '@/components/interfaces/Support/SupportLink' |
| 21 | import { DocsButton } from '@/components/ui/DocsButton' |
| 22 | import { UpgradeToPro } from '@/components/ui/UpgradeToPro' |
| 23 | import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' |
| 24 | import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' |
| 25 | import { useProjectAddonRemoveMutation } from '@/data/subscriptions/project-addon-remove-mutation' |
| 26 | import { useProjectAddonUpdateMutation } from '@/data/subscriptions/project-addon-update-mutation' |
| 27 | import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' |
| 28 | import type { AddonVariantId } from '@/data/subscriptions/types' |
| 29 | import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' |
| 30 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 31 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 32 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 33 | import { BASE_PATH, DOCS_URL } from '@/lib/constants' |
| 34 | import { formatCurrency } from '@/lib/helpers' |
| 35 | import { useAddonsPagePanel } from '@/state/addons-page' |
| 36 | |
| 37 | const PITR_CATEGORY_OPTIONS: { |
| 38 | id: 'off' | 'on' |
| 39 | name: string |
| 40 | imageUrl: string |
| 41 | imageUrlLight: string |
| 42 | }[] = [ |
| 43 | { |
| 44 | id: 'off', |
| 45 | name: 'Disable PITR', |
| 46 | imageUrl: `${BASE_PATH}/img/pitr-off.svg?v=2`, |
| 47 | imageUrlLight: `${BASE_PATH}/img/pitr-off--light.svg?v=2`, |
| 48 | }, |
| 49 | { |
| 50 | id: 'on', |
| 51 | name: 'Enable PITR', |
| 52 | imageUrl: `${BASE_PATH}/img/pitr-on.svg?v=2`, |
| 53 | imageUrlLight: `${BASE_PATH}/img/pitr-on--light.svg?v=2`, |
| 54 | }, |
| 55 | ] |
| 56 | |
| 57 | const PITRSidePanel = () => { |
| 58 | const { ref: projectRef } = useParams() |
| 59 | const { resolvedTheme } = useTheme() |
| 60 | const { data: project } = useSelectedProjectQuery() |
| 61 | const { data: organization } = useSelectedOrganizationQuery() |
| 62 | const { data: projectSettings } = useProjectSettingsV2Query({ projectRef }) |
| 63 | |
| 64 | const [selectedCategory, setSelectedCategory] = useState<'on' | 'off'>('off') |
| 65 | const [selectedOption, setSelectedOption] = useState<string>('pitr_0') |
| 66 | |
| 67 | const { can: canUpdatePitr } = useAsyncCheckPermissions( |
| 68 | PermissionAction.BILLING_WRITE, |
| 69 | 'stripe.subscriptions' |
| 70 | ) |
| 71 | const isBranchingEnabled = |
| 72 | project?.is_branch_enabled === true || project?.parent_project_ref !== undefined |
| 73 | |
| 74 | const { panel, closePanel } = useAddonsPagePanel() |
| 75 | const visible = panel === 'pitr' |
| 76 | |
| 77 | const { data: addons, isPending: isLoading } = useProjectAddonsQuery({ projectRef }) |
| 78 | const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) |
| 79 | const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) && projectSettings?.is_sensitive |
| 80 | |
| 81 | const { mutate: updateAddon, isPending: isUpdating } = useProjectAddonUpdateMutation({ |
| 82 | onSuccess: () => { |
| 83 | toast.success(`Successfully updated point in time recovery duration`) |
| 84 | closePanel() |
| 85 | }, |
| 86 | onError: (error) => { |
| 87 | toast.error(`Unable to update PITR: ${error.message}`) |
| 88 | }, |
| 89 | }) |
| 90 | const { mutate: removeAddon, isPending: isRemoving } = useProjectAddonRemoveMutation({ |
| 91 | onSuccess: () => { |
| 92 | toast.success(`Successfully disabled point in time recovery`) |
| 93 | closePanel() |
| 94 | }, |
| 95 | onError: (error) => { |
| 96 | toast.error(`Unable to disable PITR: ${error.message}`) |
| 97 | }, |
| 98 | }) |
| 99 | const isSubmitting = isUpdating || isRemoving |
| 100 | |
| 101 | const selectedAddons = addons?.selected_addons ?? [] |
| 102 | const availableAddons = addons?.available_addons ?? [] |
| 103 | |
| 104 | const subscriptionCompute = selectedAddons.find((addon) => addon.type === 'compute_instance') |
| 105 | const subscriptionPitr = selectedAddons.find((addon) => addon.type === 'pitr') |
| 106 | const availableOptions = availableAddons.find((addon) => addon.type === 'pitr')?.variants ?? [] |
| 107 | |
| 108 | const hasChanges = selectedOption !== (subscriptionPitr?.variant.identifier ?? 'pitr_0') |
| 109 | const { hasAccess: hasAccessToPitrVariants } = useCheckEntitlements('pitr.available_variants') |
| 110 | const selectedPitr = availableOptions.find((option) => option.identifier === selectedOption) |
| 111 | const hasSufficientCompute = |
| 112 | !!subscriptionCompute && subscriptionCompute.variant.identifier !== 'ci_micro' |
| 113 | |
| 114 | // These are illegal states. If they are true, we should block the user from saving them. |
| 115 | const blockDowngradeDueToHipaa = |
| 116 | hasHipaaAddon && |
| 117 | (selectedCategory !== 'on' || |
| 118 | // If the project is HIPAA, we don't allow the user to downgrade below 28 days |
| 119 | selectedPitr?.identifier !== 'pitr_28') |
| 120 | |
| 121 | const onConfirm = async () => { |
| 122 | if (!projectRef) return console.error('Project ref is required') |
| 123 | |
| 124 | if (selectedOption === 'pitr_0' && subscriptionPitr !== undefined) { |
| 125 | removeAddon({ projectRef, variant: subscriptionPitr.variant.identifier }) |
| 126 | } else { |
| 127 | updateAddon({ projectRef, type: 'pitr', variant: selectedOption as AddonVariantId }) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | useEffect(() => { |
| 132 | if (visible) { |
| 133 | if (subscriptionPitr !== undefined) { |
| 134 | setSelectedCategory('on') |
| 135 | setSelectedOption(subscriptionPitr.variant.identifier) |
| 136 | } else { |
| 137 | setSelectedCategory('off') |
| 138 | setSelectedOption('pitr_0') |
| 139 | } |
| 140 | } |
| 141 | }, [visible, isLoading]) |
| 142 | |
| 143 | return ( |
| 144 | <SidePanel |
| 145 | size="xlarge" |
| 146 | visible={visible} |
| 147 | onCancel={closePanel} |
| 148 | onConfirm={onConfirm} |
| 149 | loading={isLoading || isSubmitting} |
| 150 | disabled={ |
| 151 | !hasAccessToPitrVariants || |
| 152 | isLoading || |
| 153 | !hasChanges || |
| 154 | isSubmitting || |
| 155 | !canUpdatePitr || |
| 156 | (!!selectedPitr && !hasSufficientCompute) || |
| 157 | blockDowngradeDueToHipaa |
| 158 | } |
| 159 | tooltip={ |
| 160 | blockDowngradeDueToHipaa |
| 161 | ? 'Unable to disable PITR with HIPAA add-on' |
| 162 | : !hasAccessToPitrVariants |
| 163 | ? 'Unable to enable point in time recovery on your Plan' |
| 164 | : !canUpdatePitr |
| 165 | ? 'You do not have permission to update PITR' |
| 166 | : undefined |
| 167 | } |
| 168 | header={ |
| 169 | <div className="flex w-full items-center justify-between"> |
| 170 | <h4>Point in Time Recovery</h4> |
| 171 | <DocsButton href={`${DOCS_URL}/guides/platform/backups#point-in-time-recovery`} /> |
| 172 | </div> |
| 173 | } |
| 174 | > |
| 175 | <SidePanel.Content> |
| 176 | <div className="py-6 space-y-4"> |
| 177 | <p className="text-sm"> |
| 178 | Point-in-Time Recovery (PITR) allows a project to be backed up at much shorter |
| 179 | intervals. This provides users an option to restore to any chosen point of up to seconds |
| 180 | in granularity. |
| 181 | </p> |
| 182 | |
| 183 | <div className="mt-8! pb-4"> |
| 184 | <div className="flex gap-3"> |
| 185 | {PITR_CATEGORY_OPTIONS.map((option) => { |
| 186 | const isSelected = selectedCategory === option.id |
| 187 | return ( |
| 188 | <div |
| 189 | key={option.id} |
| 190 | className={cn( |
| 191 | 'col-span-3 group space-y-1', |
| 192 | !hasAccessToPitrVariants && 'opacity-75' |
| 193 | )} |
| 194 | onClick={() => { |
| 195 | setSelectedCategory(option.id) |
| 196 | if (option.id === 'off') { |
| 197 | setSelectedOption('pitr_0') |
| 198 | } else if (subscriptionPitr?.variant.identifier !== undefined) { |
| 199 | setSelectedOption(subscriptionPitr.variant.identifier) |
| 200 | } else { |
| 201 | if (hasHipaaAddon) { |
| 202 | setSelectedOption('pitr_28') |
| 203 | } else { |
| 204 | setSelectedOption('pitr_7') |
| 205 | } |
| 206 | } |
| 207 | }} |
| 208 | > |
| 209 | <img |
| 210 | alt="Point-In-Time-Recovery" |
| 211 | className={cn( |
| 212 | 'relative rounded-xl transition border bg-no-repeat bg-center bg-cover cursor-pointer w-[160px] h-[96px]', |
| 213 | isSelected |
| 214 | ? 'border-foreground' |
| 215 | : 'border-foreground-muted opacity-50 group-hover:border-foreground-lighter group-hover:opacity-100' |
| 216 | )} |
| 217 | width={160} |
| 218 | height={96} |
| 219 | src={resolvedTheme?.includes('dark') ? option.imageUrl : option.imageUrlLight} |
| 220 | /> |
| 221 | |
| 222 | <p |
| 223 | className={cn( |
| 224 | 'text-sm transition', |
| 225 | isSelected ? 'text-foreground' : 'text-foreground-light' |
| 226 | )} |
| 227 | > |
| 228 | {option.name} |
| 229 | </p> |
| 230 | </div> |
| 231 | ) |
| 232 | })} |
| 233 | </div> |
| 234 | </div> |
| 235 | |
| 236 | {selectedCategory === 'off' && subscriptionPitr !== undefined && isBranchingEnabled && ( |
| 237 | <Alert variant="warning"> |
| 238 | <CriticalIcon /> |
| 239 | <AlertTitle>Are you sure you want to disable this while using Branching?</AlertTitle> |
| 240 | <AlertDescription> |
| 241 | Without PITR, you might not be able to recover lost data if you accidentally merge a |
| 242 | branch that deletes a column or user data. We don't recommend this. |
| 243 | </AlertDescription> |
| 244 | </Alert> |
| 245 | )} |
| 246 | |
| 247 | {blockDowngradeDueToHipaa ? ( |
| 248 | <Alert> |
| 249 | <AlertTitle>PITR cannot be disabled on HIPAA projects</AlertTitle> |
| 250 | <AlertDescription> |
| 251 | PITR is enabled by default for all HIPAA projects and cannot be turned off. Contact |
| 252 | support for further assistance. |
| 253 | </AlertDescription> |
| 254 | <div className="mt-4"> |
| 255 | <Button type="default" asChild> |
| 256 | <SupportLink>Contact support</SupportLink> |
| 257 | </Button> |
| 258 | </div> |
| 259 | </Alert> |
| 260 | ) : null} |
| 261 | |
| 262 | {selectedCategory === 'on' && ( |
| 263 | <div className="mt-8! pb-4"> |
| 264 | {!hasAccessToPitrVariants ? ( |
| 265 | <UpgradeToPro |
| 266 | className="mb-4" |
| 267 | addon="pitr" |
| 268 | primaryText="Changing your Point-In-Time-Recovery is only available on the Pro Plan" |
| 269 | secondaryText="Upgrade your plan to change PITR for your project." |
| 270 | featureProposition="enable PITR" |
| 271 | /> |
| 272 | ) : !hasSufficientCompute ? ( |
| 273 | <UpgradeToPro |
| 274 | className="mb-4" |
| 275 | addon="computeSize" |
| 276 | primaryText="Project needs to be at least on a Small compute size to enable PITR" |
| 277 | secondaryText="This ensures enough resources to execute PITR successfully." |
| 278 | featureProposition="enable PITR" |
| 279 | /> |
| 280 | ) : null} |
| 281 | |
| 282 | <label className="block text-sm text-foreground-light mb-4" htmlFor="pitr"> |
| 283 | Choose the duration of recovery |
| 284 | </label> |
| 285 | <RadioGroupCard |
| 286 | id="pitr" |
| 287 | className="flex flex-wrap gap-3" |
| 288 | value={selectedOption} |
| 289 | onValueChange={(value) => setSelectedOption(value)} |
| 290 | disabled={!hasAccessToPitrVariants || subscriptionCompute === undefined} |
| 291 | > |
| 292 | {availableOptions.map((option) => ( |
| 293 | <RadioGroupCardItem |
| 294 | key={option.identifier} |
| 295 | value={option.identifier} |
| 296 | id={option.identifier} |
| 297 | label={ |
| 298 | <div className="w-full group"> |
| 299 | <div className="border-b border-default px-4 py-2"> |
| 300 | <p className="text-sm">{option.name}</p> |
| 301 | </div> |
| 302 | <div className="px-4 py-2"> |
| 303 | <p className="text-foreground-light"> |
| 304 | Allow database restorations to any time up to{' '} |
| 305 | {option.identifier.split('_')[1]} days ago |
| 306 | </p> |
| 307 | <div className="flex items-center space-x-1 mt-2"> |
| 308 | <p className="text-foreground text-sm" translate="no"> |
| 309 | {formatCurrency(option.price)} |
| 310 | </p> |
| 311 | <p className="text-foreground-light translate-y-px"> / month</p> |
| 312 | </div> |
| 313 | </div> |
| 314 | </div> |
| 315 | } |
| 316 | showIndicator={false} |
| 317 | /> |
| 318 | ))} |
| 319 | </RadioGroupCard> |
| 320 | <TaxDisclaimer className="mt-3" /> |
| 321 | </div> |
| 322 | )} |
| 323 | |
| 324 | {hasChanges && selectedOption !== 'pitr_0' && ( |
| 325 | <p className="text-sm text-foreground-light"> |
| 326 | There are no immediate charges. The add-on is billed at the end of your billing cycle |
| 327 | based on your usage and prorated to the hour. |
| 328 | </p> |
| 329 | )} |
| 330 | </div> |
| 331 | </SidePanel.Content> |
| 332 | </SidePanel> |
| 333 | ) |
| 334 | } |
| 335 | |
| 336 | export default PITRSidePanel |