| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { useParams } from 'common'
- import { useTheme } from 'next-themes'
- import { useEffect, useState } from 'react'
- import { toast } from 'sonner'
- import {
- Alert,
- AlertDescription,
- AlertTitle,
- Button,
- cn,
- CriticalIcon,
- RadioGroupCard,
- RadioGroupCardItem,
- SidePanel,
- } from 'ui'
- import { subscriptionHasHipaaAddon } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
- import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
- import { SupportLink } from '@/components/interfaces/Support/SupportLink'
- import { DocsButton } from '@/components/ui/DocsButton'
- import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
- import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
- import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
- import { useProjectAddonRemoveMutation } from '@/data/subscriptions/project-addon-remove-mutation'
- import { useProjectAddonUpdateMutation } from '@/data/subscriptions/project-addon-update-mutation'
- import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
- import type { AddonVariantId } from '@/data/subscriptions/types'
- import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { BASE_PATH, DOCS_URL } from '@/lib/constants'
- import { formatCurrency } from '@/lib/helpers'
- import { useAddonsPagePanel } from '@/state/addons-page'
- const PITR_CATEGORY_OPTIONS: {
- id: 'off' | 'on'
- name: string
- imageUrl: string
- imageUrlLight: string
- }[] = [
- {
- id: 'off',
- name: 'Disable PITR',
- imageUrl: `${BASE_PATH}/img/pitr-off.svg?v=2`,
- imageUrlLight: `${BASE_PATH}/img/pitr-off--light.svg?v=2`,
- },
- {
- id: 'on',
- name: 'Enable PITR',
- imageUrl: `${BASE_PATH}/img/pitr-on.svg?v=2`,
- imageUrlLight: `${BASE_PATH}/img/pitr-on--light.svg?v=2`,
- },
- ]
- const PITRSidePanel = () => {
- const { ref: projectRef } = useParams()
- const { resolvedTheme } = useTheme()
- const { data: project } = useSelectedProjectQuery()
- const { data: organization } = useSelectedOrganizationQuery()
- const { data: projectSettings } = useProjectSettingsV2Query({ projectRef })
- const [selectedCategory, setSelectedCategory] = useState<'on' | 'off'>('off')
- const [selectedOption, setSelectedOption] = useState<string>('pitr_0')
- const { can: canUpdatePitr } = useAsyncCheckPermissions(
- PermissionAction.BILLING_WRITE,
- 'stripe.subscriptions'
- )
- const isBranchingEnabled =
- project?.is_branch_enabled === true || project?.parent_project_ref !== undefined
- const { panel, closePanel } = useAddonsPagePanel()
- const visible = panel === 'pitr'
- const { data: addons, isPending: isLoading } = useProjectAddonsQuery({ projectRef })
- const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
- const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) && projectSettings?.is_sensitive
- const { mutate: updateAddon, isPending: isUpdating } = useProjectAddonUpdateMutation({
- onSuccess: () => {
- toast.success(`Successfully updated point in time recovery duration`)
- closePanel()
- },
- onError: (error) => {
- toast.error(`Unable to update PITR: ${error.message}`)
- },
- })
- const { mutate: removeAddon, isPending: isRemoving } = useProjectAddonRemoveMutation({
- onSuccess: () => {
- toast.success(`Successfully disabled point in time recovery`)
- closePanel()
- },
- onError: (error) => {
- toast.error(`Unable to disable PITR: ${error.message}`)
- },
- })
- const isSubmitting = isUpdating || isRemoving
- const selectedAddons = addons?.selected_addons ?? []
- const availableAddons = addons?.available_addons ?? []
- const subscriptionCompute = selectedAddons.find((addon) => addon.type === 'compute_instance')
- const subscriptionPitr = selectedAddons.find((addon) => addon.type === 'pitr')
- const availableOptions = availableAddons.find((addon) => addon.type === 'pitr')?.variants ?? []
- const hasChanges = selectedOption !== (subscriptionPitr?.variant.identifier ?? 'pitr_0')
- const { hasAccess: hasAccessToPitrVariants } = useCheckEntitlements('pitr.available_variants')
- const selectedPitr = availableOptions.find((option) => option.identifier === selectedOption)
- const hasSufficientCompute =
- !!subscriptionCompute && subscriptionCompute.variant.identifier !== 'ci_micro'
- // These are illegal states. If they are true, we should block the user from saving them.
- const blockDowngradeDueToHipaa =
- hasHipaaAddon &&
- (selectedCategory !== 'on' ||
- // If the project is HIPAA, we don't allow the user to downgrade below 28 days
- selectedPitr?.identifier !== 'pitr_28')
- const onConfirm = async () => {
- if (!projectRef) return console.error('Project ref is required')
- if (selectedOption === 'pitr_0' && subscriptionPitr !== undefined) {
- removeAddon({ projectRef, variant: subscriptionPitr.variant.identifier })
- } else {
- updateAddon({ projectRef, type: 'pitr', variant: selectedOption as AddonVariantId })
- }
- }
- useEffect(() => {
- if (visible) {
- if (subscriptionPitr !== undefined) {
- setSelectedCategory('on')
- setSelectedOption(subscriptionPitr.variant.identifier)
- } else {
- setSelectedCategory('off')
- setSelectedOption('pitr_0')
- }
- }
- }, [visible, isLoading])
- return (
- <SidePanel
- size="xlarge"
- visible={visible}
- onCancel={closePanel}
- onConfirm={onConfirm}
- loading={isLoading || isSubmitting}
- disabled={
- !hasAccessToPitrVariants ||
- isLoading ||
- !hasChanges ||
- isSubmitting ||
- !canUpdatePitr ||
- (!!selectedPitr && !hasSufficientCompute) ||
- blockDowngradeDueToHipaa
- }
- tooltip={
- blockDowngradeDueToHipaa
- ? 'Unable to disable PITR with HIPAA add-on'
- : !hasAccessToPitrVariants
- ? 'Unable to enable point in time recovery on your Plan'
- : !canUpdatePitr
- ? 'You do not have permission to update PITR'
- : undefined
- }
- header={
- <div className="flex w-full items-center justify-between">
- <h4>Point in Time Recovery</h4>
- <DocsButton href={`${DOCS_URL}/guides/platform/backups#point-in-time-recovery`} />
- </div>
- }
- >
- <SidePanel.Content>
- <div className="py-6 space-y-4">
- <p className="text-sm">
- Point-in-Time Recovery (PITR) allows a project to be backed up at much shorter
- intervals. This provides users an option to restore to any chosen point of up to seconds
- in granularity.
- </p>
- <div className="mt-8! pb-4">
- <div className="flex gap-3">
- {PITR_CATEGORY_OPTIONS.map((option) => {
- const isSelected = selectedCategory === option.id
- return (
- <div
- key={option.id}
- className={cn(
- 'col-span-3 group space-y-1',
- !hasAccessToPitrVariants && 'opacity-75'
- )}
- onClick={() => {
- setSelectedCategory(option.id)
- if (option.id === 'off') {
- setSelectedOption('pitr_0')
- } else if (subscriptionPitr?.variant.identifier !== undefined) {
- setSelectedOption(subscriptionPitr.variant.identifier)
- } else {
- if (hasHipaaAddon) {
- setSelectedOption('pitr_28')
- } else {
- setSelectedOption('pitr_7')
- }
- }
- }}
- >
- <img
- alt="Point-In-Time-Recovery"
- className={cn(
- 'relative rounded-xl transition border bg-no-repeat bg-center bg-cover cursor-pointer w-[160px] h-[96px]',
- isSelected
- ? 'border-foreground'
- : 'border-foreground-muted opacity-50 group-hover:border-foreground-lighter group-hover:opacity-100'
- )}
- width={160}
- height={96}
- src={resolvedTheme?.includes('dark') ? option.imageUrl : option.imageUrlLight}
- />
- <p
- className={cn(
- 'text-sm transition',
- isSelected ? 'text-foreground' : 'text-foreground-light'
- )}
- >
- {option.name}
- </p>
- </div>
- )
- })}
- </div>
- </div>
- {selectedCategory === 'off' && subscriptionPitr !== undefined && isBranchingEnabled && (
- <Alert variant="warning">
- <CriticalIcon />
- <AlertTitle>Are you sure you want to disable this while using Branching?</AlertTitle>
- <AlertDescription>
- Without PITR, you might not be able to recover lost data if you accidentally merge a
- branch that deletes a column or user data. We don't recommend this.
- </AlertDescription>
- </Alert>
- )}
- {blockDowngradeDueToHipaa ? (
- <Alert>
- <AlertTitle>PITR cannot be disabled on HIPAA projects</AlertTitle>
- <AlertDescription>
- PITR is enabled by default for all HIPAA projects and cannot be turned off. Contact
- support for further assistance.
- </AlertDescription>
- <div className="mt-4">
- <Button type="default" asChild>
- <SupportLink>Contact support</SupportLink>
- </Button>
- </div>
- </Alert>
- ) : null}
- {selectedCategory === 'on' && (
- <div className="mt-8! pb-4">
- {!hasAccessToPitrVariants ? (
- <UpgradeToPro
- className="mb-4"
- addon="pitr"
- primaryText="Changing your Point-In-Time-Recovery is only available on the Pro Plan"
- secondaryText="Upgrade your plan to change PITR for your project."
- featureProposition="enable PITR"
- />
- ) : !hasSufficientCompute ? (
- <UpgradeToPro
- className="mb-4"
- addon="computeSize"
- primaryText="Project needs to be at least on a Small compute size to enable PITR"
- secondaryText="This ensures enough resources to execute PITR successfully."
- featureProposition="enable PITR"
- />
- ) : null}
- <label className="block text-sm text-foreground-light mb-4" htmlFor="pitr">
- Choose the duration of recovery
- </label>
- <RadioGroupCard
- id="pitr"
- className="flex flex-wrap gap-3"
- value={selectedOption}
- onValueChange={(value) => setSelectedOption(value)}
- disabled={!hasAccessToPitrVariants || subscriptionCompute === undefined}
- >
- {availableOptions.map((option) => (
- <RadioGroupCardItem
- key={option.identifier}
- value={option.identifier}
- id={option.identifier}
- label={
- <div className="w-full group">
- <div className="border-b border-default px-4 py-2">
- <p className="text-sm">{option.name}</p>
- </div>
- <div className="px-4 py-2">
- <p className="text-foreground-light">
- Allow database restorations to any time up to{' '}
- {option.identifier.split('_')[1]} days ago
- </p>
- <div className="flex items-center space-x-1 mt-2">
- <p className="text-foreground text-sm" translate="no">
- {formatCurrency(option.price)}
- </p>
- <p className="text-foreground-light translate-y-px"> / month</p>
- </div>
- </div>
- </div>
- }
- showIndicator={false}
- />
- ))}
- </RadioGroupCard>
- <TaxDisclaimer className="mt-3" />
- </div>
- )}
- {hasChanges && selectedOption !== 'pitr_0' && (
- <p className="text-sm text-foreground-light">
- There are no immediate charges. The add-on is billed at the end of your billing cycle
- based on your usage and prorated to the hour.
- </p>
- )}
- </div>
- </SidePanel.Content>
- </SidePanel>
- )
- }
- export default PITRSidePanel
|