diff --git a/apps/studio/components/interfaces/Storage/BucketDataProtectionFields.tsx b/apps/studio/components/interfaces/Storage/BucketDataProtectionFields.tsx new file mode 100644 index 00000000000..6c270db5070 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/BucketDataProtectionFields.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react' +import { DialogSection, DialogSectionSeparator, Input, Label, Switch } from 'ui' + +import { + getMockBucketProtection, + PROJECT_VERSIONING_DEFAULTS, + type BucketProtection, +} from './StorageProtection.constants' + +interface BucketDataProtectionFieldsProps { + bucketName?: string +} + +/** + * "Object versioning" section for the create/edit bucket modals. + * + * Prototype: manages local state and isn't persisted through the bucket + * mutation yet. + */ +export const BucketDataProtectionFields = ({ bucketName }: BucketDataProtectionFieldsProps) => { + const initial: BucketProtection = getMockBucketProtection(bucketName) + + const [isVersioningEnabled, setIsVersioningEnabled] = useState(initial.versioning === 'enabled') + const [expiryDays, setExpiryDays] = useState( + initial.versionExpiryDays ?? PROJECT_VERSIONING_DEFAULTS.versionExpiryDays + ) + const [maxVersions, setMaxVersions] = useState( + initial.maxNoncurrentVersions ?? PROJECT_VERSIONING_DEFAULTS.maxNoncurrentVersions + ) + + return ( + <> + + + +
+
+ +

+ Keep previous versions of objects when they are overwritten or deleted +

+
+ +
+ + {isVersioningEnabled && ( +
+
+ +

+ Automatically expire noncurrent versions after this many days +

+ setExpiryDays(Number(e.target.value))} + className="max-w-[160px]" + /> +
+
+ +

+ Keep at most this many noncurrent versions per object +

+ setMaxVersions(Number(e.target.value))} + className="max-w-[160px]" + /> +
+
+ )} +
+ + ) +} diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index 38c9cc76269..1b8926078f1 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -28,8 +28,6 @@ import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' -import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils' -import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils' import { StorageSizeUnits } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.constants' import { InlineLink } from '@/components/ui/InlineLink' import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query' @@ -37,6 +35,11 @@ import { useBucketCreateMutation } from '@/data/storage/bucket-create-mutation' import { IS_PLATFORM } from '@/lib/constants' import { useTrack } from '@/lib/telemetry/track' +import { BucketDataProtectionFields } from './BucketDataProtectionFields' +import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils' +import { useIsStorageProtectionEnabled } from './StorageProtection.constants' +import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils' + const FormSchema = z .object({ name: z @@ -86,6 +89,7 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps const { ref } = useParams() const [selectedUnit, setSelectedUnit] = useState(StorageSizeUnits.MB) const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(false) + const showProtection = useIsStorageProtectionEnabled() const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM }) const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0) @@ -395,6 +399,8 @@ export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps /> )} + + {showProtection && } diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx index 125d45e2d09..1dab175df52 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx @@ -28,6 +28,8 @@ import { Admonition } from 'ui-patterns/Admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { z } from 'zod' +import { BucketDataProtectionFields } from '@/components/interfaces/Storage/BucketDataProtectionFields' +import { useIsStorageProtectionEnabled } from '@/components/interfaces/Storage/StorageProtection.constants' import { StorageSizeUnits } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.constants' import { convertFromBytes, @@ -127,6 +129,7 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro Boolean(bucket?.allowed_mime_types?.length) ) + const showProtection = useIsStorageProtectionEnabled() const isChangingBucketVisibility = bucket?.public !== isPublicBucket const isMakingBucketPrivate = bucket?.public && !isPublicBucket const isMakingBucketPublic = !bucket?.public && isPublicBucket @@ -424,6 +427,8 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro /> )} + + {showProtection && } diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx index 169ac370453..0eaff09568b 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx @@ -1,5 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import dayjs from 'dayjs' import { AlertCircle, ChevronDown, Copy, Download, LoaderCircle, Trash2, X } from 'lucide-react' +import { useState } from 'react' import SVG from 'react-inlinesvg' import { Button, @@ -7,19 +9,28 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + Tabs, + TabsContent, + TabsList, + TabsTrigger, } from 'ui' -import { URL_EXPIRY_DURATION } from '../Storage.constants' -import { StorageItem } from '../Storage.types' -import { getPathAlongOpenedFolders } from './StorageExplorer.utils' -import { useCopyUrl } from './useCopyUrl' -import { useFetchFileUrlQuery } from './useFetchFileUrlQuery' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useObjectVersionsQuery } from '@/data/storage/protection/object-versions-query' +import type { ObjectVersion } from '@/data/storage/protection/protection-mocks' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { BASE_PATH } from '@/lib/constants' import { formatBytes } from '@/lib/helpers' import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer' +import { URL_EXPIRY_DURATION } from '../Storage.constants' +import { StorageItem } from '../Storage.types' +import { useIsStorageProtectionEnabled } from '../StorageProtection.constants' +import { getPathAlongOpenedFolders } from './StorageExplorer.utils' +import { useCopyUrl } from './useCopyUrl' +import { useFetchFileUrlQuery } from './useFetchFileUrlQuery' +import { VersionHistory } from './VersionHistory' + const PREVIEW_SIZE_LIMIT = 10 * 1024 * 1024 // 10MB const PreviewFile = ({ item }: { item: StorageItem }) => { @@ -33,7 +44,6 @@ const PreviewFile = ({ item }: { item: StorageItem }) => { bucket: selectedBucket, }) - // if the size is not available, we set it to be greater than the max size const size = +(item.metadata?.size ?? PREVIEW_SIZE_LIMIT + 1) const mimeType = item.metadata?.mimetype @@ -116,6 +126,7 @@ const PreviewFile = ({ item }: { item: StorageItem }) => { export const PreviewPane = () => { const { + projectRef, selectedBucket, selectedFilePreview: file, setSelectedItemsToDelete, @@ -127,6 +138,16 @@ export const PreviewPane = () => { const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + const showVersions = useIsStorageProtectionEnabled() + const { data: versionsData } = useObjectVersionsQuery({ + projectRef, + bucketId: selectedBucket?.id, + objectName: file?.name, + }) + const versionCount = versionsData?.length + + const [previewedVersion, setPreviewedVersion] = useState() + if (!file) return null const width = 450 @@ -135,133 +156,178 @@ export const PreviewPane = () => { const createdAt = file.created_at ? new Date(file.created_at).toLocaleString() : 'Unknown' const updatedAt = file.updated_at ? new Date(file.updated_at).toLocaleString() : 'Unknown' + const isPreviewingOlderVersion = previewedVersion !== undefined && !previewedVersion.isCurrent + + const previewThumbnail = ( +
+
+ +
+ {isPreviewingOlderVersion && ( +
+

+ Previewing version{' '} + + {previewedVersion.versionId.slice(0, 6)}… + {' '} + · {dayjs(previewedVersion.createdAt).format('MMM D, HH:mm')} +

+ +
+ )} +
+ ) + + const detailsContent = ( +
+
+
{file.name}
+ {file.isCorrupted && ( +
+ +

+ File is corrupted, please delete and reupload this file again +

+
+ )} + {mimeType && ( +

+ {mimeType} + {size && - {size}} +

+ )} +
+ +
+
+ +

{createdAt}

+
+
+ +

{updatedAt}

+
+
+ +
+ + {selectedBucket.public ? ( + + ) : ( + + + + + + onCopyUrl(file.path!, URL_EXPIRY_DURATION.WEEK)} + > + Expire in 1 week + + onCopyUrl(file.path!, URL_EXPIRY_DURATION.MONTH)} + > + Expire in 1 month + + onCopyUrl(file.path!, URL_EXPIRY_DURATION.YEAR)} + > + Expire in 1 year + + setSelectedFileCustomExpiry(file)} + > + Custom expiry + + + + )} +
+ } + onClick={() => setSelectedItemsToDelete([file])} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateFiles + ? 'You need additional permissions to delete this file' + : undefined, + }, + }} + > + Delete file + +
+ ) + return (
- {/* Preview Header */}
setSelectedFilePreview(undefined)} />
- {/* Preview Thumbnail*/} -
-
- -
-
+ {previewThumbnail} -
- {/* Preview Information */} -
-
{file.name}
- {file.isCorrupted && ( -
- -

- File is corrupted, please delete and reupload this file again -

-
- )} - {mimeType && ( -

- {mimeType} - {size && - {size}} -

- )} -
- - {/* Preview Metadata */} -
-
- -

