mirror of
https://github.com/supabase/supabase.git
synced 2026-06-17 21:23:59 +08:00
## Problem Our `<Button>` component breaks the default `button` contract by redefining the `type` prop to set its variant (`primary`, `default`, etc) instead of the button type (`submit`, `button`, etc). This is confusing and forces to write more code when using it with shadcn components that expect/inject the standard button props. ## Solution - rename the `type` prop to `variant` - rename the `htmlType` prop to `type` - propagate the changes where necessary - format code ## How to test As this is just prop renaming, if it builds it's ok --------- Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
226 lines
7.6 KiB
TypeScript
226 lines
7.6 KiB
TypeScript
import { ident, safeSql } from '@supabase/pg-meta/src/pg-format'
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { LOCAL_STORAGE_KEYS, safeLocalStorage } from 'common'
|
||
import { useEffect, useState, type ReactNode } from 'react'
|
||
import { toast } from 'sonner'
|
||
import { Button } from 'ui'
|
||
import { Admonition } from 'ui-patterns/admonition'
|
||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
|
||
|
||
import { databasePoliciesKeys } from '@/data/database-policies/keys'
|
||
import { executeSql } from '@/data/sql/execute-sql-mutation'
|
||
import { storageKeys } from '@/data/storage/keys'
|
||
import { usePublicBucketsWithSelectPoliciesQuery } from '@/data/storage/public-buckets-with-select-policies-query'
|
||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||
import { useTrack } from '@/lib/telemetry/track'
|
||
|
||
const DISMISS_DURATION_MS = 14 * 24 * 60 * 60 * 1000 // 14 days
|
||
|
||
function isDismissed(projectRef: string, bucketId: string): boolean {
|
||
try {
|
||
const raw = safeLocalStorage.getItem(
|
||
LOCAL_STORAGE_KEYS.STORAGE_PUBLIC_BUCKET_SELECT_POLICY_WARNING_DISMISSED(projectRef, bucketId)
|
||
)
|
||
if (!raw) return false
|
||
const { dismissedAt } = JSON.parse(raw) as { dismissedAt: string }
|
||
return Date.now() - new Date(dismissedAt).getTime() < DISMISS_DURATION_MS
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function persistDismiss(projectRef: string, bucketId: string): void {
|
||
safeLocalStorage.setItem(
|
||
LOCAL_STORAGE_KEYS.STORAGE_PUBLIC_BUCKET_SELECT_POLICY_WARNING_DISMISSED(projectRef, bucketId),
|
||
JSON.stringify({ dismissedAt: new Date().toISOString() })
|
||
)
|
||
}
|
||
|
||
function generatePolicyRemovalSql(policyName: string) {
|
||
return safeSql`DROP POLICY IF EXISTS ${ident(policyName)} ON storage.objects;`
|
||
}
|
||
|
||
export interface PublicBucketWarningProps {
|
||
projectRef: string
|
||
bucketId: string
|
||
}
|
||
|
||
export function PublicBucketWarning({ projectRef, bucketId }: PublicBucketWarningProps): ReactNode {
|
||
const queryClient = useQueryClient()
|
||
const { data: project } = useSelectedProjectQuery()
|
||
|
||
const { data } = usePublicBucketsWithSelectPoliciesQuery({
|
||
projectRef,
|
||
connectionString: project?.connectionString,
|
||
bucketId,
|
||
})
|
||
const policyToRemove = data?.[0]
|
||
const matchingPolicyCount = data?.length ?? 0
|
||
|
||
const track = useTrack()
|
||
|
||
const { mutate: removePolicy, isPending: isRemovingPolicy } = useMutation({
|
||
mutationFn: async (policyName: string) => {
|
||
await executeSql({
|
||
projectRef,
|
||
connectionString: project?.connectionString,
|
||
sql: generatePolicyRemovalSql(policyName),
|
||
})
|
||
},
|
||
onSuccess: async () => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({
|
||
queryKey: storageKeys.publicBucketsWithSelectPolicies(projectRef, bucketId),
|
||
}),
|
||
queryClient.invalidateQueries({
|
||
queryKey: databasePoliciesKeys.list(projectRef, 'storage'),
|
||
}),
|
||
])
|
||
track('storage_public_bucket_select_policy_removed', { bucketId })
|
||
setShowModal(false)
|
||
toast.success(
|
||
matchingPolicyCount > 1
|
||
? `Policy removed successfully. ${matchingPolicyCount - 1} matching ${
|
||
matchingPolicyCount - 1 === 1 ? 'policy' : 'policies'
|
||
} remaining.`
|
||
: 'Policy removed successfully'
|
||
)
|
||
},
|
||
onError: (error) => {
|
||
console.error('Failed to remove policy', error)
|
||
toast.error(`Failed to remove policy: ${error.message}`)
|
||
},
|
||
})
|
||
|
||
const [showModal, setShowModal] = useState(false)
|
||
const [dismissed, setDismissed] = useState(() => isDismissed(projectRef, bucketId))
|
||
|
||
useEffect(() => {
|
||
setDismissed(isDismissed(projectRef, bucketId))
|
||
setShowModal(false)
|
||
}, [bucketId, projectRef])
|
||
|
||
function handleDismiss() {
|
||
persistDismiss(projectRef, bucketId)
|
||
track('storage_public_bucket_select_policy_warning_dismiss_button_clicked', { bucketId })
|
||
setDismissed(true)
|
||
}
|
||
|
||
return policyToRemove && !dismissed ? (
|
||
<PublicBucketWarningView
|
||
_tag="policy-to-remove"
|
||
policyName={policyToRemove.policyname}
|
||
policyCount={matchingPolicyCount}
|
||
isRemovingPolicy={isRemovingPolicy}
|
||
onRemovePolicy={() => removePolicy(policyToRemove.policyname)}
|
||
isModalVisible={showModal}
|
||
onShowModal={() => setShowModal(true)}
|
||
onHideModal={() => setShowModal(false)}
|
||
onDismiss={handleDismiss}
|
||
/>
|
||
) : (
|
||
<PublicBucketWarningView _tag="no-policy-to-remove" />
|
||
)
|
||
}
|
||
|
||
type PublicBucketWarningViewProps_NoPolicyToRemove = {
|
||
_tag: 'no-policy-to-remove'
|
||
}
|
||
|
||
type PublicBucketWarningViewProps_PolicyToRemove = {
|
||
_tag: 'policy-to-remove'
|
||
policyName: string
|
||
policyCount: number
|
||
isRemovingPolicy: boolean
|
||
onRemovePolicy: () => void
|
||
isModalVisible: boolean
|
||
onShowModal: () => void
|
||
onHideModal: () => void
|
||
onDismiss: () => void
|
||
}
|
||
|
||
type PublicBucketWarningViewProps =
|
||
| PublicBucketWarningViewProps_NoPolicyToRemove
|
||
| PublicBucketWarningViewProps_PolicyToRemove
|
||
|
||
function PublicBucketWarningView(props: PublicBucketWarningViewProps): ReactNode {
|
||
if (props._tag === 'no-policy-to-remove') {
|
||
return null
|
||
}
|
||
|
||
const {
|
||
policyName,
|
||
policyCount,
|
||
isRemovingPolicy,
|
||
onRemovePolicy,
|
||
isModalVisible,
|
||
onShowModal,
|
||
onHideModal,
|
||
onDismiss,
|
||
} = props
|
||
const hasMultiplePolicies = policyCount > 1
|
||
|
||
return (
|
||
<>
|
||
<Admonition
|
||
type="warning"
|
||
layout="horizontal"
|
||
title="Clients can list all files in this bucket"
|
||
description={
|
||
hasMultiplePolicies
|
||
? `${policyCount} broad SELECT policies on storage.objects allow clients to retrieve a full list of files. Public buckets don’t need these policies and they may expose more data than intended.`
|
||
: 'A broad SELECT policy on storage.objects allows clients to retrieve a full list of files. Public buckets don’t need this and it may expose more data than intended.'
|
||
}
|
||
actions={
|
||
<div className="flex gap-2">
|
||
<Button variant="default" size="tiny" onClick={onDismiss}>
|
||
Dismiss
|
||
</Button>
|
||
<Button variant="warning" size="tiny" onClick={onShowModal}>
|
||
Remove policy
|
||
</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
<ConfirmationModal
|
||
visible={isModalVisible}
|
||
variant="destructive"
|
||
title={
|
||
hasMultiplePolicies
|
||
? `Remove SELECT policy (1 of ${policyCount})`
|
||
: 'Remove SELECT policy'
|
||
}
|
||
confirmLabel="Remove policy"
|
||
loading={isRemovingPolicy}
|
||
onCancel={onHideModal}
|
||
onConfirm={onRemovePolicy}
|
||
>
|
||
<div className="flex flex-col gap-3">
|
||
<p className="text-sm text-foreground-light">
|
||
This will drop {hasMultiplePolicies ? 'one' : 'the'}{' '}
|
||
<code className="text-code-inline">SELECT</code>
|
||
{
|
||
' policy that makes the bucket’s contents listable. Object URLs will continue to work.'
|
||
}
|
||
{hasMultiplePolicies
|
||
? ` ${policyCount - 1} matching ${
|
||
policyCount - 1 === 1 ? 'policy' : 'policies'
|
||
} will remain after this.`
|
||
: null}
|
||
</p>
|
||
<div className="-mx-4 md:-mx-5 -mb-4 border-t">
|
||
<CodeBlock
|
||
hideLineNumbers
|
||
language="sql"
|
||
value={generatePolicyRemovalSql(policyName)}
|
||
wrapperClassName="[&_pre]:px-4 [&_pre]:py-3 [&>pre]:rounded-none [&>pre]:border-0 [&_pre>*]:whitespace-pre-wrap!"
|
||
className="[&_code]:text-foreground"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</ConfirmationModal>
|
||
</>
|
||
)
|
||
}
|