diff --git a/apps/docs/content/guides/database/postgres/triggers.mdx b/apps/docs/content/guides/database/postgres/triggers.mdx index e405dfff54943..81cc59964aaef 100644 --- a/apps/docs/content/guides/database/postgres/triggers.mdx +++ b/apps/docs/content/guides/database/postgres/triggers.mdx @@ -109,6 +109,14 @@ You can delete a trigger using the `drop trigger` command: drop trigger "trigger_name" on "table_name"; ``` +If your trigger is inside a restricted schema, you won't be able to drop it due to permission restrictions. In those cases, you can drop the function it depends on instead using the CASCADE clause to automatically remove all triggers that call it: + +```sql +drop function if exists restricted_schema.function_name() cascade; +``` + +Make sure you take a backup of the function before removing it in case you're planning to recreate it later. + ## Resources - Official Postgres Docs: [Triggers](https://www.postgresql.org/docs/current/triggers.html) diff --git a/apps/docs/content/guides/integrations/vercel-marketplace.mdx b/apps/docs/content/guides/integrations/vercel-marketplace.mdx index 6a16f263f81a8..2db3ac8df8013 100644 --- a/apps/docs/content/guides/integrations/vercel-marketplace.mdx +++ b/apps/docs/content/guides/integrations/vercel-marketplace.mdx @@ -94,7 +94,8 @@ Note: Supabase Organization billing cycle is separate from Vercel's. Plan change When using Vercel Marketplace, the following limitations apply: -- Projects can only be created or removed via the Vercel dashboard. +- Projects can only be created via the Vercel dashboard. - Organizations cannot be removed manually; they are removed only if you uninstall the Vercel Marketplace Integration. - Owners cannot be added manually within the Supabase dashboard. - Invoices and payments must be managed through the Vercel dashboard, not the Supabase dashboard. +- [Custom Domains](/docs/guides/platform/custom-domains) are not supported, and we always use the base `SUPABASE_URL` for the Vercel environment variables. diff --git a/apps/docs/content/guides/platform/backups.mdx b/apps/docs/content/guides/platform/backups.mdx index b18781e0ec7e7..55b43ec5041c0 100644 --- a/apps/docs/content/guides/platform/backups.mdx +++ b/apps/docs/content/guides/platform/backups.mdx @@ -26,7 +26,7 @@ Database backups can be categorized into two types: **logical** and **physical** To enable physical backups, you have three options: - Enable [Point-in-Time Recovery (PITR)](#point-in-time-recovery) -- [Increase your disk size](/docs/guides/platform/database-size) to greater than 15GB +- [Increase your database size](/docs/guides/platform/database-size) to greater than 15GB - [Create a read replica](/docs/guides/platform/read-replicas) Once a project satisfies at least one of the requirements for physical backups then logical backups are no longer made. However, your project may revert back to logical backups if you remove add-ons. diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.constants.ts b/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.constants.ts index 0e858125dfa3d..eb9977bf3f9f8 100644 --- a/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.constants.ts +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.constants.ts @@ -15,6 +15,7 @@ export const getDateRanges = () => { } export const AUTH_COMBINED_QUERY = () => ` +-- auth-overview with base as ( select json_value(event_message, "$.auth_event.action") as action, diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.tsx b/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.tsx index 2c2e7f9c22aa2..4fd3fd697ba22 100644 --- a/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.tsx +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.tsx @@ -25,6 +25,7 @@ import { } from './OverviewUsage.constants' import { useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' +import AlertError from 'components/ui/AlertError' export const StatCard = ({ title, @@ -95,13 +96,21 @@ export const StatCard = ({ export const OverviewUsage = () => { const { ref } = useParams() - const { data: currentData, isLoading: currentLoading } = useQuery({ + const { + data: currentData, + isLoading: currentLoading, + error: currentError, + } = useQuery({ queryKey: ['auth-metrics', ref, 'current'], queryFn: () => fetchAllAuthMetrics(ref as string, 'current'), enabled: !!ref, }) - const { data: previousData, isLoading: previousLoading } = useQuery({ + const { + data: previousData, + isLoading: previousLoading, + error: previousError, + } = useQuery({ queryKey: ['auth-metrics', ref, 'previous'], queryFn: () => fetchAllAuthMetrics(ref as string, 'previous'), enabled: !!ref, @@ -109,6 +118,11 @@ export const OverviewUsage = () => { const metrics = processAllAuthMetrics(currentData?.result || [], previousData?.result || []) const isLoading = currentLoading || previousLoading + const isError = !!previousError || !!currentError + const errorMessage = + (previousError as any)?.message || + (currentError as any)?.message || + 'There was an error fetching the auth metrics.' const activeUsersChange = calculatePercentageChange( metrics.current.activeUsers, @@ -124,6 +138,15 @@ export const OverviewUsage = () => { return ( + {isError && ( + + )}
Usage { const { data: organization } = useSelectedOrganizationQuery() const { mutate: sendEvent } = useSendEventMutation() + const isMatureProject = dayjs(project?.inserted_at).isBefore(dayjs().subtract(10, 'day')) + const hasShownEnableBranchingModalRef = useRef(false) const isPaused = project?.status === PROJECT_STATUS.INACTIVE @@ -103,8 +106,13 @@ export const HomeV2 = () => { ) } - if (id === 'getting-started') { - return gettingStartedState === 'hidden' ? null : ( + if ( + id === 'getting-started' && + !isMatureProject && + project && + gettingStartedState !== 'hidden' + ) { + return ( { here diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx index 98f1611f4c609..503291d1975e1 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx @@ -294,8 +294,7 @@ export const ReplicaNode = ({ data }: NodeProps) => { Restarting ) : status === REPLICA_STATUS.RESIZING ? ( Resizing - ) : initStatus === ReplicaInitializationStatus.Completed && - status === REPLICA_STATUS.ACTIVE_HEALTHY ? ( + ) : status === REPLICA_STATUS.ACTIVE_HEALTHY ? ( Healthy ) : ( Unhealthy diff --git a/apps/studio/components/interfaces/Support/AttachmentUpload.tsx b/apps/studio/components/interfaces/Support/AttachmentUpload.tsx index d875d3ee46c1e..36cd6ea6d41f2 100644 --- a/apps/studio/components/interfaces/Support/AttachmentUpload.tsx +++ b/apps/studio/components/interfaces/Support/AttachmentUpload.tsx @@ -21,8 +21,6 @@ import { createSupportStorageClient } from './support-storage-client' const MAX_ATTACHMENTS = 5 const uploadAttachments = async ({ userId, files }: { userId: string; files: File[] }) => { - if (files.length === 0) return [] - const supportSupabaseClient = createSupportStorageClient() const filesToUpload = Array.from(files) @@ -104,11 +102,13 @@ export function useAttachmentUpload() { return [] } + if (uploadedFiles.length === 0) return + const filenames = await uploadAttachments({ userId: profile.gotrue_id, files: uploadedFiles }) - const urls = await generateAttachmentURLs({ filenames }) + const urls = await generateAttachmentURLs({ bucket: 'support-attachments', filenames }) return urls // eslint-disable-next-line react-hooks/exhaustive-deps - }, [uploadedFiles]) + }, [profile, uploadedFiles]) return useMemo( () => ({ diff --git a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx index f22daad621d74..e33793c126dac 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx +++ b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx @@ -24,12 +24,12 @@ export const NO_ORG_MARKER = 'no-org' export const formatMessage = ({ message, - attachments, + attachments = [], error, commit, }: { message: string - attachments: string[] + attachments?: string[] error: string | null | undefined commit: string | undefined }) => { diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx index 3602fb585b9f4..54e30a670a711 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx @@ -5,10 +5,8 @@ import { useState } from 'react' import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_ } from 'ui' import { FeedbackWidget } from './FeedbackWidget' -const FeedbackDropdown = ({ className }: { className?: string }) => { +export const FeedbackDropdown = ({ className }: { className?: string }) => { const [isOpen, setIsOpen] = useState(false) - const [feedback, setFeedback] = useState('') - const [screenshot, setScreenshot] = useState() const [stage, setStage] = useState<'select' | 'widget'>('select') return ( @@ -17,7 +15,6 @@ const FeedbackDropdown = ({ className }: { className?: string }) => { open={isOpen} onOpenChange={(e) => { setIsOpen(e) - if (!e) setScreenshot(undefined) if (!e) setStage('select') }} > @@ -63,15 +60,7 @@ const FeedbackDropdown = ({ className }: { className?: string }) => {
)} - {stage === 'widget' && ( - setIsOpen(false)} - feedback={feedback} - setFeedback={setFeedback} - screenshot={screenshot} - setScreenshot={setScreenshot} - /> - )} + {stage === 'widget' && setIsOpen(false)} />} ) diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.utils.ts b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.utils.ts index 2cabae9c7bcf7..497c8cca5501e 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.utils.ts +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.utils.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' +import { createSupportStorageClient } from 'components/interfaces/Support/support-storage-client' +import { generateAttachmentURLs } from 'data/support/generate-attachment-urls-mutation' import { uuidv4 } from 'lib/helpers' -const SUPPORT_API_URL = process.env.NEXT_PUBLIC_SUPPORT_API_URL || '' -const SUPPORT_API_KEY = process.env.NEXT_PUBLIC_SUPPORT_ANON_KEY || '' - export const convertB64toBlob = (image: string) => { const contentType = 'image/png' const byteCharacters = atob(image.substr(`data:${contentType};base64,`.length)) @@ -25,40 +23,37 @@ export const convertB64toBlob = (image: string) => { return blob } -export const uploadAttachment = async (ref: string, image: string) => { - const supabaseClient = createClient(SUPPORT_API_URL, SUPPORT_API_KEY, { - auth: { - persistSession: false, - autoRefreshToken: false, - // @ts-ignore - multiTab: false, - detectSessionInUrl: false, - localStorage: { - getItem: (key: string) => undefined, - setItem: (key: string, value: string) => {}, - removeItem: (key: string) => {}, - }, - }, - }) +type UploadAttachmentArgs = { + image: string + userId?: string +} + +export const uploadAttachment = async ({ image, userId }: UploadAttachmentArgs) => { + if (!userId) { + console.error( + '[FeedbackWidget > uploadAttachment] Unable to upload screenshot, missing user ID' + ) + return undefined + } + + const supabaseClient = createSupportStorageClient() const blob = convertB64toBlob(image) - const name = `${ref || 'no-project'}/${uuidv4()}.png` + const filename = `${userId}/${uuidv4()}.png` const options = { cacheControl: '3600' } + const { data: file, error: uploadError } = await supabaseClient.storage .from('feedback-attachments') - .upload(name, blob, options) + .upload(filename, blob, options) - if (uploadError) { - console.error('Failed to upload:', uploadError) + if (uploadError || !file) { + console.error('Failed to upload screenshot attachment:', uploadError) return undefined } - if (file) { - const { data } = await supabaseClient.storage - .from('feedback-attachments') - .createSignedUrls([file.path], 10 * 365 * 24 * 60 * 60) - return data?.[0].signedUrl - } - - return undefined + const signedUrls = await generateAttachmentURLs({ + bucket: 'feedback-attachments', + filenames: [file.path], + }) + return signedUrls[0] } diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx index 8ad7b313a3e56..8d11bf84f5575 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx @@ -1,3 +1,4 @@ +import { useDebounce } from '@uidotdev/usehooks' import { AnimatePresence, motion } from 'framer-motion' import { toPng } from 'html-to-image' import { Camera, CircleCheck, Image as ImageIcon, Upload, X } from 'lucide-react' @@ -5,16 +6,17 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { ChangeEvent, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' -import { useDebounce } from 'use-debounce' -import { useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { InlineLink } from 'components/ui/InlineLink' import { useFeedbackCategoryQuery } from 'data/feedback/feedback-category' import { useSendFeedbackMutation } from 'data/feedback/feedback-send' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { DOCS_URL } from 'lib/constants' import { timeout } from 'lib/helpers' +import { useProfile } from 'lib/profile' import { Button, DropdownMenu, @@ -29,47 +31,40 @@ import { Admonition } from 'ui-patterns' import { convertB64toBlob, uploadAttachment } from './FeedbackDropdown.utils' interface FeedbackWidgetProps { - feedback: string - screenshot: string | undefined onClose: () => void - setFeedback: (value: string) => void - setScreenshot: (value: string | undefined) => void } -export const FeedbackWidget = ({ - feedback, - screenshot, - onClose, - setFeedback, - setScreenshot, -}: FeedbackWidgetProps) => { - const FEEDBACK_STORAGE_KEY = 'feedback_content' - const SCREENSHOT_STORAGE_KEY = 'screenshot' - +export const FeedbackWidget = ({ onClose }: FeedbackWidgetProps) => { const router = useRouter() + const { profile } = useProfile() const { ref, slug } = useParams() const { data: org } = useSelectedOrganizationQuery() - const uploadButtonRef = useRef(null) + const uploadButtonRef = useRef(null) + const [feedback, setFeedback] = useState('') const [isSending, setSending] = useState(false) const [isSavingScreenshot, setIsSavingScreenshot] = useState(false) const [isFeedbackSent, setIsFeedbackSent] = useState(false) - const [debouncedFeedback] = useDebounce(feedback, 450) - const { data: category } = useFeedbackCategoryQuery({ - prompt: debouncedFeedback, - }) + const debouncedFeedback = useDebounce(feedback, 500) - const { mutate: sendEvent } = useSendEventMutation() + const [storedFeedback, setStoredFeedback] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.FEEDBACK_WIDGET_CONTENT, + null + ) + const [screenshot, setScreenshot, { isSuccess }] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.FEEDBACK_WIDGET_SCREENSHOT, + null + ) + const { data: category } = useFeedbackCategoryQuery({ prompt: debouncedFeedback }) + + const { mutate: sendEvent } = useSendEventMutation() const { mutate: submitFeedback } = useSendFeedbackMutation({ onSuccess: () => { setIsFeedbackSent(true) setFeedback('') - setScreenshot(undefined) - localStorage.removeItem(FEEDBACK_STORAGE_KEY) - localStorage.removeItem(SCREENSHOT_STORAGE_KEY) - + setScreenshot(null) setSending(false) }, onError: (error) => { @@ -78,28 +73,6 @@ export const FeedbackWidget = ({ }, }) - useEffect(() => { - const storedFeedback = localStorage.getItem(FEEDBACK_STORAGE_KEY) - if (storedFeedback) { - setFeedback(storedFeedback) - } - - const storedScreenshot = localStorage.getItem(SCREENSHOT_STORAGE_KEY) - if (storedScreenshot) { - setScreenshot(storedScreenshot) - } - }, []) - - useEffect(() => { - localStorage.setItem(FEEDBACK_STORAGE_KEY, feedback) - }, [feedback]) - - useEffect(() => { - if (screenshot) { - localStorage.setItem(SCREENSHOT_STORAGE_KEY, screenshot) - } - }, [screenshot]) - const captureScreenshot = async () => { setIsSavingScreenshot(true) @@ -113,14 +86,9 @@ export const FeedbackWidget = ({ // Give time for dropdown to close await timeout(100) toPng(document.body, { filter }) - .then((dataUrl: any) => { - localStorage.setItem(SCREENSHOT_STORAGE_KEY, dataUrl) - setScreenshot(dataUrl) - }) + .then((dataUrl: any) => setScreenshot(dataUrl)) .catch(() => toast.error('Failed to capture screenshot')) - .finally(() => { - setIsSavingScreenshot(false) - }) + .finally(() => setIsSavingScreenshot(false)) } const onFilesUpload = async (event: ChangeEvent) => { @@ -130,10 +98,7 @@ export const FeedbackWidget = ({ const reader = new FileReader() reader.onload = function (event) { const dataUrl = event.target?.result - if (typeof dataUrl === 'string') { - setScreenshot(dataUrl) - localStorage.setItem(SCREENSHOT_STORAGE_KEY, dataUrl) - } + if (typeof dataUrl === 'string') setScreenshot(dataUrl) } reader.readAsDataURL(file) event.target.value = '' @@ -148,10 +113,7 @@ export const FeedbackWidget = ({ const reader = new FileReader() reader.onload = function (event) { const dataUrl = event.target?.result - if (typeof dataUrl === 'string') { - setScreenshot(dataUrl) - localStorage.setItem(SCREENSHOT_STORAGE_KEY, dataUrl) - } + if (typeof dataUrl === 'string') setScreenshot(dataUrl) } reader.readAsDataURL(blob) } @@ -163,9 +125,13 @@ export const FeedbackWidget = ({ } else if (feedback.length > 0) { setSending(true) - const attachmentUrl = screenshot - ? await uploadAttachment(ref as string, screenshot) - : undefined + const attachmentUrl = + screenshot && profile?.gotrue_id + ? await uploadAttachment({ + image: screenshot, + userId: profile.gotrue_id, + }) + : undefined const formattedFeedback = attachmentUrl !== undefined ? `${feedback}\n\nAttachments:\n${attachmentUrl}` : feedback @@ -178,6 +144,15 @@ export const FeedbackWidget = ({ } } + useEffect(() => { + if (storedFeedback) setFeedback(storedFeedback) + if (screenshot) setScreenshot(screenshot) + }, [isSuccess]) + + useEffect(() => { + if (debouncedFeedback.length > 0) setStoredFeedback(debouncedFeedback) + }, [debouncedFeedback]) + return isFeedbackSent ? ( ) : ( @@ -246,7 +221,7 @@ export const FeedbackWidget = ({

- {screenshot !== undefined ? ( + {!!screenshot ? (
{ @@ -254,7 +229,7 @@ export const FeedbackWidget = ({ const blobUrl = URL.createObjectURL(blob) window.open(blobUrl, '_blank') }} - className="cursor-pointer rounded h-[26px] w-[30px] border border-control relative bg-cover bg-center bg-no-repeat" + className="cursor-pointer rounded h-[26px] w-[26px] border border-control relative bg-cover bg-center bg-no-repeat" > + className="w-7" + icon={} + /> {children} {!hideClose && ( - - + + Close )} diff --git a/packages/ui/src/components/shadcn/ui/sheet.tsx b/packages/ui/src/components/shadcn/ui/sheet.tsx index 0330af9409312..b6803b39d6ef5 100644 --- a/packages/ui/src/components/shadcn/ui/sheet.tsx +++ b/packages/ui/src/components/shadcn/ui/sheet.tsx @@ -167,7 +167,12 @@ const SheetContent = React.forwardRef< > {children} {showClose ? ( - + Close