{createdAt}

-
-
- -

{updatedAt}

-
-
- - {/* Actions */} -
- - {selectedBucket.public ? ( - - ) : ( - - - - - - onCopyUrl(file.path!, URL_EXPIRY_DURATION.WEEK)} - > - Expire in 1 week - - onCopyUrl(file.path!, URL_EXPIRY_DURATION.MONTH)} - > - Expire in 1 month - - onCopyUrl(file.path!, URL_EXPIRY_DURATION.YEAR)} - > - Expire in 1 year - - setSelectedFileCustomExpiry(file)} - > - Custom expiry - - - - )} -
- } - onClick={() => setSelectedItemsToDelete([file])} - tooltip={{ - content: { - side: 'bottom', - text: !canUpdateFiles - ? 'You need additional permissions to delete this file' - : undefined, - }, - }} - > - Delete file - -
+ {showVersions ? ( + + + Details + + Versions{versionCount !== undefined ? ` (${versionCount})` : ''} + + + {detailsContent} + + + + + ) : ( + detailsContent + )}
) } diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/VersionHistory.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/VersionHistory.tsx new file mode 100644 index 00000000000..e0f0b10227d --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/VersionHistory.tsx @@ -0,0 +1,215 @@ +import dayjs from 'dayjs' +import { Download, RotateCcw, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { toast } from 'sonner' +import { Badge, cn } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { AlertError } from '@/components/ui/AlertError' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { + useObjectVersionDeleteMutation, + useObjectVersionRestoreMutation, + useObjectVersionsQuery, +} from '@/data/storage/protection/object-versions-query' +import { type ObjectVersion } from '@/data/storage/protection/protection-mocks' +import { formatBytes } from '@/lib/helpers' + +interface VersionHistoryProps { + projectRef?: string + bucketId?: string + objectName: string + mimeType?: string + previewedVersionId?: string + onPreview?: (version: ObjectVersion) => void +} + +const shortVersion = (versionId: string) => `${versionId.slice(0, 6)}…${versionId.slice(-2)}` + +export const VersionHistory = ({ + projectRef, + bucketId, + objectName, + mimeType, + previewedVersionId, + onPreview, +}: VersionHistoryProps) => { + const { + data: versions, + isPending, + isError, + error, + isSuccess, + } = useObjectVersionsQuery({ projectRef, bucketId, objectName }) + + const [versionToDelete, setVersionToDelete] = useState() + + const { mutate: restoreVersion, isPending: isRestoring } = useObjectVersionRestoreMutation({ + onSuccess: () => toast.success('Version restored as the current version'), + }) + + const { mutate: deleteVersion, isPending: isDeleting } = useObjectVersionDeleteMutation({ + onSuccess: () => { + toast.success('Version permanently deleted') + setVersionToDelete(undefined) + }, + }) + + const handleRestore = (version: ObjectVersion) => { + if (!projectRef || !bucketId) return + restoreVersion({ projectRef, bucketId, objectName, versionId: version.versionId }) + } + + if (isPending) return + if (isError) return + + const totalSize = versions.reduce((sum, version) => sum + version.size, 0) + + return ( +
+
+
{objectName}
+

+ {[mimeType, `${versions.length} versions`, `${formatBytes(totalSize)} total`] + .filter(Boolean) + .join(' · ')} +

+
+ + {isSuccess && ( +
    + {versions.map((version, index) => { + const isLast = index === versions.length - 1 + return ( +
  1. +
    + + {!isLast && } +
    + +
    onPreview?.(version)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onPreview?.(version) + }} + > +
    +
    + + {dayjs(version.createdAt).format('MMM D, HH:mm')} + + {version.isCurrent && Latest} +
    + +
    e.stopPropagation()}> + } + aria-label={`Download version ${shortVersion(version.versionId)}`} + onClick={() => + toast.success(`Downloading ${shortVersion(version.versionId)}`) + } + tooltip={{ content: { side: 'bottom', text: 'Download' } }} + /> + + {!version.isCurrent && ( + <> + } + loading={isRestoring} + aria-label={`Restore version ${shortVersion(version.versionId)}`} + onClick={() => handleRestore(version)} + tooltip={{ content: { side: 'bottom', text: 'Restore as current' } }} + /> + } + disabled={version.heldBySnapshot !== null} + aria-label={`Delete version ${shortVersion(version.versionId)}`} + onClick={() => setVersionToDelete(version)} + tooltip={{ + content: { + side: 'bottom', + text: + version.heldBySnapshot !== null + ? 'Held by a snapshot — delete the snapshot first' + : 'Delete permanently', + }, + }} + /> + + )} +
    +
    +

    + {formatBytes(version.size)} · {version.action} +

    +

    + v: {shortVersion(version.versionId)} + {version.heldBySnapshot && ` · held by ${version.heldBySnapshot}`} +

    +
    +
  2. + ) + })} +
