From 30e73ee44d0d6b1f6475d612688908cee85e35e1 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Tue, 16 Sep 2025 13:00:25 +1000 Subject: [PATCH 01/76] Home New: Report (#38341) * new home top * advisors * fix ts * add report section * add report * Nit refactor * refactor row * prevent adding snippet twice --------- Co-authored-by: Joshen Lim --- .../HomeNew/CustomReportSection.tsx | 338 ++++++++++++++++++ .../components/interfaces/HomeNew/Home.tsx | 8 + .../interfaces/HomeNew/SnippetDropdown.tsx | 108 ++++++ packages/ui-patterns/src/Row/Row.utils.ts | 38 ++ packages/ui-patterns/src/Row/index.tsx | 145 ++++---- 5 files changed, 555 insertions(+), 82 deletions(-) create mode 100644 apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx create mode 100644 apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx create mode 100644 packages/ui-patterns/src/Row/Row.utils.ts diff --git a/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx b/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx new file mode 100644 index 00000000000..19f479ada64 --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx @@ -0,0 +1,338 @@ +import { + DndContext, + DragEndEvent, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { SortableContext, arrayMove, rectSortingStrategy, useSortable } from '@dnd-kit/sortable' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import dayjs from 'dayjs' +import { Plus } from 'lucide-react' +import type { CSSProperties, ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { useParams } from 'common' +import { SnippetDropdown } from 'components/interfaces/HomeNew/SnippetDropdown' +import { ReportBlock } from 'components/interfaces/Reports/ReportBlock/ReportBlock' +import type { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' +import { DEFAULT_CHART_CONFIG } from 'components/ui/QueryBlock/QueryBlock' +import { AnalyticsInterval } from 'data/analytics/constants' +import { useContentInfiniteQuery } from 'data/content/content-infinite-query' +import { Content } from 'data/content/content-query' +import { useContentUpsertMutation } from 'data/content/content-upsert-mutation' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { uuidv4 } from 'lib/helpers' +import { useProfile } from 'lib/profile' +import type { Dashboards } from 'types' +import { Button } from 'ui' +import { Row } from 'ui-patterns' +import { toast } from 'sonner' + +export function CustomReportSection() { + const startDate = dayjs().subtract(7, 'day').toISOString() + const endDate = dayjs().toISOString() + const { ref } = useParams() + const { profile } = useProfile() + + const { data: reportsData } = useContentInfiniteQuery( + { projectRef: ref, type: 'report', name: 'Home', limit: 1 }, + { keepPreviousData: true } + ) + const homeReport = reportsData?.pages?.[0]?.content?.[0] as Content | undefined + const reportContent = homeReport?.content as Dashboards.Content | undefined + const [editableReport, setEditableReport] = useState( + reportContent + ) + + useEffect(() => { + if (reportContent) setEditableReport(reportContent) + }, [reportContent]) + + const { can: canUpdateReport } = useAsyncCheckProjectPermissions( + PermissionAction.UPDATE, + 'user_content', + { + resource: { + type: 'report', + visibility: homeReport?.visibility, + owner_id: homeReport?.owner_id, + }, + subject: { id: profile?.id }, + } + ) + + const { can: canCreateReport } = useAsyncCheckProjectPermissions( + PermissionAction.CREATE, + 'user_content', + { resource: { type: 'report', owner_id: profile?.id }, subject: { id: profile?.id } } + ) + + const { mutate: upsertContent } = useContentUpsertMutation() + + const persistReport = useCallback( + (updated: Dashboards.Content) => { + if (!ref || !homeReport) return + upsertContent({ projectRef: ref, payload: { ...homeReport, content: updated } }) + }, + [homeReport, ref, upsertContent] + ) + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })) + + const handleDragStart = () => {} + + const recomputeSimpleGrid = useCallback( + (layout: Dashboards.Chart[]) => + layout.map( + (block, idx): Dashboards.Chart => ({ + ...block, + x: idx % 2, + y: Math.floor(idx / 2), + w: 1, + h: 1, + }) + ), + [] + ) + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event + if (!editableReport || !active || !over || active.id === over.id) return + const items = editableReport.layout.map((x) => String(x.id)) + const oldIndex = items.indexOf(String(active.id)) + const newIndex = items.indexOf(String(over.id)) + if (oldIndex === -1 || newIndex === -1) return + + const moved = arrayMove(editableReport.layout, oldIndex, newIndex) + const recomputed = recomputeSimpleGrid(moved) + const updated = { ...editableReport, layout: recomputed } + setEditableReport(updated) + persistReport(updated) + }, + [editableReport, persistReport, recomputeSimpleGrid] + ) + + const findNextPlacement = useCallback((current: Dashboards.Chart[]) => { + const occupied = new Set(current.map((c) => `${c.y}-${c.x}`)) + let y = 0 + for (; ; y++) { + const left = occupied.has(`${y}-0`) + const right = occupied.has(`${y}-1`) + if (!left || !right) { + const x = left ? 1 : 0 + return { x, y } + } + } + }, []) + + const createSnippetChartBlock = useCallback( + ( + snippet: { id: string; name: string }, + position: { x: number; y: number } + ): Dashboards.Chart => ({ + x: position.x, + y: position.y, + w: 1, + h: 1, + id: snippet.id, + label: snippet.name, + attribute: `snippet_${snippet.id}` as unknown as Dashboards.Chart['attribute'], + provider: 'daily-stats', + chart_type: 'bar', + chartConfig: DEFAULT_CHART_CONFIG, + }), + [] + ) + + const addSnippetToReport = (snippet: { id: string; name: string }) => { + if ( + editableReport?.layout?.some( + (x) => + String(x.id) === String(snippet.id) || String(x.attribute) === `snippet_${snippet.id}` + ) + ) { + toast('This block is already in your report') + return + } + // If the Home report doesn't exist yet, create it with the new block + if (!editableReport || !homeReport) { + if (!ref || !profile) return + + // Initial placement for first block + const initialBlock = createSnippetChartBlock(snippet, { x: 0, y: 0 }) + + const newReport: Dashboards.Content = { + schema_version: 1, + period_start: { time_period: '7d', date: '' }, + period_end: { time_period: 'today', date: '' }, + interval: '1d', + layout: [initialBlock], + } + + setEditableReport(newReport) + upsertContent({ + projectRef: ref, + payload: { + id: uuidv4(), + type: 'report', + name: 'Home', + description: '', + visibility: 'project', + owner_id: profile.id, + content: newReport, + }, + }) + return + } + const current = [...editableReport.layout] + const { x, y } = findNextPlacement(current) + current.push(createSnippetChartBlock(snippet, { x, y })) + const updated = { ...editableReport, layout: current } + setEditableReport(updated) + persistReport(updated) + } + + const handleRemoveChart = ({ metric }: { metric: { key: string } }) => { + if (!editableReport) return + const nextLayout = editableReport.layout.filter( + (x) => x.attribute !== (metric.key as unknown as Dashboards.Chart['attribute']) + ) + const updated = { ...editableReport, layout: nextLayout } + setEditableReport(updated) + persistReport(updated) + } + + const handleUpdateChart = ( + id: string, + { + chart, + chartConfig, + }: { chart?: Partial; chartConfig?: Partial } + ) => { + if (!editableReport) return + const currentChart = editableReport.layout.find((x) => x.id === id) + if (!currentChart) return + const updatedChart: Dashboards.Chart = { ...currentChart, ...(chart ?? {}) } + if (chartConfig) { + updatedChart.chartConfig = { ...(currentChart.chartConfig ?? {}), ...chartConfig } + } + const updatedLayouts = editableReport.layout.map((x) => (x.id === id ? updatedChart : x)) + const updated = { ...editableReport, layout: updatedLayouts } + setEditableReport(updated) + persistReport(updated) + } + + const layout = useMemo(() => editableReport?.layout ?? [], [editableReport]) + + return ( +
+
+

At a glance

+ {canUpdateReport || canCreateReport ? ( + }> + Add block + + } + side="bottom" + align="end" + autoFocus + /> + ) : null} +
+
+ {(() => { + if (layout.length === 0) { + return ( +
+ {canUpdateReport || canCreateReport ? ( + }> + Add your first chart + + } + side="bottom" + align="center" + autoFocus + /> + ) : ( +

No charts set up yet in report

+ )} +
+ ) + } + return ( + + String(x.id))} + strategy={rectSortingStrategy} + > + + {layout.map((item) => ( + +
+ handleUpdateChart(item.id, config)} + /> +
+
+ ))} +
+
+
+ ) + })()} +
+
+ ) +} + +function SortableReportBlock({ id, children }: { id: string; children: ReactNode }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }) + + const style: CSSProperties = { + transform: transform + ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` + : undefined, + transition, + } + + return ( +
+ {children} +
+ ) +} diff --git a/apps/studio/components/interfaces/HomeNew/Home.tsx b/apps/studio/components/interfaces/HomeNew/Home.tsx index e2e85de77ff..6dd50690283 100644 --- a/apps/studio/components/interfaces/HomeNew/Home.tsx +++ b/apps/studio/components/interfaces/HomeNew/Home.tsx @@ -17,6 +17,7 @@ import { import { PROJECT_STATUS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' import { AdvisorSection } from './AdvisorSection' +import { CustomReportSection } from './CustomReportSection' import { GettingStartedSection, type GettingStartedState, @@ -131,6 +132,13 @@ export const HomeV2 = () => { ) } + if (id === 'custom-report') { + return ( + + + + ) + } })} diff --git a/apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx b/apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx new file mode 100644 index 00000000000..b9f8fc56abf --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx @@ -0,0 +1,108 @@ +import { useIntersectionObserver } from '@uidotdev/usehooks' +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react' + +import { useContentInfiniteQuery } from 'data/content/content-infinite-query' +import type { Content } from 'data/content/content-query' +import { SNIPPET_PAGE_LIMIT } from 'data/content/sql-folders-query' +import { + Command_Shadcn_, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from 'ui' + +type SnippetDropdownProps = { + projectRef?: string + trigger: ReactNode + side?: 'top' | 'bottom' | 'left' | 'right' + align?: 'start' | 'center' | 'end' + className?: string + autoFocus?: boolean + onSelect: (snippet: { id: string; name: string }) => void +} + +type SqlContentItem = Extract + +export const SnippetDropdown = ({ + projectRef, + trigger, + side = 'bottom', + align = 'end', + className, + autoFocus = false, + onSelect, +}: SnippetDropdownProps) => { + const [snippetSearch, setSnippetSearch] = useState('') + const scrollRootRef = useRef(null) + + const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = + useContentInfiniteQuery( + { + projectRef, + type: 'sql', + limit: SNIPPET_PAGE_LIMIT, + name: snippetSearch.length === 0 ? undefined : snippetSearch, + }, + { keepPreviousData: true } + ) + + const snippets = useMemo(() => { + const items = data?.pages.flatMap((page) => page.content) ?? [] + return items as SqlContentItem[] + }, [data?.pages]) + + const [sentinelRef, entry] = useIntersectionObserver({ + root: scrollRootRef.current, + threshold: 0, + rootMargin: '0px', + }) + + useEffect(() => { + if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage && !isLoading) { + fetchNextPage() + } + }, [entry?.isIntersecting, hasNextPage, isFetchingNextPage, isLoading, fetchNextPage]) + + return ( + + {trigger} + + + + + {isLoading ? ( + Loading… + ) : snippets.length === 0 ? ( + No snippets found + ) : null} + + {snippets.map((snippet) => ( + onSelect({ id: snippet.id, name: snippet.name })} + > + {snippet.name} + + ))} +
+ + + + + + ) +} diff --git a/packages/ui-patterns/src/Row/Row.utils.ts b/packages/ui-patterns/src/Row/Row.utils.ts new file mode 100644 index 00000000000..4abd879e8ca --- /dev/null +++ b/packages/ui-patterns/src/Row/Row.utils.ts @@ -0,0 +1,38 @@ +import { useLayoutEffect, useState } from 'react' + +export const useMeasuredWidth = (ref: React.RefObject) => { + const [measuredWidth, setMeasuredWidth] = useState(null) + + useLayoutEffect(() => { + const element = ref.current + if (!element) return + + const initial = element.getBoundingClientRect().width + setMeasuredWidth((prev) => (prev === initial ? prev : initial)) + + if (typeof ResizeObserver !== 'undefined') { + let frame = 0 + const resizeObserver = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width ?? 0 + if (frame) cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + setMeasuredWidth((prev) => (prev === width ? prev : width)) + }) + }) + resizeObserver.observe(element) + return () => { + if (frame) cancelAnimationFrame(frame) + resizeObserver.disconnect() + } + } else { + const handleResize = () => { + const width = element.getBoundingClientRect().width + setMeasuredWidth((prev) => (prev === width ? prev : width)) + } + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + } + }, [ref]) + + return measuredWidth +} diff --git a/packages/ui-patterns/src/Row/index.tsx b/packages/ui-patterns/src/Row/index.tsx index c28abb6e11e..76a8922ec87 100644 --- a/packages/ui-patterns/src/Row/index.tsx +++ b/packages/ui-patterns/src/Row/index.tsx @@ -1,13 +1,14 @@ 'use client' -import { ChevronLeft, ChevronRight } from 'lucide-react' import type React from 'react' -import type { ReactNode } from 'react' import { forwardRef, useEffect, useMemo, useRef, useState } from 'react' import { Button, cn } from 'ui' +import type { ReactNode } from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useMeasuredWidth } from './Row.utils' interface RowProps extends React.HTMLAttributes { - /** columns can be a fixed number or an array [lg, md, sm] */ + // columns can be a fixed number or an array [lg, md, sm] columns: number | [number, number, number] children: ReactNode className?: string @@ -24,13 +25,11 @@ export const Row = forwardRef(function Row( ref ) { const containerRef = useRef(null) - // We forward the ref to the outer wrapper; consumers needing the scroll container - // can use a separate ref prop in the future if required. const childrenArray = useMemo(() => (Array.isArray(children) ? children : [children]), [children]) const [scrollPosition, setScrollPosition] = useState(0) - const [maxScroll, setMaxScroll] = useState(0) + const measuredWidth = useMeasuredWidth(containerRef) const resolveColumnsForWidth = (width: number): number => { if (!Array.isArray(columns)) return columns @@ -41,101 +40,77 @@ export const Row = forwardRef(function Row( return smCols } - const getRenderColumns = (): number => { - const width = containerRef.current?.getBoundingClientRect().width ?? 0 - return resolveColumnsForWidth(width) - } + const renderColumns = useMemo( + () => resolveColumnsForWidth(measuredWidth ?? 0), + [measuredWidth, columns] + ) const scrollByStep = (direction: -1 | 1) => { const el = containerRef.current if (!el) return - const widthLocal = el.getBoundingClientRect().width - const colsLocal = resolveColumnsForWidth(widthLocal) + const widthLocal = measuredWidth ?? el.getBoundingClientRect().width + const colsLocal = renderColumns const columnWidth = (widthLocal - (colsLocal - 1) * gap) / colsLocal const scrollAmount = columnWidth + gap - setScrollPosition((prev) => Math.max(0, Math.min(maxScroll, prev + direction * scrollAmount))) + setScrollPosition((prev) => { + const next = Math.max(0, Math.min(maxScroll, prev + direction * scrollAmount)) + return next === prev ? prev : next + }) } const scrollLeft = () => scrollByStep(-1) const scrollRight = () => scrollByStep(1) + const maxScroll = useMemo(() => { + if (measuredWidth == null) return -1 + const colsLocal = renderColumns + const columnWidth = (measuredWidth - (colsLocal - 1) * gap) / colsLocal + const totalWidth = childrenArray.length * columnWidth + (childrenArray.length - 1) * gap + return Math.max(0, totalWidth - measuredWidth) + }, [measuredWidth, renderColumns, childrenArray.length, gap]) + const canScrollLeft = scrollPosition > 0 const canScrollRight = scrollPosition < maxScroll - useEffect(() => { - const element = containerRef.current - if (!element) return + const rafIdRef = useRef(0 as number) + const pendingDeltaRef = useRef(0) - const computeMaxScroll = (width: number) => { - const colsLocal = resolveColumnsForWidth(width) - const columnWidth = (width - (colsLocal - 1) * gap) / colsLocal - const totalWidth = childrenArray.length * columnWidth + (childrenArray.length - 1) * gap - const maxScrollValue = Math.max(0, totalWidth - width) - setMaxScroll(maxScrollValue) - } + const handleWheel: React.WheelEventHandler = (e) => { + if (e.deltaX === 0) return - // Initial calculation - computeMaxScroll(element.getBoundingClientRect().width) + const delta = Math.abs(e.deltaX) * 2 * (e.deltaX > 0 ? 1 : -1) + pendingDeltaRef.current += delta - if (typeof ResizeObserver !== 'undefined') { - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - computeMaxScroll(entry.contentRect.width) - } + if (!rafIdRef.current) { + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = 0 + const accumulated = pendingDeltaRef.current + pendingDeltaRef.current = 0 + setScrollPosition((prev) => { + const target = prev + accumulated + const next = Math.max(0, Math.min(maxScroll, target)) + return next === prev ? prev : next + }) }) - resizeObserver.observe(element) - return () => resizeObserver.disconnect() - } else { - const handleResize = () => computeMaxScroll(element.getBoundingClientRect().width) - window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleResize) } - }, [childrenArray.length, gap, columns]) + } useEffect(() => { - const handleWheel = (e: WheelEvent) => { - if (containerRef.current && containerRef.current.contains(e.target as Node)) { - if (e.deltaX !== 0) { - e.preventDefault() - - const scrollAmount = Math.abs(e.deltaX) * 2 - const direction = e.deltaX > 0 ? 1 : -1 - - setScrollPosition((prev) => { - const newPosition = prev + scrollAmount * direction - return Math.max(0, Math.min(maxScroll, newPosition)) - }) - } - } - } - - const container = containerRef.current - if (container) { - container.addEventListener('wheel', handleWheel, { passive: false }) - return () => container.removeEventListener('wheel', handleWheel) - } + setScrollPosition((prev) => { + const next = Math.min(prev, maxScroll) + return next === prev ? prev : next + }) }, [maxScroll]) - useEffect(() => { - setScrollPosition((prev) => Math.min(prev, maxScroll)) - }, [maxScroll]) - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (containerRef.current && document.activeElement === containerRef.current) { - if (e.key === 'ArrowLeft' && canScrollLeft) { - e.preventDefault() - scrollLeft() - } else if (e.key === 'ArrowRight' && canScrollRight) { - e.preventDefault() - scrollRight() - } - } + const handleKeyDown: React.KeyboardEventHandler = (e) => { + if (e.key === 'ArrowLeft' && canScrollLeft) { + e.preventDefault() + scrollLeft() + } else if (e.key === 'ArrowRight' && canScrollRight) { + e.preventDefault() + scrollRight() } - - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [canScrollLeft, canScrollRight]) + } return (
@@ -145,8 +120,9 @@ export const Row = forwardRef(function Row( onClick={scrollLeft} className="absolute w-8 h-8 left-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2" aria-label="Scroll left" - icon={} - /> + > + + )} {showArrows && canScrollRight && ( @@ -155,8 +131,9 @@ export const Row = forwardRef(function Row( onClick={scrollRight} className="absolute w-8 h-8 right-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2" aria-label="Scroll right" - icon={} - /> + > + + )}
(function Row( role="region" aria-roledescription="carousel" aria-label="Horizontally scrollable content" + style={{ overscrollBehaviorX: 'contain' }} + onWheel={handleWheel} + onKeyDown={handleKeyDown} >
From 0c3377975a0705efc6a1edcb28ee9a614f449e28 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Mon, 15 Sep 2025 23:04:45 -0400 Subject: [PATCH 02/76] fix: handle undefined count when fetching table rows (#38727) fix: handle undefined count when fetchint table rows count can be undefined in the return from fetching table rows (it uses executeSqlQuery under the hood which has no type safety) so we need to deal with that case --- .../grid/components/footer/pagination/Pagination.tsx | 11 ++++++----- apps/studio/data/table-rows/table-rows-count-query.ts | 6 +++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx index 8c0ff3af657..d9cc6f64e2d 100644 --- a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx +++ b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx @@ -69,9 +69,10 @@ const Pagination = () => { } ) - const count = data?.is_estimate ? formatEstimatedCount(data.count) : data?.count.toLocaleString() - const maxPages = Math.ceil((data?.count ?? 0) / tableEditorSnap.rowsPerPage) - const totalPages = (data?.count ?? 0) > 0 ? maxPages : 1 + const count = data?.count ?? 0 + const countString = data?.is_estimate ? formatEstimatedCount(count) : count.toLocaleString() + const maxPages = Math.ceil(count / tableEditorSnap.rowsPerPage) + const totalPages = count > 0 ? maxPages : 1 const onPreviousPage = () => { if (page > 1) { @@ -202,7 +203,7 @@ const Pagination = () => {

- {`${count} ${data.count === 0 || data.count > 1 ? `records` : 'record'}`}{' '} + {`${countString} ${count === 0 || count > 1 ? `records` : 'record'}`}{' '} {data.is_estimate ? '(estimated)' : ''}

@@ -217,7 +218,7 @@ const Pagination = () => { icon={} onClick={() => { // Show warning if either NOT a table entity, or table rows estimate is beyond threshold - if (rowsCountEstimate === null || data.count > THRESHOLD_COUNT) { + if (rowsCountEstimate === null || count > THRESHOLD_COUNT) { setIsConfirmFetchExactCountModalOpen(true) } else snap.setEnforceExactCount(true) }} diff --git a/apps/studio/data/table-rows/table-rows-count-query.ts b/apps/studio/data/table-rows/table-rows-count-query.ts index acfede4a8ca..f6d57cb4a2e 100644 --- a/apps/studio/data/table-rows/table-rows-count-query.ts +++ b/apps/studio/data/table-rows/table-rows-count-query.ts @@ -90,7 +90,7 @@ from approximation; } export type TableRowsCount = { - count: number + count?: number is_estimate?: boolean } @@ -144,8 +144,8 @@ export async function getTableRowsCount( ) return { - count: result[0].count, - is_estimate: result[0].is_estimate ?? false, + count: result?.[0]?.count, + is_estimate: result?.[0]?.is_estimate ?? false, } as TableRowsCount } From 7f0fbe378d36f299b86cd0283c7b7b0f57508b2b Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Tue, 16 Sep 2025 11:13:34 +0800 Subject: [PATCH 03/76] chore: add compute price to enabled features (#38696) --- .../fields/ComputeSizeField.tsx | 35 +++++++++++-------- .../enabled-features/enabled-features.json | 1 + .../enabled-features.schema.json | 5 +++ 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx index 75d12fc5d41..6f7245d9d5e 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx @@ -33,6 +33,7 @@ import { import { BillingChangeBadge } from '../ui/BillingChangeBadge' import FormMessage from '../ui/FormMessage' import { NoticeBar } from '../ui/NoticeBar' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' /** * to do: this could be a type from api-types @@ -55,6 +56,8 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { const { data: org } = useSelectedOrganizationQuery() const { data: project, isLoading: isProjectLoading } = useSelectedProjectQuery() + const showComputePrice = useIsFeatureEnabled('project_addons:show_compute_price') + const { computeSize, storageType } = form.watch() const { @@ -221,21 +224,23 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
) : ( - <> - - ${price} - - - {' '} - /{' '} - {compute.price_interval === 'monthly' - ? 'month' - : 'hour'} - - + showComputePrice && ( + <> + + ${price} + + + {' '} + /{' '} + {compute.price_interval === 'monthly' + ? 'month' + : 'hour'} + + + ) )}
diff --git a/packages/common/enabled-features/enabled-features.json b/packages/common/enabled-features/enabled-features.json index 16b692a3b83..30ca1cb6f7b 100644 --- a/packages/common/enabled-features/enabled-features.json +++ b/packages/common/enabled-features/enabled-features.json @@ -63,6 +63,7 @@ "project_homepage:show_examples": true, "project_addons:dedicated_ipv4_address": true, + "project_addons:show_compute_price": true, "project_settings:custom_domains": true, "project_settings:show_disable_legacy_api_keys": true, diff --git a/packages/common/enabled-features/enabled-features.schema.json b/packages/common/enabled-features/enabled-features.schema.json index 6e49d7d2f12..313975ad619 100644 --- a/packages/common/enabled-features/enabled-features.schema.json +++ b/packages/common/enabled-features/enabled-features.schema.json @@ -209,6 +209,10 @@ "type": "boolean", "description": "Show the dedicated IPv4 address addon" }, + "project_addons:show_compute_price": { + "type": "boolean", + "description": "Show the compute price in the compute and disk page" + }, "project_settings:custom_domains": { "type": "boolean", @@ -295,6 +299,7 @@ "project_homepage:show_instance_size", "project_homepage:show_examples", "project_addons:dedicated_ipv4_address", + "project_addons:show_compute_price", "project_settings:custom_domains", "project_settings:show_disable_legacy_api_keys", "project_settings:legacy_jwt_keys", From 57a10f2f6d01d76f937fbfad11714e2339a07714 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 16 Sep 2025 11:24:53 +0800 Subject: [PATCH 04/76] Fix project card provider undefined (#38705) --- .../components/interfaces/Home/ProjectList/ProjectCard.tsx | 3 +-- .../components/interfaces/Home/ProjectList/ProjectTableRow.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx index 650cb768cbd..ada3f39721b 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx @@ -28,8 +28,7 @@ export const ProjectCard = ({ resourceWarnings, }: ProjectCardProps) => { const { name, ref: projectRef } = project - const infraInformation = project.databases.find((x) => x.identifier === project.ref) - const desc = `${infraInformation?.cloud_provider} | ${project.region}` + const desc = `${project.cloud_provider} | ${project.region}` const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([ 'project_homepage:show_instance_size', diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx index 30563af30bb..7039687da79 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx @@ -110,7 +110,7 @@ export const ProjectTableRow = ({ - {infraInformation?.cloud_provider} | {project.region || 'N/A'} + {project.cloud_provider} | {project.region || 'N/A'} From 466760099d4c721b57e73ea006b69cd17eebf13e Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 16 Sep 2025 11:54:03 +0800 Subject: [PATCH 05/76] Support opening table rows for foreign tables in side panel (read only) (#38699) * Support opening table rows for foreign tables in side panel (read only) * Fix TS --- .../SidePanelEditor/RowEditor/RowEditor.utils.ts | 2 +- .../interfaces/TableGridEditor/TableGridEditor.tsx | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts index 1dbff8c42ed..03236f0f374 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts @@ -46,7 +46,7 @@ export const generateRowFields = ( table: PostgresTable, foreignKeys: ForeignKey[] ): RowField[] => { - const { primary_keys } = table + const { primary_keys = [] } = table const primaryKeyColumns = primary_keys.map((key) => key.name) return (table.columns ?? []).map((column) => { diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 5a31e04f07c..2cceac5f76d 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -8,9 +8,11 @@ import { SupabaseGrid } from 'components/grid/SupabaseGrid' import { useLoadTableEditorStateFromLocalStorageIntoUrl } from 'components/grid/SupabaseGrid.utils' import { Entity, + isForeignTable, isMaterializedView, isTableLike, isView, + TableLike, } from 'data/table-editor/table-editor-types' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useDashboardHistory } from 'hooks/misc/useDashboardHistory' @@ -147,6 +149,7 @@ export const TableGridEditor = ({ const isViewSelected = isView(selectedTable) || isMaterializedView(selectedTable) const isTableSelected = isTableLike(selectedTable) + const isForeignTableSelected = isForeignTable(selectedTable) const canEditViaTableEditor = isTableSelected && !isSchemaLocked const editable = !isReadOnly && canEditViaTableEditor @@ -188,11 +191,13 @@ export const TableGridEditor = ({ From 73a2d7d91351c5e485624470d9a0fb1b16e0e7fb Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Tue, 16 Sep 2025 14:57:42 +1000 Subject: [PATCH 06/76] introduce ui rules (#38728) --- .cursor/rules/studio-ui.mdc | 253 ++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 .cursor/rules/studio-ui.mdc diff --git a/.cursor/rules/studio-ui.mdc b/.cursor/rules/studio-ui.mdc new file mode 100644 index 00000000000..67db62a0ba1 --- /dev/null +++ b/.cursor/rules/studio-ui.mdc @@ -0,0 +1,253 @@ +--- +description: How to generate pages and interfaces in Studio, a web interface for managing Supabase projects +globs: +alwaysApply: true +--- + +## Project Structure + +- Next.js app using pages router +- Pages go in @apps/studio/pages + - Project related pages go in @apps/studio/pages/projects/[ref] + - Organization related pages go in @apps/studio/pages/org/[slug] +- Studio specific components go in @apps/studio/components + - Studio specific generic UI components go in @apps/studio/components/ui + - Studio specific components related to individual pages go in @apps/studio/components/interfaces e.g. @apps/studio/components/interfaces/Auth +- Generic helper functions go in @apps/studio/lib +- Generic hooks go in @apps/studio/hooks + +## Component system + +Our primitive component system is in @packages/ui and is based off shadcn/ui components. These components can be shared across all @apps e.g. studio and docs. Do not introduce new ui components unless asked to. + +- UI components are imported from this package across apps e.g. import { Button, Badge } from 'ui' +- Some components have a _Shadcn_ namespace appended to component name e.g. import { Input*Shadcn* } from 'ui' +- We should be using _Shadcn_ components where possible +- Before composing interfaces, read @packages/ui/index.tsx file for a full list of available components + +## Styling + +We use Tailwind for styling. + +- You should never use tailwind classes for colours and instead use classes we've defined ourselves + - Backgrounds // most of the time you will not need to define a background + - 'bg' used for main app surface background + - 'bg-muted' for elevating content // you can use Card instead + - 'bg-warning' for highlighting information that needs to be acted on + - 'bg-destructive' for highlighting issues + - Text + - 'text-foreground' for primary text like headings + - 'text-foreground-light' for body text + - 'text-foreground-lighter' for subtle text + - 'text-warning' for calling out information that needs action + - 'text-destructive' for calling out when something went wrong +- When needing to apply typography styles, read @apps/studio/styles/typography.scss and use one of the available classes instead of hard coding classes e.g. use "heading-default" instead of "text-sm font-medium" + +## Page structure + +When creating a new page follow these steps: + +- Create the page in @apps/studio/pages +- Use the PageLayout component that has the following props + + ```jsx + export interface NavigationItem { + id?: string + label: string + href?: string + icon?: ReactNode + onClick?: () => void + badge?: string + active?: boolean + } + + interface PageLayoutProps { + children?: ReactNode + title?: string | ReactNode + subtitle?: string | ReactNode + icon?: ReactNode + breadcrumbs?: Array<{ + label?: string + href?: string + element?: ReactNode + }> + primaryActions?: ReactNode + secondaryActions?: ReactNode + navigationItems?: NavigationItem[] + className?: string + size?: 'default' | 'full' | 'large' | 'small' + isCompact?: boolean + } + ``` + +- If a page has page related actions, add them to primary and secondary action props e.g. Users page has "Create new user" action +- If a page is within an existing section (e.g. Auth), you should use the related layout component e.g. AuthLayout +- Create a new component in @apps/studio/components/interfaces for the contents of the page +- Use ScaffoldContainer if the page should be center aligned in a container +- Use ScaffoldSection, ScaffoldSectionTitle, ScaffoldSectionDescription if the page has multiple sections + +### Page example + +```jsx +import { MyPageComponent } from 'components/interfaces/MyPage/MyPageComponent' +import AuthLayout from './AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { ScaffoldContainer } from 'components/layouts/Scaffold' +import type { NextPageWithLayout } from 'types' + +const MyPage: NextPageWithLayout = () => { + return ( + + + + ) +} + +MyPage.getLayout = (page) => ( + + {page} + +) + +export default MyPage + +export const MyPageComponent = () => ( + +
+ My page section + A brief description of the purpose of the page +
+ // Content goes here +
+) +``` + +## Forms + +- Build forms with `react-hook-form` + `zod`. +- Use our `_Shadcn_` form primitives from `ui` and prefer `FormItemLayout` with layout="flex-row-reverse" for most controls (see `apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx`). +- Keep imports from `ui` with `_Shadcn_` suffixes. +- Forms should generally be wrapped in a Card unless specified + +### Example (single field) + +```tsx +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import * as z from 'zod' + +import { Button, Form_Shadcn_, FormField_Shadcn_, FormControl_Shadcn_, Input_Shadcn_ } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +const profileSchema = z.object({ + username: z.string().min(2, 'Username must be at least 2 characters'), +}) + +export function ProfileForm() { + const form = useForm>({ + resolver: zodResolver(profileSchema), + defaultValues: { username: '' }, + mode: 'onSubmit', + reValidateMode: 'onBlur', + }) + + function onSubmit(values: z.infer) { + // handle values + } + + return ( + +
+ + + ( + + + + + + )} + /> + + + + + +
+
+ ) +} +``` + +## Cards + +- Use cards when needing to group related pieces of information +- Cards can have sections with CardContent +- Use CardFooter for actions +- Only use CardHeader and CardTitle if the card content has not been described by the surrounding content e.g. Page title or ScaffoldSectionTitle +- Use CardHeader and CardTitle when you are using multiple Cards to group related pieces of content e.g. Primary branch, Persistent branches, Preview branches + +## Sheets + +- Use a sheet when needing to reveal more complicated forms or information relating to an object and context switching away to a new page would be disruptive e.g. we list auth providers, clicking an auth provider opens a sheet with information about that provider and a form to enable, user can close sheet to go back to providers list + +## Tables + +- Use the generic ui table components for most tables +- Tables are generally contained witin a card +- If a table has associated actions, they should go above on right hand side +- If a table has associated search or filters, they should go above on left hand side +- If a table is the main content of a page, and it does not have search or filters, you can add table actions to primary and secondary actions of PageLayout +- If a table is the main content of a page section, and it does not have search or filters, you can add table actions to the right of ScaffoldSectionTitle +- For simple lists of objects you can use ResourceList with ResourceListItem instead + +### Table example + +```jsx +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from 'ui' + +; + A list of your recent invoices. + + + Invoice + Status + Method + Amount + + + + + INV001 + Paid + Credit Card + $250.00 + + +
+``` + +## Alerts + +- Use Admonition component to alert users of important actions or restrictions in place +- Place the Admonition either at the top of the contents of the page (below page title) or at the top of the related ScaffoldSection , below ScaffoldTitle +- Use sparingly + +### Alert example + +```jsx + +``` From 274ecd8ba36c45ef4cb383070a08c2a601cf60b4 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:35:59 +1000 Subject: [PATCH 07/76] New case study and blog (#38732) * Added a new case study and blog post. * ci: Autofix updates from GitHub workflow * fix yaml * fix logos * fix images temporarily * latest imagery * fix: date --------- Co-authored-by: Prashant Sridharan Co-authored-by: CoolAssPuppy <914007+CoolAssPuppy@users.noreply.github.com> --- ...cessing-large-jobs-with-edge-functions.mdx | 402 ++++++++++++++++++ apps/www/_customers/juniver.mdx | 110 +++++ apps/www/data/CustomerStories.ts | 12 + apps/www/public/customers-rss.xml | 7 + .../og.png | Bin 0 -> 38525 bytes .../three-layer-pattern-diagram.png | Bin 0 -> 74118 bytes .../blog/avatars/nick-farrant-juniver.jpg | Bin 0 -> 89143 bytes .../public/images/customers/logos/juniver.png | Bin 0 -> 9481 bytes .../images/customers/logos/light/juniver.png | Bin 0 -> 7042 bytes apps/www/public/sitemap_www.xml | 6 + 10 files changed, 537 insertions(+) create mode 100644 apps/www/_blog/2025-09-12-processing-large-jobs-with-edge-functions.mdx create mode 100644 apps/www/_customers/juniver.mdx create mode 100644 apps/www/public/images/blog/2025-09-processing-large-jobs-with-edge-functions/og.png create mode 100644 apps/www/public/images/blog/2025-09-processing-large-jobs-with-edge-functions/three-layer-pattern-diagram.png create mode 100644 apps/www/public/images/blog/avatars/nick-farrant-juniver.jpg create mode 100644 apps/www/public/images/customers/logos/juniver.png create mode 100644 apps/www/public/images/customers/logos/light/juniver.png diff --git a/apps/www/_blog/2025-09-12-processing-large-jobs-with-edge-functions.mdx b/apps/www/_blog/2025-09-12-processing-large-jobs-with-edge-functions.mdx new file mode 100644 index 00000000000..35dc7d0132a --- /dev/null +++ b/apps/www/_blog/2025-09-12-processing-large-jobs-with-edge-functions.mdx @@ -0,0 +1,402 @@ +--- +title: Processing large jobs with Edge Functions, Cron, and Queues +description: Learn how to build scalable data processing pipelines using Supabase Edge Functions, cron jobs, and database queues and handle large workloads without timeouts or crashes. +author: prashant +image: 2025-09-processing-large-jobs-with-edge-functions/og.png +thumb: 2025-09-processing-large-jobs-with-edge-functions/og.png +categories: + - postgres + - edge functions + - cron + - queues +date: 2025-09-16:08:00 +toc_depth: 2 +--- + +When you're building applications that process large amounts of data, you quickly run into a fundamental problem: trying to do everything at once leads to timeouts, crashes, and frustrated users. The solution isn't to buy bigger servers. It's to break big jobs into small, manageable pieces. + +Supabase gives you three tools that work beautifully together for this: [Edge Functions](/edge-functions) for serverless compute, [Cron](/modules/cron) for scheduling, and database [queues](/modules/queues) for reliable job processing. + +Here's how to use them to build a system that can handle serious scale. + +## The three-layer pattern + +The architecture is simple but powerful. Think of it like an assembly line: + +**Collection**: Cron jobs run Edge Functions that discover work and add tasks to queues + +**Distribution**: Other cron jobs route tasks from main queues to specialized processing queues + +**Processing**: Specialized workers handle specific types of tasks from their assigned queues + +A diagram showing the three-layer pattern described above + +This breaks apart the complexity. Instead of one giant function that scrapes websites, processes content with AI, and stores everything, you have focused functions that each do one thing well. + +## Real example: Building an NFL news aggregator + +Let's say you want to build a dashboard that tracks NFL (American football) news from multiple sources including NFL-related websites and NFL-related videos on YouTube, automatically tags articles by topic, and lets users search by player or team. When they see an article they’re interested in, they can click on it and visit the website that hosts the article. It’s like a dedicated Twitter feed for the NFL without any of the toxicity. + +This sounds straightforward, but at scale this becomes complex fast. You need to monitor dozens of news sites, process hundreds of articles daily, make API calls to OpenAI for content analysis, generate vector embeddings for search, and store everything efficiently. Do this wrong and a single broken webpage crashes your entire pipeline. + +We need to build a more resilient approach. With Supabase Edge Functions, Cron, and Queues, we have the building blocks for a robust content extraction and categorization pipeline. + +## Setting up the foundation + +Everything starts with the [database](/database), and Supabase is Postgres at its core. We know what we’re getting: scalable, dependable, and standard. + +The database design for the application follows a clean pattern. You have content tables for storing articles and videos, queue tables for managing work, entity tables for NFL players and teams, and relationship tables linking everything together. For example: + +```sql +create table articles ( + url text unique not null, + headline text, + content text, + embedding vector(1536) +); +``` + +## Collection: Finding new content + +The collection layer seeks out new NFL-related content and runs on a schedule to discover new articles and videos. We create a collector for every site we want to search. A cron job triggers every 30 minutes to begin collection: + +```sql +SELECT cron.schedule( + 'nfl-collector', + '*/30 * * * *', + $$SELECT net.http_post(url := '[https://your-project.supabase.co/functions/v1/collect-content')$$](https://your-project.supabase.co/functions/v1/collect-content')$$) +); +``` + +The Edge Function does the actual scraping. The trick is being selective about what you collect: + +```tsx +function isRelevantArticle(url: string): boolean { + return url.includes('/news/') && !url.includes('/video/') +} +``` + +This simple filter prevents collecting promotional content or videos. You only want actual news articles. + +When parsing HTML, you need to handle relative URLs properly: + +```tsx +if (href.startsWith('/')) { + href = BASE_URL + href +} +``` + +And always deduplicate within a single scraping session: + +```tsx +const seen = new Set() +if (!seen.has(href)) { + seen.add(href) + articles.push({ url: href, site: 'nfl' }) +} +``` + +For database insertion, let the database handle duplicates rather than checking in your application: + +```tsx +const { error } = await supabase.from('articles').insert({ url, site }) +if (error && !error.message.includes('duplicate')) { + console.error(`Error inserting: ${url}`, error) +} +``` + +This approach is more reliable than complex application-level deduplication logic. + +## Distribution: Smart routing + +The distribution layer identifies articles that need processing and routes them to appropriate queues. The key insight is using separate queue tables for different content sources. [NFL.com](http://NFL.com) articles need different parsing than ESPN articles, so they get routed to specialized processors. It runs more frequently than collection: every 5 minutes: + +```sql +SELECT cron.schedule( + 'distributor', + '*/5 * * * *', + $$SELECT net.http_post(url := '[https://your-project.supabase.co/functions/v1/distribute-work')$$](https://your-project.supabase.co/functions/v1/distribute-work')$$) +); +``` + +The Edge Function finds unprocessed articles using a simple SQL query: + +```tsx +const { data } = await supabase + .from('articles') + .select('url, site') + .is('headline', null) // Missing headline means unprocessed + .limit(50) +``` + +Then it routes based on the source site: + +```tsx +if ([article.site](http://article.site) === "nfl") { + await supabase.from("nfl_queue").insert({ url: article.url }); +} else if ([article.site](http://article.site) === "espn") { + await supabase.from("espn_queue").insert({ url: article.url }); +} +``` + +This separation is crucial because each site has different HTML structures and parsing requirements. + +## Processing: The heavy lifting + +Each content source gets its own processor that runs on its own schedule. [NFL.com](http://NFL.com) gets processed every 15 seconds because it's high-priority: + +```sql +SELECT cron.schedule( + 'nfl-processor', + '*/15 * * * *', + $$SELECT net.http_post(url := '[https://your-project.supabase.co/functions/v1/process-nfl')$$](https://your-project.supabase.co/functions/v1/process-nfl')$$) +); +``` + +The processor handles one article at a time to stay within Edge Function timeout limits: + +```tsx +const { data } = await supabase + .from('nfl_queue') + .select('id, url') + .eq('processed', false) + .order('created_at') + .limit(1) +``` + +Content extraction requires site-specific CSS selectors: + +```tsx +const headline = $('h1').first().text().trim() +const content = $('.article-body').text().trim() || $('article').text().trim() +``` + +Date parsing often needs custom logic for each site's format: + +```tsx +const dateText = $('.publish-date').text() +const match = dateText.match(/(\w+ \d+, \d{4})/) +if (match) { + publication_date = new Date(match[1]) +} +``` + +After scraping, the article gets analyzed with AI to extract entities: + +```tsx +const result = await classifyArticle(headline, content) +const playerIds = await upsertPlayers(supabase, result.players) +const teamIds = await upsertTeams(supabase, result.teams) +``` + +Finally, create the relationships and generate embeddings: + +```tsx +await supabase.from("article_players").insert( + [playerIds.map](http://playerIds.map)(id => ({ article_url: url, player_id: id })) +); + +const embedding = await generateEmbedding(`${headline}\n${content}`); +await supabase.from("articles").update({ embedding }).eq("url", url); +``` + +The critical pattern is the finally block. We use it to always mark queue items as processed, preventing infinite loops when articles fail to process: + +```tsx +try { + // Process article +} finally { + await supabase.from("nfl_queue") + .update({ processed: true }) + .eq("id", [item.id](http://item.id)); +} +``` + +### Monitoring with Sentry + +While the finally block prevents infinite loops, you still need visibility into what's actually failing. Sentry integration gives you detailed error tracking for your Edge Functions. + +First, set up Sentry in your Edge Function: + +```jsx +import { captureException, init } from 'https://deno.land/x/sentry/index.js' + +init({ + dsn: Deno.env.get('SENTRY_DSN'), + environment: Deno.env.get('ENVIRONMENT') || 'production', +}) +``` + +Then wrap your processing logic with proper error capture: + +```jsx +try { + const content = await scrapeArticle(url); + const analysis = await classifyArticle(headline, content); + await storeArticle(article, analysis); +} catch (error) { + *// Capture the full context for debugging* + captureException(error, { + tags: { + function: "nfl-processor", + site: article.site + }, + extra: { + url: article.url, + queueId: queueItem.id + } + }); + console.error(`Failed to process ${url}:`, error); +} finally { + await supabase.from("nfl_queue") + .update({ processed: true }) + .eq("id", queueItem.id); +} +``` + +This gives you real-time alerts when processors fail and detailed context for debugging production issues. + +## Processing user interactions through the pipeline + +The same pipeline pattern works for user-generated events. When someone clicks, shares, or saves an article, you don't want to block their response while updating trending scores for every player and team mentioned in that article. + +Instead, treat interactions like any other job to be processed: + +```tsx +// Just record the interaction quickly +await supabase.from('interaction_queue').insert({ + article_url: url, + user_id: userId, + interaction_type: 'share', +}) +``` + +Then let a separate cron job process the trending updates in batches: + +```sql +SELECT cron.schedule( + 'process-interactions', + '*/2 * * * *', -- Every 2 minutes + $$SELECT net.http_post(url := '[https://your-project.supabase.co/functions/v1/process-interactions')$$](https://your-project.supabase.co/functions/v1/process-interactions')$$) +); +``` + +The processor can handle multiple interactions efficiently: + +```tsx +const { data: interactions } = await supabase + .from('interaction_queue') + .select('*') + .eq('processed', false) + .limit(100) +``` + +This keeps your user interface snappy while ensuring trending scores get updated reliably. If the trending processor goes down, interactions are safely queued and will be processed when it recovers. + +## AI-powered content scoring + +To surface the most important content automatically, use AI to analyze article context and assign importance scores. + +Define scores for different news types: + +```tsx +const CONTEXT_SCORES = { + championship: 9, + trade: 6, + injury: 4, + practice: 1, +} +``` + +Prompt OpenAI with structured output: + +```tsx +const prompt = `Analyze this headline: "${headline}" +Return JSON: {"context": "trade|injury|etc", "score": 1-9}`; + +const result = await [openai.chat](http://openai.chat).completions.create({ + model: "gpt-3.5-turbo", + response_format: { type: "json_object" } +}); +``` + +Process articles in batches to manage API costs: + +```tsx +const unprocessed = articles.filter((a) => !processedUrls.has(a.url)).slice(0, 10) + +for (const article of unprocessed) { + const analysis = await analyzeArticle(article) + await storeAnalysis(article.url, analysis) +} +``` + +## Background tasks for expensive operations + +Some operations are too expensive to run synchronously, even in your cron-triggered processors. Vector embedding generation and bulk AI analysis benefit from background task patterns. + +Edge Functions support background tasks that continue processing after the main response completes: + +typescript + +```jsx +*// In your article processor* +const article = await scrapeAndStore(url); + +*// Start expensive operations in background* +const backgroundTasks = [ + generateEmbedding(article), + analyzeWithAI(article), + updateRelatedContent(article) +]; + +*// Run background tasks without blocking the main flow* +Promise.all(backgroundTasks).catch(error => { + captureException(error, { + tags: { operation: "background-tasks" }, + extra: { articleUrl: url } + }); +}); + +*// Main processing continues immediately* +await markAsProcessed(queueItem.id); +``` + +For operations that might take longer than Edge Function limits, break them into smaller background chunks: + +typescript + +```jsx +async function generateEmbeddingInBackground(article: Article) { + *// Process content in chunks* + const chunks = splitIntoChunks(article.content, 1000); + + for (const chunk of chunks) { + await new Promise(resolve => { + *// Use background task for each chunk* + setTimeout(async () => { + const embedding = await generateEmbedding(chunk); + await storeEmbedding(article.id, embedding); + resolve(void 0); + }, 0); + }); + } +} +``` + +This pattern keeps your main processing pipeline fast while ensuring expensive operations complete reliably. + +## Why this works + +This pattern succeeds because it embraces the constraints of serverless computing rather than fighting them. Edge Functions have time limits, so you process one item at a time. External APIs have rate limits, so you control timing with cron schedules. Failures happen, so you isolate them to individual tasks. + +The result is a system that scales horizontally by adding more cron jobs and queues. Each component can fail independently without bringing down the whole pipeline. Users get fresh content as it becomes available rather than waiting for batch jobs to complete. + +Most importantly, it's built entirely with Supabase primitives — no external queue systems or job schedulers required. You get enterprise-grade reliability with startup simplicity. diff --git a/apps/www/_customers/juniver.mdx b/apps/www/_customers/juniver.mdx new file mode 100644 index 00000000000..aa19fbf1f98 --- /dev/null +++ b/apps/www/_customers/juniver.mdx @@ -0,0 +1,110 @@ +--- +name: Juniver +title: 'Juniver built a self-serve B2B platform with Supabase to scale eating disorder recovery' +# Use meta_title to add a custom meta title. Otherwise it defaults to '{name} | Supabase Customer Stories': +# meta_title: +description: Juniver switched from Firebase to Supabase and saw immediate improvements in developer experience and performance. +# Use meta_description to add a custom meta description. Otherwise it defaults to {description}: +meta_description: Juniver switched from Firebase to Supabase and saw immediate improvements in developer experience and performance. +author: prashant +author_title: Prashant Sridharan +author_url: https://github.com/CoolAssPuppy +author_image_url: https://avatars.githubusercontent.com/u/914007?v=4 +logo: /images/customers/logos/juniver.png +logo_inverse: /images/customers/logos/light/juniver.png +og_image: /images/customers/og/juniver.jpg +tags: + - supabase +date: '2025-01-15' +company_url: https://www.joinjuniver.com +misc: [{ label: 'Founded', text: 'United Kingdom' }] +about: Science-based recovery program for disordered eating +# "healthcare" | "fintech" | "ecommerce" | "education" | "gaming" | "media" | "real-estate" | "saas" | "social" | "analytics" | "ai" | "developer-tools" +industry: ['healthcare', 'ai', 'saas'] +# "startup" | "enterprise" | "indie_dev" +company_size: 'startup' +# "Asia" | "Europe" | "North America" | "South America" | "Africa" | "Oceania" +region: 'Europe' +# "database" | "auth" | "storage" | "realtime" | "functions" | "vector" +supabase_products: ['database', 'auth', 'functions'] +--- + + + For me, the biggest benefit of Supabase is developer experience. My expertise doesn't lie in + databases and infrastructure. It really didn't take much time at all to spin up this product with + Supabase. + + +[Juniver](https://www.joinjuniver.com/) is an iOS app that guides people through recovery from eating disorders and disordered eating. The product combines an AI recovery coach, a thoroughly researched curriculum, and self-serve tools designed for moments of need. Juniver is expanding from B2C into B2B2C, licensing its platform to clinics and clinicians who then offer it to patients. + +## The challenge + +Juniver needed to evolve its backend to support a new B2B2C product, without risking the stability of its consumer app. + +- Launch a self-serve, automated, scalable platform for clinics and clinicians +- Enforce strict privacy and access controls across clinics, clinicians, patients, and licenses +- Integrate tightly with Stripe and webhooks to automate onboarding and license provisioning +- Move quickly with a lean team, choosing tools that won't have to be replaced in a few years + +They also faced significant friction with their previous Firebase setup: + +- Document-based storage made data relationships difficult to manage +- Manual relationship handling slowed development +- Limited scalability for complex B2B requirements +- Developer productivity was constrained by Firebase's limitations + + + Time is such a big currency in startup world. With Firebase, we were really struggling with + relationships within our data. It was all done very manually, as you can expect with + document-based storage. Supabase is a relational database and it's much easier and we can move + faster. + + +## Choosing Supabase + +Juniver had long run on Google Cloud with Firebase for the mobile app. The new B2B product created a clean slate to evaluate alternatives and set a foundation for future scale. Supabase offered a relational core (Postgres) and an integrated developer experience that aligned with Juniver's needs now and later. + +**Why Supabase** + +- A relational database (Postgres) to model clean, maintainable relationships +- Integrated developer experience: visual Table Editor with inline SQL, clear logs, SDKs +- Strong security posture with Row Level Security, plus an easy way to "view as user" to validate policies +- Built-in primitives (Auth, Functions) to save time and build with best-of-breed products + + + The Supabase developer experience is so clear. I've got everything at my fingertips. I'm in the + Table Editor. The SQL editor is literally right below the table editor. The feature you have to + see the table from a different user's perspective to test your Row Level Security is a wonderful + little feature that I use all the time. + + +## The approach + +Juniver used the B2B product as a greenfield build on Supabase, avoiding risky dependencies on the legacy Firebase data while preparing for an eventual B2C migration. + +- **Getting started** + - Spun up a new Supabase project and schema for clinics, clinicians, patients, and licenses + - Leaned on documentation and the iOS SDK to accelerate the build +- **Implementation highlights** + - **Database (Postgres):** Relational schema with strict Row Level Security; frequent use of "view as user" to validate policies + - **Auth & OAuth:** Managing identities for clinics and patients with fine-grained access + - **Webhooks + Edge Functions:** Stripe purchase triggers an automated sequence to authenticate partners, provision license codes, and send emails + - **Operational visibility:** Edge Function logs and an integrated console for fast debugging and iteration + + + You have to expect, plan, and build for scale. We can scale exponentially with Supabase. + + +## The results + +Juniver's B2B platform is out in the wild and early in adoption, but the team is already seeing meaningful wins in developer experience and delivery speed. + +- Rapidly build-out of a self-serve, automated B2B platform using out-of-the-box Supabase features +- Faster iteration through an integrated UI, inline SQL, clear logs, and RLS policy testing +- A relational foundation that supports today's B2B needs and tomorrow's B2C migration +- Streamlined development process with Supabase's integrated tooling + + + My advice to developers thinking of moving to Supabase: Try it, it's free. Replicate your project. + You'll see how much easier it is to think about your data from a relational point of view. + diff --git a/apps/www/data/CustomerStories.ts b/apps/www/data/CustomerStories.ts index b12e56ac878..063854d9d2a 100644 --- a/apps/www/data/CustomerStories.ts +++ b/apps/www/data/CustomerStories.ts @@ -18,6 +18,18 @@ export type CustomerStoryType = { } export const data: CustomerStoryType[] = [ + { + type: 'Customer Story', + title: + 'Juniver built automated B2B workflows with Supabase Edge Functions and Row Level Security', + description: + 'Juniver switched from Firebase to Supabase and saw immediate improvements in developer experience and performance.', + organization: 'Juniver', + imgUrl: 'images/customers/logos/juniver.png', + logo: '/images/customers/logos/juniver.png', + logo_inverse: '/images/customers/logos/light/juniver.png', + url: '/customers/juniver', + }, { type: 'Customer Story', title: 'Kayhan Space saw 8x improvement in developer speed when moving to Supabase', diff --git a/apps/www/public/customers-rss.xml b/apps/www/public/customers-rss.xml index 2c776ae0310..57ff2acef84 100644 --- a/apps/www/public/customers-rss.xml +++ b/apps/www/public/customers-rss.xml @@ -70,6 +70,13 @@ Scaling seamlessly to 5,000+ paying customers & millions of emails sent daily with Supabase Fri, 21 Feb 2025 00:00:00 -0700 + + https://supabase.com/customers/juniver + Juniver built a self-serve B2B platform with Supabase to scale eating disorder recovery + https://supabase.com/customers/juniver + Juniver switched from Firebase to Supabase and saw immediate improvements in developer experience and performance. + Wed, 15 Jan 2025 00:00:00 -0700 + https://supabase.com/customers/e2b E2B: Accelerating AI-Driven Development with Supabase diff --git a/apps/www/public/images/blog/2025-09-processing-large-jobs-with-edge-functions/og.png b/apps/www/public/images/blog/2025-09-processing-large-jobs-with-edge-functions/og.png new file mode 100644 index 0000000000000000000000000000000000000000..cca6165b2114c8d232b49241803f5ce4ef43ec3a GIT binary patch literal 38525 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 D%HOUn literal 0 HcmV?d00001 diff --git a/apps/www/public/images/blog/2025-09-processing-large-jobs-with-edge-functions/three-layer-pattern-diagram.png b/apps/www/public/images/blog/2025-09-processing-large-jobs-with-edge-functions/three-layer-pattern-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..3046914da4298e12e27eead89ed222364d49cf07 GIT binary patch literal 74118 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%0A-sHd{a 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 PY+yAdO~p)k^9TP0=Grj* literal 0 HcmV?d00001 diff --git a/apps/www/public/images/blog/avatars/nick-farrant-juniver.jpg b/apps/www/public/images/blog/avatars/nick-farrant-juniver.jpg new file mode 100644 index 0000000000000000000000000000000000000000..49c7335de71c8e7d23669f5d141d8cfd8f837dd5 GIT binary patch literal 89143 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 W1EAgIV2B2WRd0)>+)8M`HaE`Tjkq66 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�k$s+=A@hmTq6im2Si_HhH-3CK~WNX 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 bjw$~i#^pROEaa0800000NkvXXu0mjfDj20V literal 0 HcmV?d00001 diff --git a/apps/www/public/images/customers/logos/light/juniver.png b/apps/www/public/images/customers/logos/light/juniver.png new file mode 100644 index 0000000000000000000000000000000000000000..90f22bdd80dccdb9c678a323489652c66da4a887 GIT binary patch literal 7042 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 0.5 + + + https://supabase.com/customers/juniver + weekly + 0.5 + https://supabase.com/customers/kayhanspace From 7622b06fbbb19bcc3f570a91df1ef679a00950e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Tue, 16 Sep 2025 15:29:14 +0800 Subject: [PATCH 08/76] chore: transfer docs requirements (#38730) --- apps/docs/content/guides/platform/project-transfer.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/guides/platform/project-transfer.mdx b/apps/docs/content/guides/platform/project-transfer.mdx index 14a2429e6dd..91fe31b7542 100644 --- a/apps/docs/content/guides/platform/project-transfer.mdx +++ b/apps/docs/content/guides/platform/project-transfer.mdx @@ -33,7 +33,10 @@ Target organization - the organization you want to move the project to - You need to be the owner of the source organization. - You need to be at least a member of the target organization you want to move the project to. -- Projects with support tier add-ons cannot be transferred at this point. [Open a support ticket](/dashboard/support/new?category=billing&subject=Transfer%20project). +- No active GitHub integration connection +- No project-scoped roles pointing to the project (Team/Enterprise plan) +- No log drains configured +- Target organization is not managed by Vercel Marketplace (currently unsupported) ## Usage-billing and project add-ons From 48625868d3c6c37bb2f4bfe23e5f617410f6119e Mon Sep 17 00:00:00 2001 From: Alexander Korotkov Date: Tue, 16 Sep 2025 10:53:39 +0300 Subject: [PATCH 09/76] Correction to the patent blog post (#38644) --- apps/www/_blog/2025-09-09-orioledb-patent-free.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/www/_blog/2025-09-09-orioledb-patent-free.mdx b/apps/www/_blog/2025-09-09-orioledb-patent-free.mdx index 08404b9f916..f2e699e4120 100644 --- a/apps/www/_blog/2025-09-09-orioledb-patent-free.mdx +++ b/apps/www/_blog/2025-09-09-orioledb-patent-free.mdx @@ -42,7 +42,7 @@ OrioleDB will continue as an [open source project](https://github.com/orioledb/o ## License compatibility with Postgres -[OrioleDB License](https://github.com/orioledb/orioledb?tab=License-1-ov-file#readme) is based on the [PostgreSQL License](https://github.com/postgres/postgres?tab=License-1-ov-file). To reinforce the IP compatibility, Supabase is making available a non-exclusive license of U.S. Patent (“Durable multiversion B+-tree”) to all OrioleDB users, including proprietary forks, in accordance with the OrioleDB license. The patent is intended as a shield, not a sword, to protect Open Source from hostile IP claims. +[OrioleDB License](https://github.com/orioledb/orioledb?tab=Apache-2.0-1-ov-file#readme) is now Apache 2.0. This license is quite similar to [PostgreSQL License](https://github.com/postgres/postgres?tab=License-1-ov-file), but explicitly grants patent usage. With this change, Supabase is making available a non-exclusive license of U.S. Patent (“Durable multiversion B+-tree”) to all OrioleDB users, including proprietary forks. The patent is intended as a shield, not a sword, to protect Open Source from hostile IP claims. ## **Aligned with Postgres** From d46525eac14feb911e87d2b275239fe5782249ef Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 16 Sep 2025 17:05:57 +0800 Subject: [PATCH 10/76] Chore/swap use check permissions with use async check project permissions part 8 (Season Finale) (#38619) * Update perms checking in audit logs * Deprecate useCheckPermissions, useIsPermissionsLoaded and useCheckProjectPermissions as they're no longer used * Rename useAsyncCheckProjectPermissions to useAsyncCheckPermissions * Fix TS --- .../grid/components/header/Header.tsx | 4 +- .../interfaces/APIKeys/APIKeyDeleteDialog.tsx | 4 +- .../interfaces/APIKeys/ApiKeyPill.tsx | 8 +- .../interfaces/APIKeys/PublishableAPIKeys.tsx | 8 +- .../interfaces/APIKeys/SecretAPIKeys.tsx | 4 +- .../APIKeys/hooks/useApiKeysVisibility.ts | 4 +- .../Auth/AdvancedAuthSettingsForm.tsx | 6 +- .../interfaces/Auth/AuditLogsForm.tsx | 14 ++-- .../Auth/AuthProvidersForm/ProviderForm.tsx | 4 +- .../BasicAuthSettingsForm.tsx | 6 +- .../Auth/EmailTemplates/TemplateEditor.tsx | 4 +- .../interfaces/Auth/Hooks/AddHookDropdown.tsx | 7 +- .../interfaces/Auth/Hooks/HookCard.tsx | 7 +- .../MfaAuthSettingsForm.tsx | 6 +- .../PolicyEditorPanel/PolicyDetailsV2.tsx | 4 +- .../Auth/Policies/PolicyEditorPanel/index.tsx | 4 +- .../Policies/PolicyTableRow/PolicyRow.tsx | 4 +- .../PolicyTableRow/PolicyTableRowHeader.tsx | 6 +- .../ProtectionAuthSettingsForm.tsx | 6 +- .../interfaces/Auth/RateLimits/RateLimits.tsx | 6 +- .../Auth/RedirectUrls/RedirectUrlList.tsx | 4 +- .../SessionsAuthSettingsForm.tsx | 6 +- .../interfaces/Auth/SiteUrl/SiteUrl.tsx | 4 +- .../interfaces/Auth/SmtpForm/SmtpForm.tsx | 6 +- .../Auth/ThirdPartyAuthForm/index.tsx | 4 +- .../interfaces/Auth/Users/AddUserDropdown.tsx | 6 +- .../interfaces/Auth/Users/CreateUserModal.tsx | 4 +- .../interfaces/Auth/Users/InviteUserModal.tsx | 4 +- .../interfaces/Auth/Users/UserOverview.tsx | 17 ++--- .../PaymentMethods/CurrentPaymentMethod.tsx | 4 +- .../Payment/PaymentMethods/PaymentMethods.tsx | 10 ++- .../BranchManagement/CreateBranchModal.tsx | 4 +- .../interfaces/BranchManagement/Overview.tsx | 8 +- .../components/interfaces/Connect/Connect.tsx | 4 +- .../Database/Backups/BackupItem.tsx | 4 +- .../Database/Backups/PITR/PITRNotice.tsx | 4 +- .../Database/Backups/PITR/PITRStatus.tsx | 4 +- .../Database/Extensions/ExtensionRow.tsx | 4 +- .../Database/Extensions/Extensions.tsx | 8 +- .../Functions/FunctionsList/FunctionList.tsx | 6 +- .../Functions/FunctionsList/FunctionsList.tsx | 11 ++- .../Database/Hooks/HooksList/HookList.tsx | 4 +- .../Database/Hooks/HooksList/HooksList.tsx | 8 +- .../Publications/PublicationsList.tsx | 8 +- .../Publications/PublicationsTableItem.tsx | 4 +- .../Publications/PublicationsTables.tsx | 8 +- .../interfaces/Database/Roles/RolesList.tsx | 4 +- .../interfaces/Database/Tables/ColumnList.tsx | 4 +- .../interfaces/Database/Tables/TableList.tsx | 16 ++-- .../Triggers/TriggersList/TriggerList.tsx | 9 +-- .../Triggers/TriggersList/TriggersList.tsx | 11 ++- .../DiskManagement/DiskManagementForm.tsx | 4 +- .../DiskManagementReviewAndSubmitDialog.tsx | 4 +- .../interfaces/Docs/Description.tsx | 4 +- .../EdgeFunctionDetails.tsx | 4 +- .../EdgeFunctionSecret.tsx | 7 +- .../EdgeFunctionSecrets.tsx | 9 +-- .../interfaces/GraphQL/GraphiQL.tsx | 4 +- .../Home/NewProjectPanel/APIKeys.tsx | 4 +- .../HomeNew/CustomReportSection.tsx | 8 +- .../CronJobs/CreateCronJobSheet.tsx | 4 +- .../Integrations/Queues/QueuesSettings.tsx | 4 +- .../Integrations/Vault/Secrets/SecretRow.tsx | 4 +- .../Vault/Secrets/SecretsManagement.tsx | 14 ++-- .../Integrations/Webhooks/ListTab.tsx | 4 +- .../Integrations/Webhooks/OverviewTab.tsx | 4 +- .../Integrations/Wrappers/OverviewTab.tsx | 4 +- .../Integrations/Wrappers/WrapperRow.tsx | 4 +- .../Integrations/Wrappers/WrappersTab.tsx | 4 +- .../interfaces/JwtSecrets/jwt-settings.tsx | 8 +- .../Organization/AuditLogs/AuditLogs.tsx | 8 +- .../BillingBreakdown/BillingBreakdown.tsx | 12 +-- .../BillingCustomerData.tsx | 6 +- .../BillingSettings/BillingEmail.tsx | 10 ++- .../CostControl/CostControl.tsx | 8 +- .../CostControl/SpendCapSidePanel.tsx | 4 +- .../BillingSettings/CreditBalance.tsx | 8 +- .../BillingSettings/CreditTopUp.tsx | 4 +- .../Subscription/PlanUpdateSidePanel.tsx | 4 +- .../Subscription/Subscription.tsx | 8 +- .../Organization/Documents/SOC2.tsx | 8 +- .../Documents/SecurityQuestionnaire.tsx | 8 +- .../GeneralSettings/DataPrivacyForm.tsx | 4 +- .../DeleteOrganizationButton.tsx | 4 +- .../OrganizationDetailsForm.tsx | 4 +- .../IntegrationSettings.tsx | 8 +- .../InvoicesSettings/InvoicesSection.tsx | 4 +- .../Organization/OAuthApps/OAuthAppRow.tsx | 6 +- .../Organization/OAuthApps/OAuthApps.tsx | 10 ++- .../OAuthApps/OAuthSecrets/OAuthSecrets.tsx | 7 +- .../OAuthApps/OAuthSecrets/SecretRow.tsx | 7 +- .../SecuritySettings/SecuritySettings.tsx | 10 ++- .../TeamSettings/MemberActions.tsx | 6 +- .../interfaces/Organization/Usage/Usage.tsx | 8 +- .../interfaces/Realtime/Inspector/Header.tsx | 4 +- .../Realtime/Inspector/MessagesTable.tsx | 4 +- .../interfaces/Realtime/RealtimeSettings.tsx | 4 +- .../components/interfaces/Reports/Reports.tsx | 6 +- .../SQLEditor/SQLTemplates/SQLQuickstarts.tsx | 4 +- .../SQLEditor/SQLTemplates/SQLTemplates.tsx | 4 +- .../components/interfaces/SQLEditor/hooks.ts | 4 +- .../Settings/API/PostgrestConfig.tsx | 4 +- .../Settings/Addons/CustomDomainSidePanel.tsx | 4 +- .../Settings/Addons/IPv4SidePanel.tsx | 4 +- .../Settings/Addons/PITRSidePanel.tsx | 4 +- .../Settings/Database/BannedIPs.tsx | 16 ++-- .../ConnectionPooling/ConnectionPooling.tsx | 4 +- .../DatabaseSettings/ResetDbPassword.tsx | 4 +- .../Database/DiskSizeConfiguration.tsx | 4 +- .../NetworkRestrictions.tsx | 4 +- .../Settings/Database/SSLConfiguration.tsx | 4 +- .../ProjectComplianceMode.tsx | 4 +- .../CustomDomainsConfigureHostname.tsx | 4 +- .../DeleteProjectButton.tsx | 12 +-- .../interfaces/Settings/General/General.tsx | 16 ++-- .../Infrastructure/PauseProjectButton.tsx | 4 +- .../Infrastructure/RestartServerButton.tsx | 4 +- .../TransferProjectButton.tsx | 4 +- .../InstanceConfiguration.tsx | 7 +- .../InstanceNode.tsx | 7 +- .../InfrastructureConfiguration/MapView.tsx | 7 +- .../GitHubIntegrationConnectionForm.tsx | 6 +- .../GithubIntegration/GithubSection.tsx | 4 +- .../VercelIntegration/VercelSection.tsx | 8 +- .../interfaces/Settings/Logs/LogTable.tsx | 4 +- .../interfaces/Storage/BucketRow.tsx | 7 +- .../interfaces/Storage/CreateBucketModal.tsx | 7 +- .../StorageExplorer/ColumnContextMenu.tsx | 7 +- .../StorageExplorer/FileExplorerColumn.tsx | 7 +- .../StorageExplorer/FileExplorerHeader.tsx | 7 +- .../FileExplorerHeaderSelection.tsx | 7 +- .../StorageExplorer/FileExplorerRow.tsx | 7 +- .../StorageExplorer/FolderContextMenu.tsx | 7 +- .../StorageExplorer/ItemContextMenu.tsx | 7 +- .../Storage/StorageExplorer/PreviewPane.tsx | 7 +- .../StorageSettings/CreateCredentialModal.tsx | 4 +- .../Storage/StorageSettings/S3Connection.tsx | 10 ++- .../StorageSettings/StorageCredItem.tsx | 4 +- .../StorageSettings/StorageSettings.tsx | 10 ++- .../__tests__/CreateBucketModal.test.tsx | 2 +- .../TableGridEditor/GridHeaderActions.tsx | 10 ++- .../TableGridEditor/TableGridEditor.tsx | 6 +- .../BranchingPITRNotice.tsx | 4 +- .../EdgeFunctionDetailsLayout.tsx | 4 +- .../layouts/LogsLayout/LogsLayout.tsx | 4 +- .../ProjectLayout/PauseFailedState.tsx | 12 +-- .../PausedState/ProjectPausedState.tsx | 4 +- .../ProjectLayout/RestoreFailedState.tsx | 10 +-- .../layouts/ReportsLayout/ReportMenuItem.tsx | 4 +- .../layouts/ReportsLayout/ReportsMenu.tsx | 4 +- .../layouts/SQLEditorLayout/SQLEditorMenu.tsx | 4 +- .../SQLEditorNavV2/SQLEditorTreeViewItem.tsx | 4 +- .../SQLEditorLayout/SqlEditor.Commands.tsx | 4 +- .../TableEditorLayout/TableEditorLayout.tsx | 4 +- .../TableEditorLayout/TableEditorMenu.tsx | 4 +- .../studio/components/layouts/Tabs/NewTab.tsx | 4 +- .../ui/AIAssistantPanel/AIOptInModal.tsx | 4 +- .../AIAssistantPanel/DisplayBlockRenderer.tsx | 4 +- .../ui/AIAssistantPanel/MessageMarkdown.tsx | 4 +- .../ui/ProjectSettings/DisplayApiSettings.tsx | 4 +- .../ProjectSettings/ToggleLegacyApiKeys.tsx | 8 +- apps/studio/components/ui/SchemaSelector.tsx | 4 +- apps/studio/components/ui/UpgradeToPro.tsx | 4 +- .../data/config/project-settings-v2-query.ts | 4 +- .../organization-customer-profile-query.ts | 4 +- .../organization-payment-methods-query.ts | 4 +- .../organization-tax-id-query.ts | 4 +- .../iceberg-wrapper-create-mutation.ts | 4 +- .../data/subscriptions/org-plans-query.ts | 4 +- .../subscriptions/org-subscription-query.ts | 4 +- apps/studio/hooks/forms/useAIOptInForm.ts | 4 +- apps/studio/hooks/misc/useCheckPermissions.ts | 75 +------------------ apps/studio/pages/new/[slug].tsx | 4 +- .../pages/project/[ref]/auth/advanced.tsx | 8 +- .../pages/project/[ref]/auth/audit-logs.tsx | 8 +- .../studio/pages/project/[ref]/auth/hooks.tsx | 8 +- apps/studio/pages/project/[ref]/auth/mfa.tsx | 8 +- .../pages/project/[ref]/auth/policies.tsx | 4 +- .../pages/project/[ref]/auth/protection.tsx | 8 +- .../pages/project/[ref]/auth/rate-limits.tsx | 8 +- .../pages/project/[ref]/auth/sessions.tsx | 8 +- apps/studio/pages/project/[ref]/auth/smtp.tsx | 8 +- .../pages/project/[ref]/auth/templates.tsx | 8 +- .../pages/project/[ref]/auth/third-party.tsx | 8 +- .../project/[ref]/auth/url-configuration.tsx | 8 +- .../pages/project/[ref]/branches/index.tsx | 6 +- .../project/[ref]/branches/merge-requests.tsx | 4 +- .../project/[ref]/database/backups/pitr.tsx | 8 +- .../backups/restore-to-new-project.tsx | 10 ++- .../[ref]/database/backups/scheduled.tsx | 8 +- .../project/[ref]/database/extensions.tsx | 8 +- .../project/[ref]/database/functions.tsx | 4 +- .../[ref]/database/publications/[id].tsx | 8 +- .../[ref]/database/publications/index.tsx | 8 +- .../pages/project/[ref]/database/triggers.tsx | 4 +- .../[ref]/functions/[functionSlug]/code.tsx | 7 +- .../[ref]/functions/[functionSlug]/index.tsx | 6 +- .../pages/project/[ref]/logs/auth-logs.tsx | 4 +- .../pages/project/[ref]/reports/database.tsx | 4 +- .../pages/project/[ref]/reports/index.tsx | 4 +- .../[ref]/settings/jwt/signing-keys.tsx | 4 +- .../project/[ref]/settings/log-drains.tsx | 8 +- 202 files changed, 603 insertions(+), 682 deletions(-) diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 821a359c06d..696524f4d20 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -14,7 +14,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query' import { fetchAllTableRows, useTableRowsQuery } from 'data/table-rows/table-rows-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { RoleImpersonationState } from 'lib/role-impersonation' @@ -84,7 +84,7 @@ const DefaultHeader = () => { const snap = useTableEditorTableStateSnapshot() const tableEditorSnap = useTableEditorStateSnapshot() - const { can: canCreateColumns } = useAsyncCheckProjectPermissions( + const { can: canCreateColumns } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns' ) diff --git a/apps/studio/components/interfaces/APIKeys/APIKeyDeleteDialog.tsx b/apps/studio/components/interfaces/APIKeys/APIKeyDeleteDialog.tsx index 7ab3876edeb..5dbea604995 100644 --- a/apps/studio/components/interfaces/APIKeys/APIKeyDeleteDialog.tsx +++ b/apps/studio/components/interfaces/APIKeys/APIKeyDeleteDialog.tsx @@ -7,7 +7,7 @@ import { useParams } from 'common/hooks' import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { useAPIKeyDeleteMutation } from 'data/api-keys/api-key-delete-mutation' import { APIKeysData } from 'data/api-keys/api-keys-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' interface APIKeyDeleteDialogProps { @@ -19,7 +19,7 @@ export const APIKeyDeleteDialog = ({ apiKey, lastSeen }: APIKeyDeleteDialogProps const { ref: projectRef } = useParams() const [isOpen, setIsOpen] = useState(false) - const { can: canDeleteAPIKeys } = useAsyncCheckProjectPermissions( + const { can: canDeleteAPIKeys } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, '*' ) diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx index 9d060f1ff89..87334a6e5f4 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx @@ -11,7 +11,7 @@ import CopyButton from 'components/ui/CopyButton' import { useAPIKeyIdQuery } from 'data/api-keys/[id]/api-key-id-query' import { APIKeysData } from 'data/api-keys/api-keys-query' import { apiKeysKeys } from 'data/api-keys/keys' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' export function ApiKeyPill({ @@ -28,8 +28,10 @@ export function ApiKeyPill({ const isSecret = apiKey.type === 'secret' // Permission check for revealing/copying secret API keys - const { can: canManageSecretKeys, isLoading: isLoadingPermission } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'service_api_keys') + const { can: canManageSecretKeys, isLoading: isLoadingPermission } = useAsyncCheckPermissions( + PermissionAction.READ, + 'service_api_keys' + ) // This query only runs when show=true (enabled: show) // It fetches the fully revealed API key when needed diff --git a/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx index 779a9b66e7f..cd0cda84cca 100644 --- a/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx +++ b/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx @@ -6,16 +6,16 @@ import { useParams } from 'common' import CopyButton from 'components/ui/CopyButton' import { FormHeader } from 'components/ui/Forms/FormHeader' import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { cn, EyeOffIcon, Input_Shadcn_, Skeleton, - WarningIcon, Tooltip, TooltipContent, TooltipTrigger, + WarningIcon, } from 'ui' // to add in later with follow up PR @@ -36,7 +36,7 @@ export const PublishableAPIKeys = () => { [apiKeysData] ) - const { can: canReadAPIKeys, isLoading: isPermissionsLoading } = useAsyncCheckProjectPermissions( + const { can: canReadAPIKeys, isLoading: isPermissionsLoading } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, '*' ) @@ -106,7 +106,7 @@ const ApiKeyInput = () => { [apiKeysData] ) - const { can: canReadAPIKeys, isLoading: isPermissionsLoading } = useAsyncCheckProjectPermissions( + const { can: canReadAPIKeys, isLoading: isPermissionsLoading } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, '*' ) diff --git a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx index 2debd28019a..94e9523d568 100644 --- a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx +++ b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx @@ -7,7 +7,7 @@ import AlertError from 'components/ui/AlertError' import { FormHeader } from 'components/ui/Forms/FormHeader' import { APIKeysData, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import useLogsQuery from 'hooks/analytics/useLogsQuery' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Card, CardContent, EyeOffIcon, Skeleton, cn } from 'ui' import { Table, @@ -58,7 +58,7 @@ export const SecretAPIKeys = () => { isError: isErrorApiKeys, } = useAPIKeysQuery({ projectRef, reveal: false }) - const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckProjectPermissions( + const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, '*' ) diff --git a/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts b/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts index f2b602522ef..7cedd8a9f1b 100644 --- a/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts +++ b/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts @@ -3,7 +3,7 @@ import { useMemo } from 'react' import { useParams } from 'common' import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' interface ApiKeysVisibilityState { hasApiKeys: boolean @@ -19,7 +19,7 @@ interface ApiKeysVisibilityState { */ export function useApiKeysVisibility(): ApiKeysVisibilityState { const { ref: projectRef } = useParams() - const { can: canReadAPIKeys } = useAsyncCheckProjectPermissions(PermissionAction.READ, 'api_keys') + const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.READ, 'api_keys') const { data: apiKeysData, isLoading } = useAPIKeysQuery({ projectRef, diff --git a/apps/studio/components/interfaces/Auth/AdvancedAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/AdvancedAuthSettingsForm.tsx index 9f498c43c16..26bd90de84d 100644 --- a/apps/studio/components/interfaces/Auth/AdvancedAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/AdvancedAuthSettingsForm.tsx @@ -12,7 +12,7 @@ import NoPermission from 'components/ui/NoPermission' import UpgradeToPro from 'components/ui/UpgradeToPro' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from 'lib/constants' import { @@ -43,11 +43,11 @@ const FormSchema = z.object({ export const AdvancedAuthSettingsForm = () => { const { ref: projectRef } = useParams() const { data: organization } = useSelectedOrganizationQuery() - const { can: canReadConfig } = useAsyncCheckProjectPermissions( + const { can: canReadConfig } = useAsyncCheckPermissions( PermissionAction.READ, 'custom_config_gotrue' ) - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Auth/AuditLogsForm.tsx b/apps/studio/components/interfaces/Auth/AuditLogsForm.tsx index eb9ac33b096..b3e9ccded34 100644 --- a/apps/studio/components/interfaces/Auth/AuditLogsForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuditLogsForm.tsx @@ -8,11 +8,10 @@ import { boolean, object } from 'yup' import { useParams } from 'common' import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' import { InlineLink } from 'components/ui/InlineLink' -import { NoPermission } from 'components/ui/NoPermission' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' import { useTablesQuery } from 'data/tables/tables-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { AlertDescription_Shadcn_, @@ -40,8 +39,11 @@ const AUDIT_LOG_ENTRIES_TABLE = 'audit_log_entries' export const AuditLogsForm = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() - const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') - const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + + const { can: canUpdateConfig } = useAsyncCheckPermissions( + PermissionAction.UPDATE, + 'custom_config_gotrue' + ) const { data: tables = [] } = useTablesQuery({ projectRef: project?.ref, @@ -91,10 +93,6 @@ export const AuditLogsForm = () => { ) } - if (!canReadConfig) { - return - } - return (

diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index 80284a6dec9..4a142cab825 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -14,7 +14,7 @@ import type { components } from 'data/api' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' import { Button, Form, Input, Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from 'ui' import { Admonition } from 'ui-patterns' @@ -38,7 +38,7 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) const [open, setOpen] = useState(false) const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx index 5ee44c4ba4b..af36ff6689b 100644 --- a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx @@ -13,7 +13,7 @@ import { InlineLink } from 'components/ui/InlineLink' import NoPermission from 'components/ui/NoPermission' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { AlertDescription_Shadcn_, @@ -54,11 +54,11 @@ export const BasicAuthSettingsForm = () => { } = useAuthConfigQuery({ projectRef }) const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() - const { can: canReadConfig, isSuccess: isPermissionsLoaded } = useAsyncCheckProjectPermissions( + const { can: canReadConfig, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.READ, 'custom_config_gotrue' ) - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx index ae576925a30..0ef99c3b567 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx @@ -11,7 +11,7 @@ import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' import { useValidateSpamMutation, ValidateSpamResponse } from 'data/auth/validate-spam-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { FormSchema } from 'types' import { Button, @@ -40,7 +40,7 @@ interface TemplateEditorProps { const TemplateEditor = ({ template }: TemplateEditorProps) => { const { ref: projectRef } = useParams() - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx b/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx index 0cb29864869..ed20cca8654 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx @@ -4,7 +4,7 @@ import { ChevronDown } from 'lucide-react' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useAuthConfigQuery } from 'data/auth/auth-config-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Button, @@ -33,10 +33,7 @@ export const AddHookDropdown = ({ const { data: organization } = useSelectedOrganizationQuery() const { data: authConfig } = useAuthConfigQuery({ projectRef }) - const { can: canUpdateAuthHook } = useAsyncCheckProjectPermissions( - PermissionAction.AUTH_EXECUTE, - '*' - ) + const { can: canUpdateAuthHook } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, '*') const hooks: Hook[] = HOOKS_DEFINITIONS.map((definition) => { return { diff --git a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx index cf5b65112e0..e09e75bf280 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx @@ -3,7 +3,7 @@ import { Check, Webhook } from 'lucide-react' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DocsButton } from 'components/ui/DocsButton' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Badge, Input, copyToClipboard } from 'ui' import { Hook } from './hooks.constants' @@ -13,10 +13,7 @@ interface HookCardProps { } export const HookCard = ({ hook, onSelect }: HookCardProps) => { - const { can: canUpdateAuthHook } = useAsyncCheckProjectPermissions( - PermissionAction.AUTH_EXECUTE, - '*' - ) + const { can: canUpdateAuthHook } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, '*') return (
diff --git a/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx index 678e91226f7..b6bf44c39bc 100644 --- a/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx @@ -11,7 +11,7 @@ import NoPermission from 'components/ui/NoPermission' import UpgradeToPro from 'components/ui/UpgradeToPro' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from 'lib/constants' import { @@ -96,11 +96,11 @@ export const MfaAuthSettingsForm = () => { const [isConfirmationModalVisible, setIsConfirmationModalVisible] = useState(false) - const { can: canReadConfig } = useAsyncCheckProjectPermissions( + const { can: canReadConfig } = useAsyncCheckPermissions( PermissionAction.READ, 'custom_config_gotrue' ) - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx index 57a93403303..dc48f93087b 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx @@ -5,7 +5,7 @@ import { UseFormReturn } from 'react-hook-form' import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' import { useTablesQuery } from 'data/tables/tables-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, @@ -62,7 +62,7 @@ export const PolicyDetailsV2 = ({ }: PolicyDetailsV2Props) => { const { data: project } = useSelectedProjectQuery() const [open, setOpen] = useState(false) - const { can: canUpdatePolicies } = useAsyncCheckProjectPermissions( + const { can: canUpdatePolicies } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables' ) diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx index ac3c2e99e38..3ee605e2dbc 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx @@ -15,7 +15,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useDatabasePolicyUpdateMutation } from 'data/database-policies/database-policy-update-mutation' import { databasePoliciesKeys } from 'data/database-policies/keys' import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, @@ -67,7 +67,7 @@ export const PolicyEditorPanel = memo(function ({ const queryClient = useQueryClient() const { data: selectedProject } = useSelectedProjectQuery() - const { can: canUpdatePolicies } = useAsyncCheckProjectPermissions( + const { can: canUpdatePolicies } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables' ) diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx index 953d628c44e..9f97fe8dc93 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx @@ -5,7 +5,7 @@ import { Edit, MoreVertical, Trash } from 'lucide-react' import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { useAuthConfigQuery } from 'data/auth/auth-config-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { @@ -37,7 +37,7 @@ export const PolicyRow = ({ onSelectDeletePolicy = noop, }: PolicyRowProps) => { const aiSnap = useAiAssistantStateSnapshot() - const { can: canUpdatePolicies } = useAsyncCheckProjectPermissions( + const { can: canUpdatePolicies } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies' ) diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index a4aa1911705..27fd57ac00a 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -5,7 +5,7 @@ import { Lock, Table } from 'lucide-react' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { AiIconAnimation, Badge, CardTitle } from 'ui' @@ -35,11 +35,11 @@ export const PolicyTableRowHeader = ({ const { ref } = useParams() const aiSnap = useAiAssistantStateSnapshot() - const { can: canCreatePolicies } = useAsyncCheckProjectPermissions( + const { can: canCreatePolicies } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'policies' ) - const { can: canToggleRLS } = useAsyncCheckProjectPermissions( + const { can: canToggleRLS } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables' ) diff --git a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx index 53bddf2acd9..791d1ccd6f1 100644 --- a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx @@ -12,7 +12,7 @@ import { InlineLink } from 'components/ui/InlineLink' import NoPermission from 'components/ui/NoPermission' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, @@ -80,11 +80,11 @@ export const ProtectionAuthSettingsForm = () => { }) const [hidden, setHidden] = useState(true) - const { can: canReadConfig } = useAsyncCheckProjectPermissions( + const { can: canReadConfig } = useAsyncCheckPermissions( PermissionAction.READ, 'custom_config_gotrue' ) - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx index ade0bf24641..82a5a1e14d9 100644 --- a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx +++ b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx @@ -13,7 +13,7 @@ import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, Card, @@ -32,11 +32,11 @@ import { isSmtpEnabled } from '../SmtpForm/SmtpForm.utils' export const RateLimits = () => { const { ref: projectRef } = useParams() - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) - const { can: canReadConfig } = useAsyncCheckProjectPermissions( + const { can: canReadConfig } = useAsyncCheckPermissions( PermissionAction.READ, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx index fdcf729a56a..56b8644e1cb 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx @@ -3,7 +3,7 @@ import { Globe, Trash } from 'lucide-react' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { EmptyListState } from 'components/ui/States' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, Checkbox_Shadcn_ } from 'ui' import { ValueContainer } from './ValueContainer' @@ -24,7 +24,7 @@ export const RedirectUrlList = ({ onSelectRemoveURLs, onSelectClearSelection, }: RedirectUrlListProps) => { - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx index 0074539a98c..7ae7a7cc958 100644 --- a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx @@ -11,7 +11,7 @@ import NoPermission from 'components/ui/NoPermission' import UpgradeToPro from 'components/ui/UpgradeToPro' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from 'lib/constants' import { @@ -68,11 +68,11 @@ export const SessionsAuthSettingsForm = () => { const [isUpdatingRefreshTokens, setIsUpdatingRefreshTokens] = useState(false) const [isUpdatingUserSessions, setIsUpdatingUserSessions] = useState(false) - const { can: canReadConfig } = useAsyncCheckProjectPermissions( + const { can: canReadConfig } = useAsyncCheckPermissions( PermissionAction.READ, 'custom_config_gotrue' ) - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx b/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx index fd8a8a5b8d7..b1fe49a1f55 100644 --- a/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx +++ b/apps/studio/components/interfaces/Auth/SiteUrl/SiteUrl.tsx @@ -10,7 +10,7 @@ import { useParams } from 'common' import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, @@ -36,7 +36,7 @@ const SiteUrl = () => { const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation() const [isUpdatingSiteUrl, setIsUpdatingSiteUrl] = useState(false) - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx index 70d041d7f3f..01cf200b2c4 100644 --- a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx +++ b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx @@ -12,7 +12,7 @@ import { ScaffoldSection } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, @@ -53,11 +53,11 @@ export const SmtpForm = () => { const [enableSmtp, setEnableSmtp] = useState(false) const [hidden, setHidden] = useState(true) - const { can: canReadConfig } = useAsyncCheckProjectPermissions( + const { can: canReadConfig } = useAsyncCheckPermissions( PermissionAction.READ, 'custom_config_gotrue' ) - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx index 456bf706e53..3962304fe17 100644 --- a/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx +++ b/apps/studio/components/interfaces/Auth/ThirdPartyAuthForm/index.tsx @@ -17,7 +17,7 @@ import { ThirdPartyAuthIntegration, useThirdPartyAuthIntegrationsQuery, } from 'data/third-party-auth/integrations-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { cn } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { AddIntegrationDropdown } from './AddIntegrationDropdown' @@ -49,7 +49,7 @@ export const ThirdPartyAuthForm = () => { useState() const { mutateAsync: deleteIntegration } = useDeleteThirdPartyAuthIntegrationMutation() - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Auth/Users/AddUserDropdown.tsx b/apps/studio/components/interfaces/Auth/Users/AddUserDropdown.tsx index 2a7ef3f1bee..afb88ae48cb 100644 --- a/apps/studio/components/interfaces/Auth/Users/AddUserDropdown.tsx +++ b/apps/studio/components/interfaces/Auth/Users/AddUserDropdown.tsx @@ -3,7 +3,7 @@ import { ChevronDown, Mail, UserPlus } from 'lucide-react' import { useState } from 'react' import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' import CreateUserModal from './CreateUserModal' @@ -12,11 +12,11 @@ import InviteUserModal from './InviteUserModal' export const AddUserDropdown = () => { const showSendInvitation = useIsFeatureEnabled('authentication:show_send_invitation') - const { can: canInviteUsers } = useAsyncCheckProjectPermissions( + const { can: canInviteUsers } = useAsyncCheckPermissions( PermissionAction.AUTH_EXECUTE, 'invite_user' ) - const { can: canCreateUsers } = useAsyncCheckProjectPermissions( + const { can: canCreateUsers } = useAsyncCheckPermissions( PermissionAction.AUTH_EXECUTE, 'create_user' ) diff --git a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx index f2b630c3698..16ab8f410ff 100644 --- a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx @@ -7,7 +7,7 @@ import * as z from 'zod' import { useParams } from 'common' import { useUserCreateMutation } from 'data/auth/user-create-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, Checkbox_Shadcn_, @@ -38,7 +38,7 @@ const CreateUserFormSchema = z.object({ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { const { ref: projectRef } = useParams() - const { can: canCreateUsers } = useAsyncCheckProjectPermissions( + const { can: canCreateUsers } = useAsyncCheckPermissions( PermissionAction.AUTH_EXECUTE, 'create_user' ) diff --git a/apps/studio/components/interfaces/Auth/Users/InviteUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/InviteUserModal.tsx index d2565745205..0fca24fe1d6 100644 --- a/apps/studio/components/interfaces/Auth/Users/InviteUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/InviteUserModal.tsx @@ -4,7 +4,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import { useUserInviteMutation } from 'data/auth/user-invite-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, Form, Input, Modal } from 'ui' export type InviteUserModalProps = { @@ -22,7 +22,7 @@ const InviteUserModal = ({ visible, setVisible }: InviteUserModalProps) => { setVisible(false) }, }) - const { can: canInviteUsers } = useAsyncCheckProjectPermissions( + const { can: canInviteUsers } = useAsyncCheckPermissions( PermissionAction.AUTH_EXECUTE, 'invite_user' ) diff --git a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx index 7737da47b31..c0d67fe4193 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx @@ -15,7 +15,7 @@ import { useUserSendMagicLinkMutation } from 'data/auth/user-send-magic-link-mut import { useUserSendOTPMutation } from 'data/auth/user-send-otp-mutation' import { useUserUpdateMutation } from 'data/auth/user-update-mutation' import { User } from 'data/auth/users-infinite-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { BASE_PATH } from 'lib/constants' import { timeout } from 'lib/helpers' @@ -64,24 +64,21 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { } ) - const { can: canUpdateUser } = useAsyncCheckProjectPermissions(PermissionAction.AUTH_EXECUTE, '*') - const { can: canSendMagicLink } = useAsyncCheckProjectPermissions( + const { can: canUpdateUser } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, '*') + const { can: canSendMagicLink } = useAsyncCheckPermissions( PermissionAction.AUTH_EXECUTE, 'send_magic_link' ) - const { can: canSendRecovery } = useAsyncCheckProjectPermissions( + const { can: canSendRecovery } = useAsyncCheckPermissions( PermissionAction.AUTH_EXECUTE, 'send_recovery' ) - const { can: canSendOtp } = useAsyncCheckProjectPermissions( - PermissionAction.AUTH_EXECUTE, - 'send_otp' - ) - const { can: canRemoveUser } = useAsyncCheckProjectPermissions( + const { can: canSendOtp } = useAsyncCheckPermissions(PermissionAction.AUTH_EXECUTE, 'send_otp') + const { can: canRemoveUser } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_DELETE, 'auth.users' ) - const { can: canRemoveMFAFactors } = useAsyncCheckProjectPermissions( + const { can: canRemoveMFAFactors } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_DELETE, 'auth.mfa_factors' ) diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx index 31480f91c49..ca6c338b015 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx @@ -6,7 +6,7 @@ import { useParams } from 'common' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button } from 'ui' import CreditCard from './CreditCard' @@ -31,7 +31,7 @@ const CurrentPaymentMethod = () => { const defaultPaymentMethod = paymentMethods?.data.find((pm) => pm.is_default) - const { can: canReadPaymentMethods } = useAsyncCheckProjectPermissions( + const { can: canReadPaymentMethods } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, 'stripe.payment_methods' ) diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx index 95d7d1b1076..e75c4a0ed24 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/PaymentMethods.tsx @@ -19,7 +19,7 @@ import PartnerManagedResource from 'components/ui/PartnerManagedResource' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { MANAGED_BY } from 'lib/constants/infrastructure' import { getURL } from 'lib/helpers' @@ -44,9 +44,11 @@ const PaymentMethods = () => { isSuccess, } = useOrganizationPaymentMethodsQuery({ slug }) - const { can: canReadPaymentMethods, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.payment_methods') - const { can: canUpdatePaymentMethods } = useAsyncCheckProjectPermissions( + const { can: canReadPaymentMethods, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.payment_methods' + ) + const { can: canUpdatePaymentMethods } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.payment_methods' ) diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index 6ca4120b6c9..668a627bf0a 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -27,7 +27,7 @@ import { projectKeys } from 'data/projects/keys' import { DesiredInstanceSize, instanceSizeSpecs } from 'data/projects/new-project.constants' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' @@ -72,7 +72,7 @@ export const CreateBranchModal = () => { const gitlessBranching = useIsBranching2Enabled() const allowDataBranching = useFlag('allowDataBranching') - const { can: canCreateBranch } = useAsyncCheckProjectPermissions( + const { can: canCreateBranch } = useAsyncCheckPermissions( PermissionAction.CREATE, 'preview_branches' ) diff --git a/apps/studio/components/interfaces/BranchManagement/Overview.tsx b/apps/studio/components/interfaces/BranchManagement/Overview.tsx index 88257f88afd..887ada063ae 100644 --- a/apps/studio/components/interfaces/BranchManagement/Overview.tsx +++ b/apps/studio/components/interfaces/BranchManagement/Overview.tsx @@ -23,7 +23,7 @@ import { useBranchResetMutation } from 'data/branches/branch-reset-mutation' import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' import type { Branch } from 'data/branches/branches-query' import { branchKeys } from 'data/branches/keys' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, DropdownMenu, @@ -169,11 +169,11 @@ const PreviewBranchActions = ({ const queryClient = useQueryClient() const projectRef = branch.parent_project_ref ?? branch.project_ref - const { can: canDeleteBranches } = useAsyncCheckProjectPermissions( + const { can: canDeleteBranches } = useAsyncCheckPermissions( PermissionAction.DELETE, 'preview_branches' ) - const { can: canUpdateBranches } = useAsyncCheckProjectPermissions( + const { can: canUpdateBranches } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'preview_branches' ) @@ -387,7 +387,7 @@ const PreviewBranchActions = ({ // Actions for main (production) branch const MainBranchActions = ({ branch, repo }: { branch: Branch; repo: string }) => { const { ref: projectRef } = useParams() - const { can: canUpdateBranches } = useAsyncCheckProjectPermissions( + const { can: canUpdateBranches } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'preview_branches' ) diff --git a/apps/studio/components/interfaces/Connect/Connect.tsx b/apps/studio/components/interfaces/Connect/Connect.tsx index 7a816671e11..7a6f1e21333 100644 --- a/apps/studio/components/interfaces/Connect/Connect.tsx +++ b/apps/studio/components/interfaces/Connect/Connect.tsx @@ -9,7 +9,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import Panel from 'components/ui/Panel' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' @@ -99,7 +99,7 @@ export const Connect = () => { ) const { data: settings } = useProjectSettingsV2Query({ projectRef }, { enabled: showConnect }) - const { can: canReadAPIKeys } = useAsyncCheckProjectPermissions( + const { can: canReadAPIKeys } = useAsyncCheckPermissions( PermissionAction.READ, 'service_api_keys' ) diff --git a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx index dbe0a9ca5e4..85a69d5680e 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupItem.tsx @@ -6,7 +6,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { InlineLink } from 'components/ui/InlineLink' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import type { DatabaseBackup } from 'data/database/backups-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Badge, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { TimestampInfo } from 'ui-patterns' @@ -19,7 +19,7 @@ interface BackupItemProps { export const BackupItem = ({ index, isHealthy, backup, onSelectBackup }: BackupItemProps) => { const { ref: projectRef } = useParams() - const { can: canTriggerScheduledBackups } = useAsyncCheckProjectPermissions( + const { can: canTriggerScheduledBackups } = useAsyncCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_job.restore.prepare' ) diff --git a/apps/studio/components/interfaces/Database/Backups/PITR/PITRNotice.tsx b/apps/studio/components/interfaces/Database/Backups/PITR/PITRNotice.tsx index 33a4d11e0b8..05b8faf9946 100644 --- a/apps/studio/components/interfaces/Database/Backups/PITR/PITRNotice.tsx +++ b/apps/studio/components/interfaces/Database/Backups/PITR/PITRNotice.tsx @@ -6,7 +6,7 @@ import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FormPanel } from 'components/ui/Forms/FormPanel' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { getPITRRetentionDuration } from './PITR.utils' const PITRNotice = ({}) => { @@ -14,7 +14,7 @@ const PITRNotice = ({}) => { const { data: addonsResponse } = useProjectAddonsQuery({ projectRef }) const retentionPeriod = getPITRRetentionDuration(addonsResponse?.selected_addons ?? []) - const { can: canUpdateSubscription } = useAsyncCheckProjectPermissions( + const { can: canUpdateSubscription } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) diff --git a/apps/studio/components/interfaces/Database/Backups/PITR/PITRStatus.tsx b/apps/studio/components/interfaces/Database/Backups/PITR/PITRStatus.tsx index 0f774102693..763ede84c06 100644 --- a/apps/studio/components/interfaces/Database/Backups/PITR/PITRStatus.tsx +++ b/apps/studio/components/interfaces/Database/Backups/PITR/PITRStatus.tsx @@ -7,7 +7,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FormPanel } from 'components/ui/Forms/FormPanel' import { useBackupsQuery } from 'data/database/backups-query' import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { Timezone } from './PITR.types' import { TimezoneSelection } from './TimezoneSelection' @@ -41,7 +41,7 @@ const PITRStatus = ({ .tz(selectedTimezone?.utc[0]) .format('DD MMM YYYY, HH:mm:ss') - const { can: canTriggerPhysicalBackup } = useAsyncCheckProjectPermissions( + const { can: canTriggerPhysicalBackup } = useAsyncCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_job.walg.prepare_restore' ) diff --git a/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx b/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx index 45e3985ef83..3139f8286e7 100644 --- a/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/ExtensionRow.tsx @@ -7,7 +7,7 @@ import { toast } from 'sonner' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useDatabaseExtensionDisableMutation } from 'data/database-extensions/database-extension-disable-mutation' import { DatabaseExtension } from 'data/database-extensions/database-extensions-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { extensions } from 'shared-data' import { Button, Switch, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui' @@ -28,7 +28,7 @@ export const ExtensionRow = ({ extension }: ExtensionRowProps) => { const [isDisableModalOpen, setIsDisableModalOpen] = useState(false) const [showConfirmEnableModal, setShowConfirmEnableModal] = useState(false) - const { can: canUpdateExtensions } = useAsyncCheckProjectPermissions( + const { can: canUpdateExtensions } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions' ) diff --git a/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx b/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx index 3e170bcd7f0..0885380baa2 100644 --- a/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx @@ -9,7 +9,7 @@ import InformationBox from 'components/ui/InformationBox' import NoSearchResults from 'components/ui/NoSearchResults' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Card, @@ -51,8 +51,10 @@ export const Extensions = () => { (ext) => !isNull(ext.installed_version) ) - const { can: canUpdateExtensions, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions') + const { can: canUpdateExtensions, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'extensions' + ) useEffect(() => { if (filter !== undefined) setFilterString(filter as string) diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx index 680755b6c32..b2e7d008db2 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/router' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useDatabaseFunctionsQuery } from 'data/database-functions/database-functions-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { @@ -15,8 +15,8 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, - TableRow, TableCell, + TableRow, } from 'ui' interface FunctionListProps { @@ -51,7 +51,7 @@ const FunctionList = ({ (func) => func.name.toLocaleLowerCase() ) const projectRef = selectedProject?.ref - const { can: canUpdateFunctions } = useAsyncCheckProjectPermissions( + const { can: canUpdateFunctions } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'functions' ) diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index bc8f73b826a..82ca681b973 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -12,21 +12,20 @@ import SchemaSelector from 'components/ui/SchemaSelector' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useDatabaseFunctionsQuery } from 'data/database-functions/database-functions-query' import { useSchemasQuery } from 'data/database/schemas-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { AiIconAnimation, + Card, Input, Table, - TableHeader, - TableHead, TableBody, + TableHead, + TableHeader, TableRow, - TableCell, - Card, } from 'ui' import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning' import FunctionList from './FunctionList' @@ -60,7 +59,7 @@ const FunctionsList = ({ router.push(url) } - const { can: canCreateFunctions } = useAsyncCheckProjectPermissions( + const { can: canCreateFunctions } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'functions' ) diff --git a/apps/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx b/apps/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx index c259acb39d0..5ef24ae87f6 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx @@ -8,7 +8,7 @@ import { useParams } from 'common' import Table from 'components/to-be-cleaned/Table' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useDatabaseHooksQuery } from 'data/database-triggers/database-triggers-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { BASE_PATH } from 'lib/constants' import { @@ -50,7 +50,7 @@ export const HookList = ({ x.schema === schema && x.function_args.length >= 2 ) - const { can: canUpdateWebhook } = useAsyncCheckProjectPermissions( + const { can: canUpdateWebhook } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers' ) diff --git a/apps/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx b/apps/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx index a53ac137264..7e43bf126ac 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx @@ -10,7 +10,7 @@ import { DocsButton } from 'components/ui/DocsButton' import NoSearchResults from 'components/ui/NoSearchResults' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useDatabaseHooksQuery } from 'data/database-triggers/database-triggers-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { noop } from 'lib/void' import { Input } from 'ui' @@ -46,8 +46,10 @@ export const HooksList = ({ ) const filteredHookSchemas = lodashMap(uniqBy(filteredHooks, 'schema'), 'schema') - const { can: canCreateWebhooks, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') + const { can: canCreateWebhooks, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'triggers' + ) return (
diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx index deb947dd863..6bb04e304d1 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx @@ -10,7 +10,7 @@ import InformationBox from 'components/ui/InformationBox' import NoSearchResults from 'components/ui/NoSearchResults' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, @@ -57,8 +57,10 @@ export const PublicationsList = () => { }, }) - const { can: canUpdatePublications, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'publications') + const { can: canUpdatePublications, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'publications' + ) const publicationEvents: PublicationEvent[] = [ { event: 'Insert', key: 'publish_insert' }, diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx index b7a8425e002..c5aec787a99 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { toast } from 'sonner' import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import { Badge, Switch, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui' @@ -28,7 +28,7 @@ export const PublicationsTableItem = ({ selectedPublication.tables?.find((x: any) => x.id == table.id) != undefined ) - const { can: canUpdatePublications } = useAsyncCheckProjectPermissions( + const { can: canUpdatePublications } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'publications' ) diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index fe55650d79a..48931a50560 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -10,7 +10,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { Loading } from 'components/ui/Loading' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' import { useTablesQuery } from 'data/tables/tables-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' import { Admonition } from 'ui-patterns' @@ -22,8 +22,10 @@ export const PublicationsTables = () => { const { data: project } = useSelectedProjectQuery() const [filterString, setFilterString] = useState('') - const { can: canUpdatePublications, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'publications') + const { can: canUpdatePublications, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'publications' + ) const { data: publications = [] } = useDatabasePublicationsQuery({ projectRef: project?.ref, diff --git a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx index 1385c807f34..f18ec8b9064 100644 --- a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx +++ b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx @@ -8,7 +8,7 @@ import NoSearchResults from 'components/ui/NoSearchResults' import SparkBar from 'components/ui/SparkBar' import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' import { useMaxConnectionsQuery } from 'data/database/max-connections-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Badge, Button, Input, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { CreateRolePanel } from './CreateRolePanel' @@ -27,7 +27,7 @@ const RolesList = () => { const [isCreatingRole, setIsCreatingRole] = useState(false) const [selectedRoleToDelete, setSelectedRoleToDelete] = useState() - const { can: canUpdateRoles } = useAsyncCheckProjectPermissions( + const { can: canUpdateRoles } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'roles' ) diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index 83aaf0cacef..a4e9c23bca2 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -13,7 +13,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { @@ -65,7 +65,7 @@ export const ColumnList = ({ : selectedTable?.columns?.filter((column) => column.name.includes(filterString))) ?? [] const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedTable?.schema ?? '' }) - const { can: canUpdateColumns } = useAsyncCheckProjectPermissions( + const { can: canUpdateColumns } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns' ) diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 5d08596a521..a54046da943 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -32,7 +32,7 @@ import { useMaterializedViewsQuery } from 'data/materialized-views/materialized- import { usePrefetchEditorTablePage } from 'data/prefetchers/project.$ref.editor.$id' import { useTablesQuery } from 'data/tables/tables-query' import { useViewsQuery } from 'data/views/views-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' @@ -50,15 +50,15 @@ import { PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, Tooltip, TooltipContent, TooltipTrigger, - Table, - TableHeader, - TableHead, - TableBody, - TableRow, - TableCell, cn, } from 'ui' import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' @@ -88,7 +88,7 @@ export const TableList = ({ const [filterString, setFilterString] = useState('') const [visibleTypes, setVisibleTypes] = useState(Object.values(ENTITY_TYPE)) - const { can: canUpdateTables } = useAsyncCheckProjectPermissions( + const { can: canUpdateTables } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables' ) diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx index f78e526252b..bb2fede7e02 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx @@ -2,10 +2,9 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { includes, sortBy } from 'lodash' import { Check, Edit, Edit2, MoreVertical, Trash, X } from 'lucide-react' -import Table from 'components/to-be-cleaned/Table' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { @@ -15,11 +14,11 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + TableCell, + TableRow, Tooltip, TooltipContent, TooltipTrigger, - TableRow, - TableCell, } from 'ui' import { generateTriggerCreateSQL } from './TriggerList.utils' @@ -53,7 +52,7 @@ const TriggerList = ({ filteredTriggers.filter((x) => x.schema == schema), (trigger) => trigger.name.toLocaleLowerCase() ) - const { can: canUpdateTriggers } = useAsyncCheckProjectPermissions( + const { can: canUpdateTriggers } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers' ) diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx index a9097f7190d..3d6f2287f0a 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx @@ -12,21 +12,20 @@ import SchemaSelector from 'components/ui/SchemaSelector' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query' import { useTablesQuery } from 'data/tables/tables-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema, useProtectedSchemas } from 'hooks/useProtectedSchemas' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { AiIconAnimation, - Input, Card, + Input, Table, - TableHeader, - TableHead, TableBody, + TableHead, + TableHeader, TableRow, - TableCell, } from 'ui' import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning' import TriggerList from './TriggerList' @@ -67,7 +66,7 @@ const TriggersList = ({ connectionString: project?.connectionString, }) - const { can: canCreateTriggers } = useAsyncCheckProjectPermissions( + const { can: canCreateTriggers } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers' ) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index 8cb6d6c6ee0..cf0757aee79 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -25,7 +25,7 @@ import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon- import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { AddonVariantId } from 'data/subscriptions/types' import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useIsAwsCloudProvider, @@ -80,7 +80,7 @@ export function DiskManagementForm() { const isAwsK8s = useIsAwsK8sCloudProvider() const { can: canUpdateDiskConfiguration, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.UPDATE, 'projects', { + useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', { resource: { project_id: project?.id, }, diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog.tsx index 89512705ff5..ec8d3ef146d 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog.tsx @@ -5,7 +5,7 @@ import { UseFormReturn } from 'react-hook-form' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { formatCurrency } from 'lib/helpers' @@ -153,7 +153,7 @@ export const DiskManagementReviewAndSubmitDialog = ({ const { formState, getValues } = form - const { can: canUpdateDiskConfiguration } = useAsyncCheckProjectPermissions( + const { can: canUpdateDiskConfiguration } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', { diff --git a/apps/studio/components/interfaces/Docs/Description.tsx b/apps/studio/components/interfaces/Docs/Description.tsx index b2e6176400f..06dad2b652a 100644 --- a/apps/studio/components/interfaces/Docs/Description.tsx +++ b/apps/studio/components/interfaces/Docs/Description.tsx @@ -5,7 +5,7 @@ import { toast } from 'sonner' import AutoTextArea from 'components/to-be-cleaned/forms/AutoTextArea' import { executeSql } from 'data/sql/execute-sql-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { timeout } from 'lib/helpers' import { Loader } from 'lucide-react' @@ -42,7 +42,7 @@ const Description = ({ content, metadata, onChange = noop }: DescrptionProps) => const hasChanged = value != contentText const animateCss = `transition duration-150` - const { can: canUpdateDescription } = useAsyncCheckProjectPermissions( + const { can: canUpdateDescription } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_QUERY, '*' ) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index efde692501c..d4b163cfee4 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -18,7 +18,7 @@ import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' import { useEdgeFunctionDeleteMutation } from 'data/edge-functions/edge-functions-delete-mutation' import { useEdgeFunctionUpdateMutation } from 'data/edge-functions/edge-functions-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Alert_Shadcn_, AlertDescription_Shadcn_, @@ -59,7 +59,7 @@ export const EdgeFunctionDetails = () => { const router = useRouter() const { ref: projectRef, functionSlug } = useParams() const [showDeleteModal, setShowDeleteModal] = useState(false) - const { can: canUpdateEdgeFunction } = useAsyncCheckProjectPermissions( + const { can: canUpdateEdgeFunction } = useAsyncCheckPermissions( PermissionAction.FUNCTIONS_WRITE, '*' ) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx index 16edc741a3f..9e9aaf20ecc 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx @@ -3,7 +3,7 @@ import { Trash } from 'lucide-react' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { ProjectSecret } from 'data/secrets/secrets-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { TableCell, TableRow } from 'ui' import { TimestampInfo } from 'ui-patterns' @@ -13,10 +13,7 @@ interface EdgeFunctionSecretProps { } const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps) => { - const { can: canUpdateSecrets } = useAsyncCheckProjectPermissions( - PermissionAction.SECRETS_WRITE, - '*' - ) + const { can: canUpdateSecrets } = useAsyncCheckPermissions(PermissionAction.SECRETS_WRITE, '*') // [Joshen] Following API's validation: // https://github.com/supabase/infrastructure/blob/develop/api/src/routes/v1/projects/ref/secrets/secrets.controller.ts#L106 const isReservedSecret = !!secret.name.match(/^(SUPABASE_).*/) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx index 00d859249f9..01938c432b6 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx @@ -9,7 +9,7 @@ import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useSecretsDeleteMutation } from 'data/secrets/secrets-delete-mutation' import { ProjectSecret, useSecretsQuery } from 'data/secrets/secrets-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Badge, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -21,14 +21,11 @@ const EdgeFunctionSecrets = () => { const [searchString, setSearchString] = useState('') const [selectedSecret, setSelectedSecret] = useState() - const { can: canReadSecrets, isLoading: isLoadingPermissions } = useAsyncCheckProjectPermissions( + const { can: canReadSecrets, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.SECRETS_READ, '*' ) - const { can: canUpdateSecrets } = useAsyncCheckProjectPermissions( - PermissionAction.SECRETS_WRITE, - '*' - ) + const { can: canUpdateSecrets } = useAsyncCheckPermissions(PermissionAction.SECRETS_WRITE, '*') const { data, error, isLoading, isSuccess, isError } = useSecretsQuery({ projectRef: projectRef, diff --git a/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx b/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx index c7351a4db71..7d64ac69c16 100644 --- a/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx +++ b/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx @@ -36,7 +36,7 @@ import { AlertTriangle, XIcon } from 'lucide-react' import { MouseEventHandler, useCallback, useEffect, useState } from 'react' import { LOCAL_STORAGE_KEYS } from 'common' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, cn } from 'ui' import { RoleImpersonationSelector } from '../RoleImpersonationSelector' @@ -76,7 +76,7 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => { const merge = useMergeQuery() const prettify = usePrettifyEditors() - const { can: canReadJWTSecret } = useAsyncCheckProjectPermissions( + const { can: canReadJWTSecret } = useAsyncCheckPermissions( PermissionAction.READ, 'field.jwt_secret' ) diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx index 438cbd5fd70..60298fafa9d 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx @@ -9,7 +9,7 @@ import Panel from 'components/ui/Panel' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { Input, SimpleCodeBlock } from 'ui' @@ -77,7 +77,7 @@ export const APIKeys = () => { const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus - const { can: canReadAPIKeys } = useAsyncCheckProjectPermissions( + const { can: canReadAPIKeys } = useAsyncCheckPermissions( PermissionAction.READ, 'service_api_keys' ) diff --git a/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx b/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx index 19f479ada64..00e487c2052 100644 --- a/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx @@ -12,6 +12,7 @@ import dayjs from 'dayjs' import { Plus } from 'lucide-react' import type { CSSProperties, ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' import { useParams } from 'common' import { SnippetDropdown } from 'components/interfaces/HomeNew/SnippetDropdown' @@ -22,13 +23,12 @@ import { AnalyticsInterval } from 'data/analytics/constants' import { useContentInfiniteQuery } from 'data/content/content-infinite-query' import { Content } from 'data/content/content-query' import { useContentUpsertMutation } from 'data/content/content-upsert-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' import type { Dashboards } from 'types' import { Button } from 'ui' import { Row } from 'ui-patterns' -import { toast } from 'sonner' export function CustomReportSection() { const startDate = dayjs().subtract(7, 'day').toISOString() @@ -50,7 +50,7 @@ export function CustomReportSection() { if (reportContent) setEditableReport(reportContent) }, [reportContent]) - const { can: canUpdateReport } = useAsyncCheckProjectPermissions( + const { can: canUpdateReport } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'user_content', { @@ -63,7 +63,7 @@ export function CustomReportSection() { } ) - const { can: canCreateReport } = useAsyncCheckProjectPermissions( + const { can: canCreateReport } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { resource: { type: 'report', owner_id: profile?.id }, subject: { id: profile?.id } } diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx index 1d3072b5a90..e7a42f67760 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx @@ -17,7 +17,7 @@ import { useDatabaseCronJobCreateMutation } from 'data/database-cron-jobs/databa import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-query' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { @@ -223,7 +223,7 @@ export const CreateCronJobSheet = ({ const { mutate: upsertCronJob, isLoading: isUpserting } = useDatabaseCronJobCreateMutation() const isLoading = isLoadingGetCronJob || isUpserting - const { can: canToggleExtensions } = useAsyncCheckProjectPermissions( + const { can: canToggleExtensions } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'extensions' ) diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx index 2b5b6bd0792..eefac54699b 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx @@ -21,7 +21,7 @@ import { } from 'data/database-queues/database-queues-toggle-postgrest-mutation' import { useTableUpdateMutation } from 'data/tables/table-update-mutation' import { useTablesQuery } from 'data/tables/tables-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, @@ -39,7 +39,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' export const QueuesSettings = () => { const { data: project } = useSelectedProjectQuery() - const { can: canUpdatePostgrestConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdatePostgrestConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_postgrest' ) diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx index d15afd50960..b0b738b858e 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx @@ -12,7 +12,7 @@ import { import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Edit3, Eye, EyeOff, Key, Loader, MoreVertical, Trash } from 'lucide-react' import type { VaultSecret } from 'types' @@ -33,7 +33,7 @@ const SecretRow = ({ row, col, onSelectRemove }: SecretRowProps) => { const [revealSecret, setRevealSecret] = useState(false) const name = row?.name ?? 'No name provided' - const { can: canManageSecrets } = useAsyncCheckProjectPermissions( + const { can: canManageSecrets } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables' ) diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx index 05bd8a8f726..7a0575c6da2 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx @@ -1,26 +1,26 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import DataGrid, { Row } from 'react-data-grid' import { sortBy } from 'lodash' -import { Loader, RefreshCw, Search, X } from 'lucide-react' +import { RefreshCw, Search, X } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' +import DataGrid, { Row } from 'react-data-grid' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DocsButton } from 'components/ui/DocsButton' import { useVaultSecretsQuery } from 'data/vault/vault-secrets-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import type { VaultSecret } from 'types' import { Button, + cn, Input, LoadingLine, - cn, Select_Shadcn_, - SelectTrigger_Shadcn_, - SelectValue_Shadcn_, SelectContent_Shadcn_, SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, } from 'ui' import AddNewSecretModal from './AddNewSecretModal' import DeleteSecretModal from './DeleteSecretModal' @@ -35,7 +35,7 @@ export const SecretsManagement = () => { const [selectedSecretToRemove, setSelectedSecretToRemove] = useState() const [selectedSort, setSelectedSort] = useState<'updated_at' | 'name'>('updated_at') - const { can: canManageSecrets } = useAsyncCheckProjectPermissions( + const { can: canManageSecrets } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables' ) diff --git a/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx b/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx index 2e87e67a209..f5dce7cde94 100644 --- a/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx @@ -6,14 +6,14 @@ import DeleteHookModal from 'components/interfaces/Database/Hooks/DeleteHookModa import { EditHookPanel } from 'components/interfaces/Database/Hooks/EditHookPanel' import { HooksList } from 'components/interfaces/Database/Hooks/HooksList/HooksList' import NoPermission from 'components/ui/NoPermission' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' export const WebhooksListTab = () => { const [selectedHook, setSelectedHook] = useState() const [showCreateHookForm, setShowCreateHookForm] = useState(false) const [showDeleteHookForm, setShowDeleteHookForm] = useState(false) - const { can: canReadWebhooks, isSuccess: isPermissionsLoaded } = useAsyncCheckProjectPermissions( + const { can: canReadWebhooks, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers' ) diff --git a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx index a1efc6d7310..883823083cc 100644 --- a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx @@ -7,7 +7,7 @@ import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useHooksEnableMutation } from 'data/database/hooks-enable-mutation' import { useSchemasQuery } from 'data/database/schemas-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Admonition } from 'ui-patterns' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' @@ -26,7 +26,7 @@ export const WebhooksOverviewTab = () => { }) const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions') - const { can: canReadWebhooks, isLoading: isLoadingPermissions } = useAsyncCheckProjectPermissions( + const { can: canReadWebhooks, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers' ) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx index dd923c8c728..6d422b8dfe8 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx @@ -5,7 +5,7 @@ import { useState } from 'react' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Alert_Shadcn_, @@ -28,7 +28,7 @@ export const WrapperOverviewTab = () => { const [createWrapperShown, setCreateWrapperShown] = useState(false) const [isClosingCreateWrapper, setisClosingCreateWrapper] = useState(false) - const { can: canCreateWrapper } = useAsyncCheckProjectPermissions( + const { can: canCreateWrapper } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers' ) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx index f64438c7d7d..5f830a4acf0 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx @@ -7,7 +7,7 @@ import { useState } from 'react' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { FDW } from 'data/fdw/fdws-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Badge, Sheet, @@ -29,7 +29,7 @@ interface WrapperRowProps { const WrapperRow = ({ wrapper }: WrapperRowProps) => { const { ref, id } = useParams() - const { can: canManageWrappers } = useAsyncCheckProjectPermissions( + const { can: canManageWrappers } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers' ) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx index f9669f4a8ab..3c73661275c 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx @@ -4,7 +4,7 @@ import { HTMLProps, ReactNode, useCallback, useState } from 'react' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FDW, useFDWsQuery } from 'data/fdw/fdws-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Sheet, SheetContent } from 'ui' import { CreateWrapperSheet } from './CreateWrapperSheet' @@ -20,7 +20,7 @@ export const WrappersTab = () => { const [createWrapperShown, setCreateWrapperShown] = useState(false) const [isClosingCreateWrapper, setisClosingCreateWrapper] = useState(false) - const { can: canCreateWrapper } = useAsyncCheckProjectPermissions( + const { can: canCreateWrapper } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers' ) diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx index 0f5a4b34ae0..28458b0b704 100644 --- a/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx @@ -16,7 +16,7 @@ import { useJwtSecretUpdateMutation } from 'data/config/jwt-secret-update-mutati import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' import { useLegacyJWTSigningKeyQuery } from 'data/jwt-signing-keys/legacy-jwt-signing-key-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { uuidv4 } from 'lib/helpers' import { AlertCircle, @@ -73,15 +73,15 @@ const JWTSettings = () => { const [isCreatingKey, setIsCreatingKey] = useState(false) const [isRegeneratingKey, setIsGeneratingKey] = useState(false) - const { can: canReadJWTSecret } = useAsyncCheckProjectPermissions( + const { can: canReadJWTSecret } = useAsyncCheckPermissions( PermissionAction.READ, 'field.jwt_secret' ) - const { can: canGenerateNewJWTSecret } = useAsyncCheckProjectPermissions( + const { can: canGenerateNewJWTSecret } = useAsyncCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_job.projects.update_jwt' ) - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' ) diff --git a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx index f6e1fa8bc17..e4b49eecc6e 100644 --- a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx +++ b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx @@ -23,7 +23,7 @@ import { import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectsQuery } from 'data/projects/projects-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, @@ -54,8 +54,10 @@ export const AuditLogs = () => { projects: [], // project_ref[] }) - const { can: canReadAuditLogs, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'notifications') + const { can: canReadAuditLogs, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.READ, + 'notifications' + ) const { data: projectsData } = useProjectsQuery() const projects = projectsData?.projects ?? [] diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx index 0195c78d2e9..9bbcfec1b4c 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx @@ -13,19 +13,21 @@ import NoPermission from 'components/ui/NoPermission' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import SparkBar from 'components/ui/SparkBar' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import UpcomingInvoice from './UpcomingInvoice' -import { MANAGED_BY } from 'lib/constants/infrastructure' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { MANAGED_BY } from 'lib/constants/infrastructure' +import UpcomingInvoice from './UpcomingInvoice' const BillingBreakdown = () => { const { slug: orgSlug } = useParams() const { data: selectedOrganization } = useSelectedOrganizationQuery() - const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = - useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions') + const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = useAsyncCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.subscriptions' + ) const { data: subscription, diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx index 073177cae61..3d18d05d427 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx @@ -16,7 +16,7 @@ import { useOrganizationCustomerProfileQuery } from 'data/organizations/organiza import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation' import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query' import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Button, Card, CardFooter, Form_Shadcn_ as Form } from 'ui' import { @@ -31,8 +31,8 @@ export const BillingCustomerData = () => { const { data: selectedOrganization } = useSelectedOrganizationQuery() const { can: canReadBillingCustomerData, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.customer') - const { can: canUpdateBillingCustomerData } = useAsyncCheckProjectPermissions( + useAsyncCheckPermissions(PermissionAction.BILLING_READ, 'stripe.customer') + const { can: canUpdateBillingCustomerData } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.customer' ) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx index 2864351ff57..a1c57c8eee7 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx @@ -18,7 +18,7 @@ import { FormSection, FormSectionContent } from 'components/ui/Forms/FormSection import NoPermission from 'components/ui/NoPermission' import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { FormMessage_Shadcn_, Input_Shadcn_ } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -42,9 +42,11 @@ const BillingEmail = () => { const { name, billing_email } = selectedOrganization ?? {} - const { can: canReadBillingEmail, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions') - const { can: canUpdateOrganization } = useAsyncCheckProjectPermissions( + const { can: canReadBillingEmail, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.subscriptions' + ) + const { can: canUpdateOrganization } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'organizations' ) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx index c021252d87f..652d7960555 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -15,7 +15,7 @@ import NoPermission from 'components/ui/NoPermission' import { PARTNER_TO_NAME } from 'components/ui/PartnerManagedResource' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH } from 'lib/constants' import { MANAGED_BY } from 'lib/constants/infrastructure' @@ -33,8 +33,10 @@ const CostControl = ({}: CostControlProps) => { const { resolvedTheme } = useTheme() const { data: selectedOrganization } = useSelectedOrganizationQuery() - const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = - useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions') + const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = useAsyncCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.subscriptions' + ) const snap = useOrgSettingsPageStateSnapshot() const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx index 63588dedf5f..367a5480ec6 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx @@ -9,7 +9,7 @@ import { useParams } from 'common' import Table from 'components/to-be-cleaned/Table' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH, PRICING_TIER_PRODUCT_IDS } from 'lib/constants' import { ChevronRight, ExternalLink } from 'lucide-react' import { pricing } from 'shared-data/pricing' @@ -43,7 +43,7 @@ const SpendCapSidePanel = () => { const [showUsageCosts, setShowUsageCosts] = useState(false) const [selectedOption, setSelectedOption] = useState<'on' | 'off'>() - const { can: canUpdateSpendCap } = useAsyncCheckProjectPermissions( + const { can: canUpdateSpendCap } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx index cfb8a013dff..bfc48ccab37 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx @@ -11,14 +11,16 @@ import { FormPanel } from 'components/ui/Forms/FormPanel' import { FormSection, FormSectionContent } from 'components/ui/Forms/FormSection' import NoPermission from 'components/ui/NoPermission' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { CreditTopUp } from './CreditTopUp' const CreditBalance = () => { const { slug } = useParams() - const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = - useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions') + const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = useAsyncCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.subscriptions' + ) const { data: subscription, diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index 094f62f63aa..75a58613e08 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -17,7 +17,7 @@ import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/Payme import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useOrganizationCreditTopUpMutation } from 'data/organizations/organization-credit-top-up-mutation' import { subscriptionKeys } from 'data/subscriptions/keys' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { STRIPE_PUBLIC_KEY } from 'lib/constants' import { Alert_Shadcn_, @@ -63,7 +63,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { createPaymentMethod: PaymentMethodElementRef['createPaymentMethod'] }>(null) - const { can: canTopUpCredits, isSuccess: isPermissionsLoaded } = useAsyncCheckProjectPermissions( + const { can: canTopUpCredits, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index 002dd26dbe0..5d1f473e0b1 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -18,7 +18,7 @@ import { useOrgPlansQuery } from 'data/subscriptions/org-plans-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import type { OrgPlan } from 'data/subscriptions/types' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { MANAGED_BY } from 'lib/constants/infrastructure' import { formatCurrency } from 'lib/helpers' @@ -60,7 +60,7 @@ const PlanUpdateSidePanel = () => { const [showDowngradeError, setShowDowngradeError] = useState(false) const [selectedTier, setSelectedTier] = useState<'tier_free' | 'tier_pro' | 'tier_team'>() - const { can: canUpdateSubscription } = useAsyncCheckProjectPermissions( + const { can: canUpdateSubscription } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index ae9369046dc..ca0a81d48bb 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -11,7 +11,7 @@ import AlertError from 'components/ui/AlertError' import NoPermission from 'components/ui/NoPermission' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' import { Alert, Button } from 'ui' import { Admonition } from 'ui-patterns' @@ -24,8 +24,10 @@ const Subscription = () => { const snap = useOrgSettingsPageStateSnapshot() const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') - const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = - useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions') + const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = useAsyncCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.subscriptions' + ) const { data: subscription, diff --git a/apps/studio/components/interfaces/Organization/Documents/SOC2.tsx b/apps/studio/components/interfaces/Organization/Documents/SOC2.tsx index f08ed28a53e..86de90ef1f0 100644 --- a/apps/studio/components/interfaces/Organization/Documents/SOC2.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/SOC2.tsx @@ -12,7 +12,7 @@ import { import NoPermission from 'components/ui/NoPermission' import { getDocument } from 'data/documents/document-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Button } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -23,8 +23,10 @@ export const SOC2 = () => { const slug = organization?.slug const { mutate: sendEvent } = useSendEventMutation() - const { can: canReadSubscriptions, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions') + const { can: canReadSubscriptions, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.subscriptions' + ) const currentPlan = organization?.plan diff --git a/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx b/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx index 2e165448d95..e91c8735f4c 100644 --- a/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx @@ -11,7 +11,7 @@ import { import NoPermission from 'components/ui/NoPermission' import { getDocument } from 'data/documents/document-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Button } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' @@ -21,8 +21,10 @@ export const SecurityQuestionnaire = () => { const slug = organization?.slug const { mutate: sendEvent } = useSendEventMutation() - const { can: canReadSubscriptions, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions') + const { can: canReadSubscriptions, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.subscriptions' + ) const currentPlan = organization?.plan diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx index b90faee2011..fad4e87624b 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx @@ -3,13 +3,13 @@ import { useEffect } from 'react' import { FormActions } from 'components/ui/Forms/FormActions' import { useAIOptInForm } from 'hooks/forms/useAIOptInForm' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Card, CardContent, CardFooter, Form_Shadcn_ } from 'ui' import { AIOptInLevelSelector } from './AIOptInLevelSelector' export const DataPrivacyForm = () => { const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm() - const { can: canUpdateOrganization } = useAsyncCheckProjectPermissions( + const { can: canUpdateOrganization } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'organizations' ) diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx index 5fd8de703b8..238f25c5829 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx @@ -6,7 +6,7 @@ import { toast } from 'sonner' import { LOCAL_STORAGE_KEYS } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useOrganizationDeleteMutation } from 'data/organizations/organization-delete-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Button, Form, Input, Modal } from 'ui' @@ -24,7 +24,7 @@ export const DeleteOrganizationButton = () => { '' ) - const { can: canDeleteOrganization } = useAsyncCheckProjectPermissions( + const { can: canDeleteOrganization } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'organizations' ) diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx index 797f791e92e..95f3ebaadda 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx @@ -11,7 +11,7 @@ import CopyButton from 'components/ui/CopyButton' import { FormActions } from 'components/ui/Forms/FormActions' import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import type { ResponseError } from 'types' import { @@ -35,7 +35,7 @@ export const OrganizationDetailsForm = () => { const queryClient = useQueryClient() const { data: selectedOrganization } = useSelectedOrganizationQuery() - const { can: canUpdateOrganization } = useAsyncCheckProjectPermissions( + const { can: canUpdateOrganization } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'organizations' ) diff --git a/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx b/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx index 453b0ed4f31..5ccf2a0111a 100644 --- a/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx +++ b/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx @@ -19,7 +19,7 @@ import { useGitHubAuthorizationQuery } from 'data/integrations/github-authorizat import { useGitHubConnectionDeleteMutation } from 'data/integrations/github-connection-delete-mutation' import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' import type { IntegrationProjectConnection } from 'data/integrations/integrations.types' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH } from 'lib/constants' @@ -50,12 +50,12 @@ const IntegrationSettings = () => { const showVercelIntegration = useIsFeatureEnabled('integrations:vercel') const { can: canReadGithubConnection, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'integrations.github_connections') - const { can: canCreateGitHubConnection } = useAsyncCheckProjectPermissions( + useAsyncCheckPermissions(PermissionAction.READ, 'integrations.github_connections') + const { can: canCreateGitHubConnection } = useAsyncCheckPermissions( PermissionAction.CREATE, 'integrations.github_connections' ) - const { can: canUpdateGitHubConnection } = useAsyncCheckProjectPermissions( + const { can: canUpdateGitHubConnection } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'integrations.github_connections' ) diff --git a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSection.tsx b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSection.tsx index 673df43cff6..eea5037c283 100644 --- a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSection.tsx +++ b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSection.tsx @@ -5,11 +5,11 @@ import { ScaffoldSectionDetail, } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import InvoicesSettings from './InvoicesSettings' const InvoicesSection = () => { - const { isSuccess: isPermissionsLoaded, can: canReadInvoices } = useAsyncCheckProjectPermissions( + const { isSuccess: isPermissionsLoaded, can: canReadInvoices } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, 'stripe.subscriptions' ) diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthAppRow.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthAppRow.tsx index 08a9259f063..83d34da16d6 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthAppRow.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthAppRow.tsx @@ -4,7 +4,7 @@ import { Edit, MoreVertical, Trash } from 'lucide-react' import Table from 'components/to-be-cleaned/Table' import CopyButton from 'components/ui/CopyButton' import type { OAuthApp } from 'data/oauth/oauth-apps-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, DropdownMenu, @@ -25,11 +25,11 @@ export interface OAuthAppRowProps { } export const OAuthAppRow = ({ app, onSelectEdit, onSelectDelete }: OAuthAppRowProps) => { - const { can: canUpdateOAuthApps } = useAsyncCheckProjectPermissions( + const { can: canUpdateOAuthApps } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'approved_oauth_apps' ) - const { can: canDeleteOAuthApps } = useAsyncCheckProjectPermissions( + const { can: canDeleteOAuthApps } = useAsyncCheckPermissions( PermissionAction.DELETE, 'approved_oauth_apps' ) diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx index ddeff5064ec..8d303c8c2f9 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx @@ -13,7 +13,7 @@ import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { AuthorizedApp, useAuthorizedAppsQuery } from 'data/oauth/authorized-apps-query' import { OAuthAppCreateResponse } from 'data/oauth/oauth-app-create-mutation' import { OAuthApp, useOAuthAppsQuery } from 'data/oauth/oauth-apps-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, cn } from 'ui' import { AuthorizedAppRow } from './AuthorizedAppRow' import { DeleteAppModal } from './DeleteAppModal' @@ -34,9 +34,11 @@ export const OAuthApps = () => { const [selectedAppToDelete, setSelectedAppToDelete] = useState() const [selectedAppToRevoke, setSelectedAppToRevoke] = useState() - const { can: canReadOAuthApps, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'approved_oauth_apps') - const { can: canCreateOAuthApps } = useAsyncCheckProjectPermissions( + const { can: canReadOAuthApps, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.READ, + 'approved_oauth_apps' + ) + const { can: canCreateOAuthApps } = useAsyncCheckPermissions( PermissionAction.CREATE, 'approved_oauth_apps' ) diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthSecrets/OAuthSecrets.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthSecrets/OAuthSecrets.tsx index 84e003a9e83..5cc983beeb9 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthSecrets/OAuthSecrets.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthSecrets/OAuthSecrets.tsx @@ -7,7 +7,7 @@ import { InlineLink } from 'components/ui/InlineLink' import { useClientSecretCreateMutation } from 'data/oauth-secrets/client-secret-create-mutation' import { CreatedSecret, useClientSecretsQuery } from 'data/oauth-secrets/client-secrets-query' import { OAuthApp } from 'data/oauth/oauth-apps-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Alert_Shadcn_, AlertTitle_Shadcn_, InfoIcon } from 'ui' import { SecretRow } from './SecretRow' @@ -18,10 +18,7 @@ interface Props { export const OAuthSecrets = ({ selectedApp }: Props) => { const { slug } = useParams() const [createdSecret, setCreatedSecret] = useState() - const { can: canManageSecrets } = useAsyncCheckProjectPermissions( - PermissionAction.UPDATE, - 'oauth_apps' - ) + const { can: canManageSecrets } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'oauth_apps') const { id: appId } = selectedApp ?? {} diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthSecrets/SecretRow.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthSecrets/SecretRow.tsx index 92e3e3a55aa..1b080b23027 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthSecrets/SecretRow.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthSecrets/SecretRow.tsx @@ -10,7 +10,7 @@ import CopyButton from 'components/ui/CopyButton' import { useClientSecretDeleteMutation } from 'data/oauth-secrets/client-secret-delete-mutation' import { Secret, useClientSecretsQuery } from 'data/oauth-secrets/client-secrets-query' import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { cn } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -22,10 +22,7 @@ export interface SecretRowProps { export const SecretRow = ({ secret, appId }: SecretRowProps) => { const { slug } = useParams() const [showDeleteModal, setShowDeleteModal] = useState(false) - const { can: canManageSecrets } = useAsyncCheckProjectPermissions( - PermissionAction.UPDATE, - 'oauth_apps' - ) + const { can: canManageSecrets } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'oauth_apps') const { data } = useClientSecretsQuery({ slug, appId }) const secrets = data?.client_secrets ?? [] diff --git a/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx b/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx index 1dd91caccb0..4254c054a91 100644 --- a/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx +++ b/apps/studio/components/interfaces/Organization/SecuritySettings/SecuritySettings.tsx @@ -16,7 +16,7 @@ import { useOrganizationMembersQuery } from 'data/organizations/organization-mem import { useOrganizationMfaToggleMutation } from 'data/organizations/organization-mfa-mutation' import { useOrganizationMfaQuery } from 'data/organizations/organization-mfa-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useProfile } from 'lib/profile' import { @@ -48,9 +48,11 @@ export const SecuritySettings = () => { const { data: selectedOrganization } = useSelectedOrganizationQuery() const { data: members } = useOrganizationMembersQuery({ slug }) - const { can: canReadMfaConfig, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'organizations') - const { can: canUpdateMfaConfig } = useAsyncCheckProjectPermissions( + const { can: canReadMfaConfig, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.READ, + 'organizations' + ) + const { can: canUpdateMfaConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'organizations' ) diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx index 24b1a8a9fed..2a2cd9f52dc 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx @@ -16,7 +16,7 @@ import { } from 'data/organizations/organization-members-query' import { usePermissionsQuery } from 'data/permissions/permissions-query' import { useProjectsQuery } from 'data/projects/projects-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useProfile } from 'lib/profile' @@ -69,14 +69,14 @@ export const MemberActions = ({ member }: MemberActionsProps) => { const roleId = member.role_ids?.[0] ?? -1 const canRemoveMember = member.role_ids.every((id) => rolesRemovable.includes(id)) - const { can: canCreateUserInvites } = useAsyncCheckProjectPermissions( + const { can: canCreateUserInvites } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_invites', { resource: { role_id: roleId } } ) const canResendInvite = canCreateUserInvites && hasOrgRole - const { can: canDeleteUserInvites } = useAsyncCheckProjectPermissions( + const { can: canDeleteUserInvites } = useAsyncCheckPermissions( PermissionAction.DELETE, 'user_invites', { resource: { role_id: roleId } } diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx index d26883fc670..a5b718bc3ed 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -11,7 +11,7 @@ import NoPermission from 'components/ui/NoPermission' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useProjectsQuery } from 'data/projects/projects-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants/metrics' import { @@ -43,8 +43,10 @@ export const Usage = () => { const selectedProjectRef = selectedProjectRefInputValue === 'all-projects' ? undefined : selectedProjectRefInputValue - const { can: canReadSubscriptions, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.subscriptions') + const { can: canReadSubscriptions, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.subscriptions' + ) const { data: organization } = useSelectedOrganizationQuery() const { data, isSuccess } = useProjectsQuery() diff --git a/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx b/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx index 08f008921b1..10d9573184d 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx @@ -6,7 +6,7 @@ import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { getTemporaryAPIKey } from 'data/api-keys/temp-api-keys-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { ChooseChannelPopover } from './ChooseChannelPopover' import { RealtimeFilterPopover } from './RealtimeFilterPopover' @@ -23,7 +23,7 @@ export const Header = ({ config, onChangeConfig }: HeaderProps) => { const { ref } = useParams() const { data: org } = useSelectedOrganizationQuery() - const { can: canReadAPIKeys } = useAsyncCheckProjectPermissions( + const { can: canReadAPIKeys } = useAsyncCheckPermissions( PermissionAction.READ, 'service_api_keys' ) diff --git a/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx b/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx index 8f5a24e2d41..7fc0eb6271e 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx @@ -10,7 +10,7 @@ import { DocsButton } from 'components/ui/DocsButton' import NoPermission from 'components/ui/NoPermission' import ShimmerLine from 'components/ui/ShimmerLine' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Button, IconBroadcast, IconDatabaseChanges, IconPresence, cn } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns' @@ -34,7 +34,7 @@ const NoResultAlert = ({ }) => { const { ref } = useParams() - const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckProjectPermissions( + const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.READ, 'service_api_keys' ) diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx index e9caa8b52e0..c755fb51185 100644 --- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx +++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx @@ -17,7 +17,7 @@ import { REALTIME_DEFAULT_CONFIG, useRealtimeConfigurationQuery, } from 'data/realtime/realtime-config-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { @@ -41,7 +41,7 @@ export const RealtimeSettings = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() const { data: organization } = useSelectedOrganizationQuery() - const { can: canUpdateConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.REALTIME_ADMIN_READ, '*' ) diff --git a/apps/studio/components/interfaces/Reports/Reports.tsx b/apps/studio/components/interfaces/Reports/Reports.tsx index f7f1f24c37e..aa8d37a780e 100644 --- a/apps/studio/components/interfaces/Reports/Reports.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.tsx @@ -22,7 +22,7 @@ import { useContentUpsertMutation, } from 'data/content/content-upsert-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Metric, TIME_PERIODS_REPORTS } from 'lib/constants/metrics' @@ -82,7 +82,7 @@ const Reports = () => { const currentReport = userContents?.content.find((report) => report.id === id) const currentReportContent = currentReport?.content as Dashboards.Content - const { can: canReadReport, isLoading: isLoadingPermissions } = useAsyncCheckProjectPermissions( + const { can: canReadReport, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.READ, 'user_content', { @@ -94,7 +94,7 @@ const Reports = () => { subject: { id: profile?.id }, } ) - const { can: canUpdateReport } = useAsyncCheckProjectPermissions( + const { can: canUpdateReport } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'user_content', { diff --git a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLQuickstarts.tsx b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLQuickstarts.tsx index eae83009246..5e9141c41f0 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLQuickstarts.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLQuickstarts.tsx @@ -7,7 +7,7 @@ import { useParams } from 'common' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { ActionCard } from 'components/layouts/Tabs/ActionCard' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { uuidv4 } from 'lib/helpers' @@ -26,7 +26,7 @@ const SQLQuickstarts = () => { const snapV2 = useSqlEditorV2StateSnapshot() - const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { diff --git a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx index 40603c824f5..e33799346e0 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLTemplates/SQLTemplates.tsx @@ -7,7 +7,7 @@ import { useParams } from 'common' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { ActionCard } from 'components/layouts/Tabs/ActionCard' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { uuidv4 } from 'lib/helpers' @@ -26,7 +26,7 @@ const SQLTemplates = () => { const snapV2 = useSqlEditorV2StateSnapshot() - const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { diff --git a/apps/studio/components/interfaces/SQLEditor/hooks.ts b/apps/studio/components/interfaces/SQLEditor/hooks.ts index 8bba5cf16b4..6d712056d34 100644 --- a/apps/studio/components/interfaces/SQLEditor/hooks.ts +++ b/apps/studio/components/interfaces/SQLEditor/hooks.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' @@ -24,7 +24,7 @@ export const useNewQuery = () => { const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() - const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx index d5676bced49..5f2e97cc07e 100644 --- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx +++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx @@ -15,7 +15,7 @@ import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-co import { useProjectPostgrestConfigUpdateMutation } from 'data/config/project-postgrest-config-update-mutation' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useSchemasQuery } from 'data/database/schemas-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { AlertDescription_Shadcn_, @@ -102,7 +102,7 @@ export const PostgrestConfig = () => { const formId = 'project-postgres-config' const hiddenSchema = ['auth', 'pgbouncer', 'hooks', 'extensions'] const { can: canUpdatePostgrestConfig, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.UPDATE, 'custom_config_postgrest') + useAsyncCheckPermissions(PermissionAction.UPDATE, 'custom_config_postgrest') const isGraphqlExtensionEnabled = (extensions ?? []).find((ext) => ext.name === 'pg_graphql')?.installed_version !== null diff --git a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx index 3f79a908bf7..7e035c727bc 100644 --- a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx @@ -9,7 +9,7 @@ import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon- import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import type { AddonVariantId } from 'data/subscriptions/types' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { formatCurrency } from 'lib/helpers' import { useAddonsPagePanel } from 'state/addons-page' @@ -31,7 +31,7 @@ const CustomDomainSidePanel = () => { const [selectedOption, setSelectedOption] = useState('cd_none') - const { can: canUpdateCustomDomain } = useAsyncCheckProjectPermissions( + const { can: canUpdateCustomDomain } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index 81d1a1dbb79..01e54ff3308 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -10,7 +10,7 @@ import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon- import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import type { AddonVariantId } from 'data/subscriptions/types' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useIsAwsCloudProvider } from 'hooks/misc/useSelectedProject' import { formatCurrency } from 'lib/helpers' @@ -25,7 +25,7 @@ const IPv4SidePanel = () => { const [selectedOption, setSelectedOption] = useState('ipv4_none') - const { can: canUpdateIPv4 } = useAsyncCheckProjectPermissions( + const { can: canUpdateIPv4 } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) diff --git a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx index 8e80cf20d87..cfd5deb17d6 100644 --- a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx @@ -13,7 +13,7 @@ import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon- import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import type { AddonVariantId } from 'data/subscriptions/types' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { BASE_PATH } from 'lib/constants' @@ -61,7 +61,7 @@ const PITRSidePanel = () => { const [selectedCategory, setSelectedCategory] = useState<'on' | 'off'>('off') const [selectedOption, setSelectedOption] = useState('pitr_0') - const { can: canUpdatePitr } = useAsyncCheckProjectPermissions( + const { can: canUpdatePitr } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) diff --git a/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx b/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx index 29ec00bb246..952288603b5 100644 --- a/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx +++ b/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx @@ -12,7 +12,7 @@ import { FormPanel } from 'components/ui/Forms/FormPanel' import { useBannedIPsDeleteMutation } from 'data/banned-ips/banned-ips-delete-mutations' import { useBannedIPsQuery } from 'data/banned-ips/banned-ips-query' import { useUserIPAddressQuery } from 'data/misc/user-ip-address-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Badge, Skeleton } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -39,15 +39,11 @@ const BannedIPs = () => { const [showUnban, setShowUnban] = useState(false) const [confirmingIP, setConfirmingIP] = useState(null) // Track the IP being confirmed for unban - const { can: canUnbanNetworks } = useAsyncCheckProjectPermissions( - PermissionAction.UPDATE, - 'projects', - { - resource: { - project_id: project?.id, - }, - } - ) + const { can: canUnbanNetworks } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', { + resource: { + project_id: project?.id, + }, + }) const { mutate: unbanIPs, isLoading: isUnbanning } = useBannedIPsDeleteMutation({ onSuccess: () => { diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx index 66127cfa7c9..47e7700820f 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx @@ -17,7 +17,7 @@ import { useMaxConnectionsQuery } from 'data/database/max-connections-query' import { usePgbouncerConfigQuery } from 'data/database/pgbouncer-config-query' import { usePgbouncerConfigurationUpdateMutation } from 'data/database/pgbouncer-config-update-mutation' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { @@ -51,7 +51,7 @@ export const ConnectionPooling = () => { const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() - const { can: canUpdateConnectionPoolingConfiguration } = useAsyncCheckProjectPermissions( + const { can: canUpdateConnectionPoolingConfiguration } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', { resource: { project_id: project?.id } } diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx index b39ac8008fa..17e30ace690 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx @@ -9,7 +9,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import Panel from 'components/ui/Panel' import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' import { useDatabasePasswordResetMutation } from 'data/database/database-password-reset-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DEFAULT_MINIMUM_PASSWORD_STRENGTH } from 'lib/constants' import passwordStrength from 'lib/password-strength' @@ -21,7 +21,7 @@ const ResetDbPassword = ({ disabled = false }) => { const isProjectActive = useIsProjectActive() const { data: project } = useSelectedProjectQuery() - const { can: canResetDbPassword } = useAsyncCheckProjectPermissions( + const { can: canResetDbPassword } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', { diff --git a/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx index f8ae07220fe..140b110d380 100644 --- a/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DiskSizeConfiguration.tsx @@ -12,7 +12,7 @@ import { FormHeader } from 'components/ui/Forms/FormHeader' import Panel from 'components/ui/Panel' import { useProjectDiskResizeMutation } from 'data/config/project-disk-resize-mutation' import { useDatabaseSizeQuery } from 'data/database/database-size-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' @@ -35,7 +35,7 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps) setUrlParams({ show_increase_disk_size_modal: show ? 'true' : undefined }) } - const { can: canUpdateDiskSizeConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateDiskSizeConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', { diff --git a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx index 4418578341e..65455dd2ef4 100644 --- a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx +++ b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx @@ -10,7 +10,7 @@ import { FormPanel } from 'components/ui/Forms/FormPanel' import Panel from 'components/ui/Panel' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useNetworkRestrictionsQuery } from 'data/network-restrictions/network-restrictions-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Badge, @@ -75,7 +75,7 @@ const NetworkRestrictions = () => { const [selectedRestrictionToRemove, setSelectedRestrictionToRemove] = useState() const { data, isLoading } = useNetworkRestrictionsQuery({ projectRef: ref }) - const { can: canUpdateNetworkRestrictions } = useAsyncCheckProjectPermissions( + const { can: canUpdateNetworkRestrictions } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', { diff --git a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx index 70d151e18ca..70092a729d4 100644 --- a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx @@ -15,7 +15,7 @@ import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query import { useSSLEnforcementQuery } from 'data/ssl-enforcement/ssl-enforcement-query' import { useSSLEnforcementUpdateMutation } from 'data/ssl-enforcement/ssl-enforcement-update-mutation' import { useCustomContent } from 'hooks/custom-content/useCustomContent' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Alert, Button, Switch, Tooltip, TooltipContent, TooltipTrigger } from 'ui' @@ -44,7 +44,7 @@ const SSLConfiguration = () => { } ) - const { can: canUpdateSSLEnforcement } = useAsyncCheckProjectPermissions( + const { can: canUpdateSSLEnforcement } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', { diff --git a/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx b/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx index 0f33a4c6e12..d59a176634c 100644 --- a/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx +++ b/apps/studio/components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx @@ -12,7 +12,7 @@ import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui import { InlineLink } from 'components/ui/InlineLink' import { useComplianceConfigUpdateMutation } from 'data/config/project-compliance-config-mutation' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Switch, Tooltip, TooltipContent, TooltipTrigger } from 'ui' @@ -21,7 +21,7 @@ const ComplianceConfig = () => { const { data: project } = useSelectedProjectQuery() const [isSensitive, setIsSensitive] = useState(false) - const { can: canUpdateComplianceConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateComplianceConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', { diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx index 1f7f77233c6..de8a2d0541b 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx @@ -9,7 +9,7 @@ import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckCNAMERecordMutation } from 'data/custom-domains/check-cname-mutation' import { useCustomDomainCreateMutation } from 'data/custom-domains/custom-domains-create-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Form, Input } from 'ui' @@ -27,7 +27,7 @@ const CustomDomainsConfigureHostname = () => { const FORM_ID = 'custom-domains-form' const endpoint = settings?.app_config?.endpoint - const { can: canConfigureCustomDomain } = useAsyncCheckProjectPermissions( + const { can: canConfigureCustomDomain } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', { diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx index a47ecbb6cd6..d753a214cec 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx @@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useState } from 'react' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DeleteProjectModal } from './DeleteProjectModal' @@ -14,13 +14,9 @@ const DeleteProjectButton = ({ type = 'danger' }: DeleteProjectButtonProps) => { const { data: project } = useSelectedProjectQuery() const [isOpen, setIsOpen] = useState(false) - const { can: canDeleteProject } = useAsyncCheckProjectPermissions( - PermissionAction.UPDATE, - 'projects', - { - resource: { project_id: project?.id }, - } - ) + const { can: canDeleteProject } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', { + resource: { project_id: project?.id }, + }) return ( <> diff --git a/apps/studio/components/interfaces/Settings/General/General.tsx b/apps/studio/components/interfaces/Settings/General/General.tsx index ea95391fcaf..b58a5ff7162 100644 --- a/apps/studio/components/interfaces/Settings/General/General.tsx +++ b/apps/studio/components/interfaces/Settings/General/General.tsx @@ -9,7 +9,7 @@ import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui import Panel from 'components/ui/Panel' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useProjectUpdateMutation } from 'data/projects/project-update-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useProjectByRefQuery, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { @@ -33,15 +33,11 @@ const General = () => { const formId = 'project-general-settings' const initialValues = { name: project?.name ?? '', ref: project?.ref ?? '' } - const { can: canUpdateProject } = useAsyncCheckProjectPermissions( - PermissionAction.UPDATE, - 'projects', - { - resource: { - project_id: project?.id, - }, - } - ) + const { can: canUpdateProject } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', { + resource: { + project_id: project?.id, + }, + }) const { mutate: updateProject, isLoading: isUpdating } = useProjectUpdateMutation() diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx index 1cd34e163ec..3f8223e8a6c 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx @@ -9,7 +9,7 @@ import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectCont import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useProjectPauseMutation } from 'data/projects/project-pause-mutation' import { setProjectStatus } from 'data/projects/projects-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useIsAwsK8sCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' @@ -25,7 +25,7 @@ const PauseProjectButton = () => { const projectRef = project?.ref ?? '' const isPaused = project?.status === PROJECT_STATUS.INACTIVE - const { can: canPauseProject } = useAsyncCheckProjectPermissions( + const { can: canPauseProject } = useAsyncCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_jobs.projects.pause' ) diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx index 8138ea7b1c0..55c0f44e7f3 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx @@ -11,7 +11,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useProjectRestartMutation } from 'data/projects/project-restart-mutation' import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation' import { setProjectStatus } from 'data/projects/projects-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsAwsK8sCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, @@ -35,7 +35,7 @@ const RestartServerButton = () => { const projectRegion = project?.region ?? '' const projectRestartDisabled = useFlag('disableProjectRestarts') - const { can: canRestartProject } = useAsyncCheckProjectPermissions( + const { can: canRestartProject } = useAsyncCheckPermissions( PermissionAction.INFRA_EXECUTE, 'reboot' ) diff --git a/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx index 747500510b5..68d3413aab0 100644 --- a/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx @@ -9,7 +9,7 @@ import { DocsButton } from 'components/ui/DocsButton' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectTransferMutation } from 'data/projects/project-transfer-mutation' import { useProjectTransferPreviewQuery } from 'data/projects/project-transfer-preview-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, InfoIcon, Listbox, Loading, Modal, WarningIcon } from 'ui' import { Admonition } from 'ui-patterns' @@ -58,7 +58,7 @@ const TransferProjectButton = () => { } }, [isOpen]) - const { can: canTransferProject } = useAsyncCheckProjectPermissions( + const { can: canTransferProject } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'organizations' ) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx index f74301cc8c6..8d6fb5b6211 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx @@ -16,7 +16,7 @@ import { ReplicaInitializationStatus, useReadReplicasStatusesQuery, } from 'data/read-replicas/replicas-status-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { timeout } from 'lib/helpers' import { type AWS_REGIONS_KEYS } from 'shared-data' @@ -60,10 +60,7 @@ const InstanceConfigurationUI = ({ diagramOnly = false }: InstanceConfigurationU const [selectedReplicaToDrop, setSelectedReplicaToDrop] = useState() const [selectedReplicaToRestart, setSelectedReplicaToRestart] = useState() - const { can: canManageReplicas } = useAsyncCheckProjectPermissions( - PermissionAction.CREATE, - 'projects' - ) + const { can: canManageReplicas } = useAsyncCheckPermissions(PermissionAction.CREATE, 'projects') const { data: loadBalancers, diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx index a14c87464c1..07dc3bf0674 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx @@ -14,7 +14,7 @@ import { useReadReplicasStatusesQuery, } from 'data/read-replicas/replicas-status-query' import { formatDatabaseID } from 'data/read-replicas/replicas.utils' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { BASE_PATH } from 'lib/constants' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' @@ -191,10 +191,7 @@ export const ReplicaNode = ({ data }: NodeProps) => { } = data const { ref } = useParams() const dbSelectorState = useDatabaseSelectorStateSnapshot() - const { can: canManageReplicas } = useAsyncCheckProjectPermissions( - PermissionAction.CREATE, - 'projects' - ) + const { can: canManageReplicas } = useAsyncCheckPermissions(PermissionAction.CREATE, 'projects') const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([ 'project_homepage:show_instance_size', ]) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx index 6140367095b..d933d78905e 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx @@ -19,7 +19,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { Database, useReadReplicasQuery } from 'data/read-replicas/replicas-query' import { formatDatabaseID } from 'data/read-replicas/replicas.utils' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { BASE_PATH } from 'lib/constants' import type { AWS_REGIONS_KEYS } from 'shared-data' @@ -64,10 +64,7 @@ const MapView = ({ y: number region: { key: string; country?: string; name?: string; region?: string } }>() - const { can: canManageReplicas } = useAsyncCheckProjectPermissions( - PermissionAction.CREATE, - 'projects' - ) + const { can: canManageReplicas } = useAsyncCheckPermissions(PermissionAction.CREATE, 'projects') const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false)) const { data } = useReadReplicasQuery({ projectRef: ref }) diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx index e9c083c1975..e97061a5869 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx @@ -16,7 +16,7 @@ import { useGitHubConnectionDeleteMutation } from 'data/integrations/github-conn import { useGitHubConnectionUpdateMutation } from 'data/integrations/github-connection-update-mutation' import { useGitHubRepositoriesQuery } from 'data/integrations/github-repositories-query' import type { GitHubConnection } from 'data/integrations/integrations.types' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { openInstallGitHubIntegrationWindow } from 'lib/github' @@ -72,11 +72,11 @@ const GitHubIntegrationConnectionForm = ({ const [repoComboBoxOpen, setRepoComboboxOpen] = useState(false) const isParentProject = !Boolean(selectedProject?.parent_project_ref) - const { can: canUpdateGitHubConnection } = useAsyncCheckProjectPermissions( + const { can: canUpdateGitHubConnection } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'integrations.github_connections' ) - const { can: canCreateGitHubConnection } = useAsyncCheckProjectPermissions( + const { can: canCreateGitHubConnection } = useAsyncCheckPermissions( PermissionAction.CREATE, 'integrations.github_connections' ) diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx index 36256f769ef..2d538b3a805 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx @@ -11,7 +11,7 @@ import { import NoPermission from 'components/ui/NoPermission' import UpgradeToPro from 'components/ui/UpgradeToPro' import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import { cn } from 'ui' @@ -33,7 +33,7 @@ const GitHubSection = () => { const { data: organization } = useSelectedOrganizationQuery() const { can: canReadGitHubConnection, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'integrations.github_connections') + useAsyncCheckPermissions(PermissionAction.READ, 'integrations.github_connections') const isProPlanAndUp = organization?.plan?.id !== 'free' const promptProPlanUpgrade = IS_PLATFORM && !isProPlanAndUp diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx index 74c721dbe2f..59c6ca8d803 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx @@ -26,7 +26,7 @@ import type { IntegrationName, IntegrationProjectConnection, } from 'data/integrations/integrations.types' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { pluralize } from 'lib/helpers' @@ -44,12 +44,12 @@ const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean }) => { const isBranch = project?.parent_project_ref !== undefined const { can: canReadVercelConnection, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'integrations.vercel_connections') - const { can: canCreateVercelConnection } = useAsyncCheckProjectPermissions( + useAsyncCheckPermissions(PermissionAction.READ, 'integrations.vercel_connections') + const { can: canCreateVercelConnection } = useAsyncCheckPermissions( PermissionAction.CREATE, 'integrations.vercel_connections' ) - const { can: canUpdateVercelConnection } = useAsyncCheckProjectPermissions( + const { can: canUpdateVercelConnection } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'integrations.vercel_connections' ) diff --git a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx index b6e32e5d1c3..0e439d9cbd8 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -10,7 +10,7 @@ import { IS_PLATFORM, useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DownloadResultsButton } from 'components/ui/DownloadResultsButton' import { useSelectedLog } from 'hooks/analytics/useSelectedLog' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useProfile } from 'lib/profile' import { toast } from 'sonner' import { ResponseError } from 'types' @@ -94,7 +94,7 @@ const LogTable = ({ const [selectionOpen, setSelectionOpen] = useState(false) const [selectedRow, setSelectedRow] = useState(null) - const { can: canCreateLogQuery } = useAsyncCheckProjectPermissions( + const { can: canCreateLogQuery } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { diff --git a/apps/studio/components/interfaces/Storage/BucketRow.tsx b/apps/studio/components/interfaces/Storage/BucketRow.tsx index 294939db041..031de7b1412 100644 --- a/apps/studio/components/interfaces/Storage/BucketRow.tsx +++ b/apps/studio/components/interfaces/Storage/BucketRow.tsx @@ -7,7 +7,7 @@ import { DeleteBucketModal } from 'components/interfaces/Storage/DeleteBucketMod import { EditBucketModal } from 'components/interfaces/Storage/EditBucketModal' import { EmptyBucketModal } from 'components/interfaces/Storage/EmptyBucketModal' import type { Bucket } from 'data/storage/buckets-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Badge, Button, @@ -29,10 +29,7 @@ export interface BucketRowProps { } export const BucketRow = ({ bucket, projectRef = '', isSelected = false }: BucketRowProps) => { - const { can: canUpdateBuckets } = useAsyncCheckProjectPermissions( - PermissionAction.STORAGE_WRITE, - '*' - ) + const { can: canUpdateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const [modal, setModal] = useState(null) const onClose = () => setModal(null) diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index a6ad858197a..1eddfd8128d 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -17,7 +17,7 @@ import { useProjectStorageConfigQuery } from 'data/config/project-storage-config import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation' import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import { @@ -103,10 +103,7 @@ export const CreateBucketModal = () => { const [visible, setVisible] = useState(false) const [selectedUnit, setSelectedUnit] = useState(StorageSizeUnits.MB) - const { can: canCreateBuckets } = useAsyncCheckProjectPermissions( - PermissionAction.STORAGE_WRITE, - '*' - ) + const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const { mutate: sendEvent } = useSendEventMutation() const { mutateAsync: createBucket, isLoading: isCreating } = useBucketCreateMutation({ diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/ColumnContextMenu.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/ColumnContextMenu.tsx index d0acf14b033..746d1e21f09 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/ColumnContextMenu.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/ColumnContextMenu.tsx @@ -3,7 +3,7 @@ import { Item, Menu, Separator, Submenu } from 'react-contexify' import 'react-contexify/dist/ReactContexify.css' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { ChevronRight, ChevronsDown, ChevronsUp, Clipboard, Eye, FolderPlus } from 'lucide-react' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { @@ -28,10 +28,7 @@ export const ColumnContextMenu = ({ id = '' }: ColumnContextMenuProps) => { addNewFolderPlaceholder, } = useStorageExplorerStateSnapshot() - const { can: canUpdateFiles } = useAsyncCheckProjectPermissions( - PermissionAction.STORAGE_WRITE, - '*' - ) + const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const onSelectCreateFolder = (columnIndex = -1) => { addNewFolderPlaceholder(columnIndex) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx index e65e425cdb4..ebbaee5a516 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx @@ -8,7 +8,7 @@ import { toast } from 'sonner' import InfiniteList from 'components/ui/InfiniteList' import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' import { formatBytes } from 'lib/helpers' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' @@ -83,10 +83,7 @@ export const FileExplorerColumn = ({ const fileExplorerColumnRef = useRef(null) const snap = useStorageExplorerStateSnapshot() - const { can: canUpdateStorage } = useAsyncCheckProjectPermissions( - PermissionAction.STORAGE_WRITE, - '*' - ) + const { can: canUpdateStorage } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') useEffect(() => { if (fileExplorerColumnRef) { diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx index 0d080f6c90f..72135eb819e 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx @@ -19,7 +19,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { APIDocsButton } from 'components/ui/APIDocsButton' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { Button, @@ -179,10 +179,7 @@ export const FileExplorerHeader = ({ const breadcrumbs = columns.map((column) => column.name) const backDisabled = columns.length <= 1 - const { can: canUpdateStorage } = useAsyncCheckProjectPermissions( - PermissionAction.STORAGE_WRITE, - '*' - ) + const { can: canUpdateStorage } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') useEffect(() => { if (itemSearchString) setSearchString(itemSearchString) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx index 9b4559c5b5b..f7b45bd74cc 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx @@ -3,17 +3,14 @@ import { Download, Move, Trash2, X } from 'lucide-react' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { Button } from 'ui' import { downloadFile } from './StorageExplorer.utils' export const FileExplorerHeaderSelection = () => { const { ref: projectRef, bucketId } = useParams() - const { can: canUpdateFiles } = useAsyncCheckProjectPermissions( - PermissionAction.STORAGE_WRITE, - '*' - ) + const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const { selectedItems, diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx index df4a5eaa8ef..467af3341c7 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx @@ -19,7 +19,7 @@ import SVG from 'react-inlinesvg' import { useParams } from 'common' import type { ItemRenderer } from 'components/ui/InfiniteList' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' import { formatBytes } from 'lib/helpers' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' @@ -139,10 +139,7 @@ export const FileExplorerRow: ItemRenderer = const isOpened = openedFolders.length > columnIndex ? openedFolders[columnIndex].name === item.name : false const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview?.id, item.id) - const { can: canUpdateFiles } = useAsyncCheckProjectPermissions( - PermissionAction.STORAGE_WRITE, - '*' - ) + const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const onSelectFile = async (columnIndex: number, file: StorageItem) => { popColumnAtIndex(columnIndex) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FolderContextMenu.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FolderContextMenu.tsx index 1242a841a7b..c91c498c0bb 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/FolderContextMenu.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FolderContextMenu.tsx @@ -3,7 +3,7 @@ import { Clipboard, Download, Edit, Trash2 } from 'lucide-react' import { Item, Menu, Separator } from 'react-contexify' import 'react-contexify/dist/ReactContexify.css' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { copyPathToFolder } from './StorageExplorer.utils' @@ -14,10 +14,7 @@ interface FolderContextMenuProps { export const FolderContextMenu = ({ id = '' }: FolderContextMenuProps) => { const { openedFolders, downloadFolder, setSelectedItemToRename, setSelectedItemsToDelete } = useStorageExplorerStateSnapshot() - const { can: canUpdateFiles } = useAsyncCheckProjectPermissions( - PermissionAction.STORAGE_WRITE, - '*' - ) + const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') return ( diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx index 0712e4b6578..359a35878c7 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx @@ -4,7 +4,7 @@ import { Item, Menu, Separator, Submenu } from 'react-contexify' import 'react-contexify/dist/ReactContexify.css' import { useParams } from 'common' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { URL_EXPIRY_DURATION } from '../Storage.constants' import { StorageItemWithColumn } from '../Storage.types' @@ -28,10 +28,7 @@ export const ItemContextMenu = ({ id = '' }: ItemContextMenuProps) => { } = useStorageExplorerStateSnapshot() const { onCopyUrl } = useCopyUrl() const isPublic = selectedBucket.public - const { can: canUpdateFiles } = useAsyncCheckProjectPermissions( - PermissionAction.STORAGE_WRITE, - '*' - ) + const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const onHandleClick = async (event: any, item: StorageItemWithColumn, expiresIn?: number) => { if (item.isCorrupted) return diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx index 44adc6c0b30..d4cdb53dab5 100644 --- a/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx @@ -6,7 +6,7 @@ import SVG from 'react-inlinesvg' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' import { formatBytes } from 'lib/helpers' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' @@ -127,10 +127,7 @@ export const PreviewPane = () => { } = useStorageExplorerStateSnapshot() const { onCopyUrl } = useCopyUrl() - const { can: canUpdateFiles } = useAsyncCheckProjectPermissions( - PermissionAction.STORAGE_WRITE, - '*' - ) + const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') if (!file) return null diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/CreateCredentialModal.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/CreateCredentialModal.tsx index cb5b6de2ab9..0cd7cbf7c62 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/CreateCredentialModal.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/CreateCredentialModal.tsx @@ -9,7 +9,7 @@ import { useParams } from 'common' import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext' import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useS3AccessKeyCreateMutation } from 'data/storage/s3-access-key-create-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, Dialog, @@ -39,7 +39,7 @@ export const CreateCredentialModal = ({ visible, onOpenChange }: CreateCredentia const isProjectActive = useIsProjectActive() const [showSuccess, setShowSuccess] = useState(false) - const { can: canCreateCredentials } = useAsyncCheckProjectPermissions( + const { can: canCreateCredentials } = useAsyncCheckPermissions( PermissionAction.STORAGE_ADMIN_WRITE, '*' ) diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx index 0057b4e751d..a4301b64d40 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx @@ -22,7 +22,7 @@ import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useProjectStorageConfigUpdateUpdateMutation } from 'data/config/project-storage-config-update-mutation' import { useStorageCredentialsQuery } from 'data/storage/s3-access-key-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { AlertDescription_Shadcn_, @@ -54,9 +54,11 @@ export const S3Connection = () => { const [openDeleteDialog, setOpenDeleteDialog] = useState(false) const [deleteCred, setDeleteCred] = useState<{ id: string; description: string }>() - const { can: canReadS3Credentials, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.STORAGE_ADMIN_READ, '*') - const { can: canUpdateStorageSettings } = useAsyncCheckProjectPermissions( + const { can: canReadS3Credentials, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.STORAGE_ADMIN_READ, + '*' + ) + const { can: canUpdateStorageSettings } = useAsyncCheckPermissions( PermissionAction.STORAGE_ADMIN_WRITE, '*' ) diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx index 7eb0717b6ff..216449b4168 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx @@ -3,7 +3,7 @@ import { differenceInDays } from 'date-fns' import { MoreVertical, TrashIcon } from 'lucide-react' import CopyButton from 'components/ui/CopyButton' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, DropdownMenu, @@ -25,7 +25,7 @@ export const StorageCredItem = ({ access_key: string onDeleteClick: (id: string) => void }) => { - const { can: canRemoveAccessKey } = useAsyncCheckProjectPermissions( + const { can: canRemoveAccessKey } = useAsyncCheckPermissions( PermissionAction.STORAGE_ADMIN_WRITE, '*' ) diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx index 0a046eb0c9a..bd0cfb22d80 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx @@ -16,7 +16,7 @@ import UpgradeToPro from 'components/ui/UpgradeToPro' import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useProjectStorageConfigUpdateUpdateMutation } from 'data/config/project-storage-config-update-mutation' import { useBucketsQuery } from 'data/storage/buckets-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { formatBytes } from 'lib/helpers' import { @@ -58,9 +58,11 @@ interface StorageSettingsState { export const StorageSettings = () => { const { ref: projectRef } = useParams() - const { can: canReadStorageSettings, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.STORAGE_ADMIN_READ, '*') - const { can: canUpdateStorageSettings } = useAsyncCheckProjectPermissions( + const { can: canReadStorageSettings, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.STORAGE_ADMIN_READ, + '*' + ) + const { can: canUpdateStorageSettings } = useAsyncCheckPermissions( PermissionAction.STORAGE_ADMIN_WRITE, '*' ) diff --git a/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx index 36ed5327169..e3b18f770da 100644 --- a/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx +++ b/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx @@ -13,7 +13,7 @@ describe(`CreateBucketModal`, () => { beforeEach(() => { vi.mock(`hooks/misc/useCheckPermissions`, () => ({ useCheckPermissions: vi.fn(), - useAsyncCheckProjectPermissions: vi.fn().mockImplementation(() => ({ can: true })), + useAsyncCheckPermissions: vi.fn().mockImplementation(() => ({ can: true })), })) // useParams routerMock.setCurrentUrl(`/project/default/storage/buckets`) diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 0322cba739a..2a11307f97c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -22,7 +22,7 @@ import { } from 'data/table-editor/table-editor-types' import { useTableUpdateMutation } from 'data/tables/table-update-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -115,9 +115,11 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp }, }) - const { can: canSqlWriteTables, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') - const { can: canSqlWriteColumns } = useAsyncCheckProjectPermissions( + const { can: canSqlWriteTables, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'tables' + ) + const { can: canSqlWriteColumns } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns' ) diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 2cceac5f76d..8294554566d 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -14,7 +14,7 @@ import { isView, TableLike, } from 'data/table-editor/table-editor-types' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useDashboardHistory } from 'hooks/misc/useDashboardHistory' import { useUrlState } from 'hooks/ui/useUrlState' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' @@ -48,11 +48,11 @@ export const TableGridEditor = ({ const [{ view: selectedView = 'data' }] = useUrlState() - const { can: canEditTables } = useAsyncCheckProjectPermissions( + const { can: canEditTables } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables' ) - const { can: canEditColumns } = useAsyncCheckProjectPermissions( + const { can: canEditColumns } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns' ) diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx index de2a75a3580..f101320651a 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useAppStateSnapshot } from 'state/app-state' import { Button } from 'ui' @@ -12,7 +12,7 @@ export const BranchingPITRNotice = () => { const { ref } = useParams() const snap = useAppStateSnapshot() - const { can: canUpdateSubscription } = useAsyncCheckProjectPermissions( + const { can: canUpdateSubscription } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx index ef94be5b6d2..76b488fc845 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx @@ -15,7 +15,7 @@ import NoPermission from 'components/ui/NoPermission' import { useEdgeFunctionBodyQuery } from 'data/edge-functions/edge-function-body-query' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { withAuth } from 'hooks/misc/withAuth' import { @@ -43,7 +43,7 @@ const EdgeFunctionDetailsLayout = ({ const { mutate: sendEvent } = useSendEventMutation() const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() - const { isLoading, can: canReadFunctions } = useAsyncCheckProjectPermissions( + const { isLoading, can: canReadFunctions } = useAsyncCheckPermissions( PermissionAction.FUNCTIONS_READ, '*' ) diff --git a/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx index 30c4ae8bbf4..9965ed65b2a 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx @@ -3,7 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { PropsWithChildren } from 'react' import NoPermission from 'components/ui/NoPermission' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { withAuth } from 'hooks/misc/withAuth' import ProjectLayout from '../ProjectLayout/ProjectLayout' import { LogsSidebarMenuV2 } from './LogsSidebarMenuV2' @@ -13,7 +13,7 @@ interface LogsLayoutProps { } const LogsLayout = ({ title, children }: PropsWithChildren) => { - const { isLoading, can: canUseLogsExplorer } = useAsyncCheckProjectPermissions( + const { isLoading, can: canUseLogsExplorer } = useAsyncCheckPermissions( PermissionAction.ANALYTICS_READ, 'logflare' ) diff --git a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx index d82d07658b7..f0141fa2763 100644 --- a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx @@ -10,7 +10,7 @@ import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { InlineLink } from 'components/ui/InlineLink' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import { useDownloadableBackupQuery } from 'data/database/backup-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, CriticalIcon, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' @@ -19,13 +19,9 @@ export const PauseFailedState = () => { const { data: project } = useSelectedProjectQuery() const [visible, setVisible] = useState(false) - const { can: canDeleteProject } = useAsyncCheckProjectPermissions( - PermissionAction.UPDATE, - 'projects', - { - resource: { project_id: project?.id }, - } - ) + const { can: canDeleteProject } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', { + resource: { project_id: project?.id }, + }) const { data } = useDownloadableBackupQuery({ projectRef: ref }) const backups = data?.backups ?? [] diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index 353d948b00b..508f1a0b002 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -18,7 +18,7 @@ import { PostgresEngine, ReleaseChannel } from 'data/projects/new-project.consta import { useProjectPauseStatusQuery } from 'data/projects/project-pause-status-query' import { useProjectRestoreMutation } from 'data/projects/project-restore-mutation' import { setProjectStatus } from 'data/projects/projects-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { usePHFlag } from 'hooks/ui/useFlag' @@ -94,7 +94,7 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { }, }) - const { can: canResumeProject } = useAsyncCheckProjectPermissions( + const { can: canResumeProject } = useAsyncCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_jobs.projects.initialize_or_resume' ) diff --git a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx index edb62abd66c..401a91b6e84 100644 --- a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx @@ -10,7 +10,7 @@ import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { InlineLink } from 'components/ui/InlineLink' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import { useDownloadableBackupQuery } from 'data/database/backup-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, CriticalIcon, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' @@ -19,11 +19,9 @@ export const RestoreFailedState = () => { const { data: project } = useSelectedProjectQuery() const [visible, setVisible] = useState(false) - const { can: canDeleteProject } = useAsyncCheckProjectPermissions( - PermissionAction.UPDATE, - 'projects', - { resource: { project_id: project?.id } } - ) + const { can: canDeleteProject } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', { + resource: { project_id: project?.id }, + }) const { data } = useDownloadableBackupQuery({ projectRef: ref }) const backups = data?.backups ?? [] diff --git a/apps/studio/components/layouts/ReportsLayout/ReportMenuItem.tsx b/apps/studio/components/layouts/ReportsLayout/ReportMenuItem.tsx index 3ccba9049a7..781a4080ba2 100644 --- a/apps/studio/components/layouts/ReportsLayout/ReportMenuItem.tsx +++ b/apps/studio/components/layouts/ReportsLayout/ReportMenuItem.tsx @@ -3,7 +3,7 @@ import { ChevronDown, Edit2, Trash } from 'lucide-react' import Link from 'next/link' import { ContentBase } from 'data/content/content-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useProfile } from 'lib/profile' import { Dashboards } from 'types' import { @@ -41,7 +41,7 @@ export const ReportMenuItem = ({ onSelectDelete, }: ReportMenuItemProps) => { const { profile } = useProfile() - const { can: canUpdateCustomReport } = useAsyncCheckProjectPermissions( + const { can: canUpdateCustomReport } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'user_content', { diff --git a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx index a0e1a4f5d4a..bdcbc96054f 100644 --- a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx +++ b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx @@ -12,7 +12,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useContentDeleteMutation } from 'data/content/content-delete-mutation' import { Content, useContentQuery } from 'data/content/content-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useProfile } from 'lib/profile' import { Menu, cn } from 'ui' @@ -34,7 +34,7 @@ const ReportsMenu = () => { const storageSupported = useIsFeatureEnabled('project_storage:all') const storageEnabled = storageReportEnabled && storageSupported - const { can: canCreateCustomReport } = useAsyncCheckProjectPermissions( + const { can: canCreateCustomReport } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index bbc045e3083..a6af02d7eef 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' @@ -48,7 +48,7 @@ export const SQLEditorMenu = () => { const appState = getAppStateSnapshot() const debouncedSearch = useDebounce(search, 500) - const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index 4dd78e8b07c..e22c2d52213 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -21,7 +21,7 @@ import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLE import { getContentById } from 'data/content/content-id-query' import { useSQLSnippetFolderContentsQuery } from 'data/content/sql-folder-contents-query' import { Snippet } from 'data/content/sql-folders-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import useLatest from 'hooks/misc/useLatest' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' @@ -106,7 +106,7 @@ export const SQLEditorTreeViewItem = ({ const isEditing = status === 'editing' const isSaving = status === 'saving' - const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { diff --git a/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx b/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx index dc7be6eb363..0f975237051 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx @@ -9,7 +9,7 @@ import { COMMAND_MENU_SECTIONS } from 'components/interfaces/App/CommandMenu/Com import { orderCommandSectionsByPriority } from 'components/interfaces/App/CommandMenu/ordering' import { useSqlSnippetsQuery, type SqlSnippet } from 'data/content/sql-snippets-query' import { usePrefetchTables, useTablesQuery, type TablesData } from 'data/tables/tables-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import { useProfile } from 'lib/profile' @@ -103,7 +103,7 @@ function RunSnippetPage() { const snippets = snippetPages?.pages.flatMap((page) => page.contents) const { profile } = useProfile() - const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx index 2727eb42028..e7615f9f4b9 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx @@ -2,11 +2,11 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { PropsWithChildren } from 'react' import NoPermission from 'components/ui/NoPermission' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { ProjectLayoutWithAuth } from '../ProjectLayout/ProjectLayout' const TableEditorLayout = ({ children }: PropsWithChildren<{}>) => { - const { can: canReadTables, isSuccess: isPermissionsLoaded } = useAsyncCheckProjectPermissions( + const { can: canReadTables, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'tables' ) diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 929db988b7c..59d87733ade 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -16,7 +16,7 @@ import SchemaSelector from 'components/ui/SchemaSelector' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' import { getTableEditor, useTableEditorQuery } from 'data/table-editor/table-editor-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -85,7 +85,7 @@ export const TableEditorMenu = () => { [data?.pages] ) - const { can: canCreateTables } = useAsyncCheckProjectPermissions( + const { can: canCreateTables } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables' ) diff --git a/apps/studio/components/layouts/Tabs/NewTab.tsx b/apps/studio/components/layouts/Tabs/NewTab.tsx index 182b85e9664..c9826907276 100644 --- a/apps/studio/components/layouts/Tabs/NewTab.tsx +++ b/apps/studio/components/layouts/Tabs/NewTab.tsx @@ -9,7 +9,7 @@ import { useParams } from 'common' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { uuidv4 } from 'lib/helpers' @@ -46,7 +46,7 @@ export function NewTab() { const [quickstarts] = partition(SQL_TEMPLATES, { type: 'quickstart' }) const { mutate: sendEvent } = useSendEventMutation() - const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx index 919b847f50b..70f7a913602 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react' import { AIOptInLevelSelector } from 'components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector' import { useAIOptInForm } from 'hooks/forms/useAIOptInForm' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, cn, @@ -24,7 +24,7 @@ interface AIOptInModalProps { export const AIOptInModal = ({ visible, onCancel }: AIOptInModalProps) => { const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm(onCancel) - const { can: canUpdateOrganization } = useAsyncCheckProjectPermissions( + const { can: canUpdateOrganization } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'organizations' ) diff --git a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx index deb418a749e..a36502f0bfa 100644 --- a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx @@ -6,7 +6,7 @@ import { DragEvent, PropsWithChildren, useMemo, useState } from 'react' import { useParams } from 'common' import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useProfile } from 'lib/profile' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' @@ -48,7 +48,7 @@ export const DisplayBlockRenderer = ({ const snap = useAiAssistantStateSnapshot() const { mutate: sendEvent } = useSendEventMutation() - const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { diff --git a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx index f3acd11c622..7764136b47f 100644 --- a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx @@ -13,7 +13,7 @@ import { import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' @@ -232,7 +232,7 @@ export const MarkdownPre = ({ const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() - const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { diff --git a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx index 0ef28a3426f..80277032385 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx +++ b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -9,7 +9,7 @@ import { useParams } from 'common' import Panel from 'components/ui/Panel' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Input } from 'ui' import { getLastUsedAPIKeys, useLastUsedAPIKeysLogQuery } from './DisplayApiSettings.utils' @@ -36,7 +36,7 @@ export const DisplayApiSettings = ({ } = useJwtSecretUpdatingStatusQuery({ projectRef }) const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus - const { isLoading: isLoadingPermissions, can: canReadAPIKeys } = useAsyncCheckProjectPermissions( + const { isLoading: isLoadingPermissions, can: canReadAPIKeys } = useAsyncCheckPermissions( PermissionAction.READ, 'service_api_keys' ) diff --git a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx index 19b67e34cc1..8998ab5c145 100644 --- a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx +++ b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx @@ -8,7 +8,7 @@ import { useToggleLegacyAPIKeysMutation } from 'data/api-keys/legacy-api-key-tog import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query' import { useLegacyJWTSigningKeyQuery } from 'data/jwt-signing-keys/legacy-jwt-signing-key-query' import { useAuthorizedAppsQuery } from 'data/oauth/authorized-apps-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { AlertDialog, @@ -35,8 +35,10 @@ export const ToggleLegacyApiKeysPanel = () => { const { data: legacyJWTSecret } = useLegacyJWTSigningKeyQuery({ projectRef }) - const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } = - useAsyncCheckProjectPermissions(PermissionAction.SECRETS_WRITE, '*') + const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } = useAsyncCheckPermissions( + PermissionAction.SECRETS_WRITE, + '*' + ) const { data: authorizedApps = [], isSuccess: isAuthorizedAppsSuccess } = useAuthorizedAppsQuery({ slug: org?.slug, diff --git a/apps/studio/components/ui/SchemaSelector.tsx b/apps/studio/components/ui/SchemaSelector.tsx index 72669f80ffe..e3f89590ff2 100644 --- a/apps/studio/components/ui/SchemaSelector.tsx +++ b/apps/studio/components/ui/SchemaSelector.tsx @@ -3,7 +3,7 @@ import { Check, ChevronsUpDown, Plus } from 'lucide-react' import { useState } from 'react' import { useSchemasQuery } from 'data/database/schemas-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { AlertDescription_Shadcn_, @@ -52,7 +52,7 @@ const SchemaSelector = ({ align = 'start', }: SchemaSelectorProps) => { const [open, setOpen] = useState(false) - const { can: canCreateSchemas } = useAsyncCheckProjectPermissions( + const { can: canCreateSchemas } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'schemas' ) diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx index 8e4b2a9d966..7cf9aa39523 100644 --- a/apps/studio/components/ui/UpgradeToPro.tsx +++ b/apps/studio/components/ui/UpgradeToPro.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { ReactNode } from 'react' import { useFlag } from 'common' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, cn } from 'ui' @@ -34,7 +34,7 @@ const UpgradeToPro = ({ const { data: organization } = useSelectedOrganizationQuery() const plan = organization?.plan?.id - const { can: canUpdateSubscription } = useAsyncCheckProjectPermissions( + const { can: canUpdateSubscription } = useAsyncCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) diff --git a/apps/studio/data/config/project-settings-v2-query.ts b/apps/studio/data/config/project-settings-v2-query.ts index 1bba0b1806a..759c0a784c7 100644 --- a/apps/studio/data/config/project-settings-v2-query.ts +++ b/apps/studio/data/config/project-settings-v2-query.ts @@ -3,7 +3,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import type { components } from 'data/api' import { get, handleError } from 'data/fetchers' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { ResponseError } from 'types' import { configKeys } from './keys' @@ -44,7 +44,7 @@ export const useProjectSettingsV2Query = ( ) => { // [Joshen] Sync with API perms checking here - shouldReturnApiKeys // https://github.com/supabase/infrastructure/blob/develop/api/src/routes/platform/projects/ref/settings.controller.ts#L92 - const { can: canReadAPIKeys } = useAsyncCheckProjectPermissions( + const { can: canReadAPIKeys } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, '*' ) diff --git a/apps/studio/data/organizations/organization-customer-profile-query.ts b/apps/studio/data/organizations/organization-customer-profile-query.ts index b95c9a7a15e..7fe953a0a17 100644 --- a/apps/studio/data/organizations/organization-customer-profile-query.ts +++ b/apps/studio/data/organizations/organization-customer-profile-query.ts @@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { ResponseError } from 'types' import { organizationKeys } from './keys' @@ -43,7 +43,7 @@ export const useOrganizationCustomerProfileQuery = { // [Joshen] Thinking it makes sense to add this check at the RQ level - prevent // unnecessary requests, although this behaviour still needs handling on the UI - const { can: canReadCustomerProfile } = useAsyncCheckProjectPermissions( + const { can: canReadCustomerProfile } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, 'stripe.customer' ) diff --git a/apps/studio/data/organizations/organization-payment-methods-query.ts b/apps/studio/data/organizations/organization-payment-methods-query.ts index 800fd895f0f..cc22ddd36c5 100644 --- a/apps/studio/data/organizations/organization-payment-methods-query.ts +++ b/apps/studio/data/organizations/organization-payment-methods-query.ts @@ -3,7 +3,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { components } from 'api-types' import { get, handleError } from 'data/fetchers' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { ResponseError } from 'types' import { organizationKeys } from './keys' @@ -44,7 +44,7 @@ export const useOrganizationPaymentMethodsQuery = = {} ) => { - const { can: canReadSubscriptions } = useAsyncCheckProjectPermissions( + const { can: canReadSubscriptions } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, 'stripe.payment_methods' ) diff --git a/apps/studio/data/organizations/organization-tax-id-query.ts b/apps/studio/data/organizations/organization-tax-id-query.ts index 06e7ca65896..ede1efe0c2f 100644 --- a/apps/studio/data/organizations/organization-tax-id-query.ts +++ b/apps/studio/data/organizations/organization-tax-id-query.ts @@ -3,7 +3,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { components } from 'api-types' import { get, handleError } from 'data/fetchers' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { ResponseError } from 'types' import { organizationKeys } from './keys' @@ -36,7 +36,7 @@ export const useOrganizationTaxIdQuery = ( ...options }: UseQueryOptions = {} ) => { - const { can: canReadSubscriptions } = useAsyncCheckProjectPermissions( + const { can: canReadSubscriptions } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, 'stripe.tax_ids' ) diff --git a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts index ed59e90e08c..56752fd6346 100644 --- a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts +++ b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts @@ -9,7 +9,7 @@ import { import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { FDWCreateVariables, useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useS3AccessKeyCreateMutation } from './s3-access-key-create-mutation' @@ -27,7 +27,7 @@ export const useIcebergWrapperCreateMutation = () => { const wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === 'iceberg_wrapper') - const { can: canCreateCredentials } = useAsyncCheckProjectPermissions( + const { can: canCreateCredentials } = useAsyncCheckPermissions( PermissionAction.STORAGE_ADMIN_WRITE, '*' ) diff --git a/apps/studio/data/subscriptions/org-plans-query.ts b/apps/studio/data/subscriptions/org-plans-query.ts index a79e76e9527..ab1a68ee525 100644 --- a/apps/studio/data/subscriptions/org-plans-query.ts +++ b/apps/studio/data/subscriptions/org-plans-query.ts @@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { subscriptionKeys } from './keys' export type OrgPlansVariables = { @@ -27,7 +27,7 @@ export const useOrgPlansQuery = ( { orgSlug }: OrgPlansVariables, { enabled = true, ...options }: UseQueryOptions = {} ) => { - const { can: canReadSubscriptions } = useAsyncCheckProjectPermissions( + const { can: canReadSubscriptions } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, 'stripe.subscriptions' ) diff --git a/apps/studio/data/subscriptions/org-subscription-query.ts b/apps/studio/data/subscriptions/org-subscription-query.ts index a91e6f29e7f..c2d068bbbec 100644 --- a/apps/studio/data/subscriptions/org-subscription-query.ts +++ b/apps/studio/data/subscriptions/org-subscription-query.ts @@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { ResponseError } from 'types' import { subscriptionKeys } from './keys' @@ -37,7 +37,7 @@ export const useOrgSubscriptionQuery = ( ) => { // [Joshen] Thinking it makes sense to add this check at the RQ level - prevent // unnecessary requests, although this behaviour still needs handling on the UI - const { can: canReadSubscriptions } = useAsyncCheckProjectPermissions( + const { can: canReadSubscriptions } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, 'stripe.subscriptions' ) diff --git a/apps/studio/hooks/forms/useAIOptInForm.ts b/apps/studio/hooks/forms/useAIOptInForm.ts index 9a85468fb17..10f94b86252 100644 --- a/apps/studio/hooks/forms/useAIOptInForm.ts +++ b/apps/studio/hooks/forms/useAIOptInForm.ts @@ -8,7 +8,7 @@ import * as z from 'zod' import { LOCAL_STORAGE_KEYS } from 'common' import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' @@ -31,7 +31,7 @@ export type AIOptInFormValues = z.infer export const useAIOptInForm = (onSuccessCallback?: () => void) => { const queryClient = useQueryClient() const { data: selectedOrganization } = useSelectedOrganizationQuery() - const { can: canUpdateOrganization } = useAsyncCheckProjectPermissions( + const { can: canUpdateOrganization } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'organizations' ) diff --git a/apps/studio/hooks/misc/useCheckPermissions.ts b/apps/studio/hooks/misc/useCheckPermissions.ts index 15437b5f3c9..8b2106c3d58 100644 --- a/apps/studio/hooks/misc/useCheckPermissions.ts +++ b/apps/studio/hooks/misc/useCheckPermissions.ts @@ -1,9 +1,7 @@ import { useIsLoggedIn, useParams } from 'common' import jsonLogic from 'json-logic-js' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { usePermissionsQuery } from 'data/permissions/permissions-query' -import { useProjectDetailQuery } from 'data/projects/project-detail-query' import { IS_PLATFORM } from 'lib/constants' import type { Permission } from 'types' import { useSelectedOrganizationQuery } from './useSelectedOrganization' @@ -74,7 +72,7 @@ export function useGetPermissions( return useGetProjectPermissions(permissionsOverride, organizationSlugOverride, undefined, enabled) } -export function useGetProjectPermissions( +function useGetProjectPermissions( permissionsOverride?: Permission[], organizationSlugOverride?: string, projectRefOverride?: string, @@ -135,78 +133,9 @@ export function useGetProjectPermissions( } } -/** - * @deprecated If checking for project permissions, use useAsyncCheckProjectPermissions instead so that we can always - * check for loading states to not prematurely show "no perms" UIs. We'll also need a separate async check for org perms too - * - * Use `import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'` instead - * [Joshen] No longer being used, can be deprecated in follow up PR - */ -export function useCheckPermissions( - action: string, - resource: string, - data?: object, - // [Joshen] Pass the variables if you want to avoid hooks in this - // e.g If you want to use useCheckPermissions in a loop like organization settings - organizationSlug?: string, - permissions?: Permission[] -) { - return useCheckProjectPermissions(action, resource, data, { - organizationSlug, - projectRef: undefined, - permissions, - }) -} - -export function useCheckProjectPermissions( - action: string, - resource: string, - data?: object, - overrides?: { - organizationSlug?: string - projectRef?: string - permissions?: Permission[] - } -) { - const isLoggedIn = useIsLoggedIn() - const { organizationSlug, projectRef, permissions } = overrides ?? {} - - const { - permissions: allPermissions, - organizationSlug: _organizationSlug, - projectRef: _projectRef, - } = useGetProjectPermissions(permissions, organizationSlug, projectRef, isLoggedIn) - - if (!isLoggedIn) return false - if (!IS_PLATFORM) return true - - return doPermissionsCheck(allPermissions, action, resource, data, _organizationSlug, _projectRef) -} - -/** [Joshen] No longer being used, can be deprecated in follow up PR */ -export function usePermissionsLoaded() { - const isLoggedIn = useIsLoggedIn() - const { isFetched: isPermissionsFetched } = usePermissionsQuery({ enabled: isLoggedIn }) - const { isFetched: isOrganizationsFetched } = useOrganizationsQuery({ enabled: isLoggedIn }) - - const { ref } = useParams() - const { isFetched: isProjectDetailFetched } = useProjectDetailQuery( - { ref }, - { enabled: !!ref && isLoggedIn } - ) - - if (!IS_PLATFORM) return true - - if (ref) { - return isLoggedIn && isPermissionsFetched && isOrganizationsFetched && isProjectDetailFetched - } - - return isLoggedIn && isPermissionsFetched && isOrganizationsFetched -} - /** [Joshen] To be renamed to be useAsyncCheckPermissions, more generic as it covers both org and project perms */ // Useful when you want to avoid layout changes while waiting for permissions to load -export function useAsyncCheckProjectPermissions( +export function useAsyncCheckPermissions( action: string, resource: string, data?: object, diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index beaa6dacab5..c1c01b84783 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -49,7 +49,7 @@ import { import { useProjectsQuery } from 'data/projects/projects-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCustomContent } from 'hooks/custom-content/useCustomContent' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' @@ -260,7 +260,7 @@ const Wizard: NextPageWithLayout = () => { ? availableRegionsData?.recommendations.smartGroup.name : _defaultRegion - const { can: isAdmin } = useAsyncCheckProjectPermissions(PermissionAction.CREATE, 'projects') + const { can: isAdmin } = useAsyncCheckPermissions(PermissionAction.CREATE, 'projects') const isInvalidSlug = isOrganizationsSuccess && currentOrg === undefined const orgNotFound = isOrganizationsSuccess && (organizations?.length ?? 0) > 0 && isInvalidSlug diff --git a/apps/studio/pages/project/[ref]/auth/advanced.tsx b/apps/studio/pages/project/[ref]/auth/advanced.tsx index 254e8c0721d..923f7014305 100644 --- a/apps/studio/pages/project/[ref]/auth/advanced.tsx +++ b/apps/studio/pages/project/[ref]/auth/advanced.tsx @@ -9,7 +9,7 @@ import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { UnknownInterface } from 'components/ui/UnknownInterface' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import type { NextPageWithLayout } from 'types' @@ -17,8 +17,10 @@ const AdvancedPage: NextPageWithLayout = () => { const { ref } = useParams() const showAdvanced = useIsFeatureEnabled('authentication:advanced') - const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'custom_config_gotrue') + const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'custom_config_gotrue' + ) if (!showAdvanced) { return diff --git a/apps/studio/pages/project/[ref]/auth/audit-logs.tsx b/apps/studio/pages/project/[ref]/auth/audit-logs.tsx index 0b3554b0bee..6f955ce1ec1 100644 --- a/apps/studio/pages/project/[ref]/auth/audit-logs.tsx +++ b/apps/studio/pages/project/[ref]/auth/audit-logs.tsx @@ -10,14 +10,16 @@ import { DocsButton } from 'components/ui/DocsButton' import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useAuthConfigQuery } from 'data/auth/auth-config-query' -import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' const AuditLogsPage: NextPageWithLayout = () => { const { ref: projectRef } = useParams() - const isPermissionsLoaded = usePermissionsLoaded() const { isLoading: isLoadingConfig } = useAuthConfigQuery({ projectRef }) - const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') + const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'custom_config_gotrue' + ) if (isPermissionsLoaded && !canReadAuthSettings) { return diff --git a/apps/studio/pages/project/[ref]/auth/hooks.tsx b/apps/studio/pages/project/[ref]/auth/hooks.tsx index ebea0d189d3..023327154fa 100644 --- a/apps/studio/pages/project/[ref]/auth/hooks.tsx +++ b/apps/studio/pages/project/[ref]/auth/hooks.tsx @@ -7,13 +7,15 @@ import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { DocsButton } from 'components/ui/DocsButton' import NoPermission from 'components/ui/NoPermission' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' import { GenericSkeletonLoader } from 'ui-patterns' const Hooks: NextPageWithLayout = () => { - const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'custom_config_gotrue') + const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'custom_config_gotrue' + ) if (isPermissionsLoaded && !canReadAuthSettings) { return diff --git a/apps/studio/pages/project/[ref]/auth/mfa.tsx b/apps/studio/pages/project/[ref]/auth/mfa.tsx index 30844c34b80..d6919211c31 100644 --- a/apps/studio/pages/project/[ref]/auth/mfa.tsx +++ b/apps/studio/pages/project/[ref]/auth/mfa.tsx @@ -9,7 +9,7 @@ import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { UnknownInterface } from 'components/ui/UnknownInterface' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import type { NextPageWithLayout } from 'types' @@ -17,8 +17,10 @@ const MfaPage: NextPageWithLayout = () => { const { ref } = useParams() const showMFA = useIsFeatureEnabled('authentication:multi_factor') - const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'custom_config_gotrue') + const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'custom_config_gotrue' + ) if (!showMFA) { return diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index c66c3b62ae9..cc3bd1269d5 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -20,7 +20,7 @@ import SchemaSelector from 'components/ui/SchemaSelector' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import { useTablesQuery } from 'data/tables/tables-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' @@ -98,7 +98,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { }) const filteredTables = onFilterTables(tables ?? [], policies ?? [], searchString) - const { can: canReadPolicies, isSuccess: isPermissionsLoaded } = useAsyncCheckProjectPermissions( + const { can: canReadPolicies, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'policies' ) diff --git a/apps/studio/pages/project/[ref]/auth/protection.tsx b/apps/studio/pages/project/[ref]/auth/protection.tsx index 6a18dee5f60..efd6eec56a1 100644 --- a/apps/studio/pages/project/[ref]/auth/protection.tsx +++ b/apps/studio/pages/project/[ref]/auth/protection.tsx @@ -9,7 +9,7 @@ import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { UnknownInterface } from 'components/ui/UnknownInterface' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import type { NextPageWithLayout } from 'types' @@ -17,8 +17,10 @@ const ProtectionPage: NextPageWithLayout = () => { const { ref } = useParams() const showAttackProtection = useIsFeatureEnabled('authentication:attack_protection') - const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'custom_config_gotrue') + const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'custom_config_gotrue' + ) if (!showAttackProtection) { return diff --git a/apps/studio/pages/project/[ref]/auth/rate-limits.tsx b/apps/studio/pages/project/[ref]/auth/rate-limits.tsx index 9c736babb4d..4b6d186e64d 100644 --- a/apps/studio/pages/project/[ref]/auth/rate-limits.tsx +++ b/apps/studio/pages/project/[ref]/auth/rate-limits.tsx @@ -10,7 +10,7 @@ import { DocsButton } from 'components/ui/DocsButton' import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { UnknownInterface } from 'components/ui/UnknownInterface' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import type { NextPageWithLayout } from 'types' @@ -18,8 +18,10 @@ const RateLimitsPage: NextPageWithLayout = () => { const { ref } = useParams() const showRateLimits = useIsFeatureEnabled('authentication:rate_limits') - const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'custom_config_gotrue') + const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'custom_config_gotrue' + ) if (!showRateLimits) { return diff --git a/apps/studio/pages/project/[ref]/auth/sessions.tsx b/apps/studio/pages/project/[ref]/auth/sessions.tsx index 683b111196f..eb22314bb1d 100644 --- a/apps/studio/pages/project/[ref]/auth/sessions.tsx +++ b/apps/studio/pages/project/[ref]/auth/sessions.tsx @@ -7,12 +7,14 @@ import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' const SessionsPage: NextPageWithLayout = () => { - const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'custom_config_gotrue') + const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'custom_config_gotrue' + ) if (isPermissionsLoaded && !canReadAuthSettings) { return diff --git a/apps/studio/pages/project/[ref]/auth/smtp.tsx b/apps/studio/pages/project/[ref]/auth/smtp.tsx index b6bd764a908..ba95e4c9eed 100644 --- a/apps/studio/pages/project/[ref]/auth/smtp.tsx +++ b/apps/studio/pages/project/[ref]/auth/smtp.tsx @@ -5,13 +5,15 @@ import { AuthEmailsLayout } from 'components/layouts/AuthLayout/AuthEmailsLayout import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' import { GenericSkeletonLoader } from 'ui-patterns' const SmtpPage: NextPageWithLayout = () => { - const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'custom_config_gotrue') + const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'custom_config_gotrue' + ) if (isPermissionsLoaded && !canReadAuthSettings) { return diff --git a/apps/studio/pages/project/[ref]/auth/templates.tsx b/apps/studio/pages/project/[ref]/auth/templates.tsx index 8ac5ec3aacd..cc1ccbaa0fe 100644 --- a/apps/studio/pages/project/[ref]/auth/templates.tsx +++ b/apps/studio/pages/project/[ref]/auth/templates.tsx @@ -5,13 +5,15 @@ import { AuthEmailsLayout } from 'components/layouts/AuthLayout/AuthEmailsLayout import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' import { GenericSkeletonLoader } from 'ui-patterns' const TemplatesPage: NextPageWithLayout = () => { - const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'custom_config_gotrue') + const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'custom_config_gotrue' + ) if (isPermissionsLoaded && !canReadAuthSettings) { return diff --git a/apps/studio/pages/project/[ref]/auth/third-party.tsx b/apps/studio/pages/project/[ref]/auth/third-party.tsx index fac8db8e2a3..6e989a5d6c5 100644 --- a/apps/studio/pages/project/[ref]/auth/third-party.tsx +++ b/apps/studio/pages/project/[ref]/auth/third-party.tsx @@ -8,14 +8,16 @@ import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainer } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import { UnknownInterface } from 'components/ui/UnknownInterface' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import type { NextPageWithLayout } from 'types' const ThirdPartyPage: NextPageWithLayout = () => { const { ref } = useParams() - const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'custom_config_gotrue') + const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'custom_config_gotrue' + ) const showThirdPartyAuth = useIsFeatureEnabled('authentication:third_party_auth') diff --git a/apps/studio/pages/project/[ref]/auth/url-configuration.tsx b/apps/studio/pages/project/[ref]/auth/url-configuration.tsx index 81df0a87cd9..442d9e9e257 100644 --- a/apps/studio/pages/project/[ref]/auth/url-configuration.tsx +++ b/apps/studio/pages/project/[ref]/auth/url-configuration.tsx @@ -8,12 +8,14 @@ import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' const URLConfiguration: NextPageWithLayout = () => { - const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'custom_config_gotrue') + const { can: canReadAuthSettings, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'custom_config_gotrue' + ) if (isPermissionsLoaded && !canReadAuthSettings) { return diff --git a/apps/studio/pages/project/[ref]/branches/index.tsx b/apps/studio/pages/project/[ref]/branches/index.tsx index 006d7b5ff28..30cb0df9335 100644 --- a/apps/studio/pages/project/[ref]/branches/index.tsx +++ b/apps/studio/pages/project/[ref]/branches/index.tsx @@ -19,7 +19,7 @@ import { useBranchDeleteMutation } from 'data/branches/branch-delete-mutation' import { Branch, useBranchesQuery } from 'data/branches/branches-query' import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useAppStateSnapshot } from 'state/app-state' @@ -42,7 +42,7 @@ const BranchesPage: NextPageWithLayout = () => { const projectRef = project !== undefined ? (isBranch ? project.parent_project_ref : ref) : undefined - const { can: canReadBranches, isSuccess: isPermissionsLoaded } = useAsyncCheckProjectPermissions( + const { can: canReadBranches, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.READ, 'preview_branches' ) @@ -186,7 +186,7 @@ const BranchesPage: NextPageWithLayout = () => { BranchesPage.getLayout = (page) => { const BranchesPageWrapper = () => { const snap = useAppStateSnapshot() - const { can: canCreateBranches } = useAsyncCheckProjectPermissions( + const { can: canCreateBranches } = useAsyncCheckPermissions( PermissionAction.CREATE, 'preview_branches', { diff --git a/apps/studio/pages/project/[ref]/branches/merge-requests.tsx b/apps/studio/pages/project/[ref]/branches/merge-requests.tsx index 749b79d9160..d66359dc379 100644 --- a/apps/studio/pages/project/[ref]/branches/merge-requests.tsx +++ b/apps/studio/pages/project/[ref]/branches/merge-requests.tsx @@ -24,7 +24,7 @@ import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' import { Branch, useBranchesQuery } from 'data/branches/branches-query' import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import type { NextPageWithLayout } from 'types' @@ -49,7 +49,7 @@ const MergeRequestsPage: NextPageWithLayout = () => { const projectRef = project !== undefined ? (isBranch ? project.parent_project_ref : ref) : undefined - const { can: canReadBranches, isSuccess: isPermissionsLoaded } = useAsyncCheckProjectPermissions( + const { can: canReadBranches, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.READ, 'preview_branches' ) diff --git a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx index ae534f92f13..94679b8eeb6 100644 --- a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -14,7 +14,7 @@ import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import UpgradeToPro from 'components/ui/UpgradeToPro' import { useBackupsQuery } from 'data/database/backups-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useIsOrioleDbInAws, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' @@ -57,8 +57,10 @@ const PITR = () => { const isEnabled = backups?.pitr_enabled const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY - const { can: canReadPhysicalBackups, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'physical_backups') + const { can: canReadPhysicalBackups, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'physical_backups' + ) if (isPermissionsLoaded && !canReadPhysicalBackups) { return diff --git a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx index 31a58c8bccb..56016f6392c 100644 --- a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx @@ -23,7 +23,7 @@ import UpgradeToPro from 'components/ui/UpgradeToPro' import { useDiskAttributesQuery } from 'data/config/disk-attributes-query' import { useCloneBackupsQuery } from 'data/projects/clone-query' import { useCloneStatusQuery } from 'data/projects/clone-status-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useIsAwsK8sCloudProvider, @@ -82,9 +82,11 @@ const RestoreToNewProject = () => { const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY - const { can: canReadPhysicalBackups, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'physical_backups') - const { can: canTriggerPhysicalBackups } = useAsyncCheckProjectPermissions( + const { can: canReadPhysicalBackups, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'physical_backups' + ) + const { can: canTriggerPhysicalBackups } = useAsyncCheckPermissions( PermissionAction.INFRA_EXECUTE, 'queue_job.restore.prepare' ) diff --git a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx index 52b0838add5..b0a3b0c6c80 100644 --- a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx @@ -14,7 +14,7 @@ import InformationBox from 'components/ui/InformationBox' import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useBackupsQuery } from 'data/database/backups-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsOrioleDbInAws } from 'hooks/misc/useSelectedProject' import type { NextPageWithLayout } from 'types' import { Admonition } from 'ui-patterns' @@ -27,8 +27,10 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => { const isOrioleDbInAws = useIsOrioleDbInAws() const isPitrEnabled = backups?.pitr_enabled - const { can: canReadScheduledBackups, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.READ, 'back_ups') + const { can: canReadScheduledBackups, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.READ, + 'back_ups' + ) return ( diff --git a/apps/studio/pages/project/[ref]/database/extensions.tsx b/apps/studio/pages/project/[ref]/database/extensions.tsx index 01bc3ba3e84..51b06d46f71 100644 --- a/apps/studio/pages/project/[ref]/database/extensions.tsx +++ b/apps/studio/pages/project/[ref]/database/extensions.tsx @@ -6,12 +6,14 @@ import DefaultLayout from 'components/layouts/DefaultLayout' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' const DatabaseExtensions: NextPageWithLayout = () => { - const { can: canReadExtensions, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'extensions') + const { can: canReadExtensions, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_READ, + 'extensions' + ) if (isPermissionsLoaded && !canReadExtensions) { return diff --git a/apps/studio/pages/project/[ref]/database/functions.tsx b/apps/studio/pages/project/[ref]/database/functions.tsx index fc74e83002a..1ab76e36bb8 100644 --- a/apps/studio/pages/project/[ref]/database/functions.tsx +++ b/apps/studio/pages/project/[ref]/database/functions.tsx @@ -11,7 +11,7 @@ import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel' import { FormHeader } from 'components/ui/Forms/FormHeader' import NoPermission from 'components/ui/NoPermission' import { DatabaseFunction } from 'data/database-functions/database-functions-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' const DatabaseFunctionsPage: NextPageWithLayout = () => { @@ -26,7 +26,7 @@ const DatabaseFunctionsPage: NextPageWithLayout = () => { DatabaseFunction | undefined >() - const { can: canReadFunctions, isSuccess: isPermissionsLoaded } = useAsyncCheckProjectPermissions( + const { can: canReadFunctions, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'functions' ) diff --git a/apps/studio/pages/project/[ref]/database/publications/[id].tsx b/apps/studio/pages/project/[ref]/database/publications/[id].tsx index 81fec4d623a..2905ae2a7c1 100644 --- a/apps/studio/pages/project/[ref]/database/publications/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/publications/[id].tsx @@ -6,12 +6,14 @@ import DefaultLayout from 'components/layouts/DefaultLayout' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' const DatabasePublications: NextPageWithLayout = () => { - const { can: canViewPublications, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'publications') + const { can: canViewPublications, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_READ, + 'publications' + ) if (isPermissionsLoaded && !canViewPublications) { return diff --git a/apps/studio/pages/project/[ref]/database/publications/index.tsx b/apps/studio/pages/project/[ref]/database/publications/index.tsx index b5ee05c33dc..ddf605455a4 100644 --- a/apps/studio/pages/project/[ref]/database/publications/index.tsx +++ b/apps/studio/pages/project/[ref]/database/publications/index.tsx @@ -6,12 +6,14 @@ import DefaultLayout from 'components/layouts/DefaultLayout' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' const DatabasePublications: NextPageWithLayout = () => { - const { can: canViewPublications, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'publications') + const { can: canViewPublications, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_READ, + 'publications' + ) if (isPermissionsLoaded && !canViewPublications) { return diff --git a/apps/studio/pages/project/[ref]/database/triggers.tsx b/apps/studio/pages/project/[ref]/database/triggers.tsx index 4f90286f184..e483e2f752c 100644 --- a/apps/studio/pages/project/[ref]/database/triggers.tsx +++ b/apps/studio/pages/project/[ref]/database/triggers.tsx @@ -13,7 +13,7 @@ import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel' import { FormHeader } from 'components/ui/Forms/FormHeader' import NoPermission from 'components/ui/NoPermission' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' const TriggersPage: NextPageWithLayout = () => { @@ -27,7 +27,7 @@ const TriggersPage: NextPageWithLayout = () => { const [editorPanelOpen, setEditorPanelOpen] = useState(false) const [selectedTriggerForEditor, setSelectedTriggerForEditor] = useState() - const { can: canReadTriggers, isSuccess: isPermissionsLoaded } = useAsyncCheckProjectPermissions( + const { can: canReadTriggers, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers' ) diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx index 21ab897ec44..2de8d353006 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx @@ -14,7 +14,7 @@ import { useEdgeFunctionBodyQuery } from 'data/edge-functions/edge-function-body import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' import { useEdgeFunctionDeployMutation } from 'data/edge-functions/edge-functions-deploy-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { BASE_PATH } from 'lib/constants' @@ -28,10 +28,7 @@ const CodePage = () => { const { mutate: sendEvent } = useSendEventMutation() const [showDeployWarning, setShowDeployWarning] = useState(false) - const { can: canDeployFunction } = useAsyncCheckProjectPermissions( - PermissionAction.FUNCTIONS_WRITE, - '*' - ) + const { can: canDeployFunction } = useAsyncCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*') const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug }) const { diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index dc9cd65fb22..e09f4959457 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -7,6 +7,7 @@ import sumBy from 'lodash/sumBy' import { useRouter } from 'next/router' import { useMemo, useState } from 'react' +import { useFlag } from 'common' import ReportWidget from 'components/interfaces/Reports/ReportWidget' import DefaultLayout from 'components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' @@ -19,7 +20,7 @@ import { } from 'data/analytics/functions-combined-stats-query' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { ChartIntervals, NextPageWithLayout } from 'types' import { AlertDescription_Shadcn_, @@ -28,7 +29,6 @@ import { Button, WarningIcon, } from 'ui' -import { useFlag } from 'common' const CHART_INTERVALS: ChartIntervals[] = [ { @@ -122,7 +122,7 @@ const PageLayout: NextPageWithLayout = () => { endDate.toISOString() ) - const { isLoading: permissionsLoading, can: canReadFunction } = useAsyncCheckProjectPermissions( + const { isLoading: permissionsLoading, can: canReadFunction } = useAsyncCheckPermissions( PermissionAction.FUNCTIONS_READ, functionSlug as string ) diff --git a/apps/studio/pages/project/[ref]/logs/auth-logs.tsx b/apps/studio/pages/project/[ref]/logs/auth-logs.tsx index 3992a84cf04..cf03e3b2bd3 100644 --- a/apps/studio/pages/project/[ref]/logs/auth-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/auth-logs.tsx @@ -4,13 +4,13 @@ import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' import DefaultLayout from 'components/layouts/DefaultLayout' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import NoPermission from 'components/ui/NoPermission' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import type { NextPageWithLayout } from 'types' const LogsPage: NextPageWithLayout = () => { const { data: project } = useSelectedProjectQuery() - const { can: canReadAuthLogs } = useAsyncCheckProjectPermissions( + const { can: canReadAuthLogs } = useAsyncCheckPermissions( PermissionAction.ANALYTICS_READ, 'logflare' ) diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index 959967b54aa..d792f2b9114 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -34,7 +34,7 @@ import { useMaxConnectionsQuery } from 'data/database/max-connections-query' import { usePgbouncerConfigQuery } from 'data/database/pgbouncer-config-query' import { getReportAttributes, getReportAttributesV2 } from 'data/reports/database-charts' import { useDatabaseReport } from 'data/reports/database-report-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useReportDateRange } from 'hooks/misc/useReportDateRange' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -107,7 +107,7 @@ const DatabaseUsage = () => { }) const { data: poolerConfig } = usePgbouncerConfigQuery({ projectRef: project?.ref }) - const { can: canUpdateDiskSizeConfig } = useAsyncCheckProjectPermissions( + const { can: canUpdateDiskSizeConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', { diff --git a/apps/studio/pages/project/[ref]/reports/index.tsx b/apps/studio/pages/project/[ref]/reports/index.tsx index ff2cc6e12e6..3fa77b7124c 100644 --- a/apps/studio/pages/project/[ref]/reports/index.tsx +++ b/apps/studio/pages/project/[ref]/reports/index.tsx @@ -9,7 +9,7 @@ import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' import { Loading } from 'components/ui/Loading' import { useContentQuery } from 'data/content/content-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useProfile } from 'lib/profile' import type { NextPageWithLayout } from 'types' @@ -36,7 +36,7 @@ export const UserReportPage: NextPageWithLayout = () => { } ) - const { can: canCreateReport } = useAsyncCheckProjectPermissions( + const { can: canCreateReport } = useAsyncCheckPermissions( PermissionAction.CREATE, 'user_content', { diff --git a/apps/studio/pages/project/[ref]/settings/jwt/signing-keys.tsx b/apps/studio/pages/project/[ref]/settings/jwt/signing-keys.tsx index 99b866a2e94..1013828e9ed 100644 --- a/apps/studio/pages/project/[ref]/settings/jwt/signing-keys.tsx +++ b/apps/studio/pages/project/[ref]/settings/jwt/signing-keys.tsx @@ -6,11 +6,11 @@ import JWTKeysLayout from 'components/layouts/JWTKeys/JWTKeysLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' const JWTSigningKeysPage: NextPageWithLayout = () => { - const { can: canReadAPIKeys, isSuccess: isPermissionsLoaded } = useAsyncCheckProjectPermissions( + const { can: canReadAPIKeys, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.READ, 'auth_signing_keys' ) diff --git a/apps/studio/pages/project/[ref]/settings/log-drains.tsx b/apps/studio/pages/project/[ref]/settings/log-drains.tsx index cdd982e072b..b118cf9bccd 100644 --- a/apps/studio/pages/project/[ref]/settings/log-drains.tsx +++ b/apps/studio/pages/project/[ref]/settings/log-drains.tsx @@ -18,15 +18,17 @@ import { DocsButton } from 'components/ui/DocsButton' import { useCreateLogDrainMutation } from 'data/log-drains/create-log-drain-mutation' import { LogDrainData, useLogDrainsQuery } from 'data/log-drains/log-drains-query' import { useUpdateLogDrainMutation } from 'data/log-drains/update-log-drain-mutation' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import type { NextPageWithLayout } from 'types' import { Alert_Shadcn_, Button } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns' const LogDrainsSettings: NextPageWithLayout = () => { - const { can: canManageLogDrains, isLoading: isLoadingPermissions } = - useAsyncCheckProjectPermissions(PermissionAction.ANALYTICS_ADMIN_WRITE, 'logflare') + const { can: canManageLogDrains, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.ANALYTICS_ADMIN_WRITE, + 'logflare' + ) const [open, setOpen] = useState(false) const { ref } = useParams() as { ref: string } From 10cc742349eac3b39c0d6a09b5e3f4c597a61688 Mon Sep 17 00:00:00 2001 From: David <150087495+HarmonyV@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:33:47 +0200 Subject: [PATCH 11/76] Update auth-google.mdx (#36780) Update flutter section based on google_sign_in 7.x --- .../guides/auth/social-login/auth-google.mdx | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/docs/content/guides/auth/social-login/auth-google.mdx b/apps/docs/content/guides/auth/social-login/auth-google.mdx index 520fb9a3a00..e73d62e6cf0 100644 --- a/apps/docs/content/guides/auth/social-login/auth-google.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-google.mdx @@ -540,26 +540,37 @@ Future _nativeGoogleSignIn() async { /// iOS Client ID that you registered with Google Cloud. const iosClientId = 'my-ios.apps.googleusercontent.com'; - final GoogleSignIn googleSignIn = GoogleSignIn( - clientId: iosClientId, - serverClientId: webClientId, - ); - final googleUser = await googleSignIn.signIn(); - final googleAuth = await googleUser!.authentication; - final accessToken = googleAuth.accessToken; - final idToken = googleAuth.idToken; + final scopes = ['email', 'profile']; + final googleSignIn = GoogleSignIn.instance; - if (accessToken == null) { - throw 'No Access Token found.'; + await googleSignIn.initialize( + serverClientId: webClientId, + clientId: iosClientId, + ); + + final googleUser = await googleSignIn.attemptLightweightAuthentication(); + // or await googleSignIn.authenticate(); which will return a GoogleSignInAccount or throw an exception + + if (googleUser == null) { + throw AuthException('Failed to sign in with Google.'); } + + /// Authorization is required to obtain the access token with the appropriate scopes for Supabase authentication, + /// while also granting permission to access user information. + final authorization = + await googleUser.authorizationClient.authorizationForScopes(scopes) ?? + await googleUser.authorizationClient.authorizeScopes(scopes); + + final idToken = googleUser.authentication.idToken; + if (idToken == null) { - throw 'No ID Token found.'; + throw AuthException('No ID Token found.'); } await supabase.auth.signInWithIdToken( provider: OAuthProvider.google, idToken: idToken, - accessToken: accessToken, + accessToken: authorization.accessToken, ); } ... From b4922199cd22f91da3da8d561d3c84484137f3ec Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Tue, 16 Sep 2025 13:03:13 +0200 Subject: [PATCH 12/76] unblock cms draft mode (#38409) * remove port from cms start script * address cors * 30s revalidation on blog index * fix types * remove duplicate cache strategy * disable graphql * fix cms build * fix ProductDropdown crash * fix env var turbo --- apps/cms/next.config.mjs | 4 - apps/cms/package.json | 2 +- .../(payload)/api/graphql-playground/route.ts | 7 - .../src/app/(payload)/api/graphql/route.ts | 8 -- apps/cms/src/payload.config.ts | 2 +- apps/www/app/api-v2/cms-posts/route.ts | 135 ++++++++++++------ apps/www/app/api-v2/cms/revalidate/route.ts | 45 +++--- apps/www/app/api-v2/ticket-og/route.tsx | 2 +- apps/www/app/blog/[slug]/page.tsx | 2 +- .../app/blog/categories/[category]/page.tsx | 1 + apps/www/app/blog/tags/[tag]/page.tsx | 1 + apps/www/components/Nav/ProductDropdown.tsx | 2 - apps/www/lib/constants.ts | 4 +- apps/www/lib/get-cms-posts.tsx | 8 +- apps/www/pages/launch-week/6/index.tsx | 2 +- .../6/types6.d.ts => types/launch-week-6.ts} | 0 turbo.json | 1 + 17 files changed, 128 insertions(+), 98 deletions(-) delete mode 100644 apps/cms/src/app/(payload)/api/graphql-playground/route.ts delete mode 100644 apps/cms/src/app/(payload)/api/graphql/route.ts rename apps/www/{pages/launch-week/6/types6.d.ts => types/launch-week-6.ts} (100%) diff --git a/apps/cms/next.config.mjs b/apps/cms/next.config.mjs index 3317297c825..092c737bfb8 100644 --- a/apps/cms/next.config.mjs +++ b/apps/cms/next.config.mjs @@ -47,10 +47,6 @@ const nextConfig = { // We are already running linting via GH action, this will skip linting during production build on Vercel ignoreDuringBuilds: true, }, - experimental: { - // Ensure compatibility with Turbopack and Sharp - serverComponentsExternalPackages: ['sharp'], - }, // Configure Sharp as an external package for server-side rendering serverExternalPackages: ['sharp'], } diff --git a/apps/cms/package.json b/apps/cms/package.json index 13e5841a04e..b4d5728204f 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -15,7 +15,7 @@ "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", "migrate": "cross-env NODE_OPTIONS=--no-deprecation tsx scripts/migrate.ts", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload", - "start": "cross-env NODE_OPTIONS=--no-deprecation next start --port 3030", + "start": "cross-env NODE_OPTIONS=--no-deprecation next start", "vercel-build": "pnpm migrate && pnpm build", "typecheck_IGNORED": "tsc --noEmit" }, diff --git a/apps/cms/src/app/(payload)/api/graphql-playground/route.ts b/apps/cms/src/app/(payload)/api/graphql-playground/route.ts deleted file mode 100644 index 17d2954ca2d..00000000000 --- a/apps/cms/src/app/(payload)/api/graphql-playground/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ -/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ -import config from '@payload-config' -import '@payloadcms/next/css' -import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' - -export const GET = GRAPHQL_PLAYGROUND_GET(config) diff --git a/apps/cms/src/app/(payload)/api/graphql/route.ts b/apps/cms/src/app/(payload)/api/graphql/route.ts deleted file mode 100644 index 2069ff86b0a..00000000000 --- a/apps/cms/src/app/(payload)/api/graphql/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ -/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ -import config from '@payload-config' -import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes' - -export const POST = GRAPHQL_POST(config) - -export const OPTIONS = REST_OPTIONS(config) diff --git a/apps/cms/src/payload.config.ts b/apps/cms/src/payload.config.ts index 8858ce58a5e..8e7a31c40d2 100644 --- a/apps/cms/src/payload.config.ts +++ b/apps/cms/src/payload.config.ts @@ -102,7 +102,7 @@ export default buildConfig({ // Global configuration for better performance globals: [], graphQL: { - disable: process.env.NODE_ENV !== 'development', // Disable GraphQL in production for better performance + disable: true, }, // Reduce payload init overhead telemetry: false, diff --git a/apps/www/app/api-v2/cms-posts/route.ts b/apps/www/app/api-v2/cms-posts/route.ts index 2243cab04db..04f64cf7680 100644 --- a/apps/www/app/api-v2/cms-posts/route.ts +++ b/apps/www/app/api-v2/cms-posts/route.ts @@ -6,6 +6,12 @@ import { generateReadingTime } from '~/lib/helpers' // Lightweight runtime for better performance export const runtime = 'edge' +// CORS headers for cross-origin requests +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} + // Lightweight TOC generation for edge runtime type TocItem = { content: string; slug: string; lvl: number } @@ -115,6 +121,14 @@ function convertRichTextToMarkdown(content: any): string { .join('\n\n') } +// Handle preflight requests +export async function OPTIONS() { + return new Response(null, { + status: 200, + headers: corsHeaders, + }) +} + export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) @@ -212,12 +226,15 @@ export async function GET(request: NextRequest) { _status: latestVersion._status, } - return NextResponse.json({ - success: true, - post: processedPost, - mode, - isDraft: shouldFetchDraft, - }) + return NextResponse.json( + { + success: true, + post: processedPost, + mode, + isDraft: shouldFetchDraft, + }, + { headers: corsHeaders } + ) } } } @@ -289,12 +306,15 @@ export async function GET(request: NextRequest) { _status: post._status, } - return NextResponse.json({ - success: true, - post: processedPost, - mode, - isDraft: shouldFetchDraft, - }) + return NextResponse.json( + { + success: true, + post: processedPost, + mode, + isDraft: shouldFetchDraft, + }, + { headers: corsHeaders } + ) } } @@ -364,12 +384,15 @@ export async function GET(request: NextRequest) { _status: post._status, } - return NextResponse.json({ - success: true, - post: processedPost, - mode, - isDraft: shouldFetchDraft, - }) + return NextResponse.json( + { + success: true, + post: processedPost, + mode, + isDraft: shouldFetchDraft, + }, + { headers: corsHeaders } + ) } } } @@ -452,16 +475,21 @@ export async function GET(request: NextRequest) { toc_depth: post.toc_depth || 3, } - return NextResponse.json({ - success: true, - post: processedPost, - mode, - source: 'versions-api', - }) + return NextResponse.json( + { + success: true, + post: processedPost, + mode, + source: 'versions-api', + }, + { headers: corsHeaders } + ) } } } else { - console.log('[cms-posts] Versions API failed, response:', await versionsResponse.text()) + if (process.env.NODE_ENV !== 'production') { + console.log('[cms-posts] Versions API failed, response:', await versionsResponse.text()) + } } // Strategy 2: If versions API didn't work, try finding the parent post first, then get its latest published version @@ -556,12 +584,15 @@ export async function GET(request: NextRequest) { richContent: mode === 'full' ? post.content : undefined, } - return NextResponse.json({ - success: true, - post: processedPost, - mode, - source: 'versions-by-parent-api', - }) + return NextResponse.json( + { + success: true, + post: processedPost, + mode, + source: 'versions-by-parent-api', + }, + { headers: corsHeaders } + ) } } } @@ -590,6 +621,12 @@ export async function GET(request: NextRequest) { // For individual post requests, don't cache to ensure fresh data cache: slug ? 'no-store' : 'default', next: slug ? undefined : { revalidate: 300 }, + // Add SSL configuration for production + ...(process.env.NODE_ENV === 'production' && { + // Allow self-signed certificates in development, but use proper SSL in production + // This helps with Vercel's internal networking + agent: false, + }), }) if (!response.ok) { @@ -600,7 +637,7 @@ export async function GET(request: NextRequest) { error: 'Failed to fetch posts from CMS', status: response.status, }, - { status: response.status } + { status: response.status, headers: corsHeaders } ) } @@ -613,7 +650,7 @@ export async function GET(request: NextRequest) { error: 'CMS returned non-JSON response', contentType, }, - { status: 502 } + { status: 502, headers: corsHeaders } ) } @@ -695,20 +732,26 @@ export async function GET(request: NextRequest) { // For single post requests, return the post directly if (slug && posts.length > 0) { - return NextResponse.json({ - success: true, - post: posts[0], - mode, - }) + return NextResponse.json( + { + success: true, + post: posts[0], + mode, + }, + { headers: corsHeaders } + ) } - return NextResponse.json({ - success: true, - posts, - total: posts.length, - mode, - cached: true, - }) + return NextResponse.json( + { + success: true, + posts, + total: posts.length, + mode, + cached: true, + }, + { headers: corsHeaders } + ) } catch (error) { console.error('[cms-posts] Error:', error) return NextResponse.json( @@ -717,7 +760,7 @@ export async function GET(request: NextRequest) { error: 'Internal server error', message: error instanceof Error ? error.message : 'Unknown error', }, - { status: 500 } + { status: 500, headers: corsHeaders } ) } } diff --git a/apps/www/app/api-v2/cms/revalidate/route.ts b/apps/www/app/api-v2/cms/revalidate/route.ts index b743c4870dd..3ff73f9e8d8 100644 --- a/apps/www/app/api-v2/cms/revalidate/route.ts +++ b/apps/www/app/api-v2/cms/revalidate/route.ts @@ -1,27 +1,32 @@ -import type { NextApiRequest, NextApiResponse } from 'next' - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - // Check for secret to confirm this is a valid request - if (req.query.secret !== process.env.CMS_PREVIEW_SECRET) { - return res.status(401).json({ message: 'Invalid token' }) - } - - const { path } = req.query - - if (!path) { - return res.status(400).json({ message: 'Missing path parameter' }) - } +import { revalidatePath } from 'next/cache' +import { NextRequest, NextResponse } from 'next/server' +export async function POST(request: NextRequest) { try { - // This will revalidate the specific page - await res.revalidate(String(path)) + const body = await request.json() + const { secret, path } = body - return res.json({ revalidated: true, path }) + // Check for secret to confirm this is a valid request + if (secret !== process.env.CMS_PREVIEW_SECRET) { + return NextResponse.json({ message: 'Invalid token' }, { status: 401 }) + } + + if (!path) { + return NextResponse.json({ message: 'Missing path parameter' }, { status: 400 }) + } + + // This will revalidate the specific page + revalidatePath(path) + + return NextResponse.json({ revalidated: true, path }) } catch (error) { console.error('[Revalidate API] Error during revalidation:', error) - return res.status(500).json({ - message: 'Error revalidating', - error: error instanceof Error ? error.message : 'Unknown error', - }) + return NextResponse.json( + { + message: 'Error revalidating', + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) } } diff --git a/apps/www/app/api-v2/ticket-og/route.tsx b/apps/www/app/api-v2/ticket-og/route.tsx index 6c9ba7807a3..46b216f66f4 100644 --- a/apps/www/app/api-v2/ticket-og/route.tsx +++ b/apps/www/app/api-v2/ticket-og/route.tsx @@ -24,7 +24,7 @@ const FONT_URLS = { const LW_TABLE = 'tickets' const LW_MATERIALIZED_VIEW = 'tickets_view' -export async function GET(req: Request, res: Response) { +export async function GET(req: Request) { const url = new URL(req.url) // Just here to silence snyk false positives diff --git a/apps/www/app/blog/[slug]/page.tsx b/apps/www/app/blog/[slug]/page.tsx index 7f142ae4026..dd3ce29f66f 100644 --- a/apps/www/app/blog/[slug]/page.tsx +++ b/apps/www/app/blog/[slug]/page.tsx @@ -28,7 +28,7 @@ async function getCMSPostFromAPI( const fetchOptions = isDraft ? { // For draft mode: always fresh data, no caching - cache: 'no-store' as const, + // cache: 'no-store' as const, next: { revalidate: 0 }, } : { diff --git a/apps/www/app/blog/categories/[category]/page.tsx b/apps/www/app/blog/categories/[category]/page.tsx index edafaa817a4..b0c8f9ab3be 100644 --- a/apps/www/app/blog/categories/[category]/page.tsx +++ b/apps/www/app/blog/categories/[category]/page.tsx @@ -15,6 +15,7 @@ export async function generateStaticParams() { return categories.map((category: string) => ({ category })) } +export const revalidate = 30 export const dynamic = 'force-static' export async function generateMetadata({ params }: { params: Params }): Promise { diff --git a/apps/www/app/blog/tags/[tag]/page.tsx b/apps/www/app/blog/tags/[tag]/page.tsx index ca270ca9d63..edda447f4a1 100644 --- a/apps/www/app/blog/tags/[tag]/page.tsx +++ b/apps/www/app/blog/tags/[tag]/page.tsx @@ -15,6 +15,7 @@ export async function generateStaticParams() { return tags.map((tag: string) => ({ tag })) } +export const revalidate = 30 export const dynamic = 'force-static' export async function generateMetadata({ params }: { params: Params }): Promise { diff --git a/apps/www/components/Nav/ProductDropdown.tsx b/apps/www/components/Nav/ProductDropdown.tsx index 85207759b10..83e0e586a76 100644 --- a/apps/www/components/Nav/ProductDropdown.tsx +++ b/apps/www/components/Nav/ProductDropdown.tsx @@ -14,10 +14,8 @@ import ComparisonsData from 'data/Comparisons' import CustomersData from 'data/CustomerStories' import MainProductsData from 'data/MainProducts' import ProductModulesData from 'data/ProductModules' -import { useRouter } from 'next/router' export const ProductDropdown = () => { - const { basePath } = useRouter() const isTablet = useBreakpoint(1279) return ( diff --git a/apps/www/lib/constants.ts b/apps/www/lib/constants.ts index b77ad67d3ea..61a17e59bbb 100644 --- a/apps/www/lib/constants.ts +++ b/apps/www/lib/constants.ts @@ -34,8 +34,8 @@ export const SITE_ORIGIN = export const CMS_SITE_ORIGIN = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' - ? // In production, require env or fall back to localhost to avoid hitting supabase.com - 'http://localhost:3030' + ? // In production, use the actual CMS domain + process.env.CMS_SITE_ORIGIN || 'https://cms.supabase.com' : process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL && typeof process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL === 'string' ? `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL.replace('zone-www-dot-com-git-', 'cms-git-')}` diff --git a/apps/www/lib/get-cms-posts.tsx b/apps/www/lib/get-cms-posts.tsx index 6507b71dd27..01dd212eeb8 100644 --- a/apps/www/lib/get-cms-posts.tsx +++ b/apps/www/lib/get-cms-posts.tsx @@ -288,7 +288,7 @@ export async function getCMSPostBySlug(slug: string, preview = false) { 'Content-Type': 'application/json', ...(CMS_API_KEY && { Authorization: `Bearer ${CMS_API_KEY}` }), }, - cache: 'no-store', + // cache: 'no-store', next: { revalidate: 0 }, }) @@ -300,7 +300,7 @@ export async function getCMSPostBySlug(slug: string, preview = false) { 'Content-Type': 'application/json', ...(CMS_API_KEY && { Authorization: `Bearer ${CMS_API_KEY}` }), }, - cache: 'no-store', + // cache: 'no-store', next: { revalidate: 0 }, }) } @@ -490,8 +490,8 @@ export async function getAllCMSPosts({ 'Content-Type': 'application/json', ...(CMS_API_KEY && { Authorization: `Bearer ${CMS_API_KEY}` }), }, - cache: 'no-store', // Ensure we always get fresh data - next: { revalidate: 0 }, // Disable caching for this fetch + // cache: 'no-store', // Ensure we always get fresh data + next: { revalidate: 30 }, // Disable caching for this fetch } ) diff --git a/apps/www/pages/launch-week/6/index.tsx b/apps/www/pages/launch-week/6/index.tsx index f7a2e94ea4a..22989cff44d 100644 --- a/apps/www/pages/launch-week/6/index.tsx +++ b/apps/www/pages/launch-week/6/index.tsx @@ -15,7 +15,7 @@ import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { Accordion, Badge } from 'ui' import { SITE_ORIGIN } from '~/lib/constants' -import { WeekDayProps } from './types6' +import type { WeekDayProps } from '~/types/launch-week-6' import styles from './styles/launchWeek6.module.css' import styleUtils from './styles/utils6.module.css' diff --git a/apps/www/pages/launch-week/6/types6.d.ts b/apps/www/types/launch-week-6.ts similarity index 100% rename from apps/www/pages/launch-week/6/types6.d.ts rename to apps/www/types/launch-week-6.ts diff --git a/turbo.json b/turbo.json index b1b8c12160f..64a448b08ab 100644 --- a/turbo.json +++ b/turbo.json @@ -116,6 +116,7 @@ "ANALYZE", "CMS_API_KEY", "CMS_PREVIEW_SECRET", + "CMS_SITE_ORIGIN", "NEXT_PUBLIC_MISC_USE_URL", "NEXT_PUBLIC_MISC_USE_ANON_KEY", "NEXT_PUBLIC_STUDIO_URL", From 83122eb4c620d1c56428152193b6fed272dd911a Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Tue, 16 Sep 2025 13:58:55 +0200 Subject: [PATCH 13/76] fix query performance links (#38738) * fix links * fix links * rm nextenv * fix formatting changes --- .../troubleshooting/all-about-supabase-egress-a_Sg_e.mdx | 2 +- .../canceling-statement-due-to-statement-timeout-581wFv.mdx | 2 +- ...ficient-privilege-when-accessing-pgstatstatements-e5M_EQ.mdx | 2 +- .../components/layouts/ReportsLayout/Reports.Commands.tsx | 2 +- apps/studio/next.config.js | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx b/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx index a5b41236001..00bd84d90a8 100644 --- a/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx +++ b/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx @@ -20,7 +20,7 @@ You can read about Unified egress, included quota, and how to check the egress u While pointing out the exact cause for egress may not be straightforward, there are various steps you can take to determine the source of these issues: - Picking the "Top Paths" from the [log explorer](https://app.supabase.com/project/_/logs/explorer/templates) will help you identify heavily queried paths -- By finding the most requested queries from Query performance report: https://app.supabase.com/project/_/reports/query-performance +- By finding the most requested queries from Query performance report: https://app.supabase.com/project/_/advisors/query-performance - Supavisor Egress is independent of client. There is no direct relation between a single query and Supavisor egress, it is harder to debug and identify. But you can make use of frequent queries from the link in above step that also displays average number of rows which will help to identify queries with a large number of rows returned. While this does not display Supavisor queries specifically, it will give an overview of queries with lots of rows that can help. - For Storage Egress, all outgoing traffic for storage-related requests to download/view your Storage items are considered as Storage egress. We have a "Storage Egress Requests" template in logs explorer that you can use to get the number of requests for each Storage object - If you pull 1mb of data out of the database using the Supavisor connection in your edge function, but only sends 100kb back to the user, you will have the Egress from the Supavisor to your Edge function plus from the edge function to your user diff --git a/apps/docs/content/troubleshooting/canceling-statement-due-to-statement-timeout-581wFv.mdx b/apps/docs/content/troubleshooting/canceling-statement-due-to-statement-timeout-581wFv.mdx index 29fd5c7f98d..5986805ee2c 100644 --- a/apps/docs/content/troubleshooting/canceling-statement-due-to-statement-timeout-581wFv.mdx +++ b/apps/docs/content/troubleshooting/canceling-statement-due-to-statement-timeout-581wFv.mdx @@ -13,6 +13,6 @@ You can run this query to check the current settings set for your roles: `SELECT To increase the `statement_timeout` for a specific role, you may follow the instructions [here](/docs/guides/database/timeouts#changing-the-default-timeout). Note that it may require a quick reboot for the changes to take effect. -Additionally, to check how long a query is taking, you can check the Query Performance report which can give you more information on the query's performance: https://app.supabase.com/project/_/reports/query-performance. You can use the [query plan analyzer](https://www.postgresql.org/docs/current/sql-explain.html) on any expensive queries that you have identified: `explain analyze ;`. For supabase-js/ PostgREST queries you can use `.explain()`. +Additionally, to check how long a query is taking, you can check the Query Performance report which can give you more information on the query's performance: https://app.supabase.com/project/_/advisors/query-performance. You can use the [query plan analyzer](https://www.postgresql.org/docs/current/sql-explain.html) on any expensive queries that you have identified: `explain analyze ;`. For supabase-js/ PostgREST queries you can use `.explain()`. You can also make use of Postgres logs that will give you useful information like when the query was executed: https://app.supabase.com/project/_/logs/postgres-logs. diff --git a/apps/docs/content/troubleshooting/insufficient-privilege-when-accessing-pgstatstatements-e5M_EQ.mdx b/apps/docs/content/troubleshooting/insufficient-privilege-when-accessing-pgstatstatements-e5M_EQ.mdx index f0e4bd28065..14ce81ba1ba 100644 --- a/apps/docs/content/troubleshooting/insufficient-privilege-when-accessing-pgstatstatements-e5M_EQ.mdx +++ b/apps/docs/content/troubleshooting/insufficient-privilege-when-accessing-pgstatstatements-e5M_EQ.mdx @@ -10,7 +10,7 @@ database_id = "1da315be-38ac-487a-b178-f865a1a73c09" message = "insufficient privilege" --- -If you see the error "insufficient privilege" when accessing [pg_stat_statements](/docs/guides/platform/performance#postgres-cumulative-statistics-system) or when accessing [Query Performance Report](/dashboard/project/_/reports/query-performance), it means that the Postgres role does not have required permissions. +If you see the error "insufficient privilege" when accessing [pg_stat_statements](/docs/guides/platform/performance#postgres-cumulative-statistics-system) or when accessing [Query Performance Report](/dashboard/project/_/advisors/query-performance), it means that the Postgres role does not have required permissions. In this case, you can run the below command to allow the Postgres role to read all statistics from the system: diff --git a/apps/studio/components/layouts/ReportsLayout/Reports.Commands.tsx b/apps/studio/components/layouts/ReportsLayout/Reports.Commands.tsx index e1bc9f7b152..da329451b08 100644 --- a/apps/studio/components/layouts/ReportsLayout/Reports.Commands.tsx +++ b/apps/studio/components/layouts/ReportsLayout/Reports.Commands.tsx @@ -41,7 +41,7 @@ export function useReportsGotoCommands(options?: CommandOptions) { { id: 'nav-reports-query-performance', name: 'Query Performance Reports', - route: `/project/${ref}/reports/query-performance`, + route: `/project/${ref}/advisors/query-performance`, defaultHidden: true, }, ] diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 4645351dbf4..c3846e9c7ca 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -304,7 +304,7 @@ const nextConfig = { }, { permanent: true, - source: '/project/:ref/reports/query-performance', + source: '/project/:ref/query-performance', destination: '/project/:ref/advisors/query-performance', }, { From bc2dac6d68f2ec84c5bea723f32fb9ca67fad137 Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Tue, 16 Sep 2025 13:43:43 +0100 Subject: [PATCH 14/76] feat(studio): add columns and update labels for query performance (#38744) * feat: change calls label to count This changes the column title for calls to count for clarity * feat: add cache hit rate col and number formatting Adds a cache hit rate column for each query as well as tidies up some number formatting. * feat: add styling for 0 numbers This makes anything marked as 0 feint in the table for easier parsing. * chore: remove debug console log * fix: silly next-env again * nit: remove avg rows col * nit: add toFixed to cache hit rate * feat: add tooltip for role column --- .../QueryPerformance.constants.ts | 47 +++++++- .../QueryPerformance/QueryPerformanceGrid.tsx | 113 ++++++++++++++++-- .../interfaces/Reports/Reports.constants.ts | 21 +++- 3 files changed, 166 insertions(+), 15 deletions(-) diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts index 3fd1dbfbbea..88bd1b0cf94 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts @@ -16,10 +16,51 @@ export const QUERY_PERFORMANCE_COLUMNS = [ { id: 'query', name: 'Query', description: undefined, minWidth: 500 }, { id: 'prop_total_time', name: 'Time consumed', description: undefined, minWidth: 130 }, { id: 'total_time', name: 'Total time', description: 'latency', minWidth: 150 }, - { id: 'calls', name: 'Calls', description: undefined, minWidth: 100 }, + { id: 'calls', name: 'Count', description: undefined, minWidth: 100 }, { id: 'max_time', name: 'Max time', description: undefined, minWidth: 100 }, { id: 'mean_time', name: 'Mean time', description: undefined, minWidth: 100 }, { id: 'min_time', name: 'Min time', description: undefined, minWidth: 100 }, - { id: 'avg_rows', name: 'Avg. Rows', description: undefined, minWidth: 100 }, - { id: 'rolname', name: 'Role', description: undefined, minWidth: 120 }, + { id: 'rows_read', name: 'Rows read', description: undefined, minWidth: 100 }, + { id: 'cache_hit_rate', name: 'Cache hit rate', description: undefined, minWidth: 130 }, + { id: 'rolname', name: 'Role', description: undefined, minWidth: 160 }, +] as const + +export const QUERY_PERFORMANCE_ROLE_DESCRIPTION = [ + { name: 'postgres', description: 'The default Postgres role. This has admin privileges.' }, + { + name: 'anon', + description: + 'For unauthenticated, public access. This is the role which the API (PostgREST) will use when a user is not logged in.', + }, + { + name: 'authenticator', + description: + 'A special role for the API (PostgREST). It has very limited access, and is used to validate a JWT and then "change into" another role determined by the JWT verification.', + }, + { + name: 'authenticated', + description: + 'For "authenticated access." This is the role which the API (PostgREST) will use when a user is logged in.', + }, + { + name: 'service_role', + description: + 'For elevated access. This role is used by the API (PostgREST) to bypass Row Level Security.', + }, + { + name: 'supabase_auth_admin', + description: + 'Used by the Auth middleware to connect to the database and run migration. Access is scoped to the auth schema.', + }, + { + name: 'supabase_storage_admin', + description: + 'Used by the Auth middleware to connect to the database and run migration. Access is scoped to the storage schema.', + }, + { name: 'dashboard_user', description: 'For running commands via the Supabase UI.' }, + { + name: 'supabase_admin', + description: + 'An internal role Supabase uses for administrative tasks, such as running upgrades and automations.', + }, ] as const diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index acf01e6b997..e77aa1aaf62 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -20,6 +20,7 @@ import { cn, CodeBlock, } from 'ui' +import { InfoTooltip } from 'ui-patterns/info-tooltip' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { hasIndexRecommendations } from './index-advisor.utils' import { IndexSuggestionIcon } from './IndexSuggestionIcon' @@ -28,6 +29,7 @@ import { QueryIndexes } from './QueryIndexes' import { QUERY_PERFORMANCE_COLUMNS, QUERY_PERFORMANCE_REPORT_TYPES, + QUERY_PERFORMANCE_ROLE_DESCRIPTION, } from './QueryPerformance.constants' import { useQueryPerformanceSort } from './hooks/useQueryPerformanceSort' @@ -138,14 +140,6 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance ) } - if (col.id === 'rolname') { - return ( -
-

{value || 'n/a'}

-
- ) - } - if (col.id === 'prop_total_time') { const percentage = props.row.prop_total_time || 0 const fillWidth = Math.min(percentage, 100) @@ -159,7 +153,13 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance opacity: 0.04, }} /> -

{value ? `${value.toFixed(1)}%` : 'n/a'}

+ {value ? ( +

+ {value.toFixed(1)}% +

+ ) : ( +

–

+ )}
) } @@ -175,8 +175,99 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance if (col.id === 'total_time') { return (
- {isTime && typeof value === 'number' && !isNaN(value) && isFinite(value) && ( -

{(value / 1000).toFixed(2) + 's' || 'n/a'}

+ {isTime && typeof value === 'number' && !isNaN(value) && isFinite(value) ? ( +

+ {(value / 1000).toFixed(2) + 's'} +

+ ) : ( +

–

+ )} +
+ ) + } + + if (col.id === 'calls') { + return ( +
+ {typeof value === 'number' && !isNaN(value) && isFinite(value) ? ( +

+ {value.toLocaleString()} +

+ ) : ( +

–

+ )} +
+ ) + } + + if (col.id === 'max_time' || col.id === 'mean_time' || col.id === 'min_time') { + return ( +
+ {typeof value === 'number' && !isNaN(value) && isFinite(value) ? ( +

+ {value.toFixed(0)}ms +

+ ) : ( +

–

+ )} +
+ ) + } + + if (col.id === 'rows_read') { + return ( +
+ {typeof value === 'number' && !isNaN(value) && isFinite(value) ? ( +

+ {value.toLocaleString()} +

+ ) : ( +

–

+ )} +
+ ) + } + + const cacheHitRateToNumber = (value: number | string) => { + if (typeof value === 'number') return value + return parseFloat(value.toString().replace('%', '')) || 0 + } + + if (col.id === 'cache_hit_rate') { + return ( +
+ {typeof value === 'string' ? ( +

+ {cacheHitRateToNumber(value).toFixed(2)}% +

+ ) : ( +

–

+ )} +
+ ) + } + + if (col.id === 'rolname') { + return ( +
+ {value ? ( + +

{value}

+ + { + QUERY_PERFORMANCE_ROLE_DESCRIPTION.find((role) => role.name === value) + ?.description + } + +
+ ) : ( +

–

)}
) diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.ts b/apps/studio/components/interfaces/Reports/Reports.constants.ts index aea68a98aea..010d3096508 100644 --- a/apps/studio/components/interfaces/Reports/Reports.constants.ts +++ b/apps/studio/components/interfaces/Reports/Reports.constants.ts @@ -369,7 +369,17 @@ select -- min_time, -- max_time, -- mean_time, - statements.rows / statements.calls as avg_rows${ + statements.rows / statements.calls as avg_rows, + statements.rows as rows_read, + case + when (statements.shared_blks_hit + statements.shared_blks_read) > 0 + then round( + (statements.shared_blks_hit * 100.0) / + (statements.shared_blks_hit + statements.shared_blks_read), + 2 + ) + else 0 + end as cache_hit_rate${ runIndexAdvisor ? `, case @@ -513,6 +523,15 @@ select -- max_time, -- mean_time, statements.rows / statements.calls as avg_rows, + statements.rows as rows_read, + statements.shared_blks_hit as debug_hit, + statements.shared_blks_read as debug_read, + case + when (statements.shared_blks_hit + statements.shared_blks_read) > 0 + then (statements.shared_blks_hit::numeric * 100.0) / + (statements.shared_blks_hit + statements.shared_blks_read) + else 0 + end as cache_hit_rate, ((statements.total_exec_time + statements.total_plan_time)/sum(statements.total_exec_time + statements.total_plan_time) OVER()) * 100 as prop_total_time${ runIndexAdvisor ? `, From 4a69c113e00781c49f28f8d335ce512593249c83 Mon Sep 17 00:00:00 2001 From: Han Qiao Date: Tue, 16 Sep 2025 20:57:59 +0800 Subject: [PATCH 15/76] chore: update docs on minting custom jwt (#38653) --- apps/docs/content/guides/auth/jwts.mdx | 10 ++++ .../docs/content/guides/auth/signing-keys.mdx | 59 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/apps/docs/content/guides/auth/jwts.mdx b/apps/docs/content/guides/auth/jwts.mdx index f9f7209ea5f..576a87819cc 100644 --- a/apps/docs/content/guides/auth/jwts.mdx +++ b/apps/docs/content/guides/auth/jwts.mdx @@ -165,6 +165,16 @@ val supabase = createSupabaseClient( + + +```bash +curl 'https://.supabase.co/rest/v1/my_table?select=id' \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer " +``` + + + In the past there was a recommendation to set custom headers on the Supabase client with the `Authorization` header including your custom JWT. This is no longer recommended as it's less flexible and causes confusion when combined with a user session from Supabase Auth. diff --git a/apps/docs/content/guides/auth/signing-keys.mdx b/apps/docs/content/guides/auth/signing-keys.mdx index 6532cf7341e..f78a3f671ba 100644 --- a/apps/docs/content/guides/auth/signing-keys.mdx +++ b/apps/docs/content/guides/auth/signing-keys.mdx @@ -176,6 +176,65 @@ supabase gen signing-key --algorithm ES256 Make sure you store this private key in a secure location, as it will not be extractable from Supabase. +To import the generated private key to your project, create a [new standby key](/dashboard/project/_/settings/jwt/signing-keys) from the dashboard: + +```json +{ + "kty": "EC", + "kid": "3a18cfe2-7226-43b0-bbb4-7c5242f2406e", + "d": "RDbwqThwtGP4WnvACvO_0nL0oMMSmMFSYMPosprlAog", + "crv": "P-256", + "x": "gyLVvp9dyEgylYH7nR2E2qdQ_-9Pv5i1tk7c2qZD4Nk", + "y": "CD9RfYOTyjR5U-PC9UDlsthRpc7vAQQQ2FTt8UsX0fY" +} +``` + +Once imported, click **Rotate key** to activate your new signing key. Any JWT signed by your old key will continue to be usable until your old signing key is manually revoked. + +To mint a new JWT using the asymmetric signing key, you need to set the following [JWT headers](/docs/guides/auth/jwts#introduction) to match your generated private key. + +```json +{ + "alg": "ES256", + "kid": "3a18cfe2-7226-43b0-bbb4-7c5242f2406e", + "typ": "JWT" +} +``` + + + +The `kid` header is used to identify your public key for verification. You must use the same value when importing on platform. + + + +In addition, you need to provide the following custom claims as the JWT payload. + +```json +{ + "sub": "ef0493c9-3582-425f-a362-aef909588df7", + "role": "authenticated", + "exp": 1757749466 +} +``` + +- `sub` is an optional UUID that uniquely identifies a user you want to impersonate in `auth.users` table. +- `role` must be set to an existing Postgres role in your database, such as `anon`, `authenticated`, or `service_role`. +- `exp` must be set to a timestamp in the future (seconds since 1970) when this token expires. Prefer shorter-lived tokens. + +For simplicity, use the following CLI command to generate tokens with the desired header and payload. + +```bash +supabase gen bearer-jwt --role authenticated --sub ef0493c9-3582-425f-a362-aef909588df7 +``` + +Finally, you can use your newly minted JWT by setting the `Authorization: Bearer ` header to all [Data API requests](/docs/guides/auth/jwts#using-custom-or-third-party-jwts). + + + +A separate `apikey` header is required to access your project's APIs. This can be a [publishable, secret or the legacy `anon` or `service_role` keys](/docs/guides/api/api-keys). Using your minted JWT is not possible in this header. + + + ### Why is a 5 minute wait imposed when changing signing key states? Changing a JWT signing key's state sets off many changes inside the Supabase platform. To ensure a consistent setup, most actions that change the state of a JWT signing key are throttled for approximately 5 minutes. From e09df40f939d384b57e175c248a4158a41731b7d Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Tue, 16 Sep 2025 15:43:16 +0200 Subject: [PATCH 16/76] docs: update google auth docs (#38740) * docs: update google auth docs * fix issues * fix more issues * more fixes --- .../guides/auth/social-login/auth-google.mdx | 287 +++++++++--------- .../auth-google-consent-screen.png | Bin 81513 -> 0 bytes 2 files changed, 138 insertions(+), 149 deletions(-) delete mode 100644 apps/docs/public/img/guides/auth-google/auth-google-consent-screen.png diff --git a/apps/docs/content/guides/auth/social-login/auth-google.mdx b/apps/docs/content/guides/auth/social-login/auth-google.mdx index e73d62e6cf0..023aa782b00 100644 --- a/apps/docs/content/guides/auth/social-login/auth-google.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-google.mdx @@ -2,16 +2,51 @@ id: 'auth-google' title: 'Login with Google' description: 'Use Sign in with Google on the web, in native apps or with Chrome extensions' -tocVideo: 'vojHmGUGUGc' --- -Supabase Auth supports Sign in with Google for the web, native Android applications, and Chrome extensions. +Supabase Auth supports [Sign in with Google for the web](https://developers.google.com/identity/gsi/web/guides/overview), native applications ([Android](https://developer.android.com/identity/sign-in/credential-manager-siwg), [macOS and iOS](https://developers.google.com/identity/sign-in/ios/start-integrating)), and [Chrome extensions](https://cloud.google.com/identity-platform/docs/web/chrome-extension). + +You can use Sign in with Google in two ways: + +- [By writing application code](#application-code) for the web, native applications or Chrome extensions +- [By using Google's pre-built solutions](#google-pre-built) such as [personalized sign-in buttons](https://developers.google.com/identity/gsi/web/guides/personalized-button), [One Tap](https://developers.google.com/identity/gsi/web/guides/features) or [automatic sign-in](https://developers.google.com/identity/gsi/web/guides/automatic-sign-in-sign-out) ## Prerequisites -- A Google Cloud project. Go to the [Google Cloud Platform](https://console.cloud.google.com/home/dashboard) and create a new project if necessary. +You need to do some setup to get started with Sign in with Google: -## Configuration +- Prepare a Google Cloud project. Go to the [Google Cloud Platform](https://console.cloud.google.com/home/dashboard) and create a new project if necessary. +- Use the [Google Auth Platform console](https://console.cloud.google.com/auth/overview) to register and set up your application's: + - [**Audience**](https://console.cloud.google.com/auth/audience) by configuring which Google users are allowed to sign in to your application. + - [**Data Access (Scopes)**](https://console.cloud.google.com/auth/scopes) define what your application can do with your user's Google data and APIs, such as access profile information or more. + - [**Branding**](https://console.cloud.google.com/auth/branding) and [**Verification**](https://console.cloud.google.com/auth/verification) show a logo and name instead of the Supabase project ID in the consent screen, improving user retention. Brand verification may take a few business days. + +### Setup required scopes + +Supabase Auth needs a few scopes granting access to profile data of your end users, which you have to configure in the [**Data Access (Scopes)**](https://console.cloud.google.com/auth/scopes) screen: + +- `openid` (add manually) +- `.../auth/userinfo.email` (added by default) +- `...auth/userinfo.profile` (added by default) + +If you add more scopes, especially those on the sensitive or restricted list your application might be subject to verification which may take a long time. + +### Setup consent screen branding + + + +It's strongly recommended you set up a custom domain and optionally verify your brand information with Google, as this makes phishing attempts easier to spot by your users. + + + +Google's consent screen is shown to users when they sign in. Optionally configure the following to improve the appearance of the screen, increasing the perception of trust by your users: + +1. Set up a [custom domain for your project](/docs/guides/platform/custom-domains) to present the user with a clear relationship to the website they clicked Sign in with Google on. + - A good approach is to use `auth.example.com` or `api.example.com`, if your application is hosted on `example.com`. + - If you don't set this up, users will see `.supabase.co` which does not inspire trust and can make your application more susceptible to successful phishing attempts. +1. Verify your application's brand (logo and name) by configuring it in the [Branding](https://console.cloud.google.com/auth/branding) section of the Google Auth Platform console. Brand verification is not automatic and may take a few business days. + +## Project setup To support Sign In with Google, you need to configure the Google provider for your Supabase project. @@ -24,126 +59,67 @@ To support Sign In with Google, you need to configure the Google provider for yo > -For web applications, you can set up your signin button two different ways: +Regardless of whether you use application code or Google's pre-built solutions to implement the sign in flow, you need to configure your project by obtaining a Client ID and Client Secret in the [Clients](https://console.cloud.google.com/auth/clients) section of the Google Auth Platform console: -- Use your own [application code](#application-code) for the button. -- Use [Google's pre-built sign-in or One Tap flows](#google-pre-built). - -### Application code configuration - -To use your own application code: - -1. In the Google Cloud console, go to the [Consent Screen configuration page](https://console.cloud.google.com/apis/credentials/consent). The consent screen is the view shown to your users when they consent to signing in to your app. -1. Under **Authorized domains**, add your Supabase project's domain, which has the form `.supabase.co`. -1. Configure the following non-sensitive scopes: - - `.../auth/userinfo.email` - - `...auth/userinfo.profile` - - `openid` -1. Go to the [API Credentials page](https://console.cloud.google.com/apis/credentials). -1. Click `Create credentials` and choose `OAuth Client ID`. -1. For application type, choose `Web application`. -1. Under **Authorized JavaScript origins**, add your site URL. -1. Under **Authorized redirect URLs**, enter the callback URL from the [Supabase dashboard](/dashboard/project/_/auth/providers). Expand the Google Auth Provider section to display it. - - - - The redirect URL is visible to your users. You can customize it by configuring [custom domains](/docs/guides/platform/custom-domains). - - - -1. When you finish configuring your credentials, you will be shown your client ID and secret. Add these to the [Google Auth Provider section of the Supabase Dashboard](/dashboard/project/_/auth/providers). - - Alternatively, you can configure Google authentication using the Management API: - - ```bash - # First, get your access token from https://supabase.com/dashboard/account/tokens - export SUPABASE_ACCESS_TOKEN="your-access-token" - export PROJECT_REF="your-project-ref" - - # Update auth config to enable Google provider - curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ - -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "external_google_enabled": true, - "external_google_client_id": "your-google-client-id", - "external_google_secret": "your-google-client-secret" - }' - ``` - - - - In local development, you can add the client ID and secret to your `config.toml` file. - - - -### Google pre-built configuration - -To use Google's pre-built signin buttons: - -1. In the Google Cloud console, go to the [Consent Screen configuration page](https://console.cloud.google.com/apis/credentials/consent). The consent screen is the view shown to your users when they consent to signing in to your app. -1. Configure the screen to your liking, making sure you add links to your app's privacy policy and terms of service. -1. Go to the [API Credentials page](https://console.cloud.google.com/apis/credentials). -1. Click `Create credentials` and choose `OAuth Client ID`. -1. For application type, choose `Web application`. -1. Under **Authorized JavaScript origins** and **Authorized redirect URLs**, add your site URL. This is the URL of the website where the signin button will appear, _not_ your Supabase project domain. If you're testing in localhost, ensure that you have `http://localhost` set in the **Authorized JavaScript origins** section as well. This is important when integrating with Google One-Tap to ensure you can use it locally. -1. When you finish configuring your credentials, you will be shown your client ID. Add this to the **Client IDs** field in the [Google Auth Provider section of the Supabase Dashboard](/dashboard/project/_/auth/providers). Leave the OAuth client ID and secret blank. You don't need them when using Google's pre-built approach. +1. [Create a new OAuth client ID](https://console.cloud.google.com/auth/clients/create) and choose **Web application** for the application type. +1. Under **Authorized JavaScript origins** add your application's URL. These should also be configured as the [Site URL or redirect configuration in your project](/docs/guides/auth/redirect-urls). + - If your app is hosted on `https://example.com/app` add `https://example.com`. + - Add `http://localhost:` while developing locally. Remember to remove this when your application [goes into production](/docs/guides/deployment/going-into-prod). +1. Under **Authorized redirect URIs** add your Supabase project's callback URL. + - Access it from the [Google provider page on the Dashboard](/dashboard/project/_/auth/providers?provider=Google). + - For local development, use `http://localhost:3000/auth/v1/callback`. +1. Click `Create` and make sure you save the Client ID and Client Secret. + - Add these values to the [Google provider page on the Dashboard](/dashboard/project/_/auth/providers?provider=Google). - 1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. When creating a new OAuth client ID, choose _Android_ or _iOS_ depending on the mobile operating system your app is built for. - - For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app. - - You will have a different set of SHA-1 certificate fingerprints for testing locally and going to production. Make sure to add both to the Google Cloud Console, and add all of the Client IDs to the Supabase dashboard. - - For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store. - 2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service. - 3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](/dashboard/project/_/auth/providers), under _Client IDs_. - - Note that you do not have to configure the OAuth flow in the Supabase Dashboard in order to use native sign in. +1. [Create a new OAuth client ID](https://console.cloud.google.com/auth/clients/create) and choose **Android** or **iOS** depending on the OS you're building the app for. + - For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app. + - You will have a different set of SHA-1 certificate fingerprints for testing locally and going to production. Make sure to add both to the Google Cloud Console, and add all of the Client IDs to the Supabase dashboard. + - For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store. +1. Register the Client ID in the [Google provider page on the Dashboard](/dashboard/project/_/auth/providers?provider=Google). - + - ### iOS and Android +1. [Create a new OAuth client ID](https://console.cloud.google.com/auth/clients/create) and choose **Android** or **iOS** depending on the OS you're building the app for. + - For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app. + - You will have a different set of SHA-1 certificate fingerprints for testing locally and going to production. Make sure to add both to the Google Cloud Console, and add all of the Client IDs to the Supabase dashboard. + - For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store. +1. Register the Client ID in the [Google provider page on the Dashboard](/dashboard/project/_/auth/providers?provider=Google). + - For iOS enable the `Skip nonce check` option. - 1. Configure Web, Android, and iOS OAuth credentials. Follow the Platform integration instructions on the [README of google_sign_in package](https://pub.dev/packages/google_sign_in#platform-integration) for Android and iOS. - - For both Android and iOS, you will need to create a Web client ID in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials). - - For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app. - - You will have a different set of SHA-1 certificate fingerprint for testing locally and going to production. Make sure to add both to the Google Cloud Console. and add all of the Client IDs to Supabase dashboard. - - For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store. - 2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service. - 3. Add only the web client ID from step 1 in the [Google provider on the Supabase Dashboard](/dashboard/project/_/auth/providers), under _Client IDs_. If you need iOS support, enable the `Skip nonce check` option. - 4. For iOS, add the `CFBundleURLTypes` attributes below into the `/ios/Runner/Info.plist` file. - ```xml - - - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - - - com.googleusercontent.apps.861823949799-vc35cprkp249096uujjn0vvnmcvjppkn - - - - - ``` +For iOS add a `CFBundleURLTypes` key in the `/ios/Runner/Info.plist` file: - ### Web, macOS, Windows, and Linux +```xml + + +CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + + + com.googleusercontent.apps.861823949799-vc35cprkp249096uujjn0vvnmcvjppkn + + + + +``` - To use the OAuth 2.0 flow, you will require the following information: + - 1. Obtain OAuth credentials for your Google Cloud project in the [Credentials](https://console.developers.google.com/apis/credentials) page of the console. If you already have a web client ID, no need to create a new one. When creating a new credential, choose _Web application_. In _Authorized redirect URIs_ enter `https://.supabase.co/auth/v1/callback`. This URL will be seen by your users, and you can customize it by configuring [custom domains](/docs/guides/platform/custom-domains). - 2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. Within _Authorized domains_ make sure you add your Supabase project's domain `.supabase.co`. Configure the non-sensitive scopes by making sure the following ones are selected: `.../auth/userinfo.email`, `.../auth/userinfo.profile`, `openid`. If you're selecting other sensitive scopes, your app may require additional verification. In those cases, it's best to use [custom domains](/docs/guides/platform/custom-domains). - 3. Finally, add the client ID and secret from step 1 in the [Google provider on the Supabase Dashboard](/dashboard/project/_/auth/providers). + - +Follow the same configuration guide as if your app was a Web application when building a desktop Flutter application. + + <$Show if="sdk:swift"> @@ -195,41 +171,54 @@ To use Google's pre-built signin buttons: - ### Using Google sign-in on Android - - 1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. You need two client IDs, a web client ID and Android client ID. When creating a new Android OAuth client ID, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app. - 2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service. - 3. Finally, add the web client ID from step 1 in the [Google provider on the Supabase Dashboard](/dashboard/project/_/auth/providers), under _Client IDs_. - - Note that you do not have to configure the OAuth flow in the Supabase Dashboard in order to use native sign in. - - ### Using Google sign-in with Kotlin Multiplatform - - 1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. When creating a new OAuth client ID, choose _Android_ or _iOS_ depending on the mobile operating system your app is built for. - - - For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app. - - For iOS, use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store. - - 2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. In particular, make sure you have set up links to your app's privacy policy and terms of service. - 3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](/dashboard/project/_/auth/providers), under _Client IDs_. - - Note that you do not have to configure the OAuth flow in the Supabase Dashboard in order to use native sign in. +1. [Create a new OAuth client ID](https://console.cloud.google.com/auth/clients/create) and choose **Android** or **iOS** if also building an iOS app with Kotlin Multiplatform. + - For Android, use the instructions on screen to provide the SHA-1 certificate fingerprint used to sign your Android app. + - You will have a different set of SHA-1 certificate fingerprints for testing locally and going to production. Make sure to add both to the Google Cloud Console, and add all of the Client IDs to the Supabase dashboard. + - For iOS (with Kotlin Multiplatform), use the instructions on screen to provide the app Bundle ID, and App Store ID and Team ID if the app is already published on the Apple App Store. +1. Register the Client ID in the [Google provider page on the Dashboard](/dashboard/project/_/auth/providers?provider=Google). - You will need to configure a client ID for your Chrome extension: - - 1. Configure OAuth credentials for your Google Cloud project in the [Credentials](https://console.cloud.google.com/apis/credentials) page of the console. When creating a new OAuth client ID, choose _Chrome extension_ for the application type. For _Item ID_ provide the unique ID of your Chrome extension. You can get this by calling `chrome.runtime.id` within the extension, or from the Web Store URL of the extension. For example, the [Google Translate extension](https://chrome.google.com/webstore/detail/google-translate/aapbdbdomjkkjkaonfhkkikfgjllcleb) has the Web Store URL `https://chrome.google.com/webstore/detail/google-translate/aapbdbdomjkkjkaonfhkkikfgjllcleb` and the last part `aapbdbdomjkkjkaonfhkkikfgjllcleb` is its unique ID. - 2. Configure the [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent). This information is shown to the user when giving consent to your app. - 3. Finally, add the client ID from step 1 in the [Google provider on the Supabase Dashboard](/dashboard/project/_/auth/providers), under _Client IDs_. - - Note that you do not have to configure the OAuth flow in the Supabase Dashboard to sign in with Google inside Chrome extensions. +1. [Create a new OAuth client ID](https://console.cloud.google.com/auth/clients/create) and choose **Chrome Extension** for application type. + - Enter your extension's Item ID and optionally verify app ownership. +1. Register the Client ID in the [Google provider page on the Dashboard](/dashboard/project/_/auth/providers?provider=Google) under _Client IDs_. +### Local development + +To use the Google provider in local development: + +1. Add a new environment variable: + ```env + SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_SECRET="" + ``` +2. Configure the provider: + ```toml + [auth.external.google] + enabled = true + client_id = "" + secret = "env(SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_SECRET)" + skip_nonce_check = false + ``` + +If you have multiple client IDs, such as one for Web, iOS and Android, concatenate all of the client IDs with a comma but make sure the web's client ID is first in the list. + +### Using the management API + +Use the [PATCH `/v1/projects/{ref}/config/auth` Management API endpoint](/docs/reference/api/v1-update-auth-service-config) to configure the project's Auth settings programmatically. For configuring the Google provider send these options: + +```json +{ + "external_google_enabled": true, + "external_google_client_id": "your-google-client-id", + "external_google_secret": "your-google-client-secret" +} +``` + ## Signing users in - - -### iOS and Android + Google sign-in with Supabase is done through the [google_sign_in](https://pub.dev/packages/google_sign_in) package for iOS and Android. @@ -576,7 +563,18 @@ Future _nativeGoogleSignIn() async { ... ``` -### Web, macOS, Windows, and Linux +
+ +
+ +
+ + Google sign-in with Supabase on Web, macOS, Windows, and Linux is done through the [`signInWithOAuth`](docs/reference/dart/auth-signinwithoauth) method. @@ -808,12 +806,3 @@ chrome.identity.launchWebAuthFlow(
- -## Google consent screen - -![Google Consent Screen](/docs/img/guides/auth-google/auth-google-consent-screen.png) - -By default, the Google consent screen shows the root domain of the callback URL, where Google will send the authentication response. With Supabase Auth, it is your Supabase project's domain `(https://.supabase.co)`. - -You can change this domain in the settings for your Google app. Go to Google App Platform, click on Branding, and then set your application home page, privacy policy, and terms of use. You can also add your logo. -Next, go to Audience and click publish. Google will need to verify the app, which usually takes around 24 hours. diff --git a/apps/docs/public/img/guides/auth-google/auth-google-consent-screen.png b/apps/docs/public/img/guides/auth-google/auth-google-consent-screen.png deleted file mode 100644 index 76942f3b74d5f38741ad9e2e7191930270ebdccf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 81513 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 ~K{48;W30xx^7eWcwkMJL!<8vqKJ6di^ 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�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

5_MNV#9Cw-va>F8*AE+qFeQS)1CZ&k*) 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 OK;Y@>=d#Wzp$PymW|uku From c0da4fc689bc966da8ebaeef8dc6535f10b9635a Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Tue, 16 Sep 2025 22:06:57 +0800 Subject: [PATCH 17/76] chore: add custom content provider label (#38745) * chore: add custom content provider label * Remove lowercase for desc in ProjectCard --------- Co-authored-by: Joshen Lim --- .../Home/ProjectList/ProjectCard.tsx | 10 ++++-- .../InstanceNode.tsx | 10 ++++-- .../custom-content/CustomContent.types.ts | 1 + .../hooks/custom-content/custom-content.json | 1 + .../custom-content/custom-content.sample.json | 36 +------------------ .../custom-content/custom-content.schema.json | 5 +++ 6 files changed, 24 insertions(+), 39 deletions(-) diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx index ada3f39721b..35afb66f841 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx @@ -7,6 +7,7 @@ import type { IntegrationProjectConnection } from 'data/integrations/integration import { ProjectIndexPageLink } from 'data/prefetchers/project.$ref' import { OrgProject } from 'data/projects/projects-infinite-query' import type { ResourceWarning } from 'data/usage/resource-warnings-query' +import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { BASE_PATH } from 'lib/constants' import { inferProjectStatus } from './ProjectCard.utils' @@ -28,7 +29,12 @@ export const ProjectCard = ({ resourceWarnings, }: ProjectCardProps) => { const { name, ref: projectRef } = project - const desc = `${project.cloud_provider} | ${project.region}` + + const { infraAwsNimbusLabel } = useCustomContent(['infra:aws_nimbus_label']) + const providerLabel = + project.cloud_provider === 'AWS_NIMBUS' ? infraAwsNimbusLabel : project.cloud_provider + + const desc = `${providerLabel} | ${project.region}` const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([ 'project_homepage:show_instance_size', @@ -47,7 +53,7 @@ export const ProjectCard = ({ title={

{name}

- {desc} + {desc}
{project.status !== 'INACTIVE' && projectHomepageShowInstanceSize && ( diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx index 07dc3bf0674..98f1611f4c6 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx @@ -14,6 +14,7 @@ import { useReadReplicasStatusesQuery, } from 'data/read-replicas/replicas-status-query' import { formatDatabaseID } from 'data/read-replicas/replicas.utils' +import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { BASE_PATH } from 'lib/constants' @@ -113,6 +114,8 @@ export const PrimaryNode = ({ data }: NodeProps) => { const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([ 'project_homepage:show_instance_size', ]) + const { infraAwsNimbusLabel } = useCustomContent(['infra:aws_nimbus_label']) + const providerLabel = provider === 'AWS_NIMBUS' ? infraAwsNimbusLabel : provider return ( <> @@ -137,7 +140,7 @@ export const PrimaryNode = ({ data }: NodeProps) => { {region.name}

- {provider} + {providerLabel} {projectHomepageShowInstanceSize && ( <> • @@ -231,6 +234,9 @@ export const ReplicaNode = ({ data }: NodeProps) => { ] as string[] ).includes(status) || initStatus === ReplicaInitializationStatus.InProgress + const { infraAwsNimbusLabel } = useCustomContent(['infra:aws_nimbus_label']) + const providerLabel = provider === 'AWS_NIMBUS' ? infraAwsNimbusLabel : provider + return ( <> @@ -298,7 +304,7 @@ export const ReplicaNode = ({ data }: NodeProps) => {

{region.name}

- {provider} + {providerLabel} {projectHomepageShowInstanceSize && !!computeSize && ( <> • diff --git a/apps/studio/hooks/custom-content/CustomContent.types.ts b/apps/studio/hooks/custom-content/CustomContent.types.ts index 8d79b162c03..7fbe87e0741 100644 --- a/apps/studio/hooks/custom-content/CustomContent.types.ts +++ b/apps/studio/hooks/custom-content/CustomContent.types.ts @@ -28,6 +28,7 @@ export type CustomContentTypes = { logsDefaultQuery: string infraCloudProviders: CloudProvider[] + infraAwsNimbusLabel: string sslCertificateUrl: string } diff --git a/apps/studio/hooks/custom-content/custom-content.json b/apps/studio/hooks/custom-content/custom-content.json index 65686e6f129..2702c8c2728 100644 --- a/apps/studio/hooks/custom-content/custom-content.json +++ b/apps/studio/hooks/custom-content/custom-content.json @@ -50,6 +50,7 @@ "logs:default_query": null, "infra:cloud_providers": ["AWS", "AWS_K8S", "FLY"], + "infra:aws_nimbus_label": "AWS Nimbus", "ssl:certificate_url": "https://supabase-downloads.s3-ap-southeast-1.amazonaws.com/${env}/ssl/${env}-ca-2021.crt" } diff --git a/apps/studio/hooks/custom-content/custom-content.sample.json b/apps/studio/hooks/custom-content/custom-content.sample.json index 3573fc9471a..b7adc83af68 100644 --- a/apps/studio/hooks/custom-content/custom-content.sample.json +++ b/apps/studio/hooks/custom-content/custom-content.sample.json @@ -49,42 +49,8 @@ "logs:default_query": "This is a sample query", - "connect:frameworks": { - "key": "frameworks", - "label": "Frameworks", - "obj": [ - { - "key": "framework-1", - "label": "Framework 1", - "icon": "https://supabase.com/dashboard/img/supabase-logo.svg", - "guideLink": "https://supabase.com/docs", - "children": [], - "files": [ - { - "name": "sample_env", - "content": "NEXT_PUBLIC_SUPABASE_URL={{apiUrl}}\nNEXT_PUBLIC_SUPABASE_ANON_KEY={{anonKey}}" - }, - { - "name": "sample.ts", - "content": "import { createClient } from \"@supabase/supabase-js\";\n\nconst supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\nconst supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\nexport const supabase = createClient(supabaseUrl, supabaseKey);" - } - ] - }, - { - "key": "framework-2", - "label": "Framework 2", - "icon": "https://supabase.com/dashboard/img/supabase-logo.svg", - "guideLink": "https://supabase.com/docs", - "children": [], - "files": [ - { "name": "file3.tsx", "content": "Content of File 3" }, - { "name": "file4.tsx", "content": "Content of File 4" } - ] - } - ] - }, - "infra:cloud_providers": ["AWS_NIMBUS"], + "infra:aws_nimbus_label": "AWS Nimbus", "ssl:certificate_url": "https://supabase-downloads.s3-ap-southeast-1.amazonaws.com/${env}/ssl/${env}-ca-2021.crt" } diff --git a/apps/studio/hooks/custom-content/custom-content.schema.json b/apps/studio/hooks/custom-content/custom-content.schema.json index bb7f5f8a8a0..5569c4d71fa 100644 --- a/apps/studio/hooks/custom-content/custom-content.schema.json +++ b/apps/studio/hooks/custom-content/custom-content.schema.json @@ -74,6 +74,10 @@ "enum": ["AWS", "AWS_K8S", "AWS_NIMBUS", "FLY"] } }, + "infra:aws_nimbus_label": { + "type": "string", + "description": "The label of the AWS Nimbus provider" + }, "ssl:certificate_url": { "type": "string", @@ -86,6 +90,7 @@ "project_homepage:example_projects", "logs:default_query", "infra:cloud_providers", + "infra:aws_nimbus_label", "ssl:certificate_url" ], "additionalProperties": false From cdde0d574743e6804aa81fefdb9bfc022e5d826e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=83=A5=EB=83=90=EC=B1=A0?= Date: Tue, 16 Sep 2025 23:10:11 +0900 Subject: [PATCH 18/76] fix: functions on the Supabase Preview do not deploy properly (#38729) --- supabase/functions/deno.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 supabase/functions/deno.json diff --git a/supabase/functions/deno.json b/supabase/functions/deno.json new file mode 100644 index 00000000000..17971333806 --- /dev/null +++ b/supabase/functions/deno.json @@ -0,0 +1,3 @@ +{ + +} From 98b6bff713f253ec19ca7b732670d882e8b99fcd Mon Sep 17 00:00:00 2001 From: Chris Stockton <180184+cstockton@users.noreply.github.com> Date: Tue, 16 Sep 2025 07:10:41 -0700 Subject: [PATCH 19/76] fix(docs/auth): update auth settings links (#38725) Many links were redirecting to a 404, this updates them to point to each links new settings page. Co-authored-by: Chris Stockton --- .../content/guides/auth/auth-anonymous.mdx | 2 +- .../docs/content/guides/auth/auth-captcha.mdx | 2 +- .../guides/auth/auth-identity-linking.mdx | 2 +- .../content/guides/auth/auth-mfa/phone.mdx | 2 +- apps/docs/content/guides/auth/auth-smtp.mdx | 2 +- .../guides/auth/general-configuration.mdx | 20 +++++++++++++------ apps/docs/content/guides/auth/sessions.mdx | 10 +++++----- .../content/guides/auth/third-party/auth0.mdx | 4 ++-- .../guides/auth/third-party/aws-cognito.mdx | 4 ++-- .../guides/auth/third-party/firebase-auth.mdx | 4 ++-- .../guides/auth/third-party/workos.mdx | 4 ++-- .../getting-started/quickstarts/hono.mdx | 2 +- .../cli/testing-and-linting.mdx | 2 +- 13 files changed, 34 insertions(+), 26 deletions(-) diff --git a/apps/docs/content/guides/auth/auth-anonymous.mdx b/apps/docs/content/guides/auth/auth-anonymous.mdx index f6c16268b0f..5a32482cefe 100644 --- a/apps/docs/content/guides/auth/auth-anonymous.mdx +++ b/apps/docs/content/guides/auth/auth-anonymous.mdx @@ -112,7 +112,7 @@ response = supabase.auth.sign_in_anonymously() ## Convert an anonymous user to a permanent user -Converting an anonymous user to a permanent user requires [linking an identity](/docs/guides/auth/auth-identity-linking#manual-linking-beta) to the user. This requires you to [enable manual linking](/dashboard/project/_/settings/auth) in your Supabase project. +Converting an anonymous user to a permanent user requires [linking an identity](/docs/guides/auth/auth-identity-linking#manual-linking-beta) to the user. This requires you to [enable manual linking](/dashboard/project/_/auth/providers) in your Supabase project. ### Link an email / phone identity diff --git a/apps/docs/content/guides/auth/auth-captcha.mdx b/apps/docs/content/guides/auth/auth-captcha.mdx index d28f7df4a6f..bc832cf0cb8 100644 --- a/apps/docs/content/guides/auth/auth-captcha.mdx +++ b/apps/docs/content/guides/auth/auth-captcha.mdx @@ -41,7 +41,7 @@ In the Settings page, look for the **Sitekey** section and copy the key. ## Enable CAPTCHA protection for your Supabase project -Navigate to the **[Auth](/dashboard/project/_/settings/auth)** section of your Project Settings in the Supabase Dashboard and find the **Enable CAPTCHA protection** toggle under Settings > Authentication > Bot and Abuse Protection > Enable CAPTCHA protection. +Navigate to the **[Auth](/dashboard/project/_/auth/protection)** section of your Project Settings in the Supabase Dashboard and find the **Enable CAPTCHA protection** toggle under Settings > Authentication > Bot and Abuse Protection > Enable CAPTCHA protection. Select your CAPTCHA provider from the dropdown, enter your CAPTCHA **Secret key**, and click **Save**. diff --git a/apps/docs/content/guides/auth/auth-identity-linking.mdx b/apps/docs/content/guides/auth/auth-identity-linking.mdx index 8ccdb45aae4..aa8a98b22ec 100644 --- a/apps/docs/content/guides/auth/auth-identity-linking.mdx +++ b/apps/docs/content/guides/auth/auth-identity-linking.mdx @@ -88,7 +88,7 @@ response = supabase.auth.link_identity({'provider': 'google'}) -In the example above, the user will be redirected to Google to complete the OAuth2.0 flow. Once the OAuth2.0 flow has completed successfully, the user will be redirected back to the application and the Google identity will be linked to the user. You can enable manual linking from your project's authentication [configuration options](/dashboard/project/_/settings/auth) or by setting the environment variable `GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: true` when self-hosting. +In the example above, the user will be redirected to Google to complete the OAuth2.0 flow. Once the OAuth2.0 flow has completed successfully, the user will be redirected back to the application and the Google identity will be linked to the user. You can enable manual linking from your project's authentication [configuration options](/dashboard/project/_/auth/providers) or by setting the environment variable `GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: true` when self-hosting. ## Unlink an identity diff --git a/apps/docs/content/guides/auth/auth-mfa/phone.mdx b/apps/docs/content/guides/auth/auth-mfa/phone.mdx index f29187d7227..d1fcc3e86fc 100644 --- a/apps/docs/content/guides/auth/auth-mfa/phone.mdx +++ b/apps/docs/content/guides/auth/auth-mfa/phone.mdx @@ -306,7 +306,7 @@ function AuthMFA() { ### Security configuration -Each code is valid for up to 5 minutes, after which a new one can be sent. Successive codes remain valid until expiry. When possible choose the longest code length acceptable to your use case, at a minimum of 6. This can be configured in the [Authentication Settings](/dashboard/project/_/settings/auth). +Each code is valid for up to 5 minutes, after which a new one can be sent. Successive codes remain valid until expiry. When possible choose the longest code length acceptable to your use case, at a minimum of 6. This can be configured in the [Authentication Settings](/dashboard/project/_/auth/mfa). Be aware that Phone MFA is vulnerable to SIM swap attacks where an attacker will call a mobile provider and ask to port the target's phone number to a new SIM card and then use the said SIM card to intercept an MFA code. Evaluate the your application's tolerance for such an attack. You can read more about SIM swapping attacks [here](https://en.wikipedia.org/wiki/SIM_swap_scam) diff --git a/apps/docs/content/guides/auth/auth-smtp.mdx b/apps/docs/content/guides/auth/auth-smtp.mdx index 5f7f05b0ed5..2e4d9fefd33 100644 --- a/apps/docs/content/guides/auth/auth-smtp.mdx +++ b/apps/docs/content/guides/auth/auth-smtp.mdx @@ -48,7 +48,7 @@ A non-exhaustive list of services that work with Supabase Auth is: - [ZeptoMail](https://www.zoho.com/zeptomail/help/smtp-home.html) - [Brevo](https://help.brevo.com/hc/en-us/articles/7924908994450-Send-transactional-emails-using-Brevo-SMTP) -Once you've set up your account with an email sending service, head to the [Authentication settings page](/dashboard/project/_/settings/auth) to enable and configure custom SMTP. +Once you've set up your account with an email sending service, head to the [Authentication settings page](/dashboard/project/_/auth/smtp) to enable and configure custom SMTP. You can also configure custom SMTP using the Management API: diff --git a/apps/docs/content/guides/auth/general-configuration.mdx b/apps/docs/content/guides/auth/general-configuration.mdx index 0eefbd1a922..e1619774dcb 100644 --- a/apps/docs/content/guides/auth/general-configuration.mdx +++ b/apps/docs/content/guides/auth/general-configuration.mdx @@ -4,13 +4,21 @@ title: 'General configuration' subtitle: 'General configuration options for Supabase Auth' --- -This section covers the [general configuration options](/dashboard/project/_/settings/auth) for Supabase Auth. If you are looking for another type of configuration, you may be interested in one of the following sections: +This section covers the [general configuration options](/dashboard/project/_/auth) for Supabase Auth. If you are looking for another type of configuration, you may be interested in one of the following sections: -- [Provider-specific configuration](/dashboard/project/_/auth/providers) -- [Rate limits](/dashboard/project/_/auth/rate-limits) -- [Email Templates](/dashboard/project/_/auth/templates) -- [Redirect URLs](/dashboard/project/_/auth/url-configuration) -- [Auth Hooks](/dashboard/project/_/auth/hooks) +- [Policies](/dashboard/project/_/auth/policies) to manage Row Level Security policies for your tables. +- [Sign In / Providers](/dashboard/project/_/auth/providers) to configure authentication providers and login methods for your users. +- [Third Party Auth](/dashboard/project/_/auth/third-party) to use third-party authentication (TPA) systems based on JWTs to access your project. +- [Sessions](/dashboard/project/_/auth/sessions) to configure settings for user sessions and refresh tokens. +- [Rate limits](/dashboard/project/_/auth/rate-limits) to safeguard against bursts of incoming traffic to prevent abuse and maximize stability. +- [Email Templates](/dashboard/project/_/auth/templates) to configure what emails your users receive. +- [Custom SMTP](/dashboard/project/_/auth/smtp) to configure how emails are sent. +- [Multi-Factor](/dashboard/project/_/auth/mfa) to require users to provide additional verification factors to authenticate. +- [URL Configuration](/dashboard/project/_/auth/url-configuration) to configure site URL and redirect URLs for authentication. +- [Attack Protection](/dashboard/project/_/auth/protection) to configure security settings to protect your project from attacks. +- [Auth Hooks (BETA)](/dashboard/project/_/auth/auth-hooks) to use Postgres functions or HTTP endpoints to customize the behavior of Supabase Auth to meet your needs. +- [Audit Logs (BETA)](/dashboard/project/_/auth/audit-logs) to track and monitor auth events in your project. +- [Advanced](/dashboard/project/_/auth/advanced) to configure advanced authentication server settings. Supabase Auth provides these [general configuration options](/dashboard/project/_/settings/auth) to control user access to your application: diff --git a/apps/docs/content/guides/auth/sessions.mdx b/apps/docs/content/guides/auth/sessions.mdx index b83940ccba2..ebaeec2937c 100644 --- a/apps/docs/content/guides/auth/sessions.mdx +++ b/apps/docs/content/guides/auth/sessions.mdx @@ -55,11 +55,11 @@ There are three ways to limit the lifetime of a session: - Set an inactivity timeout, which terminates sessions that haven't been refreshed within the timeout duration. - Enforce a single-session per user, which only keeps the most recently active session. -To make sure that users are required to re-authenticate periodically, you can set a positive value for the **Time-box user sessions** option in the [Auth settings](/dashboard/project/_/settings/auth) for your project. +To make sure that users are required to re-authenticate periodically, you can set a positive value for the **Time-box user sessions** option in the [Auth settings](/dashboard/project/_/auth/sessions) for your project. -To make sure that sessions expire after a period of inactivity, you can set a positive duration for the **Inactivity timeout** option in the [Auth settings](/dashboard/project/_/settings/auth). +To make sure that sessions expire after a period of inactivity, you can set a positive duration for the **Inactivity timeout** option in the [Auth settings](/dashboard/project/_/auth/sessions). -You can also enforce only one active session per user per device or browser. When this is enabled, the session from the most recent sign in will remain active, while the rest are terminated. Enable this via the _Single session per user_ option in the [Auth settings](/dashboard/project/_/settings/auth). +You can also enforce only one active session per user per device or browser. When this is enabled, the session from the most recent sign in will remain active, while the rest are terminated. Enable this via the _Single session per user_ option in the [Auth settings](/dashboard/project/_/auth/sessions). Sessions are not proactively destroyed when you change these settings, but rather the check is enforced whenever a session is refreshed next. This can confuse developers because the actual duration of a session is the configured timeout plus the JWT expiration time. For single session per user, the effect will only be noticed at intervals of the JWT expiration time. Make sure you adjust this setting depending on your needs. We do not recommend going below 5 minutes for the JWT expiration time. @@ -69,7 +69,7 @@ Otherwise sessions are progressively deleted from the database 24 hours after th ### What are recommended values for access token (JWT) expiration? -Most applications should use the default expiration time of 1 hour. This can be customized in your project's [Auth settings](/dashboard/project/_/settings/auth) in the Advanced Settings section. +Most applications should use the default expiration time of 1 hour. This can be customized in your project's [Auth settings](/dashboard/project/_/auth/sessions) in the Advanced Settings section. Setting a value over 1 hour is generally discouraged for security reasons, but it may make sense in certain situations. @@ -93,7 +93,7 @@ The general rule is that a refresh token can only be used once. However, strictl - All clients such as browsers, mobile or desktop apps, and even some servers are inherently unreliable due to network issues. A request does not indicate that they received a response or even processed the response they received. - If a refresh token is revoked after being used only once, and the response wasn't received and processed by the client, when the client comes back online, it will attempt to use the refresh token that was already used. Since this might happen outside of the reuse interval, it can cause sudden and unexpected session termination. -Should the reuse attempt not fall under these two exceptions, the whole session is regarded as terminated and all refresh tokens belonging to it are marked as revoked. You can disable this behavior in the Advanced Settings of the [Auth settings](/dashboard/project/_/settings/auth) page, though it is generally not recommended. +Should the reuse attempt not fall under these two exceptions, the whole session is regarded as terminated and all refresh tokens belonging to it are marked as revoked. You can disable this behavior in the Advanced Settings of the [Auth settings](/dashboard/project/_/auth/sessions) page, though it is generally not recommended. The purpose of this mechanism is to guard against potential security issues where a refresh token could have been stolen from the user, for example by exposing it accidentally in logs that leak (like logging cookies, request bodies or URL params) or via vulnerable third-party servers. It does not guard against the case where a user's session is stolen from their device. diff --git a/apps/docs/content/guides/auth/third-party/auth0.mdx b/apps/docs/content/guides/auth/third-party/auth0.mdx index 634378b437a..d50f96913f6 100644 --- a/apps/docs/content/guides/auth/third-party/auth0.mdx +++ b/apps/docs/content/guides/auth/third-party/auth0.mdx @@ -9,7 +9,7 @@ Auth0 can be used as a third-party authentication provider alongside Supabase Au ## Getting started 1. First you need to add an integration to connect your Supabase project with your Auth0 tenant. You will need your tenant ID (and in some cases region ID). -2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth). +2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/auth/third-party). 3. Assign the `role: 'authenticated'` custom claim to all JWTs by using an Auth0 Action. 4. Finally setup the Supabase client in your application. @@ -128,7 +128,7 @@ val supabase = createSupabaseClient( ## Add a new Third-Party Auth integration to your project -In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/settings/auth) and find the Third-Party Auth section to add a new integration. +In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/auth/third-party) and find the Third-Party Auth section to add a new integration. In the CLI add the following config to your `supabase/config.toml` file: diff --git a/apps/docs/content/guides/auth/third-party/aws-cognito.mdx b/apps/docs/content/guides/auth/third-party/aws-cognito.mdx index 72e99ecf8a8..208f838d2b5 100644 --- a/apps/docs/content/guides/auth/third-party/aws-cognito.mdx +++ b/apps/docs/content/guides/auth/third-party/aws-cognito.mdx @@ -9,7 +9,7 @@ Amazon Cognito User Pools (via AWS Amplify or on its own) can be used as a third ## Getting started 1. First you need to add an integration to connect your Supabase project with your Amazon Cognito User Pool. You will need the pool's ID and region. -2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth) or configure it in the CLI. +2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/auth/third-party) or configure it in the CLI. 3. Assign the `role: 'authenticated'` custom claim to all JWTs by using a Pre-Token Generation Trigger. 4. Finally setup the Supabase client in your application. @@ -142,7 +142,7 @@ suspend fun getAccessToken(): String? { ## Add a new Third-Party Auth integration to your project -In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/settings/auth) and find the Third-Party Auth section to add a new integration. +In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/auth/third-party) and find the Third-Party Auth section to add a new integration. In the CLI add the following config to your `supabase/config.toml` file: diff --git a/apps/docs/content/guides/auth/third-party/firebase-auth.mdx b/apps/docs/content/guides/auth/third-party/firebase-auth.mdx index a9a2b9246c0..3d8fd20cf4f 100644 --- a/apps/docs/content/guides/auth/third-party/firebase-auth.mdx +++ b/apps/docs/content/guides/auth/third-party/firebase-auth.mdx @@ -9,7 +9,7 @@ Firebase Auth can be used as a third-party authentication provider alongside Sup ## Getting started 1. First you need to add an integration to connect your Supabase project with your Firebase project. You will need to get the Project ID in the [Firebase Console](https://console.firebase.google.com/u/0/project/_/settings/general). -2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth). +2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/auth/third-party). 3. If you are using Third Party Auth when self hosting, create and attach restrictive RLS policies to all tables in your public schema, Storage and Realtime to **prevent unauthorized access from unrelated Firebase projects**. 4. Assign the `role: 'authenticated'` [custom user claim](https://firebase.google.com/docs/auth/admin/custom-claims) to all your users. 5. Finally set up the Supabase client in your application. @@ -143,7 +143,7 @@ val supabase = createSupabaseClient( ## Add a new Third-Party Auth integration to your project -In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/settings/auth) and find the Third-Party Auth section to add a new integration. +In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/auth/third-party) and find the Third-Party Auth section to add a new integration. In the CLI add the following config to your `supabase/config.toml` file: diff --git a/apps/docs/content/guides/auth/third-party/workos.mdx b/apps/docs/content/guides/auth/third-party/workos.mdx index 3edbf1d9d92..5ca2630ab46 100644 --- a/apps/docs/content/guides/auth/third-party/workos.mdx +++ b/apps/docs/content/guides/auth/third-party/workos.mdx @@ -9,7 +9,7 @@ WorkOS can be used as a third-party authentication provider alongside Supabase A ## Getting started 1. First you need to add an integration to connect your Supabase project with your WorkOS tenant. You will need your WorkOS issuer. The issuer is `https://api.workos.com/user_management/`. Substitute your [custom auth domain](https://workos.com/docs/custom-domains/auth-api) for "api.workos.com" if configured. -2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth). +2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/auth/third-party). 3. Set up a JWT template to assign the `role: 'authenticated'` claim to your access token. ## Setup the Supabase client library @@ -43,7 +43,7 @@ const supabase = createClient( ## Add a new Third-Party Auth integration to your project -In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/settings/auth) and find the Third-Party Auth section to add a new integration. +In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/auth/third-party) and find the Third-Party Auth section to add a new integration. ## Set up a JWT template to add the authenticated role. diff --git a/apps/docs/content/guides/getting-started/quickstarts/hono.mdx b/apps/docs/content/guides/getting-started/quickstarts/hono.mdx index dda758571bd..565ee5fae01 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/hono.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/hono.mdx @@ -47,7 +47,7 @@ hideToc: true Copy the `.env.example` file to `.env` and update the values with your Supabase project URL and anon key. - Lastly, [enable anonymous sign-ins](/dashboard/project/_/settings/auth) in the Auth settings. + Lastly, [enable anonymous sign-ins](/dashboard/project/_/auth/providers) in the Auth settings. diff --git a/apps/docs/content/guides/local-development/cli/testing-and-linting.mdx b/apps/docs/content/guides/local-development/cli/testing-and-linting.mdx index bd1c44b453d..0d0abc5f641 100644 --- a/apps/docs/content/guides/local-development/cli/testing-and-linting.mdx +++ b/apps/docs/content/guides/local-development/cli/testing-and-linting.mdx @@ -44,7 +44,7 @@ By default, Mailpit is available at [localhost:54324](http://localhost:54324) wh ### Going into production -The "default" email provided by Supabase is only for development purposes. It is [heavily restricted](/docs/guides/platform/going-into-prod#auth-rate-limits) to ensure that it is not used for spam. Before going into production, you must configure your own email provider. This is as simple as enabling a new SMTP credentials in your [project settings](/dashboard/project/_/settings/auth). +The "default" email provided by Supabase is only for development purposes. It is [heavily restricted](/docs/guides/platform/going-into-prod#auth-rate-limits) to ensure that it is not used for spam. Before going into production, you must configure your own email provider. This is as simple as enabling a new SMTP credentials in your [project settings](/dashboard/project/_/auth/smtp). ## Linting your database From 8e1e21f4d6837b6f6a38de2e65068c787bebb1b6 Mon Sep 17 00:00:00 2001 From: hallidayo <22655069+Hallidayo@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:20:28 +0100 Subject: [PATCH 20/76] docs: Fix delete objects limit (#38134) add admonition about deleting object limit --- .../content/guides/storage/management/delete-objects.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/docs/content/guides/storage/management/delete-objects.mdx b/apps/docs/content/guides/storage/management/delete-objects.mdx index 0565ad84ff1..92dacf02ac4 100644 --- a/apps/docs/content/guides/storage/management/delete-objects.mdx +++ b/apps/docs/content/guides/storage/management/delete-objects.mdx @@ -26,6 +26,12 @@ const supabase = createClient('your_project_url', 'your_supabase_api_key') await supabase.storage.from('bucket').remove(['object-path-2', 'folder/avatar2.png']) ``` + + +When deleting objects, there is a limit of 1000 objects at a time using the `remove` method. + + + ## RLS To delete an object, the user must have the `delete` permission on the object. For example: From 2ec5228a587d06aa5c7f4f657c0aeed9f1510f4c Mon Sep 17 00:00:00 2001 From: issuedat <165281975+issuedat@users.noreply.github.com> Date: Tue, 16 Sep 2025 16:20:36 +0200 Subject: [PATCH 21/76] Update humans.txt (#38381) --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index e82d57cc990..2aa31e79e6f 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -50,6 +50,7 @@ Emmett Folger Eric Kharitonashvili Etienne Stalmans Fabrizio Fenoglio +Fady A Felipe Stival Francesco Sansalvadore Greg Kress From 699dd6d19e66064d7ce67e5dc005176d543a7446 Mon Sep 17 00:00:00 2001 From: "Yadong (Adam) Zhang" Date: Tue, 16 Sep 2025 22:31:24 +0800 Subject: [PATCH 22/76] docs: update outdated langchain usage. (#38616) * fix(apps/docs): update outdated langchain usage. * fix(apps/docs): update outdated langchain usage. --- apps/docs/content/guides/ai/langchain.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/docs/content/guides/ai/langchain.mdx b/apps/docs/content/guides/ai/langchain.mdx index 4315c88a0f1..a25168eb31c 100644 --- a/apps/docs/content/guides/ai/langchain.mdx +++ b/apps/docs/content/guides/ai/langchain.mdx @@ -76,8 +76,8 @@ $$; You can now search your documents using any Node.js application. This is intended to be run on a secure server route. ```js -import { SupabaseVectorStore } from 'langchain/vectorstores/supabase' -import { OpenAIEmbeddings } from 'langchain/embeddings/openai' +import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase' +import { OpenAIEmbeddings } from '@langchain/openai' import { createClient } from '@supabase/supabase-js' const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY @@ -111,8 +111,8 @@ export const run = async () => { Given the above `match_documents` Postgres function, you can also pass a filter parameter to only return documents with a specific metadata field value. This filter parameter is a JSON object, and the `match_documents` function will use the Postgres JSONB Containment operator `@>` to filter documents by the metadata field values you specify. See details on the [Postgres JSONB Containment operator](https://www.postgresql.org/docs/current/datatype-json.html#JSON-CONTAINMENT) for more information. ```js -import { SupabaseVectorStore } from 'langchain/vectorstores/supabase' -import { OpenAIEmbeddings } from 'langchain/embeddings/openai' +import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase' +import { OpenAIEmbeddings } from '@langchain/openai' import { createClient } from '@supabase/supabase-js' // First, follow set-up instructions above @@ -150,8 +150,8 @@ export const run = async () => { You can also use query builder-style filtering ([similar to how the Supabase JavaScript library works](/docs/reference/javascript/using-filters)) instead of passing an object. Note that since the filter properties will be in the metadata column, you need to use arrow operators (`->` for integer or `->>` for text) as defined in [PostgREST API documentation](https://postgrest.org/en/stable/references/api/tables_views.html?highlight=operators#json-columns) and specify the data type of the property (e.g. the column should look something like `metadata->some_int_value::int`). ```js -import { SupabaseFilterRPCCall, SupabaseVectorStore } from 'langchain/vectorstores/supabase' -import { OpenAIEmbeddings } from 'langchain/embeddings/openai' +import { SupabaseFilterRPCCall, SupabaseVectorStore } from '@langchain/community/vectorstores/supabase' +import { OpenAIEmbeddings } from '@langchain/openai' import { createClient } from '@supabase/supabase-js' // First, follow set-up instructions above From 87ab63a1d7f84745ed24a2d94ef0ed4daf186a6d Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 16 Sep 2025 22:58:41 +0800 Subject: [PATCH 23/76] Chore/optimize auth users UI query (#38739) * only select required columns, instead of * * Use CTE, run agg only within the results block --- apps/studio/data/auth/users-infinite-query.ts | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/studio/data/auth/users-infinite-query.ts b/apps/studio/data/auth/users-infinite-query.ts index f64d68f7711..8fbf6d50ba0 100644 --- a/apps/studio/data/auth/users-infinite-query.ts +++ b/apps/studio/data/auth/users-infinite-query.ts @@ -43,9 +43,6 @@ export const getUsersSQL = ({ const hasValidKeywords = keywords && keywords !== '' const conditions: string[] = [] - const baseQueryUsers = ` - select *, coalesce((select array_agg(distinct i.provider) from auth.identities i where i.user_id = auth.users.id), '{}'::text[]) as providers from auth.users - `.trim() if (hasValidKeywords) { // [Joshen] Escape single quotes properly @@ -81,7 +78,52 @@ export const getUsersSQL = ({ const sortOn = sort ?? 'created_at' const sortOrder = order ?? 'desc' - return `${baseQueryUsers}${conditions.length > 0 ? ` where ${combinedConditions}` : ''} order by "${sortOn}" ${sortOrder} nulls last limit ${USERS_PAGE_LIMIT} offset ${offset};` + const usersQuery = ` +with + users_data as ( + select + id, + email, + banned_until, + created_at, + confirmed_at, + confirmation_sent_at, + is_anonymous, + is_sso_user, + invited_at, + last_sign_in_at, + phone, + raw_app_meta_data, + raw_user_meta_data, + updated_at + from + auth.users + ${conditions.length > 0 ? ` where ${combinedConditions}` : ''} + order by + "${sortOn}" ${sortOrder} nulls last + limit + ${USERS_PAGE_LIMIT} + offset + ${offset} + ) +select + *, + coalesce( + ( + select + array_agg(distinct i.provider) + from + auth.identities i + where + i.user_id = users_data.id + ), + '{}'::text[] + ) as providers +from + users_data; + `.trim() + + return usersQuery } export type UsersData = { result: User[] } From 757332fec85548b704f415baf42247b8d8ad2007 Mon Sep 17 00:00:00 2001 From: Lakshan Perera Date: Wed, 17 Sep 2025 01:51:58 +1000 Subject: [PATCH 24/76] docs: add default environment variables available in hosted enviroment (#38666) --- apps/docs/content/guides/functions/secrets.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/docs/content/guides/functions/secrets.mdx b/apps/docs/content/guides/functions/secrets.mdx index d7b556e4047..9fd3aadc306 100644 --- a/apps/docs/content/guides/functions/secrets.mdx +++ b/apps/docs/content/guides/functions/secrets.mdx @@ -14,6 +14,12 @@ Edge Functions have access to these secrets by default: - `SUPABASE_SERVICE_ROLE_KEY`: The `service_role` key for your Supabase API. This is safe to use in Edge Functions, but it should NEVER be used in a browser. This key will bypass Row Level Security - `SUPABASE_DB_URL`: The URL for your Postgres database. You can use this to connect directly to your database +In a hosted environment, functions have access to the following environment variables: + +- `SB_REGION`: The region function was invoked +- `SB_EXECUTION_ID`: A UUID of function instance ([isolate](/docs/guides/functions/architecture#4-execution-mechanics-fast-and-isolated)) +- `DENO_DEPLOYMENT_ID`: Version of the function code (`{project_ref}_{function_id}_{version}`) + --- ## Accessing environment variables From 9907fb25f42879a10bc81ef42d5a0471044d065a Mon Sep 17 00:00:00 2001 From: Chris Stockton <180184+cstockton@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:20:33 -0700 Subject: [PATCH 25/76] fix(ui/auth-sessions): always show refresh token reuse interval (#38723) * fix(ui/auth-sessions): always show refresh token reuse interval The reuse interval input was previously hidden when rotation was disabled. This caused confusion, since the interval still applies regardless of rotation setting. Allow zero values for reuse interval as it is supported in auth server and is used by some projects. * Always render SECURITY_REFRESH_TOKEN_REUSE_INTERVAL field. * Removed .positive() schema check to allow 0 values * Improves alignment between dashboard UI and backend behavior * chore: prettier --------- Co-authored-by: Chris Stockton --- .../SessionsAuthSettingsForm.tsx | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx index 7ae7a7cc958..3cea989af22 100644 --- a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx @@ -44,10 +44,7 @@ function HoursOrNeverText({ value }: { value: number }) { const RefreshTokenSchema = z.object({ REFRESH_TOKEN_ROTATION_ENABLED: z.boolean(), - SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: z.coerce - .number() - .positive() - .min(0, 'Must be a value more than 0'), + SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: z.coerce.number().min(0, 'Must be a value more than 0'), }) const UserSessionsSchema = z.object({ @@ -202,32 +199,30 @@ export const SessionsAuthSettingsForm = () => { )} /> - {refreshTokenForm.watch('REFRESH_TOKEN_ROTATION_ENABLED') && ( - - ( - - - - - - - - )} - /> - - )} + + ( + + + + + + + + )} + /> + {refreshTokenForm.formState.isDirty && (

diff --git a/packages/ui-patterns/src/Banners/SelectBanner.tsx b/packages/ui-patterns/src/Banners/SelectBanner.tsx new file mode 100644 index 00000000000..32afcf91928 --- /dev/null +++ b/packages/ui-patterns/src/Banners/SelectBanner.tsx @@ -0,0 +1,30 @@ +import Link from 'next/link' +import { Button } from 'ui/src/components/Button' +import announcement from './data.json' + +export function SelectBanner() { + return ( +
+
+
+ + Supabase Select + + +

+ {announcement.desc} + {announcement.verbose} +

+ + +
+
+
+ ) +} + +export default SelectBanner diff --git a/packages/ui-patterns/src/Banners/data.json b/packages/ui-patterns/src/Banners/data.json index a7f1e2fee14..bd3f41eb0f4 100644 --- a/packages/ui-patterns/src/Banners/data.json +++ b/packages/ui-patterns/src/Banners/data.json @@ -1,7 +1,8 @@ { - "text": "", - "launch": "", - "launchDate": "2025-07-17T08:00:00.000-07:00", - "link": "#", - "cta": "Learn more" + "title": "Supabase Select", + "desc": "Our first user conference · October 3, San Francisco", + "verbose": " · Guillermo Rauch, Dylan Field, and more", + "link": "https://select.supabase.com/", + "target": "_blank", + "button": "Save your seat" } diff --git a/packages/ui-patterns/src/PromoToast/PromoToast.tsx b/packages/ui-patterns/src/PromoToast/PromoToast.tsx index af51c5e3515..6c7c49049d2 100644 --- a/packages/ui-patterns/src/PromoToast/PromoToast.tsx +++ b/packages/ui-patterns/src/PromoToast/PromoToast.tsx @@ -46,7 +46,7 @@ const PromoToast = () => { poster={`${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/launch-week/lw15/assets/lw15-galaxy.png`} />
- {announcement.text} + {announcement.title}

diff --git a/packages/ui/src/layout/banners/Announcement.tsx b/packages/ui/src/layout/banners/Announcement.tsx index 364280e5050..60dc358c34a 100644 --- a/packages/ui/src/layout/banners/Announcement.tsx +++ b/packages/ui/src/layout/banners/Announcement.tsx @@ -9,8 +9,8 @@ import { X } from 'lucide-react' export interface AnnouncementProps { show: boolean - text: string - launchDate: string + title: string + launchDate: string | null link: string badge?: string } @@ -66,7 +66,7 @@ const Announcement = ({
{dismissable && !isLaunchWeekSection && (
From 11b6f77db45ed8a2dab8b6f1feacd9b70f4f1d37 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 17 Sep 2025 16:51:14 +0800 Subject: [PATCH 42/76] Support for projects pagination (Part 2) (#38654) * Create OrganizationProjectSelector which handles infinite scrolling in Popover * Use OrganizationProjectSelector in InviteMemberButton * Refactor OrganizationProjectSelector to optimize UX * Use OrganizationProjectSelector in SupportFormV2 * Update apps/studio/data/projects/projects-infinite-query.ts * Nit --------- Co-authored-by: Jordi Enric <37541088+jordienr@users.noreply.github.com> --- .../TeamSettings/InviteMemberButton.tsx | 114 +-------- .../interfaces/Support/SupportFormV2.tsx | 81 ++++-- .../layouts/AppLayout/ProjectDropdown.tsx | 152 ++++------- .../ui/OrganizationProjectSelector.tsx | 241 ++++++++++++++++++ 4 files changed, 363 insertions(+), 225 deletions(-) create mode 100644 apps/studio/components/ui/OrganizationProjectSelector.tsx diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 244c40cd725..a1c756df52d 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -1,6 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Check, ChevronsUpDown } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' @@ -10,10 +9,10 @@ import * as z from 'zod' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import InformationBox from 'components/ui/InformationBox' +import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' import { useOrganizationCreateInvitationMutation } from 'data/organization-members/organization-invitation-create-mutation' import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' -import { useProjectsQuery } from 'data/projects/projects-query' import { useHasAccessToProjectLevelPermissions } from 'data/subscriptions/org-subscription-query' import { doPermissionsCheck, useGetPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' @@ -21,12 +20,6 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization import { useProfile } from 'lib/profile' import { Button, - CommandEmpty_Shadcn_, - CommandGroup_Shadcn_, - CommandInput_Shadcn_, - CommandItem_Shadcn_, - CommandList_Shadcn_, - Command_Shadcn_, Dialog, DialogContent, DialogHeader, @@ -38,17 +31,12 @@ import { FormField_Shadcn_, Form_Shadcn_, Input_Shadcn_, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, - ScrollArea, SelectContent_Shadcn_, SelectGroup_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, Select_Shadcn_, Switch, - cn, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { useGetRolesManagementPermissions } from './TeamSettings.utils' @@ -66,15 +54,10 @@ export const InviteMemberButton = () => { const [isOpen, setIsOpen] = useState(false) const [projectDropdownOpen, setProjectDropdownOpen] = useState(false) - const { data } = useProjectsQuery() const { data: members } = useOrganizationMembersQuery({ slug }) const { data: allRoles, isSuccess } = useOrganizationRolesV2Query({ slug }) const orgScopedRoles = allRoles?.org_scoped_roles ?? [] - const orgProjects = (data?.projects ?? []) - .filter((project) => project.organization_id === organization?.id) - .sort((a, b) => a.name.localeCompare(b.name)) - const currentPlan = organization?.plan const hasAccessToProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string) @@ -118,7 +101,7 @@ export const InviteMemberButton = () => { defaultValues: { email: '', role: '', applyToOrg: true, projectRef: '' }, }) - const { applyToOrg } = form.watch() + const { applyToOrg, projectRef } = form.watch() const onInviteMember = async (values: z.infer) => { if (!slug) return console.error('Slug is required') @@ -164,17 +147,9 @@ export const InviteMemberButton = () => { const developerRole = orgScopedRoles.find((role) => role.name === 'Developer') if (developerRole !== undefined) form.setValue('role', developerRole.id.toString()) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSuccess, isOpen]) - useEffect(() => { - if (!applyToOrg) { - const firstProject = orgProjects?.[0] - if (firstProject !== undefined) form.setValue('projectRef', firstProject.ref) - } else { - form.setValue('projectRef', '') - } - }, [applyToOrg]) - return ( @@ -278,81 +253,16 @@ export const InviteMemberButton = () => { description="You can assign roles to multiple projects once the invite is accepted" > - - - - - - { - const project = orgProjects.find((project) => project.ref === value) - const projectName = project?.name.toLowerCase() - if ( - projectName !== undefined && - projectName.includes(search.toLowerCase()) - ) { - return 1 - } else if (value.includes(search)) { - return 1 - } else { - return 0 - } - }} - > - - - No projects found... - - 7 && - 'max-h-[210px] overflow-y-auto' - )} - > - {orgProjects.map((project) => { - return ( - { - form.setValue('projectRef', value) - setProjectDropdownOpen(false) - }} - > - - {project.name} - - ) - })} - - - - - - + setOpen={setProjectDropdownOpen} + searchPlaceholder="Search project..." + onSelect={(project) => field.onChange(project.ref)} + onInitialLoad={(projects) => field.onChange(projects[0]?.ref ?? '')} + /> )} diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index 7246e96887c..c0c1c7ef988 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -1,6 +1,17 @@ import { zodResolver } from '@hookform/resolvers/zod' import * as Sentry from '@sentry/nextjs' -import { Book, ChevronRight, ExternalLink, Github, Loader2, Mail, Plus, X } from 'lucide-react' +import { + Book, + Check, + ChevronRight, + ChevronsUpDown, + ExternalLink, + Github, + Loader2, + Mail, + Plus, + X, +} from 'lucide-react' import Link from 'next/link' import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' @@ -9,6 +20,7 @@ import * as z from 'zod' import { useDocsSearch, useParams, type DocsSearchResult as Page } from 'common' import { CLIENT_LIBRARIES } from 'common/constants' +import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' import { getProjectAuthConfig } from 'data/auth/auth-config-query' import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send' import { useOrganizationsQuery } from 'data/organizations/organizations-query' @@ -24,6 +36,8 @@ import { Collapsible_Shadcn_, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, + CommandGroup_Shadcn_, + CommandItem_Shadcn_, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, @@ -41,6 +55,7 @@ import { import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { MultiSelectV2 } from 'ui-patterns/MultiSelectDeprecated/MultiSelectV2' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import { IPV4SuggestionAlert } from './IPV4SuggestionAlert' import { LibrarySuggestions } from './LibrarySuggestions' import { PlanExpectationInfoBox } from './PlanExpectationInfoBox' @@ -388,31 +403,53 @@ export const SupportFormV2 = ({ render={({ field }) => ( - { - if (val.length > 0) field.onChange(val) - }} - > - - - - - - {projects?.map((project) => ( - - {project.name} - - ))} - - - + field.onChange(projects[0]?.ref ?? 'no-project')} + onSelect={(project) => field.onChange(project.ref)} + renderTrigger={({ isLoading, project }) => ( + + )} + renderActions={(setOpen) => ( + + { + field.onChange('no-project') + setOpen(false) + }} + > + {field.value === 'no-project' && } +

+ No specific project +

+
+
+ )} + />
)} /> + {organizationSlug && subscriptionPlanId !== 'enterprise' && category !== 'Login_issues' && ( diff --git a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx index afabafbead6..de923374f34 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx @@ -5,35 +5,14 @@ import { ParsedUrlQuery } from 'querystring' import { useState } from 'react' import { useParams } from 'common' +import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { ProjectInfo, useProjectsQuery } from 'data/projects/projects-query' +import { useProjectsQuery } from 'data/projects/projects-query' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { IS_PLATFORM } from 'lib/constants' -import type { Organization } from 'types' -import { - Button, - CommandEmpty_Shadcn_, - CommandGroup_Shadcn_, - CommandInput_Shadcn_, - CommandItem_Shadcn_, - CommandList_Shadcn_, - CommandSeparator_Shadcn_, - Command_Shadcn_, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, - ScrollArea, - cn, -} from 'ui' - -// [Fran] the idea is to let users change projects without losing the current page, -// but at the same time we need to redirect correctly between urls that might be -// unique to a project e.g. '/project/projectRef/editor/tableId' -// Right now, I'm gonna assume that any router query after the projectId, -// is a unique project id/marker so we'll redirect the user to the -// highest common route with just projectRef in the router queries. +import { Button, CommandGroup_Shadcn_, CommandItem_Shadcn_, cn } from 'ui' export const sanitizeRoute = (route: string, routerQueries: ParsedUrlQuery) => { const queryArray = Object.entries(routerQueries) @@ -53,40 +32,6 @@ export const sanitizeRoute = (route: string, routerQueries: ParsedUrlQuery) => { } } -const ProjectLink = ({ - project, - setOpen, -}: { - project: ProjectInfo - organization?: Organization - setOpen: (value: boolean) => void -}) => { - const router = useRouter() - const { ref } = useParams() - const sanitizedRoute = sanitizeRoute(router.route, router.query) - - // [Joshen] Temp while we're interim between v1 and v2 billing - let href = sanitizedRoute?.replace('[ref]', project.ref) ?? `/project/${project.ref}` - - return ( - { - router.push(href) - setOpen(false) - }} - onClick={() => setOpen(false)} - > - - {project.name} - {project.ref === ref && } - - - ) -} - export const ProjectDropdown = () => { const router = useRouter() const { ref } = useParams() @@ -122,57 +67,62 @@ export const ProjectDropdown = () => { {selectedProject?.name} - - + + { + router.push(`/project/${project.ref}`) + }} + renderTrigger={() => ( + )} + + + + setSearch('')} + /> + + + {isLoadingProjects ? ( + <> +
+ +
+
+ +
+ + ) : isErrorProjects ? ( +
+

Failed to retrieve projects

+ + + + + Error: {projectsError?.message} + +
+ ) : ( + <> + {search.length > 0 && projects.length === 0 && ( +

+ No projects found based on your search +

+ )} + 7 ? 'h-[210px]' : ''}> + {projects?.map((project) => ( + { + onSelect?.(project) + setOpen(false) + }} + onClick={() => setOpen(false)} + > + {!!renderRow ? ( + renderRow(project) + ) : ( +
+ {checkPosition === 'left' && project.ref === selectedRef && ( + + )} + {project.name} + {checkPosition === 'right' && project.ref === selectedRef && ( + + )} +
+ )} +
+ ))} +
+ {hasNextPage && ( +
+ +
+ )} + + + )} + + {!!renderActions && ( + <> + {/* [Joshen] Not using CommandSeparator to persist this while searching */} +
+ {renderActions(setOpen)} + + )} + + + + + ) +} From 08960d0f481e7ddd9f0a965a295e5133f0439c8c Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:19:16 +0200 Subject: [PATCH 43/76] FE-1856: fix shared api report filters (#38775) * fix filters * handle numbers --- .../Reports/Reports.constants.test.ts | 105 ++++++++++++++++++ .../interfaces/Reports/Reports.constants.ts | 25 +++-- 2 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 apps/studio/components/interfaces/Reports/Reports.constants.test.ts diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.test.ts b/apps/studio/components/interfaces/Reports/Reports.constants.test.ts new file mode 100644 index 00000000000..50d40d413fe --- /dev/null +++ b/apps/studio/components/interfaces/Reports/Reports.constants.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest' +import { generateRegexpWhere } from './Reports.constants' +import type { ReportFilterItem } from './Reports.types' + +describe('generateRegexpWhere', () => { + it('should return empty string when no filters provided', () => { + const result = generateRegexpWhere([]) + expect(result).toBe('') + }) + + it('should generate WHERE clause for single filter', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.path', + value: '/api/users', + compare: 'is', + }, + ] + const result = generateRegexpWhere(filters, true) + expect(result).toBe("WHERE request.path = '/api/users'") + }) + + it('should generate AND clause for single filter with prepend=false', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.path', + value: '/api/users', + compare: 'is', + }, + ] + const result = generateRegexpWhere(filters, false) + expect(result).toBe("AND request.path = '/api/users'") + }) + + it('should handle different comparison operators', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.path', + value: '/api/*', + compare: 'matches', + }, + { + key: 'response.status_code', + value: 404, + compare: 'is', + }, + ] + const result = generateRegexpWhere(filters, true) + expect(result).toBe( + "WHERE REGEXP_CONTAINS(request.path, '/api/*') AND response.status_code = 404" + ) + }) + + it('should handle values with quotes', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.path', + value: '"/api/users"', + compare: 'is', + }, + ] + + const result = generateRegexpWhere(filters, true) + expect(result).toBe(`WHERE request.path = "/api/users"`) + }) + + it('should handle values without quotes', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.path', + value: '/api/users', + compare: 'is', + }, + ] + + const result = generateRegexpWhere(filters, true) + expect(result).toBe("WHERE request.path = '/api/users'") + }) + + it('should handle values with quotes and lowercase', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.path', + value: '"/Api/Users"', + compare: 'is', + }, + ] + + const result = generateRegexpWhere(filters, true) + expect(result).toBe(`WHERE request.path = "/api/users"`) + }) + + it('should handle numbers', () => { + const filters: ReportFilterItem[] = [ + { + key: 'request.status_code', + value: 200, + compare: 'is', + }, + ] + + const result = generateRegexpWhere(filters, true) + expect(result).toBe(`WHERE request.status_code = 200`) + }) +}) diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.ts b/apps/studio/components/interfaces/Reports/Reports.constants.ts index 010d3096508..b80f349bb6b 100644 --- a/apps/studio/components/interfaces/Reports/Reports.constants.ts +++ b/apps/studio/components/interfaces/Reports/Reports.constants.ts @@ -86,25 +86,34 @@ export const generateRegexpWhere = (filters: ReportFilterItem[], prepend = true) const normalizedKey = [splitKey[splitKey.length - 2], splitKey[splitKey.length - 1]].join('.') const filterKey = filter.key.includes('.') ? normalizedKey : filter.key + const hasQuotes = + filter.value.toString().includes('"') || filter.value.toString().includes("'") + + const valueIsNumber = !isNaN(Number(filter.value)) + const valueWithQuotes = !valueIsNumber && hasQuotes ? filter.value : `'${filter.value}'` + const lowercaseValue = !valueIsNumber && String(valueWithQuotes).toLowerCase() + + const finalValue = valueIsNumber ? filter.value : lowercaseValue + // Handle different comparison operators switch (filter.compare) { case 'matches': - return `REGEXP_CONTAINS(${filterKey}, '${filter.value}')` + return `REGEXP_CONTAINS(${filterKey}, ${finalValue})` case 'is': - return `${filterKey} = ${filter.value}` + return `${filterKey} = ${finalValue}` case '!=': - return `${filterKey} != ${filter.value}` + return `${filterKey} != ${finalValue}` case '>=': - return `${filterKey} >= ${filter.value}` + return `${filterKey} >= ${finalValue}` case '<=': - return `${filterKey} <= ${filter.value}` + return `${filterKey} <= ${finalValue}` case '>': - return `${filterKey} > ${filter.value}` + return `${filterKey} > ${finalValue}` case '<': - return `${filterKey} < ${filter.value}` + return `${filterKey} < ${finalValue}` default: // Fallback to exact match for unknown operators - return `${filterKey} = ${filter.value}` + return `${filterKey} = ${finalValue}` } }) .filter(Boolean) // Remove any null/undefined conditions From 4236f8781e4491dc40ac182bfa65383b36c3e532 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:30:57 +0200 Subject: [PATCH 44/76] add status code to tags instead of context (#38706) * add attachment with error for better filtering * fix type error * move status code to tag * skip 4xxs in sentry * Update apps/studio/data/permissions/permissions-query.ts Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * Update apps/studio/data/permissions/permissions-query.ts Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --------- Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --- apps/studio/data/permissions/permissions-query.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/studio/data/permissions/permissions-query.ts b/apps/studio/data/permissions/permissions-query.ts index 0a12374f134..783ed966bd3 100644 --- a/apps/studio/data/permissions/permissions-query.ts +++ b/apps/studio/data/permissions/permissions-query.ts @@ -11,11 +11,18 @@ export type PermissionsResponse = Permission[] export async function getPermissions(signal?: AbortSignal) { const { data, error } = await get('/platform/profile/permissions', { signal }) if (error) { + const statusCode = (!!error && typeof error === 'object' && (error as any).code) || 'unknown' + + // This is to avoid sending 4XX errors + // But we still want to capture errors without a status code or 5XXs + // since those may require investigation if they spike + const sendError = statusCode >= 500 || statusCode === 'unknown' handleError(error, { - alwaysCapture: true, + alwaysCapture: sendError, sentryContext: { tags: { permissionsQuery: true, + statusCode, }, contexts: { rawError: error, From 3d55164563db987c9eaf077f6de310f835c8deb8 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 17 Sep 2025 12:59:07 +0200 Subject: [PATCH 45/76] propagate sentry issue id to form (#38714) * propagate sentry issue id to form * fix, add to payload * empty * name fix * add test button * rm thrower * Update apps/studio/components/ui/GlobalErrorBoundaryState.tsx Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --------- Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --- .../components/interfaces/Support/SupportFormV2.tsx | 4 ++++ apps/studio/components/ui/GlobalErrorBoundaryState.tsx | 8 ++++---- apps/studio/data/feedback/support-ticket-send.ts | 3 +++ apps/studio/pages/_app.tsx | 7 +++++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index c0c1c7ef988..5d8077f91a8 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -66,6 +66,7 @@ import { SEVERITY_OPTIONS, } from './Support.constants' import { formatMessage, uploadAttachments } from './SupportForm.utils' +import { useRouter } from 'next/router' const MAX_ATTACHMENTS = 5 const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive'] @@ -93,6 +94,8 @@ export const SupportFormV2 = ({ message: urlMessage, error, } = useParams() + const router = useRouter() + const dashboardSentryIssueId = router.query.sid as string const uploadButtonRef = useRef(null) const [isSubmitting, setIsSubmitting] = useState(false) @@ -239,6 +242,7 @@ export const SupportFormV2 = ({ .map((x) => x.trim().replace(/ /g, '_').toLowerCase()) .join(';'), browserInformation: detectBrowser(), + ...(dashboardSentryIssueId && { dashboardSentryIssueId }), } if (values.projectRef !== 'no-project') { diff --git a/apps/studio/components/ui/GlobalErrorBoundaryState.tsx b/apps/studio/components/ui/GlobalErrorBoundaryState.tsx index 87bb6b27f22..92f916d1964 100644 --- a/apps/studio/components/ui/GlobalErrorBoundaryState.tsx +++ b/apps/studio/components/ui/GlobalErrorBoundaryState.tsx @@ -15,7 +15,6 @@ import { } from 'ui' import { InlineLinkClassName } from './InlineLink' -// More correct version of FallbackProps from react-error-boundary export type FallbackProps = { error: unknown resetErrorBoundary: (...args: any[]) => void @@ -31,6 +30,9 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback ? errorMessage.includes("Failed to execute 'removeChild' on 'Node'") : false + // Get Sentry issue ID from error if available + const sentryIssueId = (!!error && typeof error === 'object' && (error as any).sentryId) ?? '' + const handleClearStorage = () => { try { localStorage.clear() @@ -67,7 +69,6 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback

{errorMessage}

- {isRemoveChildError ? ( @@ -130,11 +131,10 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback )} -
) } return ( -
+
{items?.map((entry, index) => )}
diff --git a/apps/studio/data/reports/auth-charts.ts b/apps/studio/data/reports/auth-charts.ts deleted file mode 100644 index 33f20b979ae..00000000000 --- a/apps/studio/data/reports/auth-charts.ts +++ /dev/null @@ -1,200 +0,0 @@ -export const getAuthReportAttributes = () => [ - { - id: 'active-users', - label: 'Active Users', - valuePrecision: 0, - hide: false, - showTooltip: false, - showLegend: false, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - attributes: [ - { attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true }, - ], - }, - { - id: 'sign-in-attempts', - label: 'Sign In Attempts by Type', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - titleTooltip: 'The total number of sign in attempts by grant type.', - attributes: [ - { - attribute: 'SignInAttempts', - provider: 'logs', - label: 'Password', - login_type_provider: 'password', - enabled: true, - }, - { - attribute: 'SignInAttempts', - provider: 'logs', - label: 'PKCE', - login_type_provider: 'pkce', - enabled: true, - }, - { - attribute: 'SignInAttempts', - provider: 'logs', - label: 'Refresh Token', - login_type_provider: 'token', - enabled: true, - }, - { - attribute: 'SignInAttempts', - provider: 'logs', - label: 'ID Token', - login_type_provider: 'id_token', - enabled: true, - }, - ], - }, - { - id: 'signups', - label: 'Sign Ups', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: false, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - titleTooltip: 'The total number of sign ups.', - attributes: [ - { - attribute: 'TotalSignUps', - provider: 'logs', - label: 'Sign Ups', - enabled: true, - }, - ], - }, - { - id: 'auth-errors', - label: 'Auth Errors', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - titleTooltip: 'The total number of auth errors by status code.', - attributes: [ - { - attribute: 'ErrorsByStatus', - provider: 'logs', - label: 'Auth Errors', - }, - ], - }, - { - id: 'password-reset-requests', - label: 'Password Reset Requests', - valuePrecision: 0, - hide: false, - showTooltip: false, - showLegend: false, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - attributes: [ - { - attribute: 'PasswordResetRequests', - provider: 'logs', - label: 'Password Reset Requests', - enabled: true, - }, - ], - }, - { - id: 'sign-in-latency', - label: 'Sign In Latency', - valuePrecision: 2, - hide: true, // Jordi: Hidden until we can fix the query - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'line', - titleTooltip: 'Average latency for sign in operations by grant type.', - attributes: [ - { - attribute: 'SignInLatency', - provider: 'logs', - label: 'Password', - grantType: 'password', - enabled: true, - }, - { - attribute: 'SignInLatency', - provider: 'logs', - label: 'PKCE', - grantType: 'pkce', - enabled: true, - }, - { - attribute: 'SignInLatency', - provider: 'logs', - label: 'Refresh Token', - grantType: 'refresh_token', - enabled: true, - }, - { - attribute: 'SignInLatency', - provider: 'logs', - label: 'ID Token', - grantType: 'id_token', - enabled: true, - }, - ], - }, - { - id: 'sign-up-latency', - label: 'Sign Up Latency', - valuePrecision: 2, - hide: true, // Jordi: Hidden until we can fix the query - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'line', - titleTooltip: 'Average latency for sign up operations by provider.', - attributes: [ - { - attribute: 'SignUpLatency', - provider: 'logs', - label: 'Email', - providerType: 'email', - enabled: true, - }, - { - attribute: 'SignUpLatency', - provider: 'logs', - label: 'Google', - providerType: 'google', - enabled: true, - }, - { - attribute: 'SignUpLatency', - provider: 'logs', - label: 'GitHub', - providerType: 'github', - enabled: true, - }, - { - attribute: 'SignUpLatency', - provider: 'logs', - label: 'Apple', - providerType: 'apple', - enabled: true, - }, - ], - }, -] diff --git a/apps/studio/data/reports/auth-report-query.ts b/apps/studio/data/reports/auth-report-query.ts deleted file mode 100644 index 0b98e1bd2d1..00000000000 --- a/apps/studio/data/reports/auth-report-query.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { get } from 'data/fetchers' -import { AnalyticsInterval } from 'data/analytics/constants' -import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' -import { getHttpStatusCodeInfo } from 'lib/http-status-codes' -import { analyticsIntervalToGranularity } from './report.utils' - -/** - * METRICS - * Each chart in the UI has a corresponding metric key. - */ - -const METRIC_KEYS = [ - 'ActiveUsers', - 'SignInAttempts', - 'PasswordResetRequests', - 'TotalSignUps', - 'SignInLatency', - 'SignUpLatency', - 'ErrorsByStatus', -] - -const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = { - '400': { light: '#FFD54F', dark: '#FFF176' }, - '401': { light: '#FF8A65', dark: '#FFAB91' }, - '403': { light: '#FFB74D', dark: '#FFCC80' }, - '404': { light: '#90A4AE', dark: '#B0BEC5' }, - '409': { light: '#BA68C8', dark: '#CE93D8' }, - '410': { light: '#A1887F', dark: '#BCAAA4' }, - '422': { light: '#FF9800', dark: '#FFB74D' }, - '429': { light: '#E65100', dark: '#F57C00' }, - '500': { light: '#B71C1C', dark: '#D32F2F' }, - '502': { light: '#9575CD', dark: '#B39DDB' }, - '503': { light: '#0097A7', dark: '#4DD0E1' }, - '504': { light: '#C0CA33', dark: '#D4E157' }, - default: { light: '#757575', dark: '#9E9E9E' }, -} - -type MetricKey = (typeof METRIC_KEYS)[number] - -/** - * SQL - * Each metric has a corresponding SQL query. - */ - -const METRIC_SQL: Record string> = { - ActiveUsers: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --active-users - select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - count(distinct json_value(f.event_message, "$.auth_event.actor_id")) as count - from auth_logs f - where json_value(f.event_message, "$.auth_event.action") in ( - 'login', 'user_signedup', 'token_refreshed', 'user_modified', - 'user_recovery_requested', 'user_reauthenticate_requested' - ) - group by timestamp - order by timestamp desc - ` - }, - SignInAttempts: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --sign-in-attempts - SELECT - timestamp_trunc(timestamp, ${granularity}) as timestamp, - CASE - WHEN JSON_VALUE(event_message, "$.provider") IS NOT NULL - AND JSON_VALUE(event_message, "$.provider") != '' - THEN CONCAT( - JSON_VALUE(event_message, "$.login_method"), - ' (', - JSON_VALUE(event_message, "$.provider"), - ')' - ) - ELSE JSON_VALUE(event_message, "$.login_method") - END as login_type_provider, - COUNT(*) as count - FROM - auth_logs - WHERE - JSON_VALUE(event_message, "$.action") = 'login' - AND JSON_VALUE(event_message, "$.metering") = "true" - GROUP BY - timestamp, login_type_provider - ORDER BY - timestamp desc, login_type_provider - ` - }, - PasswordResetRequests: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --password-reset-requests - select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - count(*) as count - from auth_logs f - where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested' - group by timestamp - order by timestamp desc - ` - }, - TotalSignUps: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --total-signups - select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - count(*) as count - from auth_logs - where json_value(event_message, "$.auth_event.action") = 'user_signedup' - group by timestamp - order by timestamp desc - ` - }, - SignInLatency: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --signin-latency - select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - json_value(event_message, "$.grant_type") as grant_type, - count(*) as request_count, - round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms, - round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms, - round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms, - round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms, - round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms, - round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms - from auth_logs - where json_value(event_message, "$.path") = '/token' - group by timestamp, grant_type - order by timestamp desc, grant_type - ` - }, - SignUpLatency: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --signup-latency - select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - json_value(event_message, "$.auth_event.traits.provider") as provider, - round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms, - round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms, - round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms, - round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms, - round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms - from auth_logs - where json_value(event_message, "$.auth_event.action") = 'user_signedup' - and json_value(event_message, "$.status") = '200' - group by timestamp, provider - order by timestamp desc, provider - ` - }, - ErrorsByStatus: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --auth-errors-by-status -select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - count(*) as count, - response.status_code -from edge_logs - cross join unnest(metadata) as m - cross join unnest(m.request) as request - cross join unnest(m.response) as response -where path like '%/auth%' - and response.status_code >= 400 and response.status_code <= 599 -group by timestamp, status_code -order by timestamp desc - ` - }, -} - -/** - * FORMATTERS. - * Metrics need to be formatted before being passed on to the UI charts. - */ - -function defaultFormatter(rawData: any, attributes: MultiAttribute[]) { - const chartAttributes = attributes - if (!rawData) return { data: undefined, chartAttributes } - const result = rawData.result || [] - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[attributes[0].attribute] = p.count - }) - return point - }) - return { data, chartAttributes } -} - -const METRIC_FORMATTER: Record< - MetricKey, - ( - rawData: any, - attributes: MultiAttribute[], - logsMetric: string - ) => { data: any; chartAttributes: any } -> = { - ActiveUsers: (rawData, attributes) => defaultFormatter(rawData, attributes), - SignInAttempts: (rawData, attributes) => { - const chartAttributes = attributes.map((attr) => { - if (attr.attribute === 'SignInAttempts' && attr.login_type_provider) { - return { ...attr, attribute: `${attr.attribute}_${attr.login_type_provider}` } - } - return attr - }) - if (!rawData) return { data: undefined, chartAttributes } - const result = rawData.result || [] - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[`SignInAttempts_${p.login_type_provider}`] = p.count - }) - return point - }) - return { data, chartAttributes } - }, - PasswordResetRequests: (rawData, attributes) => defaultFormatter(rawData, attributes), - TotalSignUps: (rawData, attributes) => defaultFormatter(rawData, attributes), - SignInLatency: (rawData, attributes) => defaultFormatter(rawData, attributes), - SignUpLatency: (rawData, attributes) => defaultFormatter(rawData, attributes), - ErrorsByStatus: (rawData, attributes) => { - if (!rawData) return { data: undefined, chartAttributes: attributes } - const result = rawData.result || [] - - const statusCodes = Array.from(new Set(result.map((p: any) => p.status_code))) - - const chartAttributes = statusCodes.map((statusCode) => { - const statusCodeInfo = getHttpStatusCodeInfo(Number(statusCode)) - const color = STATUS_CODE_COLORS[String(statusCode)] || STATUS_CODE_COLORS.default - - return { - attribute: `status_${statusCode}`, - label: `${statusCode} ${statusCodeInfo.label}`, - provider: 'logs', - enabled: true, - color: color, - statusCode: String(statusCode), - } - }) - - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[`status_${p.status_code}`] = p.count - }) - return point - }) - - return { data, chartAttributes } - }, -} - -/** - * REPORT QUERY. - * Fetching and state management for the report. - */ - -export function useAuthLogsReport({ - projectRef, - attributes, - startDate, - endDate, - interval, - enabled = true, -}: { - projectRef: string - attributes: MultiAttribute[] - startDate: string - endDate: string - interval: AnalyticsInterval - enabled?: boolean -}) { - const logsMetric = attributes.length > 0 ? attributes[0].attribute : '' - - const isAuthMetric = METRIC_KEYS.includes(logsMetric) - - const sql = isAuthMetric ? METRIC_SQL[logsMetric](interval) : '' - - const { - data: rawData, - error, - isLoading, - isFetching, - } = useQuery( - ['auth-logs-report', projectRef, logsMetric, startDate, endDate, interval, sql], - async () => { - const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { - params: { - path: { ref: projectRef }, - query: { - sql, - iso_timestamp_start: startDate, - iso_timestamp_end: endDate, - }, - }, - }) - if (error) throw error - return data - }, - { - enabled: Boolean(projectRef && sql && enabled && isAuthMetric), - refetchOnWindowFocus: false, - } - ) - - // Use formatter if available - const formatter = - (isAuthMetric ? METRIC_FORMATTER[logsMetric as MetricKey] : undefined) || defaultFormatter - const { data, chartAttributes } = formatter(rawData, attributes, logsMetric) - - return { - data, - attributes: chartAttributes, - isLoading, - error, - isFetching, - } -} diff --git a/apps/studio/data/reports/report.utils.ts b/apps/studio/data/reports/report.utils.ts index 67629df57fc..0c6b38b61c2 100644 --- a/apps/studio/data/reports/report.utils.ts +++ b/apps/studio/data/reports/report.utils.ts @@ -1,5 +1,6 @@ import { AnalyticsInterval } from 'data/analytics/constants' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' +import { get } from 'data/fetchers' export type Granularity = 'minute' | 'hour' | 'day' export function analyticsIntervalToGranularity(interval: AnalyticsInterval): Granularity { @@ -51,3 +52,39 @@ export const useEdgeFnIdToName = ({ projectRef }: { projectRef: string }) => { isLoading, } } + +export async function fetchLogs( + projectRef: string, + sql: string, + startDate: string, + endDate: string +) { + const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { + path: { ref: projectRef }, + query: { + sql, + iso_timestamp_start: startDate, + iso_timestamp_end: endDate, + }, + }, + }) + if (error) throw error + return data +} + +export const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = { + '400': { light: '#FFD54F', dark: '#FFF176' }, + '401': { light: '#FF8A65', dark: '#FFAB91' }, + '403': { light: '#FFB74D', dark: '#FFCC80' }, + '404': { light: '#90A4AE', dark: '#B0BEC5' }, + '409': { light: '#BA68C8', dark: '#CE93D8' }, + '410': { light: '#A1887F', dark: '#BCAAA4' }, + '422': { light: '#FF9800', dark: '#FFB74D' }, + '429': { light: '#E65100', dark: '#F57C00' }, + '500': { light: '#B71C1C', dark: '#D32F2F' }, + '502': { light: '#9575CD', dark: '#B39DDB' }, + '503': { light: '#0097A7', dark: '#4DD0E1' }, + '504': { light: '#C0CA33', dark: '#D4E157' }, + default: { light: '#757575', dark: '#9E9E9E' }, +} diff --git a/apps/studio/data/reports/v2/auth-report.test.tsx b/apps/studio/data/reports/v2/auth-report.test.tsx new file mode 100644 index 00000000000..198b1ce902a --- /dev/null +++ b/apps/studio/data/reports/v2/auth-report.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { defaultAuthReportFormatter } from './auth.config' + +describe('defaultAuthReportFormatter', () => { + const timestamp = new Date('2021-01-01').getTime() + + it('should format the data correctly', () => { + const data = { result: [{ timestamp: String(timestamp), count: 1 }] } + + const attributes = [ + { attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true }, + ] + + const result = defaultAuthReportFormatter(data, attributes) + + expect(result).toEqual({ + data: [{ timestamp: String(timestamp), ActiveUsers: 1 }], + chartAttributes: attributes, + }) + }) + + it('should format the data correctly with multiple attributes', () => { + const data = { + result: [ + { timestamp: String(timestamp), active_users: 1 }, + { timestamp: String(timestamp + 1), sign_in_attempts: 2 }, + ], + } + + const attributes = [ + { attribute: 'active_users', label: 'Active Users' }, + { attribute: 'sign_in_attempts', label: 'Sign In Attempts' }, + ] + + const result = defaultAuthReportFormatter(data, attributes) + + expect(result).toEqual({ + data: [ + { timestamp: String(timestamp), active_users: 1, sign_in_attempts: 0 }, + { timestamp: String(timestamp + 1), active_users: 0, sign_in_attempts: 2 }, + ], + chartAttributes: attributes, + }) + }) + + it('should handle empty data', () => { + const data = { result: [] } + + const attributes = [ + { attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true }, + ] + + const result = defaultAuthReportFormatter(data, attributes) + + expect(result).toEqual({ + data: [], + chartAttributes: attributes, + }) + }) +}) diff --git a/apps/studio/data/reports/v2/auth.config.ts b/apps/studio/data/reports/v2/auth.config.ts new file mode 100644 index 00000000000..d291195be6c --- /dev/null +++ b/apps/studio/data/reports/v2/auth.config.ts @@ -0,0 +1,514 @@ +import type { AnalyticsInterval } from 'data/analytics/constants' + +import { analyticsIntervalToGranularity } from 'data/reports/report.utils' +import { ReportConfig, ReportDataProviderAttribute } from './reports.types' +import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter' +import { fetchLogs } from 'data/reports/report.utils' +import z from 'zod' + +const METRIC_KEYS = [ + 'ActiveUsers', + 'SignInAttempts', + 'PasswordResetRequests', + 'TotalSignUps', + 'SignInLatency', + 'SignUpLatency', + 'ErrorsByStatus', +] + +type MetricKey = (typeof METRIC_KEYS)[number] + +const AUTH_REPORT_SQL: Record< + MetricKey, + (interval: AnalyticsInterval, filters?: AuthReportFilters) => string +> = { + ActiveUsers: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --active-users + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(distinct json_value(f.event_message, "$.auth_event.actor_id")) as count + from auth_logs f + where json_value(f.event_message, "$.auth_event.action") in ( + 'login', 'user_signedup', 'token_refreshed', 'user_modified', + 'user_recovery_requested', 'user_reauthenticate_requested' + ) + group by timestamp + order by timestamp desc + ` + }, + SignInAttempts: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --sign-in-attempts + SELECT + timestamp_trunc(timestamp, ${granularity}) as timestamp, + CASE + WHEN JSON_VALUE(event_message, "$.provider") IS NOT NULL + AND JSON_VALUE(event_message, "$.provider") != '' + THEN CONCAT( + JSON_VALUE(event_message, "$.login_method"), + ' (', + JSON_VALUE(event_message, "$.provider"), + ')' + ) + ELSE JSON_VALUE(event_message, "$.login_method") + END as login_type_provider, + COUNT(*) as count + FROM + auth_logs + WHERE + JSON_VALUE(event_message, "$.action") = 'login' + AND JSON_VALUE(event_message, "$.metering") = "true" + GROUP BY + timestamp, login_type_provider + ORDER BY + timestamp desc, login_type_provider + ` + }, + PasswordResetRequests: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --password-reset-requests + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count + from auth_logs f + where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested' + group by timestamp + order by timestamp desc + ` + }, + TotalSignUps: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --total-signups + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count + from auth_logs + where json_value(event_message, "$.auth_event.action") = 'user_signedup' + group by timestamp + order by timestamp desc + ` + }, + SignInLatency: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --signin-latency + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count, + round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms, + round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms, + round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms + from auth_logs + where json_value(event_message, "$.auth_event.action") = 'login' + group by timestamp + order by timestamp desc + ` + }, + SignUpLatency: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --signup-latency + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count, + round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms, + round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms, + round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms + from auth_logs + where json_value(event_message, "$.auth_event.action") = 'user_signedup' + group by timestamp + order by timestamp desc + ` + }, + ErrorsByStatus: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --auth-errors-by-status + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count, + response.status_code + from edge_logs + cross join unnest(metadata) as m + cross join unnest(m.request) as request + cross join unnest(m.response) as response + where path like '%/auth%' + and response.status_code >= 400 and response.status_code <= 599 + group by timestamp, status_code + order by timestamp desc + ` + }, +} + +type AuthReportFilters = { + status_code: NumericFilter | null +} + +function filterToWhereClause(filters?: AuthReportFilters): string { + if (!filters) return '' + return `` +} + +/** + * Transforms raw analytics data into a chart-ready format by ensuring data consistency and completeness. + * + * This function addresses several key requirements for chart rendering: + * 1. Fills missing timestamps with zero values to prevent gaps in charts + * 2. Creates a consistent data structure with `period_start` as the time axis + * 3. Initializes all chart attributes to 0, then populates actual values + * 4. Sorts timestamps chronologically for proper chart ordering + * + * @param rawData - Raw analytics data from backend queries containing timestamp and count fields + * @param attributes - Chart attribute configuration defining what metrics to display + * @returns Formatted data object with consistent time series data and chart attributes + */ +export function defaultAuthReportFormatter( + rawData: unknown, + attributes: ReportDataProviderAttribute[] +) { + const chartAttributes = attributes + + const rawDataSchema = z.object({ + result: z.array( + z + .object({ + timestamp: z.coerce.number(), + }) + .catchall(z.any()) + ), + }) + + const parsedRawData = rawDataSchema.parse(rawData) + const result = parsedRawData.result + + if (!result) return { data: undefined, chartAttributes } + + const timestamps = new Set(result.map((p: any) => String(p.timestamp))) + const data = Array.from(timestamps) + .sort() + .map((timestamp) => { + const point: any = { timestamp } + chartAttributes.forEach((attr) => { + point[attr.attribute] = 0 + }) + const matchingPoints = result.filter((p: any) => String(p.timestamp) === timestamp) + + matchingPoints.forEach((p: any) => { + chartAttributes.forEach((attr) => { + // Optional dimension filters used by some reports + if ('login_type_provider' in (attr as any)) { + if (p.login_type_provider !== (attr as any).login_type_provider) return + } + if ('providerType' in (attr as any)) { + if (p.provider !== (attr as any).providerType) return + } + + const valueFromField = + typeof p[attr.attribute] === 'number' + ? p[attr.attribute] + : typeof p.count === 'number' + ? p.count + : undefined + + if (typeof valueFromField === 'number') { + point[attr.attribute] = (point[attr.attribute] ?? 0) + valueFromField + } + }) + }) + return point + }) + return { data, chartAttributes } +} + +export const createAuthReportConfig = ({ + projectRef, + startDate, + endDate, + interval, + filters, +}: { + projectRef: string + startDate: string + endDate: string + interval: AnalyticsInterval + filters: AuthReportFilters +}): ReportConfig[] => [ + { + id: 'active-user', + label: 'Active Users', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of active users over time.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true }, + ] + + const sql = AUTH_REPORT_SQL.ActiveUsers(interval, filters) + + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, + { + id: 'sign-in-attempts', + label: 'Sign In Attempts by Type', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of sign in attempts by type.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'Password', + login_type_provider: 'password', + enabled: true, + }, + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'PKCE', + login_type_provider: 'pkce', + enabled: true, + }, + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'Refresh Token', + login_type_provider: 'token', + enabled: true, + }, + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'ID Token', + login_type_provider: 'id_token', + enabled: true, + }, + ] + + const sql = AUTH_REPORT_SQL.SignInAttempts(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, + { + id: 'signups', + label: 'Sign Ups', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of sign ups.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { + attribute: 'TotalSignUps', + provider: 'logs', + label: 'Sign Ups', + enabled: true, + }, + ] + + const sql = AUTH_REPORT_SQL.TotalSignUps(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, + { + id: 'auth-errors', + label: 'Auth Errors', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of auth errors by status code.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { + attribute: 'ErrorsByStatus', + provider: 'logs', + label: 'Auth Errors', + }, + ] + + const sql = AUTH_REPORT_SQL.ErrorsByStatus(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, + { + id: 'password-reset-requests', + label: 'Password Reset Requests', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of password reset requests.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { + attribute: 'PasswordResetRequests', + provider: 'logs', + label: 'Password Reset Requests', + enabled: true, + }, + ] + + const sql = AUTH_REPORT_SQL.PasswordResetRequests(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, + { + id: 'sign-in-latency', + label: 'Sign In Latency', + valuePrecision: 2, + hide: false, + hideHighlightedValue: true, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The average latency for sign in operations by grant type.', + availableIn: ['pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { + attribute: 'avg_latency_ms', + label: 'Avg. Latency (ms)', + }, + { + attribute: 'max_latency_ms', + label: 'Max. Latency (ms)', + }, + { + attribute: 'min_latency_ms', + label: 'Min. Latency (ms)', + }, + { + attribute: 'p50_latency_ms', + label: 'P50 Latency (ms)', + }, + { + attribute: 'p95_latency_ms', + label: 'P95 Latency (ms)', + }, + { + attribute: 'p99_latency_ms', + label: 'P99 Latency (ms)', + }, + { + attribute: 'request_count', + label: 'Request Count', + }, + ] + + const sql = AUTH_REPORT_SQL.SignInLatency(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, + { + id: 'sign-up-latency', + label: 'Sign Up Latency', + valuePrecision: 2, + hide: false, + hideHighlightedValue: true, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The average latency for sign up operations by provider.', + availableIn: ['pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { + attribute: 'avg_latency_ms', + label: 'Avg. Latency (ms)', + }, + { + attribute: 'max_latency_ms', + label: 'Max. Latency (ms)', + }, + { + attribute: 'min_latency_ms', + label: 'Min. Latency (ms)', + }, + { + attribute: 'p50_latency_ms', + label: 'P50 Latency (ms)', + }, + { + attribute: 'p95_latency_ms', + label: 'P95 Latency (ms)', + }, + { + attribute: 'p99_latency_ms', + label: 'P99 Latency (ms)', + }, + { + attribute: 'request_count', + label: 'Request Count', + }, + ] + + const sql = AUTH_REPORT_SQL.SignUpLatency(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, +] diff --git a/apps/studio/data/reports/v2/edge-functions.config.ts b/apps/studio/data/reports/v2/edge-functions.config.ts index 64d7307f1ce..40f78efd1d2 100644 --- a/apps/studio/data/reports/v2/edge-functions.config.ts +++ b/apps/studio/data/reports/v2/edge-functions.config.ts @@ -14,6 +14,7 @@ import { getHttpStatusCodeInfo } from 'lib/http-status-codes' import { ReportConfig } from './reports.types' import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter' import { SelectFilters } from 'components/interfaces/Reports/v2/ReportsSelectFilter' +import { fetchLogs } from 'data/reports/report.utils' type EdgeFunctionReportFilters = { status_code: NumericFilter | null @@ -151,21 +152,6 @@ order by }, } -async function runQuery(projectRef: string, sql: string, startDate: string, endDate: string) { - const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { - params: { - path: { ref: projectRef }, - query: { - sql, - iso_timestamp_start: startDate, - iso_timestamp_end: endDate, - }, - }, - }) - if (error) throw error - return data -} - export function extractStatusCodesFromData(data: any[]): string[] { const statusCodes = new Set() @@ -272,7 +258,7 @@ export const edgeFunctionReports = ({ availableIn: ['free', 'pro', 'team', 'enterprise'], dataProvider: async () => { const sql = METRIC_SQL.TotalInvocations(interval, filters) - const response = await runQuery(projectRef, sql, startDate, endDate) + const response = await fetchLogs(projectRef, sql, startDate, endDate) if (!response?.result) return { data: [] } @@ -304,7 +290,7 @@ export const edgeFunctionReports = ({ availableIn: ['free', 'pro', 'team', 'enterprise'], dataProvider: async () => { const sql = METRIC_SQL.ExecutionStatusCodes(interval, filters) - const rawData = await runQuery(projectRef, sql, startDate, endDate) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) if (!rawData?.result) return { data: [] } @@ -341,7 +327,7 @@ export const edgeFunctionReports = ({ format: (value: unknown) => `${Number(value).toFixed(0)}ms`, dataProvider: async () => { const sql = METRIC_SQL.ExecutionTime(interval, filters) - const rawData = await runQuery(projectRef, sql, startDate, endDate) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) if (!rawData?.result) return { data: [] } @@ -398,7 +384,7 @@ export const edgeFunctionReports = ({ availableIn: ['pro', 'team', 'enterprise'], dataProvider: async () => { const sql = METRIC_SQL.InvocationsByRegion(interval, filters) - const rawData = await runQuery(projectRef, sql, startDate, endDate) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) const data = rawData.result?.map((point: any) => ({ ...point, timestamp: isUnixMicro(point.timestamp) diff --git a/apps/studio/data/reports/v2/reports.types.ts b/apps/studio/data/reports/v2/reports.types.ts index cfa86deab93..28377e0e1e3 100644 --- a/apps/studio/data/reports/v2/reports.types.ts +++ b/apps/studio/data/reports/v2/reports.types.ts @@ -1,6 +1,12 @@ import { AnalyticsInterval } from 'data/analytics/constants' import { YAxisProps } from 'recharts' +export type ReportDataProviderAttribute = { + attribute: string + label: string + color?: { light: string; dark: string } +} + export interface ReportDataProvider { ( projectRef: string, @@ -10,13 +16,9 @@ export interface ReportDataProvider { filters?: FiltersType ): Promise<{ data: any - attributes?: { - attribute: string - label: string - color?: { light: string; dark: string } - }[] + attributes?: ReportDataProviderAttribute[] query?: string // The SQL used to fetch the data if any - }> // [jordi] would be cool to have a type that forces data keys to match the attributes + }> } export interface ReportConfig { @@ -25,6 +27,7 @@ export interface ReportConfig { dataProvider: ReportDataProvider valuePrecision: number hide: boolean + hideHighlightedValue?: boolean showTooltip: boolean showLegend: boolean showMaxValue: boolean diff --git a/apps/studio/hooks/useChartData.ts b/apps/studio/hooks/useChartData.ts deleted file mode 100644 index 29f129b464a..00000000000 --- a/apps/studio/hooks/useChartData.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * useChartData - * - * A hook for fetching and processing data for a chart. - * This hook is responsible for all the data fetching, combining, and state management logic - * that was previously inside ComposedChartHandler. - * - * It takes all necessary parameters like project reference, date range, and attributes, - * and returns the final chart data, loading state, and derived attributes. - */ -import { useMemo } from 'react' -import { useRouter } from 'next/router' - -import type { AnalyticsInterval, DataPoint } from 'data/analytics/constants' -import { useAuthLogsReport } from 'data/reports/auth-report-query' -import type { ChartData } from 'components/ui/Charts/Charts.types' -import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' - -export const useChartData = ({ - attributes, - startDate, - endDate, - interval, - data, - highlightedValue, - functionIds, - enabled = true, -}: { - attributes: MultiAttribute[] - startDate: string - endDate: string - interval: string - data?: ChartData - highlightedValue?: string | number - functionIds?: string[] - enabled?: boolean -}) => { - const router = useRouter() - const { ref } = router.query - - const logsAttributes = attributes.filter((attr) => attr.provider === 'logs') - - const isEdgeFunctionRoute = router.asPath.includes('/reports/edge-functions') - - const { - data: authData, - attributes: authChartAttributes, - isLoading: isAuthLoading, - } = useAuthLogsReport({ - projectRef: ref as string, - attributes: logsAttributes, - startDate, - endDate, - interval: interval as AnalyticsInterval, - enabled: enabled && logsAttributes.length > 0 && !isEdgeFunctionRoute, - }) - - const logsData = authData - const logsChartAttributes = authChartAttributes - const isLogsLoading = isAuthLoading - - const combinedData = useMemo(() => { - if (data) return data - - // Get all unique timestamps from all datasets - const timestamps = new Set() - if (logsData) { - logsData.forEach((point: any) => { - if (point?.period_start) { - timestamps.add(point.period_start) - } - }) - } - - // Combine data points for each timestamp - const combined = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - - const logPoint = logsData?.find((p: any) => p.period_start === timestamp) || {} - Object.assign(point, logPoint) - - return point as DataPoint - }) - - return combined as DataPoint[] - }, [data, attributes, isLogsLoading, logsData, logsAttributes]) - - const loading = logsAttributes.length > 0 && isLogsLoading - - // Calculate highlighted value based on the first attribute's data - const _highlightedValue = useMemo(() => { - if (highlightedValue !== undefined) return highlightedValue - - const firstAttr = attributes[0] - const firstData = logsChartAttributes.find((p: any) => p.attribute === firstAttr.attribute) - - if (!firstData) return undefined - - const shouldHighlightMaxValue = - firstAttr.provider === 'daily-stats' && - !firstAttr.attribute.includes('ingress') && - !firstAttr.attribute.includes('egress') && - 'maximum' in firstData - - const shouldHighlightTotalGroupedValue = 'totalGrouped' in firstData - - return shouldHighlightMaxValue - ? firstData.maximum - : firstAttr.provider === 'daily-stats' - ? firstData.total - : shouldHighlightTotalGroupedValue - ? firstData.totalGrouped?.[firstAttr.attribute as keyof typeof firstData.totalGrouped] - : (firstData.data?.[firstData.data?.length - 1] as any)?.[firstAttr.attribute] - }, [highlightedValue, attributes]) - - return { - data: combinedData, - isLoading: loading, - highlightedValue: _highlightedValue, - } -} diff --git a/apps/studio/pages/project/[ref]/reports/auth.tsx b/apps/studio/pages/project/[ref]/reports/auth.tsx index 34838356c35..c70822a3a01 100644 --- a/apps/studio/pages/project/[ref]/reports/auth.tsx +++ b/apps/studio/pages/project/[ref]/reports/auth.tsx @@ -4,7 +4,7 @@ import dayjs from 'dayjs' import { ArrowRight, RefreshCw } from 'lucide-react' import { useState } from 'react' -import { ReportChart } from 'components/interfaces/Reports/ReportChart' +import { ReportChartV2 } from 'components/interfaces/Reports/v2/ReportChartV2' import ReportHeader from 'components/interfaces/Reports/ReportHeader' import ReportPadding from 'components/interfaces/Reports/ReportPadding' import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav' @@ -18,9 +18,10 @@ import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Re import { SharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport' import { useSharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants' import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt' -import { getAuthReportAttributes } from 'data/reports/auth-charts' import { useReportDateRange } from 'hooks/misc/useReportDateRange' import type { NextPageWithLayout } from 'types' +import { createAuthReportConfig } from 'data/reports/v2/auth.config' +import { ReportSettings } from 'components/ui/Charts/ReportSettings' const AuthReport: NextPageWithLayout = () => { return ( @@ -41,6 +42,7 @@ export default AuthReport const AuthUsage = () => { const { ref } = useParams() + const chartSyncId = `auth-report` const { selectedDateRange, @@ -71,17 +73,19 @@ const AuthUsage = () => { const queryClient = useQueryClient() const [isRefreshing, setIsRefreshing] = useState(false) - const AUTH_REPORT_ATTRIBUTES = getAuthReportAttributes() + const authReportConfig = createAuthReportConfig({ + projectRef: ref || '', + startDate: selectedDateRange?.period_start?.date, + endDate: selectedDateRange?.period_end?.date, + interval: selectedDateRange?.interval, + filters: { status_code: null }, + }) const onRefreshReport = async () => { if (!selectedDateRange) return setIsRefreshing(true) - AUTH_REPORT_ATTRIBUTES.forEach((attr) => { - attr.attributes.forEach((subAttr) => { - queryClient.invalidateQueries(['auth-logs-report', 'auth-metrics']) - }) - }) + refetch() setTimeout(() => setIsRefreshing(false), 1000) } @@ -100,6 +104,7 @@ const AuthUsage = () => { tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }} onClick={onRefreshReport} /> +
{ } > - {selectedDateRange && - AUTH_REPORT_ATTRIBUTES.filter((attr) => !attr.hide).map((attr, i) => ( - - ))} + {authReportConfig.map((metric, i) => ( + + ))}
Auth API Gateway
diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index d792f2b9114..c5314558f90 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -7,7 +7,6 @@ import { useEffect, useState } from 'react' import { toast } from 'sonner' import { useFlag, useParams } from 'common' -import { ReportChart } from 'components/interfaces/Reports/ReportChart' import ReportHeader from 'components/interfaces/Reports/ReportHeader' import ReportPadding from 'components/interfaces/Reports/ReportPadding' import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants' @@ -42,6 +41,7 @@ import { formatBytes } from 'lib/helpers' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import type { NextPageWithLayout } from 'types' import { AlertDescription_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { ReportChartUpsell } from 'components/interfaces/Reports/v2/ReportChartUpsell' const DatabaseReport: NextPageWithLayout = () => { return ( @@ -307,13 +307,13 @@ const DatabaseUsage = () => { } /> ) : ( - ) ))} From ff7f5021f41f1a9d74e27a851701f90b3781a154 Mon Sep 17 00:00:00 2001 From: Riccardo Busetti Date: Wed, 17 Sep 2025 17:49:51 +0200 Subject: [PATCH 55/76] feat(replication): Show warning when publication is not found (#38786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(replication): Show warning when publication is not found * Fix * Pretty ✨ * Smol tweaks --------- Co-authored-by: Joshen Lim --- .../Database/Replication/DestinationPanel.tsx | 106 ++++++++++++------ .../Replication/PublicationsComboBox.tsx | 6 +- 2 files changed, 74 insertions(+), 38 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx index 36b0407e88d..10c152d69ee 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx @@ -21,6 +21,7 @@ import { AccordionItem_Shadcn_, AccordionTrigger_Shadcn_, Button, + DialogSectionSeparator, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, @@ -30,7 +31,6 @@ import { SelectGroup_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, - Separator, Sheet, SheetContent, SheetDescription, @@ -40,6 +40,7 @@ import { SheetTitle, TextArea_Shadcn_, } from 'ui' +import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import NewPublicationPanel from './NewPublicationPanel' import PublicationsComboBox from './PublicationsComboBox' @@ -96,10 +97,12 @@ export const DestinationPanel = ({ const { mutateAsync: startPipeline, isLoading: startingPipeline } = useStartPipelineMutation() - const { data: publications, isLoading: loadingPublications } = useReplicationPublicationsQuery({ - projectRef, - sourceId, - }) + const { + data: publications = [], + isLoading: isLoadingPublications, + isSuccess: isSuccessPublications, + refetch: refetchPublications, + } = useReplicationPublicationsQuery({ projectRef, sourceId }) const { data: destinationData } = useReplicationDestinationByIdQuery({ projectRef, @@ -132,11 +135,21 @@ export const DestinationPanel = ({ resolver: zodResolver(FormSchema), defaultValues, }) + const publicationName = form.watch('publicationName') const isSaving = creatingDestinationPipeline || updatingDestinationPipeline || startingPipeline + const publicationNames = useMemo(() => publications?.map((pub) => pub.name) ?? [], [publications]) + const isSelectedPublicationMissing = + isSuccessPublications && !!publicationName && !publicationNames.includes(publicationName) + + const isSubmitDisabled = isSaving || isSelectedPublicationMissing + const onSubmit = async (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') if (!sourceId) return console.error('Source id is required') + if (isSelectedPublicationMissing) { + return toast.error('Please select another publication before continuing') + } try { if (editMode && existingDestination) { @@ -236,6 +249,12 @@ export const DestinationPanel = ({ } }, [visible, defaultValues, form]) + useEffect(() => { + if (visible && projectRef && sourceId) { + refetchPublications() + } + }, [visible, projectRef, sourceId, refetchPublications]) + return ( <> @@ -247,59 +266,72 @@ export const DestinationPanel = ({ {editMode ? null : 'Send data to a new destination'} - + +
-
- ( - - - - - - )} - /> + ( + + + + + + )} + /> -

What data to send

+ +
+

What data to send

( pub.name) || []} - loading={loadingPublications} + publications={publicationNames} + loading={isLoadingPublications} field={field} onNewPublicationClick={() => setPublicationPanelVisible(true)} /> + {isSelectedPublicationMissing && ( + +

+ The publication{' '} + {publicationName} was + not found, it may have been renamed or deleted, please select + another one. +

+
+ )}
)} /> +
-

Where to send that data

+ +
+

Where to send that data

( ( ( - + @@ -373,7 +400,7 @@ export const DestinationPanel = ({ />
- +
@@ -443,7 +470,12 @@ export const DestinationPanel = ({ -
) } - -export default LogsPreviewer diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx index 6925123da72..cd89b526d14 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx @@ -1,5 +1,5 @@ import { useParams } from 'common' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import DefaultLayout from 'components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx index ff82efab095..90264bf866b 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx @@ -1,5 +1,5 @@ import { useParams } from 'common' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import DefaultLayout from 'components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' diff --git a/apps/studio/pages/project/[ref]/logs/auth-logs.tsx b/apps/studio/pages/project/[ref]/logs/auth-logs.tsx index cf03e3b2bd3..6503270a7c1 100644 --- a/apps/studio/pages/project/[ref]/logs/auth-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/auth-logs.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import DefaultLayout from 'components/layouts/DefaultLayout' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import NoPermission from 'components/ui/NoPermission' diff --git a/apps/studio/pages/project/[ref]/logs/cron-logs.tsx b/apps/studio/pages/project/[ref]/logs/cron-logs.tsx index 44bb6f108d4..58bccd410de 100644 --- a/apps/studio/pages/project/[ref]/logs/cron-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/cron-logs.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/router' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' diff --git a/apps/studio/pages/project/[ref]/logs/dedicated-pooler-logs.tsx b/apps/studio/pages/project/[ref]/logs/dedicated-pooler-logs.tsx index 7527bc88a3f..62f26ace503 100644 --- a/apps/studio/pages/project/[ref]/logs/dedicated-pooler-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/dedicated-pooler-logs.tsx @@ -1,6 +1,6 @@ import { useParams } from 'common' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import DefaultLayout from 'components/layouts/DefaultLayout' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import { Loading } from 'components/ui/Loading' diff --git a/apps/studio/pages/project/[ref]/logs/edge-functions-logs.tsx b/apps/studio/pages/project/[ref]/logs/edge-functions-logs.tsx index 213544a74de..34ca6a8bf90 100644 --- a/apps/studio/pages/project/[ref]/logs/edge-functions-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/edge-functions-logs.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' import DefaultLayout from 'components/layouts/DefaultLayout' diff --git a/apps/studio/pages/project/[ref]/logs/edge-logs.tsx b/apps/studio/pages/project/[ref]/logs/edge-logs.tsx index 21cbc34cc86..b97ef42a875 100644 --- a/apps/studio/pages/project/[ref]/logs/edge-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/edge-logs.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' import DefaultLayout from 'components/layouts/DefaultLayout' diff --git a/apps/studio/pages/project/[ref]/logs/etl-replication-logs.tsx b/apps/studio/pages/project/[ref]/logs/etl-replication-logs.tsx index e80111dc90e..25171a4d1e2 100644 --- a/apps/studio/pages/project/[ref]/logs/etl-replication-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/etl-replication-logs.tsx @@ -1,5 +1,5 @@ import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' diff --git a/apps/studio/pages/project/[ref]/logs/pg-upgrade-logs.tsx b/apps/studio/pages/project/[ref]/logs/pg-upgrade-logs.tsx index 0b9cc41594f..9661aa7bdd9 100644 --- a/apps/studio/pages/project/[ref]/logs/pg-upgrade-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/pg-upgrade-logs.tsx @@ -1,5 +1,5 @@ import { useParams } from 'common' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import DefaultLayout from 'components/layouts/DefaultLayout' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' diff --git a/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx b/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx index fbb829360f7..e3a32652066 100644 --- a/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' diff --git a/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx b/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx index 707286cee0b..bee89264a26 100644 --- a/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx @@ -1,6 +1,6 @@ import { useParams } from 'common' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import DefaultLayout from 'components/layouts/DefaultLayout' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import { Loading } from 'components/ui/Loading' diff --git a/apps/studio/pages/project/[ref]/logs/postgres-logs.tsx b/apps/studio/pages/project/[ref]/logs/postgres-logs.tsx index 3f93fb25981..55cbed7ceee 100644 --- a/apps/studio/pages/project/[ref]/logs/postgres-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/postgres-logs.tsx @@ -1,6 +1,6 @@ import { useRouter } from 'next/router' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' diff --git a/apps/studio/pages/project/[ref]/logs/postgrest-logs.tsx b/apps/studio/pages/project/[ref]/logs/postgrest-logs.tsx index 09c25060447..66a5622f6e6 100644 --- a/apps/studio/pages/project/[ref]/logs/postgrest-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/postgrest-logs.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' import { LogsTableEmptyState } from 'components/interfaces/Settings/Logs/LogsTableEmptyState' diff --git a/apps/studio/pages/project/[ref]/logs/realtime-logs.tsx b/apps/studio/pages/project/[ref]/logs/realtime-logs.tsx index ac99ca8a000..f3233584c16 100644 --- a/apps/studio/pages/project/[ref]/logs/realtime-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/realtime-logs.tsx @@ -1,6 +1,6 @@ import { useRouter } from 'next/router' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' import DefaultLayout from 'components/layouts/DefaultLayout' diff --git a/apps/studio/pages/project/[ref]/logs/storage-logs.tsx b/apps/studio/pages/project/[ref]/logs/storage-logs.tsx index 2be8683e54d..1461ad5917c 100644 --- a/apps/studio/pages/project/[ref]/logs/storage-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/storage-logs.tsx @@ -1,6 +1,6 @@ import { useRouter } from 'next/router' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { LogsPreviewer } from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' diff --git a/apps/studio/tests/features/logs/LogsPreviewer.test.tsx b/apps/studio/tests/features/logs/LogsPreviewer.test.tsx index af44a42a950..9f11eaefeea 100644 --- a/apps/studio/tests/features/logs/LogsPreviewer.test.tsx +++ b/apps/studio/tests/features/logs/LogsPreviewer.test.tsx @@ -1,9 +1,12 @@ import { screen, waitFor } from '@testing-library/react' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' -import { beforeEach, expect, test, vi } from 'vitest' +import { beforeEach, describe, expect, test, vi } from 'vitest' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' -import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import { + LogsPreviewer, + calculateBarClickTimeRange, +} from 'components/interfaces/Settings/Logs/LogsPreviewer' import { customRender, customRenderHook } from 'tests/lib/custom-render' import userEvent from '@testing-library/user-event' @@ -127,3 +130,88 @@ test('can click load older', async () => { expect(loadOlder.onclick).toHaveBeenCalled() }) + +describe('calculateBarClickTimeRange', () => { + const clickedTime = '2024-01-15T12:30:00.000Z' + + test('uses 15-second range for time ranges less than 2 minutes', () => { + const rangeStart = '2024-01-15T12:00:00.000Z' + const rangeEnd = '2024-01-15T12:01:30.000Z' // 1.5 minutes + + const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime) + + expect(result.start).toBe('2024-01-15T12:29:52.500Z') // 7.5 seconds before + expect(result.end).toBe('2024-01-15T12:30:07.500Z') // 7.5 seconds after + }) + + test('uses 2-minute range for time ranges between 2 minutes and 1 hour', () => { + const rangeStart = '2024-01-15T12:00:00.000Z' + const rangeEnd = '2024-01-15T12:30:00.000Z' // 30 minutes + + const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime) + + expect(result.start).toBe('2024-01-15T12:29:00.000Z') // 1 minute before + expect(result.end).toBe('2024-01-15T12:31:00.000Z') // 1 minute after + }) + + test('uses 5-minute range for time ranges between 1 and 12 hours', () => { + const rangeStart = '2024-01-15T10:00:00.000Z' + const rangeEnd = '2024-01-15T14:00:00.000Z' // 4 hours + + const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime) + + expect(result.start).toBe('2024-01-15T12:27:30.000Z') // 2.5 minutes before + expect(result.end).toBe('2024-01-15T12:32:30.000Z') // 2.5 minutes after + }) + + test('uses 1-hour range for time ranges 12 hours or more', () => { + const rangeStart = '2024-01-15T00:00:00.000Z' + const rangeEnd = '2024-01-15T24:00:00.000Z' // 24 hours + + const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime) + + expect(result.start).toBe('2024-01-15T12:00:00.000Z') // 30 minutes before + expect(result.end).toBe('2024-01-15T13:00:00.000Z') // 30 minutes after + }) + + test('handles edge case of exactly 2 minutes range', () => { + const rangeStart = '2024-01-15T12:00:00.000Z' + const rangeEnd = '2024-01-15T12:02:00.000Z' // exactly 2 minutes + + const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime) + + expect(result.start).toBe('2024-01-15T12:29:00.000Z') // 1 minute before + expect(result.end).toBe('2024-01-15T12:31:00.000Z') // 1 minute after + }) + + test('handles edge case of exactly 1 hour range', () => { + const rangeStart = '2024-01-15T12:00:00.000Z' + const rangeEnd = '2024-01-15T13:00:00.000Z' // exactly 1 hour + + const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime) + + expect(result.start).toBe('2024-01-15T12:27:30.000Z') // 2.5 minutes before + expect(result.end).toBe('2024-01-15T12:32:30.000Z') // 2.5 minutes after + }) + + test('handles edge case of exactly 12 hours range', () => { + const rangeStart = '2024-01-15T00:00:00.000Z' + const rangeEnd = '2024-01-15T12:00:00.000Z' // exactly 12 hours + + const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime) + + expect(result.start).toBe('2024-01-15T12:00:00.000Z') // 30 minutes before + expect(result.end).toBe('2024-01-15T13:00:00.000Z') // 30 minutes after + }) + + test('handles different clicked timestamps correctly', () => { + const rangeStart = '2024-01-15T00:00:00.000Z' + const rangeEnd = '2024-01-15T24:00:00.000Z' // 24 hours + const differentClickedTime = '2024-01-15T06:15:30.000Z' + + const result = calculateBarClickTimeRange(rangeStart, rangeEnd, differentClickedTime) + + expect(result.start).toBe('2024-01-15T05:45:30.000Z') // 30 minutes before + expect(result.end).toBe('2024-01-15T06:45:30.000Z') // 30 minutes after + }) +}) From 6cbc8b174b19439808fb0c9d14046c06a788909b Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Thu, 18 Sep 2025 10:07:24 +0200 Subject: [PATCH 63/76] FE-1860: Shared API Report Fixes (#38777) * rm assertion * rm old button * refetch on filterBy change * pass sql to metrics for link * pass sql to auth report * pass sql to postgrest report * pass sql to realtime report * fix type errs --- .../interfaces/Reports/ReportWidget.tsx | 4 +- .../SharedAPIReport.constants.ts | 40 ++++++++++++++++++- .../SharedAPIReport/SharedAPIReport.tsx | 18 +++++++++ .../Reports/renderers/ApiRenderers.tsx | 16 -------- .../pages/project/[ref]/reports/auth.tsx | 2 + .../pages/project/[ref]/reports/postgrest.tsx | 2 + .../pages/project/[ref]/reports/realtime.tsx | 2 + 7 files changed, 64 insertions(+), 20 deletions(-) diff --git a/apps/studio/components/interfaces/Reports/ReportWidget.tsx b/apps/studio/components/interfaces/Reports/ReportWidget.tsx index 01f96d9939c..4d3c6857e7b 100644 --- a/apps/studio/components/interfaces/Reports/ReportWidget.tsx +++ b/apps/studio/components/interfaces/Reports/ReportWidget.tsx @@ -74,8 +74,8 @@ const ReportWidget = (props: ReportWidgetProps) => { query.content = props.resolvedSql } else { query.q = props.params?.sql - query.its = props.params!.iso_timestamp_start - query.ite = props.params!.iso_timestamp_end + query.its = props.params?.iso_timestamp_start || '' + query.ite = props.params?.iso_timestamp_end || '' } router.push({ pathname, query }) diff --git a/apps/studio/components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants.ts b/apps/studio/components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants.ts index e9d97dbea32..768925edf86 100644 --- a/apps/studio/components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants.ts +++ b/apps/studio/components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants.ts @@ -219,8 +219,15 @@ const fetchLogs = async ({ const DEFAULT_KEYS = ['shared-api-report'] +export type SharedAPIReportFilterBy = + | 'auth' + | 'realtime' + | 'storage' + | 'graphql' + | 'functions' + | 'postgrest' type SharedAPIReportParams = { - filterBy: 'auth' | 'realtime' | 'storage' | 'graphql' | 'functions' | 'postgrest' + filterBy: SharedAPIReportFilterBy start: string end: string projectRef: string @@ -263,7 +270,16 @@ export const useSharedAPIReport = ({ const queries = useQueries({ queries: Object.entries(SHARED_API_REPORT_SQL).map(([key, value]) => ({ - queryKey: [...DEFAULT_KEYS, key, filterByMapSource[filterBy], filters, start, end, ref], + queryKey: [ + ...DEFAULT_KEYS, + filterBy, + key, + filterByMapSource[filterBy], + filters, + start, + end, + ref, + ], enabled: enabled && !!ref && !!filterBy, queryFn: () => fetchLogs({ @@ -324,6 +340,22 @@ export const useSharedAPIReport = ({ const isLoadingData = Object.values(isLoading).some(Boolean) + const SQLMap: Record = { + totalRequests: SHARED_API_REPORT_SQL.totalRequests.sql(allFilters, filterByMapSource[filterBy]), + topRoutes: SHARED_API_REPORT_SQL.topRoutes.sql(allFilters, filterByMapSource[filterBy]), + errorCounts: SHARED_API_REPORT_SQL.errorCounts.sql(allFilters, filterByMapSource[filterBy]), + topErrorRoutes: SHARED_API_REPORT_SQL.topErrorRoutes.sql( + allFilters, + filterByMapSource[filterBy] + ), + responseSpeed: SHARED_API_REPORT_SQL.responseSpeed.sql(allFilters, filterByMapSource[filterBy]), + topSlowRoutes: SHARED_API_REPORT_SQL.topSlowRoutes.sql(allFilters, filterByMapSource[filterBy]), + networkTraffic: SHARED_API_REPORT_SQL.networkTraffic.sql( + allFilters, + filterByMapSource[filterBy] + ), + } + return { data, error, @@ -334,5 +366,9 @@ export const useSharedAPIReport = ({ filters, addFilter, removeFilters, + /** + * The SQL queries used to fetch each metric + */ + sql: SQLMap, } } diff --git a/apps/studio/components/interfaces/Reports/SharedAPIReport/SharedAPIReport.tsx b/apps/studio/components/interfaces/Reports/SharedAPIReport/SharedAPIReport.tsx index 8a60490cf8a..3adccf66482 100644 --- a/apps/studio/components/interfaces/Reports/SharedAPIReport/SharedAPIReport.tsx +++ b/apps/studio/components/interfaces/Reports/SharedAPIReport/SharedAPIReport.tsx @@ -14,6 +14,7 @@ type SharedAPIReportWidgetsProps = { isLoading: any isRefetching: boolean hiddenReports?: SharedAPIReportKey[] + sql: Record } export function SharedAPIReport({ @@ -22,6 +23,7 @@ export function SharedAPIReport({ isLoading, isRefetching, hiddenReports = [], + sql, }: SharedAPIReportWidgetsProps) { return (
@@ -34,6 +36,10 @@ export function SharedAPIReport({ renderer={TotalRequestsChartRenderer} append={TopApiRoutesRenderer} appendProps={{ data: data.topRoutes }} + queryType="logs" + params={{ + sql: sql.totalRequests, + }} /> )} {!hiddenReports.includes('errorCounts') && ( @@ -48,6 +54,10 @@ export function SharedAPIReport({ data: data.topErrorRoutes || [], }} append={TopApiRoutesRenderer} + queryType="logs" + params={{ + sql: sql.errorCounts, + }} /> )} {!hiddenReports.includes('responseSpeed') && ( @@ -60,6 +70,10 @@ export function SharedAPIReport({ renderer={ResponseSpeedChartRenderer} appendProps={{ data: data.topSlowRoutes || [] }} append={TopApiRoutesRenderer} + queryType="logs" + params={{ + sql: sql.responseSpeed, + }} /> )} {!hiddenReports.includes('networkTraffic') && ( @@ -70,6 +84,10 @@ export function SharedAPIReport({ tooltip="Ingress and egress of requests and responses respectively" data={data.networkTraffic || []} renderer={NetworkTrafficRenderer} + queryType="logs" + params={{ + sql: sql.networkTraffic, + }} /> )}
diff --git a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx index 397b383d98e..f6a0884353a 100644 --- a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx +++ b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx @@ -220,22 +220,6 @@ export const TopApiRoutesRenderer = ( > {!showMore ? 'Show more' : 'Show less'} -
diff --git a/apps/studio/pages/project/[ref]/reports/auth.tsx b/apps/studio/pages/project/[ref]/reports/auth.tsx index c70822a3a01..1a26d3fdee9 100644 --- a/apps/studio/pages/project/[ref]/reports/auth.tsx +++ b/apps/studio/pages/project/[ref]/reports/auth.tsx @@ -64,6 +64,7 @@ const AuthUsage = () => { addFilter, removeFilters, isLoadingData, + sql, } = useSharedAPIReport({ filterBy: 'auth', start: selectedDateRange?.period_start?.date, @@ -169,6 +170,7 @@ const AuthUsage = () => { error={error} isLoading={isLoading} isRefetching={isRefetching} + sql={sql} />
diff --git a/apps/studio/pages/project/[ref]/reports/postgrest.tsx b/apps/studio/pages/project/[ref]/reports/postgrest.tsx index 65f1c238a7d..343e300e0f7 100644 --- a/apps/studio/pages/project/[ref]/reports/postgrest.tsx +++ b/apps/studio/pages/project/[ref]/reports/postgrest.tsx @@ -61,6 +61,7 @@ const PostgrestReport = () => { addFilter, removeFilters, isLoadingData, + sql, } = useSharedAPIReport({ filterBy: 'postgrest', start: selectedDateRange?.period_start?.date, @@ -151,6 +152,7 @@ const PostgrestReport = () => { error={error} isLoading={isLoading} isRefetching={isRefetching} + sql={sql} />
diff --git a/apps/studio/pages/project/[ref]/reports/realtime.tsx b/apps/studio/pages/project/[ref]/reports/realtime.tsx index 14ef2d580bd..ba982757c0d 100644 --- a/apps/studio/pages/project/[ref]/reports/realtime.tsx +++ b/apps/studio/pages/project/[ref]/reports/realtime.tsx @@ -79,6 +79,7 @@ const RealtimeUsage = () => { addFilter, removeFilters, isLoadingData, + sql, } = useSharedAPIReport({ filterBy: 'realtime', start: selectedDateRange?.period_start?.date, @@ -218,6 +219,7 @@ const RealtimeUsage = () => { isLoading={isLoading} isRefetching={isRefetching} hiddenReports={['networkTraffic']} + sql={sql} />
From dc41cfd7e19440ce7208b2ba95cf215a72d8f086 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 18 Sep 2025 19:46:50 +1000 Subject: [PATCH 64/76] Refine top section home new (#38802) * refine activity * make component * Smol fix * Nit * add group --------- Co-authored-by: Joshen Lim --- .../interfaces/HomeNew/ActivityStats.tsx | 78 +++++++++---------- .../interfaces/HomeNew/ServiceStatus.tsx | 54 ++++++------- apps/studio/components/ui/SingleStat.tsx | 45 +++++++++++ 3 files changed, 108 insertions(+), 69 deletions(-) create mode 100644 apps/studio/components/ui/SingleStat.tsx diff --git a/apps/studio/components/interfaces/HomeNew/ActivityStats.tsx b/apps/studio/components/interfaces/HomeNew/ActivityStats.tsx index 22464a7a5fb..4612f6c4b76 100644 --- a/apps/studio/components/interfaces/HomeNew/ActivityStats.tsx +++ b/apps/studio/components/interfaces/HomeNew/ActivityStats.tsx @@ -1,9 +1,9 @@ import dayjs from 'dayjs' -import { GitBranch } from 'lucide-react' -import Link from 'next/link' +import { Archive, Database, GitBranch } from 'lucide-react' import { useMemo } from 'react' import { useParams } from 'common' +import { SingleStat } from 'components/ui/SingleStat' import { useBranchesQuery } from 'data/branches/branches-query' import { useBackupsQuery } from 'data/database/backups-query' import { useMigrationsQuery } from 'data/database/migrations-query' @@ -55,17 +55,15 @@ export const ActivityStats = () => { return (
-
-
-

Status

- -
+
+ - -

Last migration

- -
- {isLoadingMigrations ? ( + } + label={Last migration} + value={ + isLoadingMigrations ? ( ) : latestMigration ? ( { /> ) : (

No migrations

- )} -
- + ) + } + /> - -

Last backup

- -
- {isLoadingBackups ? ( + } + label={Last backup} + value={ + isLoadingBackups ? ( ) : backupsData?.pitr_enabled ? (

PITR enabled

@@ -95,25 +94,26 @@ export const ActivityStats = () => { /> ) : (

No backups

- )} -
- + ) + } + /> - -

- {isDefaultProject ? 'Recent branch' : 'Branch Created'} -

- -
- {isLoadingBranches ? ( + } + label={{isDefaultProject ? 'Recent branch' : 'Branch Created'}} + value={ + isLoadingBranches ? ( ) : isDefaultProject ? ( -
- -

- {latestNonDefaultBranch?.name ?? 'No branches'} -

-
+

+ {latestNonDefaultBranch?.name ?? 'No branches'} +

) : currentBranch?.created_at ? ( { /> ) : (

Unknown

- )} -
- + ) + } + />
) diff --git a/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx b/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx index 768c0de480a..0b1539a933e 100644 --- a/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx +++ b/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx @@ -1,9 +1,9 @@ -import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Loader2 } from 'lucide-react' +import { AlertTriangle, CheckCircle2, ChevronRight, Loader2 } from 'lucide-react' import Link from 'next/link' -import { useState } from 'react' import { PopoverSeparator } from '@ui/components/shadcn/ui/popover' import { useParams } from 'common' +import { SingleStat } from 'components/ui/SingleStat' import { useBranchesQuery } from 'data/branches/branches-query' import { useEdgeFunctionServiceStatusQuery } from 'data/service-status/edge-functions-status-query' import { @@ -12,14 +12,7 @@ import { } from 'data/service-status/service-status-query' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { - Button, - InfoIcon, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, - cn, -} from 'ui' +import { InfoIcon, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, cn } from 'ui' /** * [Joshen] JFYI before we go live with this, we need to revisit the migrations section @@ -85,7 +78,6 @@ const StatusIcon = ({ export const ServiceStatus = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() - const [open, setOpen] = useState(false) const { projectAuthAll: authEnabled, @@ -262,27 +254,29 @@ export const ServiceStatus = () => { : 'Healthy' return ( - - - + label={Health} + value={{overallStatusLabel}} + /> {services.map((service) => ( diff --git a/apps/studio/components/ui/SingleStat.tsx b/apps/studio/components/ui/SingleStat.tsx new file mode 100644 index 00000000000..e34ea09773c --- /dev/null +++ b/apps/studio/components/ui/SingleStat.tsx @@ -0,0 +1,45 @@ +import Link from 'next/link' +import type { ReactNode } from 'react' + +type SingleStatProps = { + icon: ReactNode + label: ReactNode + value: ReactNode + className?: string + href?: string + onClick?: () => void +} + +export const SingleStat = ({ icon, label, value, className, href, onClick }: SingleStatProps) => { + const content = ( +
+
+ {icon} +
+
+
{label}
+
+ {value} +
+
+
+ ) + + if (href) { + return ( + + {content} + + ) + } + + if (onClick) { + return ( + + ) + } + + return content +} From 1b91f5ae79b66dc8898b1d493b55a9003be3eb77 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 18 Sep 2025 18:54:28 +0800 Subject: [PATCH 65/76] Address odd bug between data grid contextmenu and overlay (#38809) * Address odd bug between data grid contextmenu and overlay * nit * Smol tweak --- .../interfaces/Auth/Users/DeleteUserModal.tsx | 2 -- .../interfaces/Auth/Users/UsersV2.tsx | 8 ++++++-- .../Integrations/CronJobs/CronJobsTab.tsx | 3 ++- .../Integrations/CronJobs/DeleteCronJob.tsx | 11 +++++++++-- apps/studio/lib/helpers.ts | 17 +++++++++++++++++ 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Users/DeleteUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/DeleteUserModal.tsx index ff513fb397f..8c596658878 100644 --- a/apps/studio/components/interfaces/Auth/Users/DeleteUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/DeleteUserModal.tsx @@ -3,7 +3,6 @@ import { toast } from 'sonner' import { useUserDeleteMutation } from 'data/auth/user-delete-mutation' import { User } from 'data/auth/users-infinite-query' -import { timeout } from 'lib/helpers' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' interface DeleteUserModalProps { @@ -29,7 +28,6 @@ export const DeleteUserModal = ({ }) const handleDeleteUser = async () => { - await timeout(200) if (!projectRef) return console.error('Project ref is required') if (selectedUser?.id === undefined) { return toast.error(`Failed to delete user: User ID not found`) diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 98d1e3316d8..e2819a9bdca 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -30,7 +30,7 @@ import { THRESHOLD_COUNT } from 'data/table-rows/table-rows-count-query' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { isAtBottom } from 'lib/helpers' +import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers' import { Button, cn, @@ -680,10 +680,14 @@ export const UsersV2 = () => { setSelectedUserToDelete(undefined)} + onClose={() => { + setSelectedUserToDelete(undefined) + cleanPointerEventsNoneOnBody() + }} onDeleteSuccess={() => { if (selectedUserToDelete?.id === selectedUser) setSelectedUser(undefined) setSelectedUserToDelete(undefined) + cleanPointerEventsNoneOnBody(500) }} /> diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 0f1e2e2e85e..7240e0d909c 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -18,7 +18,7 @@ import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { BASE_PATH } from 'lib/constants' -import { isAtBottom } from 'lib/helpers' +import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers' import { Button, cn, LoadingLine, Sheet, SheetContent } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { formatCronJobColumns } from './CronJobs.utils' @@ -279,6 +279,7 @@ export const CronjobsTab = () => { setIsClosingCreateCronJobSheet(false) setCronJobForEditing(undefined) setCreateCronJobSheetShown(false) + cleanPointerEventsNoneOnBody(500) }} isClosing={isClosingCreateCronJobSheet} setIsClosing={setIsClosingCreateCronJobSheet} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx index bfe269faf99..3e59394213f 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx @@ -6,6 +6,7 @@ import { CronJob } from 'data/database-cron-jobs/database-cron-jobs-infinite-que import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { cleanPointerEventsNoneOnBody } from 'lib/helpers' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' @@ -53,7 +54,10 @@ export const DeleteCronJob = ({ cronJob, visible, onClose }: DeleteCronJobProps) onClose()} + onCancel={() => { + onClose() + cleanPointerEventsNoneOnBody() + }} onConfirm={handleDelete} title={`Delete the cron job`} loading={isLoading} @@ -67,8 +71,11 @@ export const DeleteCronJob = ({ cronJob, visible, onClose }: DeleteCronJobProps) onClose()} onConfirm={handleDelete} + onCancel={() => { + onClose() + cleanPointerEventsNoneOnBody() + }} title="Delete this cron job" loading={isLoading} confirmLabel={`Delete cron job ${cronJob.jobname}`} diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index 505b4712eea..7143e4cff21 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -331,3 +331,20 @@ export const formatCurrency = (amount: number | undefined | null): string | null return currencyFormatterDefault.format(amount) } } + +/** + * [Joshen] This is to address an incredibly weird bug that's happening between Data Grid + Shadcn ContextMenu + Shadcn Overlay + * This trifecta is causing a pointer events none style getting left behind on the body element which makes the dashboard become + * unresponsive, hence the attempt to clean things up here + * + * Timeout is made configurable as I've observed it requires a higher timeout sometimes (e.g when closing the cron job sheet) + */ +export const cleanPointerEventsNoneOnBody = (timeoutMs: number = 300) => { + if (typeof window !== 'undefined') { + setTimeout(() => { + if (document.body.style.pointerEvents === 'none') { + document.body.style.pointerEvents = '' + } + }, timeoutMs) + } +} From d70600c0294a42f0575514bc492aec36c9b1107b Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:11:50 +0100 Subject: [PATCH 66/76] fix(studio): query performance tabular nums (#38819) * fix: tabular numbers and right align query perf nums * nit: time consumed column as well --- .../QueryPerformance/QueryPerformanceGrid.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index 001b8cbba4e..0fbbd5b0e0f 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -145,7 +145,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance const fillWidth = Math.min(percentage, 100) return ( -
+
+
{isTime && typeof value === 'number' && !isNaN(value) && isFinite(value) ? (

+

{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (

{value.toLocaleString()} @@ -208,7 +208,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance if (col.id === 'max_time' || col.id === 'mean_time' || col.id === 'min_time') { return ( -

+
{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (

{Math.round(value).toLocaleString()}ms @@ -222,7 +222,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance if (col.id === 'rows_read') { return ( -

+
{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (

{value.toLocaleString()} @@ -241,7 +241,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance if (col.id === 'cache_hit_rate') { return ( -

+
{typeof value === 'string' ? (

Date: Thu, 18 Sep 2025 16:57:32 +0200 Subject: [PATCH 67/76] add cf headers for cms draft mode (#38822) * add cf headers for cms draft mode * add env vars to turbo.json allowlist --- apps/www/app/api-v2/cms-posts/route.ts | 3 ++- turbo.json | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/www/app/api-v2/cms-posts/route.ts b/apps/www/app/api-v2/cms-posts/route.ts index 04f64cf7680..9df5606bde0 100644 --- a/apps/www/app/api-v2/cms-posts/route.ts +++ b/apps/www/app/api-v2/cms-posts/route.ts @@ -6,10 +6,11 @@ import { generateReadingTime } from '~/lib/helpers' // Lightweight runtime for better performance export const runtime = 'edge' -// CORS headers for cross-origin requests const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + 'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID ?? '', + 'CF-Access-Client-Secret': process.env.CF_ACCESS_CLIENT_SECRET ?? '', } // Lightweight TOC generation for edge runtime diff --git a/turbo.json b/turbo.json index 64a448b08ab..d7ed335d3bb 100644 --- a/turbo.json +++ b/turbo.json @@ -114,6 +114,8 @@ "dependsOn": ["^build"], "env": [ "ANALYZE", + "CF_ACCESS_CLIENT_ID", + "CF_ACCESS_CLIENT_SECRET", "CMS_API_KEY", "CMS_PREVIEW_SECRET", "CMS_SITE_ORIGIN", From 3f6ae4b922ddc68550f5289246e7bfe10aaaa82f Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:41:16 +0100 Subject: [PATCH 68/76] fix(studio): request for monospace back on query perf (#38825) fix: request for monospace back on query perf --- .../QueryPerformance/QueryPerformanceGrid.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index 0fbbd5b0e0f..52eeda41fee 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -145,7 +145,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance const fillWidth = Math.min(percentage, 100) return ( -

+
+
{isTime && typeof value === 'number' && !isNaN(value) && isFinite(value) ? (

+

{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (

{value.toLocaleString()} @@ -208,7 +208,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance if (col.id === 'max_time' || col.id === 'mean_time' || col.id === 'min_time') { return ( -

+
{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (

{Math.round(value).toLocaleString()}ms @@ -222,7 +222,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance if (col.id === 'rows_read') { return ( -

+
{typeof value === 'number' && !isNaN(value) && isFinite(value) ? (

{value.toLocaleString()} @@ -241,7 +241,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance if (col.id === 'cache_hit_rate') { return ( -

+
{typeof value === 'string' ? (

Date: Fri, 19 Sep 2025 02:11:09 +0900 Subject: [PATCH 69/76] fix: Correct learn more link on developer.apple.com (#38174) --- .../components/interfaces/Auth/AuthProvidersFormValidation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx index b3dc3724fd5..5d0a3d42ea5 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx @@ -436,7 +436,7 @@ const EXTERNAL_PROVIDER_APPLE = { }, EXTERNAL_APPLE_CLIENT_ID: { title: 'Client IDs', - description: `Comma separated list of allowed Apple app (Web, OAuth, iOS, macOS, watchOS, or tvOS) bundle IDs for native sign in, or service IDs for Sign in with Apple JS. [Learn more](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js)`, + description: `Comma separated list of allowed Apple app (Web, OAuth, iOS, macOS, watchOS, or tvOS) bundle IDs for native sign in, or service IDs for Sign in with Apple JS. [Learn more](https://developer.apple.com/documentation/signinwithapplejs)`, type: 'string', }, EXTERNAL_APPLE_SECRET: { From b4c6c414a8eb99d2b03bba82e0bb846b7061e755 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:02:26 -0400 Subject: [PATCH 70/76] fix: feature flag command menu & ingerations section (#38794) - Disabled pages were still showing up as navigation items on the command menu, removing those as well. - Integrations section was also not fully disabled, removing that. --- .../components/Navigation/Navigation.commands.tsx | 13 +++++++++++-- apps/docs/components/Navigation/Navigation.types.ts | 1 + .../NavigationMenu/NavigationMenu.constants.ts | 4 ++++ .../NavigationMenu/NavigationMenuGuideListItems.tsx | 4 ++++ .../common/enabled-features/enabled-features.json | 3 ++- .../enabled-features/enabled-features.schema.json | 11 ++++++++--- 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/apps/docs/components/Navigation/Navigation.commands.tsx b/apps/docs/components/Navigation/Navigation.commands.tsx index 77080e7fd30..2ab00c9e487 100644 --- a/apps/docs/components/Navigation/Navigation.commands.tsx +++ b/apps/docs/components/Navigation/Navigation.commands.tsx @@ -1,5 +1,6 @@ import { ArrowRight } from 'lucide-react' +import { isFeatureEnabled } from 'common' import type { ICommand } from 'ui-patterns/CommandMenu' import { useRegisterCommands } from 'ui-patterns/CommandMenu' @@ -89,6 +90,7 @@ const navCommands = [ value: 'Reference, API, SDK: Go to Dart reference (Flutter)', route: '/reference/dart/introduction', icon: () => , + enabled: isFeatureEnabled('sdk:dart'), }, { id: 'nav-ref-python', @@ -96,6 +98,7 @@ const navCommands = [ value: 'Reference, API, SDK: Go to Python reference', route: '/reference/python/introduction', icon: () => , + enabled: isFeatureEnabled('sdk:python'), }, { id: 'nav-ref-csharp', @@ -103,6 +106,7 @@ const navCommands = [ value: 'Reference, API, SDK: Go to C# reference', route: '/reference/csharp/introduction', icon: () => , + enabled: isFeatureEnabled('sdk:csharp'), }, { id: 'nav-ref-swift', @@ -110,6 +114,7 @@ const navCommands = [ value: 'Reference, API, SDK: Go to Swift reference', route: '/reference/swift/introduction', icon: () => , + enabled: isFeatureEnabled('sdk:swift'), }, { id: 'nav-ref-kotlin', @@ -117,6 +122,7 @@ const navCommands = [ value: 'Reference, API, SDK: Go to Kotlin reference', route: '/reference/kotlin/introduction', icon: () => , + enabled: isFeatureEnabled('sdk:kotlin'), }, { id: 'nav-ref-cli', @@ -143,11 +149,14 @@ const navCommands = [ name: 'Go to Integrations', route: 'https://supabase.com/partners/integrations', icon: () => , + enabled: isFeatureEnabled('integrations:partners'), }, -] satisfies ICommand[] +] satisfies Array + +const filteredNavCommands = navCommands.filter((command) => command.enabled !== false) const useDocsNavCommands = () => { - useRegisterCommands('Go to', navCommands) + useRegisterCommands('Go to', filteredNavCommands) } export { useDocsNavCommands } diff --git a/apps/docs/components/Navigation/Navigation.types.ts b/apps/docs/components/Navigation/Navigation.types.ts index 816a5b363b0..b786a96586e 100644 --- a/apps/docs/components/Navigation/Navigation.types.ts +++ b/apps/docs/components/Navigation/Navigation.types.ts @@ -38,4 +38,5 @@ export type NavMenuConstant = Readonly<{ icon: string url?: `/${string}` items: ReadonlyArray> + enabled?: boolean }> diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 53fb2090045..611b514922a 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -9,6 +9,7 @@ const { billingAll: billingEnabled, docsCompliance: complianceEnabled, 'docsSelf-hosting': selfHostingEnabled, + integrationsPartners: integrationsEnabled, sdkCsharp: sdkCsharpEnabled, sdkDart: sdkDartEnabled, sdkKotlin: sdkKotlinEnabled, @@ -19,6 +20,7 @@ const { 'billing:all', 'docs:compliance', 'docs:self-hosting', + 'integrations:partners', 'sdk:csharp', 'sdk:dart', 'sdk:kotlin', @@ -128,6 +130,7 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ hasLightIcon: true, href: '/guides/integrations' as `/${string}`, level: 'integrations', + enabled: integrationsEnabled, }, ], ], @@ -2626,6 +2629,7 @@ export const integrations: NavMenuConstant = { title: 'Integrations', icon: 'integrations', url: '/guides/integrations', + enabled: integrationsEnabled, items: [ { name: 'Overview', diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideListItems.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideListItems.tsx index 9991c5c7266..bf34f7541a4 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideListItems.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideListItems.tsx @@ -146,6 +146,10 @@ const ContentLink = React.memo(function ContentLink(props: any) { const Content = (props) => { const { menu, id } = props + if (menu.enabled === false) { + return null + } + return (

    diff --git a/packages/common/enabled-features/enabled-features.json b/packages/common/enabled-features/enabled-features.json index e39c1ba6829..6c125708c9a 100644 --- a/packages/common/enabled-features/enabled-features.json +++ b/packages/common/enabled-features/enabled-features.json @@ -38,8 +38,9 @@ "feedback:docs": true, - "integrations:vercel": true, + "integrations:partners": true, "integrations:show_stripe_wrapper": true, + "integrations:vercel": true, "logs:templates": true, "logs:collections": true, diff --git a/packages/common/enabled-features/enabled-features.schema.json b/packages/common/enabled-features/enabled-features.schema.json index c34c1c1a222..8d649907e79 100644 --- a/packages/common/enabled-features/enabled-features.schema.json +++ b/packages/common/enabled-features/enabled-features.schema.json @@ -130,14 +130,18 @@ "description": "Enable feedback submission for docs site" }, - "integrations:vercel": { + "integrations:partners": { "type": "boolean", - "description": "Enable the vercel integration section in the organization and project settings pages" + "description": "Show all third-party-managed integrations" }, "integrations:show_stripe_wrapper": { "type": "boolean", "description": "Show the Stripe wrapper under project integrations" }, + "integrations:vercel": { + "type": "boolean", + "description": "Enable the vercel integration section in the organization and project settings pages" + }, "logs:templates": { "type": "boolean", @@ -291,8 +295,9 @@ "docs:compliance", "docs:self-hosting", "feedback:docs", - "integrations:vercel", + "integrations:partners", "integrations:show_stripe_wrapper", + "integrations:vercel", "profile:show_email", "profile:show_information", "profile:show_analytics_and_marketing", From fb4f7fc2846888b5a7a4b47f8bf9c8657a0ed4fa Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:06:48 -0400 Subject: [PATCH 71/76] fix: make dashboard login button a relative link (#38793) Change the link to a relative one so that it'll automatically reconfigure based on the domain. --- .../components/Navigation/NavigationMenu/TopNavBar.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index d505347d6e3..2655bb509c6 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -84,12 +84,7 @@ const TopNavBar: FC = () => {
    {!isUserLoading && ( From 66d8ee67b9dc0554698d674108da556ebb2e56c7 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:05:23 -0400 Subject: [PATCH 72/76] fix: docs sync script (#38795) --- .github/workflows/docs-sync.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index d6aaaf8ef80..e4e78f0fc72 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -30,6 +30,7 @@ jobs: with: sparse-checkout: | apps/docs + packages - uses: pnpm/action-setup@v4 name: Install pnpm From 789af2d53540ce1ab53bbf1a9c4c9b7d00f5f2e3 Mon Sep 17 00:00:00 2001 From: hallidayo <22655069+Hallidayo@users.noreply.github.com> Date: Thu, 18 Sep 2025 21:19:55 +0100 Subject: [PATCH 73/76] feat: show toast if table name blank (#28172) * show toast if table name blank * type toasts --- .../SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts | 3 +++ .../SidePanelEditor/ColumnEditor/ColumnType.tsx | 3 ++- .../SidePanelEditor/TableEditor/TableEditor.tsx | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts index ec127adad74..c767ee78a2c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils.ts @@ -12,6 +12,7 @@ import { UpdateColumnPayload, } from '../SidePanelEditor.types' import type { RetrievedTableColumn, RetrieveTableResult } from 'data/tables/table-retrieve-query' +import { toast } from 'sonner' const isSQLExpression = (input: string) => { if (['CURRENT_DATE'].includes(input)) return true @@ -170,9 +171,11 @@ export const validateFields = (field: ColumnField) => { const errors = {} as Dictionary if (field.name.length === 0) { errors['name'] = `Please assign a name for your column` + toast.error(errors['name']) } if (field.format.length === 0) { errors['format'] = `Please select a type for your column` + toast.error(errors['format']) } return errors } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx index 9c813d61bb2..dc918a77b07 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx @@ -66,6 +66,7 @@ const ColumnType = ({ description, showRecommendation = false, onOptionSelect = noop, + error, }: ColumnTypeProps) => { const [open, setOpen] = useState(false) const availableTypes = POSTGRES_DATA_TYPES.concat( @@ -167,7 +168,7 @@ const ColumnType = ({
diff --git a/packages/ui-patterns/src/Banners/SelectBanner.tsx b/packages/ui-patterns/src/Banners/SelectBanner.tsx index 32afcf91928..b3cae094a4b 100644 --- a/packages/ui-patterns/src/Banners/SelectBanner.tsx +++ b/packages/ui-patterns/src/Banners/SelectBanner.tsx @@ -1,26 +1,84 @@ +import React from 'react' import Link from 'next/link' -import { Button } from 'ui/src/components/Button' -import announcement from './data.json' export function SelectBanner() { + const selectSiteUrl = 'https://select.supabase.com/' + const desc = ['Our first user conference', 'Oct 3 2025', 'Guillermo Rauch, Dylan Field, and more'] + const cta = 'Save your seat' + + const baseStyles = 'flex flex-col justify-center border-l border-muted py-8 ' + const textBlockStyles = + baseStyles + + 'pr-8 text-xs font-mono uppercase leading-none tracking-wide text-white/50 [&_p]:mt-[5px]' + return ( -
-
-
- - Supabase Select +
+
+
+ + Supabase Select +
+ + + -

- {announcement.desc} - {announcement.verbose} -

- - +
+ + {cta} + {/* Crosshairs */} +
+ {['top-left', 'top-right', 'bottom-left', 'bottom-right'].map((position) => ( +
+
+
+
+ ))} +
+
diff --git a/packages/ui-patterns/src/Banners/data.json b/packages/ui-patterns/src/Banners/data.json index bd3f41eb0f4..a7f1e2fee14 100644 --- a/packages/ui-patterns/src/Banners/data.json +++ b/packages/ui-patterns/src/Banners/data.json @@ -1,8 +1,7 @@ { - "title": "Supabase Select", - "desc": "Our first user conference · October 3, San Francisco", - "verbose": " · Guillermo Rauch, Dylan Field, and more", - "link": "https://select.supabase.com/", - "target": "_blank", - "button": "Save your seat" + "text": "", + "launch": "", + "launchDate": "2025-07-17T08:00:00.000-07:00", + "link": "#", + "cta": "Learn more" } diff --git a/packages/ui-patterns/src/PromoToast/PromoToast.tsx b/packages/ui-patterns/src/PromoToast/PromoToast.tsx index 6c7c49049d2..af51c5e3515 100644 --- a/packages/ui-patterns/src/PromoToast/PromoToast.tsx +++ b/packages/ui-patterns/src/PromoToast/PromoToast.tsx @@ -46,7 +46,7 @@ const PromoToast = () => { poster={`${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/launch-week/lw15/assets/lw15-galaxy.png`} />
- {announcement.title} + {announcement.text}

diff --git a/packages/ui/src/layout/banners/Announcement.tsx b/packages/ui/src/layout/banners/Announcement.tsx index 60dc358c34a..aef0cde8e3e 100644 --- a/packages/ui/src/layout/banners/Announcement.tsx +++ b/packages/ui/src/layout/banners/Announcement.tsx @@ -9,8 +9,8 @@ import { X } from 'lucide-react' export interface AnnouncementProps { show: boolean - title: string - launchDate: string | null + text: string + launchDate: string link: string badge?: string } @@ -66,7 +66,7 @@ const Announcement = ({
{dismissable && !isLaunchWeekSection && (
From 031c5644e7d6d3068206e7ce4ab6905f1634af20 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:38:34 +1000 Subject: [PATCH 76/76] fix: add overflow to resizable panels (#38812) * fix: add overflow * dismissible footer section --- .../interfaces/Linter/LinterDataGrid.tsx | 9 +++++-- .../interfaces/Linter/LinterPageFooter.tsx | 24 +++++++++++++++++-- .../project/[ref]/advisors/performance.tsx | 2 +- .../pages/project/[ref]/advisors/security.tsx | 2 +- packages/common/constants/local-storage.ts | 2 ++ 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx b/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx index a7e9c29e8a9..ae9bcad47b1 100644 --- a/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx +++ b/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx @@ -166,7 +166,12 @@ const LinterDataGrid = ({ {selectedLint !== null && ( <> - +

@@ -176,7 +181,7 @@ const LinterDataGrid = ({

-
+
diff --git a/apps/studio/components/interfaces/Linter/LinterPageFooter.tsx b/apps/studio/components/interfaces/Linter/LinterPageFooter.tsx index 65a072ac9ed..76bb8b6a3d1 100644 --- a/apps/studio/components/interfaces/Linter/LinterPageFooter.tsx +++ b/apps/studio/components/interfaces/Linter/LinterPageFooter.tsx @@ -1,3 +1,6 @@ +import { X } from 'lucide-react' +import { LOCAL_STORAGE_KEYS } from 'common' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { Button, cn } from 'ui' import { Markdown } from '../Markdown' @@ -14,8 +17,25 @@ const LinterPageFooter = ({ refetch, hideDbInspectCTA, }: LinterPageFooterProps) => { + const [showBottomSection, setShowBottomSection] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.LINTER_SHOW_FOOTER, + true + ) + + if (!showBottomSection) { + return null + } + return ( -
+
+
@@ -64,4 +84,4 @@ const LinterPageFooter = ({ ) } -export default LinterPageFooter +export { LinterPageFooter } diff --git a/apps/studio/pages/project/[ref]/advisors/performance.tsx b/apps/studio/pages/project/[ref]/advisors/performance.tsx index ff4321175aa..3b0e83b7a23 100644 --- a/apps/studio/pages/project/[ref]/advisors/performance.tsx +++ b/apps/studio/pages/project/[ref]/advisors/performance.tsx @@ -6,7 +6,7 @@ import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants' import { lintInfoMap } from 'components/interfaces/Linter/Linter.utils' import LinterDataGrid from 'components/interfaces/Linter/LinterDataGrid' import LinterFilters from 'components/interfaces/Linter/LinterFilters' -import LinterPageFooter from 'components/interfaces/Linter/LinterPageFooter' +import { LinterPageFooter } from 'components/interfaces/Linter/LinterPageFooter' import AdvisorsLayout from 'components/layouts/AdvisorsLayout/AdvisorsLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import { FormHeader } from 'components/ui/Forms/FormHeader' diff --git a/apps/studio/pages/project/[ref]/advisors/security.tsx b/apps/studio/pages/project/[ref]/advisors/security.tsx index cd324bed41f..aaa28234f92 100644 --- a/apps/studio/pages/project/[ref]/advisors/security.tsx +++ b/apps/studio/pages/project/[ref]/advisors/security.tsx @@ -6,7 +6,7 @@ import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants' import { lintInfoMap } from 'components/interfaces/Linter/Linter.utils' import LinterDataGrid from 'components/interfaces/Linter/LinterDataGrid' import LinterFilters from 'components/interfaces/Linter/LinterFilters' -import LinterPageFooter from 'components/interfaces/Linter/LinterPageFooter' +import { LinterPageFooter } from 'components/interfaces/Linter/LinterPageFooter' import AdvisorsLayout from 'components/layouts/AdvisorsLayout/AdvisorsLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import { FormHeader } from 'components/ui/Forms/FormHeader' diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 3572600e273..03d597f0366 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -39,6 +39,7 @@ export const LOCAL_STORAGE_KEYS = { CLS_DIFF_WARNING: 'cls-diff-warning-dismissed', CLS_SELECT_STAR_WARNING: 'cls-select-star-warning-dismissed', QUERY_PERF_SHOW_BOTTOM_SECTION: 'supabase-query-perf-show-bottom-section', + LINTER_SHOW_FOOTER: 'supabase-linter-show-footer', // Key to track account deletion requests ACCOUNT_DELETION_REQUEST: 'supabase-account-deletion-request', // Used for storing a user id when sending reports to Sentry. The id is hashed for anonymity. @@ -110,6 +111,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [ LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN, LOCAL_STORAGE_KEYS.UI_PREVIEW_REALTIME_SETTINGS, LOCAL_STORAGE_KEYS.UI_PREVIEW_BRANCHING_2_0, + LOCAL_STORAGE_KEYS.LINTER_SHOW_FOOTER, ] export function clearLocalStorage() {