Files
supabase/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx
T
Jordi EnricandClaude Opus 4.6 21584fe512 feat(studio): add backup cli instructions (#44621)
## Problem

When a project is paused, in a failed state, or about to be deleted,
users have no obvious way to take a logical backup of their data before
proceeding. This is particularly risky at deletion time — once deleted,
data is gone.

## Solution

Introduce a new `LogicalBackupCliInstructions` component that surfaces
ready-to-run `supabase db dump` commands pre-filled with the project's
direct connection details.

### Where it appears

| State | How |
|---|---|
| Project paused (restorable) | Inline in `ProjectPausedState` with a
note to resume first |
| Pause failed | Dialog via "Download backup" button when no backup is
available |
| Restore failed | Dialog via "Download backup" button when no backup is
available |
| Delete project modal | Inline in `DeleteProjectModal` for all plans |

Not shown in `PauseDisabledState` (project paused 90+ days, compute
stopped — `pg_dump` would fail anyway).

### What the component does

- Fetches the project's direct connection settings via
`useProjectSettingsV2Query`
- Builds a connection URI with a `[YOUR-PASSWORD]` placeholder (password
is never stored or displayed)
- Shows three shell commands to dump roles, schema, and data separately
— mirroring the [logical backup
docs](https://supabase.com/docs/guides/platform/backups)
- Optionally shows a **Reset database password** button (gated on
`UPDATE projects` permission); shown in the paused state, hidden
elsewhere via `showResetPassword={false}`
- Includes inline guidance to percent-encode special characters in the
password

### Shell safety

The generated `--db-url` values are wrapped in single quotes to prevent
shell metacharacter expansion when users paste and run the commands.
`npx supabase login` is intentionally omitted — the `--db-url` flag
authenticates directly against Postgres and does not require a Supabase
account.

### Backup button behaviour in failed states

The "Download backup" button in `PauseFailedState` and
`RestoreFailedState` now always stays enabled:
- **Backup available** — downloads immediately (unchanged)
- **No backup / physical backups** — opens a dialog with CLI
instructions instead of silently failing

## How to test

**Delete project flow**
1. Open any project → Settings → General → Delete project
2. Verify the CLI backup section appears with the project's host, port,
user, and db name pre-filled
3. Verify no Reset database password button is shown

**Paused project**
1. Open a paused project (`ProjectPausedState`) — verify CLI
instructions appear with the "Your project must be resumed before
running these commands." note
2. Open a project paused for 90+ days (`PauseDisabledState`) — verify
CLI instructions do not appear

**Failed states**
1. Simulate a pause-failed or restore-failed state
2. If a downloadable backup exists — "Download backup" downloads it
directly
3. Block the backup API or use a project with physical backups —
"Download backup" should open the CLI instructions dialog

**Error state**
1. Block the project settings API call (DevTools → Network → block
request)
2. Verify an error message appears with a link to Database settings
3. Verify a loading skeleton shows while the request is in flight

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 16:39:32 +02:00

200 lines
7.3 KiB
TypeScript

import { LOCAL_STORAGE_KEYS } from 'common'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Input } from 'ui'
import { CANCELLATION_REASONS } from '@/components/interfaces/Billing/Billing.constants'
import { LogicalBackupCliInstructions } from '@/components/layouts/ProjectLayout/LogicalBackupCliInstructions'
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
import { useSendDowngradeFeedbackMutation } from '@/data/feedback/exit-survey-send'
import type { OrgProject } from '@/data/projects/org-projects-infinite-query'
import { useProjectDeleteMutation } from '@/data/projects/project-delete-mutation'
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import type { Organization } from '@/types'
export const DeleteProjectModal = ({
visible,
onClose,
project: projectProp,
organization: organizationProp,
}: {
visible: boolean
onClose: () => void
project?: OrgProject
organization?: Organization
}) => {
const router = useRouter()
const { data: projectFromQuery } = useSelectedProjectQuery()
const { data: organizationFromQuery } = useSelectedOrganizationQuery()
// Use props if provided, otherwise fall back to hooks
const project = projectProp || projectFromQuery
const organization = organizationProp || organizationFromQuery
const [lastVisitedOrganization] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
''
)
const projectRef = project?.ref
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const projectPlan = subscription?.plan?.id ?? 'free'
const isFree = projectPlan === 'free'
const [message, setMessage] = useState<string>('')
const [selectedReason, setSelectedReason] = useState<string[]>([])
// Single select for cancellation reason
const onSelectCancellationReason = (reason: string) => {
setSelectedReason([reason])
}
// Helper to get label for selected reason
const getReasonLabel = (reason: string | undefined) => {
const found = CANCELLATION_REASONS.find((r) => r.value === reason)
return found?.label || 'What can we improve on?'
}
const textareaLabel = getReasonLabel(selectedReason[0])
const [shuffledReasons] = useState(() => [
...CANCELLATION_REASONS.sort(() => Math.random() - 0.5),
{ value: 'None of the above' },
])
const { mutate: deleteProject, isPending: isDeleting } = useProjectDeleteMutation({
onSuccess: async () => {
if (!isFree) {
try {
await sendExitSurvey({
orgSlug: organization?.slug,
projectRef,
message,
reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''),
exitAction: 'delete',
})
} catch (error) {
// [Joshen] In this case we don't raise any errors if the exit survey fails to send since it shouldn't block the user
}
}
toast.success(`Successfully deleted ${project?.name}`)
// Only redirect if still viewing the deleted project
if (router.asPath.startsWith(`/project/${projectRef}`)) {
if (lastVisitedOrganization) {
router.push(`/org/${lastVisitedOrganization}`)
} else {
router.push('/organizations')
}
}
},
})
const { mutateAsync: sendExitSurvey, isPending: isSending } = useSendDowngradeFeedbackMutation()
const isSubmitting = isDeleting || isSending
async function handleDeleteProject() {
if (project === undefined) return
if (!isFree && selectedReason.length === 0) {
return toast.error('Please select a reason for deleting your project')
}
deleteProject({ projectRef: project.ref, organizationSlug: organization?.slug })
}
useEffect(() => {
if (visible) {
setSelectedReason([])
setMessage('')
}
}, [visible])
return (
<TextConfirmModal
visible={visible}
loading={isSubmitting}
size={isFree ? 'medium' : 'xlarge'}
title={`Confirm deletion of ${project?.name}`}
variant="destructive"
alert={{
title: isFree
? 'This action cannot be undone.'
: `This will permanently delete the ${project?.name}`,
description: !isFree ? `All project data will be lost, and cannot be undone` : '',
}}
text={
isFree
? `This will permanently delete the ${project?.name} project and all of its data.`
: undefined
}
confirmPlaceholder="Type the project name in here"
confirmString={project?.name || ''}
confirmLabel="I understand, delete this project"
onConfirm={handleDeleteProject}
onCancel={() => {
if (!isSubmitting) onClose()
}}
>
<div className="space-y-6">
<LogicalBackupCliInstructions enabled={visible} showResetPassword={false} />
{/*
[Joshen] This is basically ExitSurvey.tsx, ideally we have one shared component but the one
in ExitSurvey has a Form wrapped around it already. Will probably need some effort to refactor
but leaving that for the future.
*/}
{!isFree && (
<>
<div className="space-y-1">
<h4 className="text-base">What made you decide to delete your project?</h4>
</div>
<div className="space-y-4 pt-4">
<div className="flex flex-wrap gap-2" data-toggle="buttons">
{shuffledReasons.map((option) => {
const active = selectedReason[0] === option.value
return (
<label
key={option.value}
className={[
'flex cursor-pointer items-center space-x-2 rounded-md py-1',
'pl-2 pr-3 text-center text-sm shadow-sm transition-all duration-100',
`${
active
? ` bg-foreground text-background opacity-100 hover:bg-opacity-75`
: ` bg-border-strong text-foreground opacity-50 hover:opacity-75`
}`,
].join(' ')}
>
<input
type="radio"
name="options"
value={option.value}
className="hidden"
checked={active}
onChange={() => onSelectCancellationReason(option.value)}
/>
<div>{option.value}</div>
</label>
)
})}
</div>
<div className="text-area-text-sm flex flex-col gap-y-2">
<label className="text-sm whitespace-pre-line break-words">{textareaLabel}</label>
<Input.TextArea
name="message"
rows={3}
value={message}
onChange={(event) => setMessage(event.target.value)}
/>
</div>
</div>
</>
)}
</div>
</TextConfirmModal>
)
}