+ )} + + + + setVersionToDelete(undefined)} + onConfirm={() => { + if (!projectRef || !bucketId || !versionToDelete) return + deleteVersion({ + projectRef, + bucketId, + objectName, + versionId: versionToDelete.versionId, + }) + }} + > +

+ Version{' '} + + {versionToDelete ? shortVersion(versionToDelete.versionId) : ''} + {' '} + of {objectName} will be permanently deleted. The current version is not affected. This + action cannot be undone. +

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/StorageProtection.constants.ts b/apps/studio/components/interfaces/Storage/StorageProtection.constants.ts new file mode 100644 index 00000000000..317d733005a --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageProtection.constants.ts @@ -0,0 +1,47 @@ +/** + * Shared constants + gating for the Storage Object Versioning prototype. + */ + +// Prototype flag: force-enabled so the surfaces are visible via `pnpm dev:studio`. +// A real rollout would replace this with `useFlag('storageObjectVersioning')`. +export const STORAGE_PROTECTION_ENABLED = true + +export const useIsStorageProtectionEnabled = () => STORAGE_PROTECTION_ENABLED + +export type BucketVersioningState = 'enabled' | 'suspended' | 'disabled' + +export const PROJECT_VERSIONING_DEFAULTS = { + versionExpiryDays: 30, + maxNoncurrentVersions: 100, +} as const + +export interface BucketProtection { + versioning: BucketVersioningState + versionExpiryDays: number | null + maxNoncurrentVersions: number | null +} + +const PROTECTED_BUCKETS: Record = { + 'match-media': { + versioning: 'enabled', + versionExpiryDays: PROJECT_VERSIONING_DEFAULTS.versionExpiryDays, + maxNoncurrentVersions: PROJECT_VERSIONING_DEFAULTS.maxNoncurrentVersions, + }, + avatars: { + versioning: 'enabled', + versionExpiryDays: 30, + maxNoncurrentVersions: 10, + }, +} + +const DEFAULT_PROTECTION: BucketProtection = { + versioning: 'disabled', + versionExpiryDays: null, + maxNoncurrentVersions: null, +} + +export const getMockBucketProtection = (bucketName: string | undefined): BucketProtection => + (bucketName && PROTECTED_BUCKETS[bucketName]) || DEFAULT_PROTECTION + +export const isBucketVersioned = (bucketName: string | undefined) => + getMockBucketProtection(bucketName).versioning === 'enabled' diff --git a/apps/studio/components/interfaces/Storage/Trash/Trash.tsx b/apps/studio/components/interfaces/Storage/Trash/Trash.tsx new file mode 100644 index 00000000000..4902d9aecd5 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/Trash/Trash.tsx @@ -0,0 +1,237 @@ +import { useParams } from 'common' +import { Trash2 } from 'lucide-react' +import { useState } from 'react' +import { toast } from 'sonner' +import { Card } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { PageContainer } from 'ui-patterns/PageContainer' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { AlertError } from '@/components/ui/AlertError' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper' +import { + useBucketTrashDeleteMutation, + useBucketTrashQuery, + useBucketTrashRestoreMutation, +} from '@/data/storage/protection/bucket-trash-query' +import { type TrashObject } from '@/data/storage/protection/protection-mocks' + +import { toggleSelectAll, toggleSelection } from './Trash.utils' +import { TrashList } from './TrashList' +import { TrashSelectionBar } from './TrashSelectionBar' + +interface TrashProps { + bucketId: string +} + +export const Trash = ({ bucketId }: TrashProps) => { + const { ref } = useParams() + + const [selectedIds, setSelectedIds] = useState([]) + const [lastToggledId, setLastToggledId] = useState(null) + const [objectToDelete, setObjectToDelete] = useState() + const [showBulkDelete, setShowBulkDelete] = useState(false) + const [showDeleteAll, setShowDeleteAll] = useState(false) + + const { + data: objects, + isPending, + isError, + error, + isSuccess, + } = useBucketTrashQuery({ projectRef: ref, bucketId }) + + const { mutate: restoreObjects, isPending: isRestoring } = useBucketTrashRestoreMutation({ + onSuccess: (_data, variables) => { + const count = variables.objectIds.length + toast.success(count === 1 ? 'File restored' : `${count} files restored`) + setSelectedIds([]) + }, + }) + + const { mutate: deleteObjects, isPending: isDeleting } = useBucketTrashDeleteMutation({ + onSuccess: () => { + toast.success('Files permanently deleted') + setSelectedIds([]) + setObjectToDelete(undefined) + setShowBulkDelete(false) + setShowDeleteAll(false) + }, + }) + + const orderedIds = (objects ?? []).map((object) => object.id) + const selectedObjects = (objects ?? []).filter((object) => selectedIds.includes(object.id)) + const heldCount = selectedObjects.filter((object) => object.heldBySnapshot).length + const deletableIds = selectedObjects + .filter((object) => !object.heldBySnapshot) + .map((object) => object.id) + const heldInBucketCount = (objects ?? []).filter((object) => object.heldBySnapshot).length + + const handleToggleSelect = (id: string, isShiftHeld: boolean) => { + setSelectedIds(toggleSelection({ selectedIds, orderedIds, id, lastToggledId, isShiftHeld })) + setLastToggledId(id) + } + + const handleRestore = (objectIds: string[]) => { + if (!ref || objectIds.length === 0) return + restoreObjects({ projectRef: ref, bucketId, objectIds }) + } + + return ( + <> + + + +
+

