PITRSidePanel.tsx 13 KB

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