PITRSidePanel.tsx336 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useParams } from 'common'
3import { useTheme } from 'next-themes'
4import { useEffect, useState } from 'react'
5import { toast } from 'sonner'
6import {
7 Alert,
8 AlertDescription,
9 AlertTitle,
10 Button,
11 cn,
12 CriticalIcon,
13 RadioGroupCard,
14 RadioGroupCardItem,
15 SidePanel,
16} from 'ui'
17
18import { subscriptionHasHipaaAddon } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
19import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
20import { SupportLink } from '@/components/interfaces/Support/SupportLink'
21import { DocsButton } from '@/components/ui/DocsButton'
22import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
23import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
24import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
25import { useProjectAddonRemoveMutation } from '@/data/subscriptions/project-addon-remove-mutation'
26import { useProjectAddonUpdateMutation } from '@/data/subscriptions/project-addon-update-mutation'
27import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
28import type { AddonVariantId } from '@/data/subscriptions/types'
29import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
30import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
31import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
32import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
33import { BASE_PATH, DOCS_URL } from '@/lib/constants'
34import { formatCurrency } from '@/lib/helpers'
35import { useAddonsPagePanel } from '@/state/addons-page'
36
37const 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
57const 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
336export default PITRSidePanel