+ Soft-deleted objects in this bucket, restorable until their retention policy expires + them +

+ {isSuccess && objects.length > 0 && ( + } + disabled={objects.length === heldInBucketCount} + onClick={() => setShowDeleteAll(true)} + tooltip={{ + content: { + side: 'bottom', + text: + objects.length === heldInBucketCount + ? 'Every deleted file is held by a snapshot' + : undefined, + }, + }} + > + Delete all permanently + + )} +
+ + {isPending && } + {isError && } + {isSuccess && objects.length === 0 && ( + + )} + {isSuccess && objects.length > 0 && ( + <> + + {selectedIds.length > 0 && ( + handleRestore(selectedIds)} + onDelete={() => setShowBulkDelete(true)} + onClear={() => setSelectedIds([])} + /> + )} + + setSelectedIds(toggleSelectAll(selectedIds, orderedIds)) + } + onRestore={(object) => handleRestore([object.id])} + onDeleteForever={setObjectToDelete} + /> + +

+ Items held by a snapshot stay recoverable — and billable — until every snapshot + referencing them is deleted, even past their retention period. +

+ + )} +
+
+
+ + setObjectToDelete(undefined)} + onConfirm={() => { + if (!ref || !objectToDelete) return + deleteObjects({ + projectRef: ref, + bucketId, + objectIds: [objectToDelete.id], + }) + }} + > +

+ {objectToDelete?.name} will be permanently deleted and can no longer be restored. This + action cannot be undone. +

+
+ + setShowBulkDelete(false)} + onConfirm={() => { + if (!ref) return + deleteObjects({ projectRef: ref, bucketId, objectIds: deletableIds }) + }} + > +

+ These files will be permanently deleted and can no longer be restored. This action cannot + be undone. +

+ {heldCount > 0 && ( + + )} +
+ + setShowDeleteAll(false)} + onConfirm={() => { + if (!ref) return + deleteObjects({ projectRef: ref, bucketId }) + }} + alert={{ + title: 'You cannot recover these files once deleted', + description: 'This action cannot be undone', + }} + > +

+ Every soft-deleted file in {bucketId}{' '} + will be permanently deleted. + {heldInBucketCount > 0 && + ` ${heldInBucketCount} file${heldInBucketCount === 1 ? '' : 's'} held by a snapshot will be kept.`} +

+
+ + ) +} diff --git a/apps/studio/components/interfaces/Storage/Trash/Trash.utils.test.ts b/apps/studio/components/interfaces/Storage/Trash/Trash.utils.test.ts new file mode 100644 index 00000000000..5e259d5ffa6 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/Trash/Trash.utils.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' + +import { toggleSelectAll, toggleSelection } from './Trash.utils' + +const orderedIds = ['a', 'b', 'c', 'd'] + +describe('toggleSelection', () => { + it('adds an unselected row', () => { + expect( + toggleSelection({ + selectedIds: [], + orderedIds, + id: 'b', + lastToggledId: null, + isShiftHeld: false, + }) + ).toEqual(['b']) + }) + + it('removes an already-selected row', () => { + expect( + toggleSelection({ + selectedIds: ['a', 'b'], + orderedIds, + id: 'b', + lastToggledId: null, + isShiftHeld: false, + }) + ).toEqual(['a']) + }) + + it('selects the range forwards on shift-click', () => { + expect( + toggleSelection({ + selectedIds: ['a'], + orderedIds, + id: 'c', + lastToggledId: 'a', + isShiftHeld: true, + }) + ).toEqual(['a', 'b', 'c']) + }) + + it('selects the range backwards on shift-click', () => { + expect( + toggleSelection({ + selectedIds: ['d'], + orderedIds, + id: 'b', + lastToggledId: 'd', + isShiftHeld: true, + }).sort() + ).toEqual(['b', 'c', 'd']) + }) + + it('does not duplicate rows already in the range', () => { + const result = toggleSelection({ + selectedIds: ['a', 'b'], + orderedIds, + id: 'c', + lastToggledId: 'a', + isShiftHeld: true, + }) + expect(result).toEqual([...new Set(result)]) + }) + + it('falls back to a plain toggle when there is no anchor', () => { + expect( + toggleSelection({ + selectedIds: [], + orderedIds, + id: 'c', + lastToggledId: null, + isShiftHeld: true, + }) + ).toEqual(['c']) + }) +}) + +describe('toggleSelectAll', () => { + it('selects everything when nothing is selected', () => { + expect(toggleSelectAll([], orderedIds)).toEqual(orderedIds) + }) + + it('selects everything when only some rows are selected', () => { + expect(toggleSelectAll(['b'], orderedIds)).toEqual(orderedIds) + }) + + it('clears the selection when everything is already selected', () => { + expect(toggleSelectAll([...orderedIds], orderedIds)).toEqual([]) + }) + + it('stays empty for an empty list', () => { + expect(toggleSelectAll([], [])).toEqual([]) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/Trash/Trash.utils.ts b/apps/studio/components/interfaces/Storage/Trash/Trash.utils.ts new file mode 100644 index 00000000000..e4c4b8ab4c3 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/Trash/Trash.utils.ts @@ -0,0 +1,39 @@ +/** + * Selection helpers for the Deleted files list. + * + * Mirrors the file explorer's behaviour: plain click toggles one row, shift-click + * selects the contiguous range from the last-touched row. + */ + +export interface ToggleSelectionParams { + selectedIds: string[] + orderedIds: string[] + id: string + lastToggledId: string | null + isShiftHeld: boolean +} + +export const toggleSelection = ({ + selectedIds, + orderedIds, + id, + lastToggledId, + isShiftHeld, +}: ToggleSelectionParams): string[] => { + const anchorIndex = lastToggledId === null ? -1 : orderedIds.indexOf(lastToggledId) + const targetIndex = orderedIds.indexOf(id) + + if (isShiftHeld && anchorIndex !== -1 && targetIndex !== -1) { + const [start, end] = + anchorIndex < targetIndex ? [anchorIndex, targetIndex] : [targetIndex, anchorIndex] + const range = orderedIds.slice(start, end + 1) + return [...new Set([...selectedIds, ...range])] + } + + return selectedIds.includes(id) + ? selectedIds.filter((selectedId) => selectedId !== id) + : [...selectedIds, id] +} + +export const toggleSelectAll = (selectedIds: string[], orderedIds: string[]): string[] => + orderedIds.length > 0 && orderedIds.every((id) => selectedIds.includes(id)) ? [] : orderedIds diff --git a/apps/studio/components/interfaces/Storage/Trash/TrashList.tsx b/apps/studio/components/interfaces/Storage/Trash/TrashList.tsx new file mode 100644 index 00000000000..1b81ed5b75b --- /dev/null +++ b/apps/studio/components/interfaces/Storage/Trash/TrashList.tsx @@ -0,0 +1,138 @@ +import dayjs from 'dayjs' +import { Lock } from 'lucide-react' +import { + Button, + Checkbox, + cn, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' + +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { type TrashObject } from '@/data/storage/protection/protection-mocks' +import { formatBytes } from '@/lib/helpers' + +interface TrashListProps { + objects: TrashObject[] + selectedIds: string[] + isRestoring: boolean + onToggleSelect: (id: string, isShiftHeld: boolean) => void + onToggleSelectAll: () => void + onRestore: (object: TrashObject) => void + onDeleteForever: (object: TrashObject) => void +} + +export const TrashList = ({ + objects, + selectedIds, + isRestoring, + onToggleSelect, + onToggleSelectAll, + onRestore, + onDeleteForever, +}: TrashListProps) => { + const isAllSelected = objects.length > 0 && objects.every((o) => selectedIds.includes(o.id)) + const isSomeSelected = selectedIds.length > 0 && !isAllSelected + + return ( + + + + + + + Object + Original location + Deleted + Size + Expires + + + + + {objects.map((object) => { + const isSelected = selectedIds.includes(object.id) + return ( + + + onToggleSelect(object.id, event.nativeEvent.shiftKey)} + aria-label={`Select ${object.name}`} + /> + + {object.name} + + {object.originalPath} + + + + {dayjs(object.deletedAt).fromNow()} + + {dayjs(object.deletedAt).format('MMM D, YYYY · HH:mm')} · {object.deletedBy} + + + + + {formatBytes(object.size)} + + + {object.heldBySnapshot ? ( + + Held by snapshot + + ) : object.expiresAt ? ( + {dayjs(object.expiresAt).fromNow()} + ) : ( + Never + )} + + +
+ + onDeleteForever(object)} + tooltip={{ + content: { + side: 'bottom', + text: object.heldBySnapshot + ? 'Held by a snapshot — delete the snapshot first' + : 'Delete permanently', + }, + }} + > + Delete permanently + +
+
+
+ ) + })} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/Trash/TrashSelectionBar.tsx b/apps/studio/components/interfaces/Storage/Trash/TrashSelectionBar.tsx new file mode 100644 index 00000000000..54c531bd6b3 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/Trash/TrashSelectionBar.tsx @@ -0,0 +1,91 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { RotateCcw, Trash2, X } from 'lucide-react' +import { Button } from 'ui' + +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' + +import { bulkActionBarClassName } from '../StorageExplorer/storageExplorerChrome' + +interface TrashSelectionBarProps { + count: number + heldCount: number + isRestoring: boolean + onRestore: () => void + onDelete: () => void + onClear: () => void +} + +export const TrashSelectionBar = ({ + count, + heldCount, + isRestoring, + onRestore, + onDelete, + onClear, +}: TrashSelectionBarProps) => { + const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + const isEveryItemHeld = heldCount === count + + return ( +
+ + {count} item{count !== 1 ? 's' : ''} selected + + + {heldCount > 0 && ( + {heldCount} held by a snapshot + )} + +
+ } + loading={isRestoring} + disabled={!canUpdateFiles} + onClick={onRestore} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateFiles + ? 'You need additional permissions to restore files' + : undefined, + }, + }} + > + Restore + + + } + disabled={!canUpdateFiles || isEveryItemHeld} + onClick={onDelete} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateFiles + ? 'You need additional permissions to delete files' + : isEveryItemHeld + ? 'Every selected file is held by a snapshot' + : undefined, + }, + }} + > + Delete permanently + + +
+
+ ) +} diff --git a/apps/studio/data/storage/keys.ts b/apps/studio/data/storage/keys.ts index 70d12059147..ee3ade587b6 100644 --- a/apps/studio/data/storage/keys.ts +++ b/apps/studio/data/storage/keys.ts @@ -54,6 +54,13 @@ export const storageKeys = { ...(path ? [path] : []), ...(params ? [params] : []), ] as const, + objectVersions: ( + projectRef: string | undefined, + bucketId: string | undefined, + objectName: string | undefined + ) => ['projects', projectRef, 'buckets', bucketId, 'object-versions', objectName] as const, + trash: (projectRef: string | undefined, bucketId: string | undefined) => + ['projects', projectRef, 'buckets', bucketId, 'trash'] as const, icebergNamespaces: ({ projectRef, warehouse }: { projectRef?: string; warehouse?: string }) => [projectRef, 'warehouse', warehouse, 'namespaces'] as const, icebergNamespace: ({ diff --git a/apps/studio/data/storage/protection/bucket-trash-query.ts b/apps/studio/data/storage/protection/bucket-trash-query.ts new file mode 100644 index 00000000000..1014749d57a --- /dev/null +++ b/apps/studio/data/storage/protection/bucket-trash-query.ts @@ -0,0 +1,73 @@ +import { useMutation, UseMutationOptions, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { storageKeys } from '../keys' +import { getMockTrashObjects, mockDelay, type TrashObject } from './protection-mocks' + +export type BucketTrashVariables = { + projectRef?: string + bucketId?: string +} + +export const useBucketTrashQuery = ({ projectRef, bucketId }: BucketTrashVariables) => + useQuery({ + queryKey: storageKeys.trash(projectRef, bucketId), + queryFn: () => mockDelay(getMockTrashObjects(bucketId ?? '')), + enabled: !!projectRef && !!bucketId, + }) + +type TrashRestoreVariables = { + projectRef: string + bucketId: string + objectIds: string[] +} + +export const useBucketTrashRestoreMutation = ({ + onSuccess, + onError, + ...options +}: UseMutationOptions = {}) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => mockDelay(undefined, 500), + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ + queryKey: storageKeys.trash(variables.projectRef, variables.bucketId), + }) + await onSuccess?.(data, variables, context) + }, + onError(error, variables, context) { + if (onError === undefined) toast.error(`Failed to restore objects: ${error.message}`) + else onError(error, variables, context) + }, + ...options, + }) +} + +type TrashDeleteVariables = { + projectRef: string + bucketId: string + objectIds?: string[] +} + +export const useBucketTrashDeleteMutation = ({ + onSuccess, + onError, + ...options +}: UseMutationOptions = {}) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => mockDelay(undefined, 600), + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ + queryKey: storageKeys.trash(variables.projectRef, variables.bucketId), + }) + await onSuccess?.(data, variables, context) + }, + onError(error, variables, context) { + if (onError === undefined) toast.error(`Failed to delete objects: ${error.message}`) + else onError(error, variables, context) + }, + ...options, + }) +} diff --git a/apps/studio/data/storage/protection/object-versions-query.ts b/apps/studio/data/storage/protection/object-versions-query.ts new file mode 100644 index 00000000000..c72c290aa1c --- /dev/null +++ b/apps/studio/data/storage/protection/object-versions-query.ts @@ -0,0 +1,88 @@ +import { useMutation, UseMutationOptions, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { storageKeys } from '../keys' +import { getMockObjectVersions, mockDelay, type ObjectVersion } from './protection-mocks' + +export type ObjectVersionsVariables = { + projectRef?: string + bucketId?: string + objectName?: string +} + +export const useObjectVersionsQuery = ({ + projectRef, + bucketId, + objectName, +}: ObjectVersionsVariables) => + useQuery({ + queryKey: storageKeys.objectVersions(projectRef, bucketId, objectName), + queryFn: () => mockDelay(getMockObjectVersions(objectName ?? '')), + enabled: !!projectRef && !!bucketId && !!objectName, + }) + +type ObjectVersionRestoreVariables = { + projectRef: string + bucketId: string + objectName: string + versionId: string +} + +export const useObjectVersionRestoreMutation = ({ + onSuccess, + onError, + ...options +}: UseMutationOptions = {}) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => mockDelay(undefined, 600), + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ + queryKey: storageKeys.objectVersions( + variables.projectRef, + variables.bucketId, + variables.objectName + ), + }) + await onSuccess?.(data, variables, context) + }, + onError(error, variables, context) { + if (onError === undefined) toast.error(`Failed to restore version: ${error.message}`) + else onError(error, variables, context) + }, + ...options, + }) +} + +type ObjectVersionDeleteVariables = { + projectRef: string + bucketId: string + objectName: string + versionId: string +} + +export const useObjectVersionDeleteMutation = ({ + onSuccess, + onError, + ...options +}: UseMutationOptions = {}) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => mockDelay(undefined, 500), + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ + queryKey: storageKeys.objectVersions( + variables.projectRef, + variables.bucketId, + variables.objectName + ), + }) + await onSuccess?.(data, variables, context) + }, + onError(error, variables, context) { + if (onError === undefined) toast.error(`Failed to delete version: ${error.message}`) + else onError(error, variables, context) + }, + ...options, + }) +} diff --git a/apps/studio/data/storage/protection/protection-mocks.ts b/apps/studio/data/storage/protection/protection-mocks.ts new file mode 100644 index 00000000000..b6bd8e40996 --- /dev/null +++ b/apps/studio/data/storage/protection/protection-mocks.ts @@ -0,0 +1,117 @@ +/** + * PROTOTYPE mock data for Storage Object Versioning & Deleted files. + * + * These features don't have a platform API yet — this module returns + * deterministic, in-memory sample data so the dashboard surfaces can be + * designed and demoed end-to-end. The query and mutation hooks in this folder + * are shaped exactly like the real ones so swapping in a real fetcher later is + * a localized change. + */ + +export type ObjectVersionAction = 'initial upload' | 'overwrite' | 'restore' + +export interface ObjectVersion { + versionId: string + size: number + createdAt: string + isCurrent: boolean + action: ObjectVersionAction + heldBySnapshot: string | null +} + +export interface TrashObject { + id: string + name: string + originalPath: string + deletedAt: string + deletedBy: string + size: number + expiresAt: string | null + heldBySnapshot: boolean +} + +const MB = 1024 * 1024 +const KB = 1024 + +const BASE_DATE = '2026-07-24T09:14:00.000Z' + +const daysAgo = (days: number, time = '02:00:00') => { + const base = new Date(BASE_DATE) + base.setUTCDate(base.getUTCDate() - days) + const [h, m, s] = time.split(':').map(Number) + base.setUTCHours(h, m, s, 0) + return base.toISOString() +} + +const daysAhead = (days: number) => daysAgo(-days) + +export const getMockObjectVersions = (_objectName: string): ObjectVersion[] => [ + { + versionId: '8f3a2c9b41c1', + size: 812 * KB, + createdAt: BASE_DATE, + isCurrent: true, + action: 'overwrite', + heldBySnapshot: null, + }, + { + versionId: '2b7d9153aa9e', + size: 790 * KB, + createdAt: daysAgo(4, '18:02:00'), + isCurrent: false, + action: 'overwrite', + heldBySnapshot: 'snap_8f3a…c1', + }, + { + versionId: 'a19c04f7de40', + size: 760 * KB, + createdAt: daysAgo(10, '11:40:00'), + isCurrent: false, + action: 'overwrite', + heldBySnapshot: null, + }, + { + versionId: '5c0278b3ac7a', + size: 744 * KB, + createdAt: daysAgo(22, '08:20:00'), + isCurrent: false, + action: 'initial upload', + heldBySnapshot: 'snap_a19c…40', + }, +] + +export const getMockTrashObjects = (_bucketId: string): TrashObject[] => [ + { + id: 'trash-1', + name: 'round-3/final.png', + originalPath: 'matches/round-3/', + deletedAt: daysAgo(0, '07:20:00'), + deletedBy: 'jane@acme.co', + size: 1.1 * MB, + expiresAt: daysAhead(30), + heldBySnapshot: false, + }, + { + id: 'trash-2', + name: 'roster.csv', + originalPath: 'exports/', + deletedAt: daysAgo(1, '14:20:00'), + deletedBy: 'api key ····a91', + size: 44 * KB, + expiresAt: daysAhead(29), + heldBySnapshot: false, + }, + { + id: 'trash-3', + name: 'og/banner.png', + originalPath: 'og/', + deletedAt: daysAgo(3, '09:02:00'), + deletedBy: 'jane@acme.co', + size: 820 * KB, + expiresAt: null, + heldBySnapshot: true, + }, +] + +export const mockDelay = (value: T, ms = 350): Promise => + new Promise((resolve) => setTimeout(() => resolve(value), ms)) diff --git a/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx index 3028a520c86..dcdedf9421a 100644 --- a/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/files/buckets/[bucketId].tsx @@ -2,7 +2,7 @@ import { useParams } from 'common' import { ChevronDown, FolderOpen, Settings, Shield, Trash2 } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { parseAsBoolean, useQueryState } from 'nuqs' +import { parseAsBoolean, parseAsStringEnum, useQueryState } from 'nuqs' import { useEffect } from 'react' import { toast } from 'sonner' import { @@ -18,6 +18,8 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + NavMenu, + NavMenuItem, Tooltip, TooltipContent, TooltipTrigger, @@ -33,12 +35,17 @@ import { PublicBucketWarning } from '@/components/interfaces/Storage/PublicBucke import { PUBLIC_BUCKET_TOOLTIP } from '@/components/interfaces/Storage/Storage.constants' import StorageBucketsError from '@/components/interfaces/Storage/StorageBucketsError' import { StorageExplorer } from '@/components/interfaces/Storage/StorageExplorer/StorageExplorer' +import { useIsStorageProtectionEnabled } from '@/components/interfaces/Storage/StorageProtection.constants' +import { Trash } from '@/components/interfaces/Storage/Trash/Trash' import { useBucketPolicyCount } from '@/components/interfaces/Storage/useBucketPolicyCount' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import StorageLayout from '@/components/layouts/StorageLayout/StorageLayout' import { StorageExplorerStateContextProvider } from '@/state/storage-explorer' import type { NextPageWithLayout } from '@/types' +type BucketView = 'files' | 'trash' +const BUCKET_VIEWS: BucketView[] = ['files', 'trash'] + const BucketPage: NextPageWithLayout = () => { const router = useRouter() const { bucketId, ref } = useParams() @@ -57,8 +64,16 @@ const BucketPage: NextPageWithLayout = () => { parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) ) + const [view, setView] = useQueryState( + 'view', + parseAsStringEnum(BUCKET_VIEWS) + .withDefault('files') + .withOptions({ history: 'push', clearOnDefault: true }) + ) + const { getPolicyCount } = useBucketPolicyCount() const policyCount = bucket ? getPolicyCount(bucket.id) : 0 + const showProtection = useIsStorageProtectionEnabled() useEffect(() => { if (isSuccess && !bucket) { @@ -162,16 +177,34 @@ const BucketPage: NextPageWithLayout = () => { - - {ref && bucketId && ( -
- + {showProtection && ( + + + + + + + + + )} + + {view === 'files' && ( + + {ref && bucketId && ( +
+ +
+ )} +
+
- )} -
- -
-
+ + )} + {view === 'trash' && bucket && }
{bucket && (