diff --git a/apps/docs/content/guides/platform/backups.mdx b/apps/docs/content/guides/platform/backups.mdx index 64efb201ba51b..ae79d74e7cf8f 100644 --- a/apps/docs/content/guides/platform/backups.mdx +++ b/apps/docs/content/guides/platform/backups.mdx @@ -131,6 +131,14 @@ If you enable PITR, Daily Backups will no longer be taken. PITR provides a finer +When you disable PITR, all new backups will still be taken as physical backups only. Physical backups can still be used for restoration, but they are not available for direct download. If you need to download a backup after PITR is disabled, you’ll need to take a manual [logical backup using the Supabase CLI or pg_dump](https://supabase.com/docs/guides/platform/migrating-within-supabase/backup-restore#backup-database-using-the-cli). + + + +If PITR has been disabled, logical backups remain available until they pass the backup retention period for your plan. After that window passes, only physical backups will be shown. + + + ### Backup process [#pitr-backup-process] As discussed [here](https://supabase.com/blog/postgresql-physical-logical-backups), PITR is made possible by a combination of taking physical backups of a project, as well as archiving [Write Ahead Log (WAL)](https://www.postgresql.org/docs/current/wal-intro.html) files. Physical backups provide a snapshot of the underlying directory of the database, while WAL files contain records of every change made in the database. diff --git a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/index.tsx index 71a229b4660bd..e9d6cbcca8077 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/index.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/index.tsx @@ -69,7 +69,7 @@ export const RealtimeFilterPopover = ({ config, onChangeConfig }: RealtimeFilter )} - +
Listen to event types
diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx index 87c29b77990b7..4553a4ee9a14e 100644 --- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx +++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx @@ -1,5 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' +import Link from 'next/link' import { useEffect } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -11,6 +12,7 @@ import { ScaffoldSection } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' import { InlineLink } from 'components/ui/InlineLink' +import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import { useMaxConnectionsQuery } from 'data/database/max-connections-query' import { useRealtimeConfigurationUpdateMutation } from 'data/realtime/realtime-config-mutation' import { @@ -50,8 +52,21 @@ export const RealtimeSettings = () => { projectRef, }) + const { data: policies } = useDatabasePoliciesQuery({ + projectRef, + connectionString: project?.connectionString, + schema: 'realtime', + }) + const isUsageBillingEnabled = organization?.usage_billing_enabled + // Check if RLS policies exist for realtime.messages table + const realtimeMessagesPolicies = policies?.filter( + (policy) => policy.schema === 'realtime' && policy.table === 'messages' + ) + const hasRealtimeMessagesPolicies = + realtimeMessagesPolicies && realtimeMessagesPolicies.length > 0 + const { mutate: updateRealtimeConfig, isLoading: isUpdatingConfig } = useRealtimeConfigurationUpdateMutation({ onSuccess: () => { @@ -83,6 +98,9 @@ export const RealtimeSettings = () => { }, }) + const { allow_public } = form.watch() + const isSettingToPrivate = !data?.private_only && !allow_public + const onSubmit: SubmitHandler> = (data) => { if (!projectRef) return console.error('Project ref is required') updateRealtimeConfig({ @@ -132,6 +150,30 @@ export const RealtimeSettings = () => { /> + + {!hasRealtimeMessagesPolicies && !allow_public && ( + +

+ Private mode is {isSettingToPrivate ? 'being ' : ''} + enabled, but no RLS policies exists on the{' '} + realtime.messages table. No + messages will be received by users. +

+ + + + } + /> + )} )} diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx index 971d7ee7136f2..551e5ef2ac1e0 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx @@ -73,7 +73,7 @@ const RoleImpersonationPopover = ({ diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx index fa591e1e8a029..80a35929e3654 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx @@ -131,9 +131,7 @@ const RoleImpersonationSelector = ({ {selectedOption === 'authenticated' && ( <> -
- -
+ )} diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx index 970cefaeb09de..edc0e0c753c89 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx @@ -1,11 +1,12 @@ import { useDebounce } from '@uidotdev/usehooks' -import { ChevronDown, ExternalLink, User as IconUser, Loader2, Search, X } from 'lucide-react' +import { ChevronDown, User as IconUser, Loader2, Search, X } from 'lucide-react' import { useMemo, useState } from 'react' import { toast } from 'sonner' import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' +import { InlineLink } from 'components/ui/InlineLink' import { User, useUsersInfiniteQuery } from 'data/auth/users-infinite-query' import { useCustomAccessTokenHookDetails } from 'hooks/misc/useCustomAccessTokenHookDetails' import { useLocalStorage } from 'hooks/misc/useLocalStorage' @@ -13,13 +14,18 @@ import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-stat import { ResponseError } from 'types' import { Button, + cn, Collapsible_Shadcn_, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, + DropdownMenuSeparator, Input, - Switch, ScrollArea, - cn, + Switch, + Tabs_Shadcn_, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, } from 'ui' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { getAvatarUrl, getDisplayName } from '../Auth/Users/Users.utils' @@ -33,13 +39,13 @@ const UserImpersonationSelector = () => { const [additionalClaims, setAdditionalClaims] = useState('') const { id: tableId } = useParams() + const [selectedTab, setSelectedTab] = useState<'user' | 'external'>('user') const [previousSearches, setPreviousSearches] = useLocalStorage( LOCAL_STORAGE_KEYS.USER_IMPERSONATION_SELECTOR_PREVIOUS_SEARCHES(tableId!), [] ) - const [showExternalAuth, setShowExternalAuth] = useState(false) const state = useRoleImpersonationStateSnapshot() const debouncedSearchText = useDebounce(searchText, 300) @@ -146,7 +152,6 @@ const UserImpersonationSelector = () => { function stopImpersonating() { state.setRole(undefined) - setShowExternalAuth(false) // Reset external auth impersonation when stopping impersonation } function toggleAalState() { @@ -167,251 +172,246 @@ const UserImpersonationSelector = () => { setPreviousSearches([]) } - // Select a previous search - function selectPreviousSearch(prevUser: User) { - setSearchText(prevUser.email ?? prevUser.phone ?? prevUser.id ?? '') - } - return ( -
-

- {displayName ? `Impersonating ${displayName}` : 'Impersonate a User'} -

-

- {!impersonatingUser && !isExternalAuthImpersonating - ? "Select a user to respect your database's Row-Level Security policies for that particular user." - : "Results will respect your database's Row-Level Security policies for this user."} -

- - {/* Check for both regular user and external auth impersonation since they use different data structures but both need to be handled for displaying impersonation UI */} - {!impersonatingUser && !isExternalAuthImpersonating ? ( -
- - ) : ( - - ) - } - placeholder="Search by id, email, phone, or name..." - onChange={(e) => setSearchText(e.target.value)} - value={searchText} - size="small" - actions={ - searchText && ( - - ) - } + <> +
+

+ {displayName ? `Impersonating ${displayName}` : 'Impersonate a User'} +

+

+ {!impersonatingUser && !isExternalAuthImpersonating + ? "Select a user to respect your database's Row-Level Security policies for that particular user." + : "Results will respect your database's Row-Level Security policies for this user."} +

+ + {impersonatingUser && ( + + )} + {isExternalAuthImpersonating && ( + + )} - - -
-

- Advanced options -

- -
-
- -
-
-

MFA assurance level

- -

- AAL1 verifies users via standard login methods, while AAL2 adds a second - authentication factor. -
- If you're not using MFA, you can leave this on AAL1. -

- - Learn more about MFA - -
-
- -
-

AAL1

- -

AAL2

-
-
- + {!impersonatingUser && !isExternalAuthImpersonating && ( + setSelectedTab(value)}> + + Project user + + External user + + Test RLS policies with external auth providers like Clerk or Auth0 by providing a + user ID and optional claims. + + + + +
-
-

External Auth Impersonation

- -

- Test RLS policies with external auth providers like Clerk or Auth0 by - providing a user ID and optional claims. -

-
-
- -
- -

- Enable external auth impersonation -

-
- - {showExternalAuth && ( -
- setExternalUserId(e.target.value)} - size="small" - /> - setAdditionalClaims(e.target.value)} - size="small" - /> -
-
+ + ) : ( + + ) + } + placeholder="Search by id, email, phone, or name..." + onChange={(e) => setSearchText(e.target.value)} + value={searchText} + actions={ + searchText && ( -
+ ) + } + /> + {isLoading && ( +
+ + Loading users...
)} -
- - - {!showExternalAuth && ( - <> - {isLoading && ( -
- - Loading users... -
- )} - - {isError && } - - {isSuccess && - (users.length > 0 ? ( -
-
    - {users.map((user) => ( -
  • - -
  • - ))} -
-
- ) : ( -
-

- No users found -

-
- ))} - {previousSearches.length > 0 && ( -
- {previousSearches.length > 0 ? ( - <> - - -
-

- Recents -

- } + + {isSuccess && + (users.length > 0 ? ( +
+
    + {users.map((user) => ( +
  • + -
- - - - - 3 ? 'h-36' : 'h-auto')} - > -
    - {previousSearches.map((search) => ( -
  • - -
  • - ))} -
-
-
- - + + ))} + +
) : ( -
No recent searches
+
+

+ No users found +

+
+ ))} + + <> + {previousSearches.length > 0 && ( +
+ {previousSearches.length > 0 ? ( + <> + + +
+

+ Recents +

+ +
+
+ + + + 3 ? 'h-36' : 'h-auto')} + > +
    + {previousSearches.map((search) => ( +
  • + +
  • + ))} +
+
+
+
+ + ) : ( +
+ No recent searches +
+ )} +
)} + +
+ + + +
+ setExternalUserId(e.target.value)} + /> + setAdditionalClaims(e.target.value)} + /> +
+
- )} - - )} -
- ) : ( +
+ + + )} +
+ + {/* Check for both regular user and external auth impersonation since they use different data structures but both need to be handled for displaying impersonation UI */} + {!impersonatingUser && !isExternalAuthImpersonating ? ( <> - {impersonatingUser && ( - - )} - {isExternalAuthImpersonating && ( - - )} + +
+ + +
+

+ Advanced options +

+ +
+
+ +
+
+

MFA assurance level

+ + AAL1 verifies users via standard login methods, while AAL2 adds a second + authentication factor. If you're not using MFA, you can leave this on AAL1. + Learn more about MFA{' '} + + here + + . + +
+ +
+

AAL1

+ +

AAL2

+
+
+
+
+
- )} -
+ ) : null} + ) } diff --git a/apps/studio/components/layouts/APIAuthorizationLayout.tsx b/apps/studio/components/layouts/APIAuthorizationLayout.tsx index 10a57ab76b752..bebbb882323d0 100644 --- a/apps/studio/components/layouts/APIAuthorizationLayout.tsx +++ b/apps/studio/components/layouts/APIAuthorizationLayout.tsx @@ -14,7 +14,7 @@ const APIAuthorizationLayout = ({ children }: PropsWithChildren Authorize API access | Supabase -
+
diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index 78e1e45a09c7b..5f891992832c2 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -235,7 +235,10 @@ export default function ComposedChart({ }) : [] - const stackedAttributes = chartData.filter((att) => !att.name.includes('max')) + const stackedAttributes = chartData.filter((att) => { + const attribute = attributes.find((attr) => attr.attribute === att.name) + return !attribute?.isMaxValue + }) const isPercentage = format === '%' const isRamChart = !chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') && diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts index d57f7a0c4ee45..1f38e714f72a7 100644 --- a/apps/studio/data/reports/database-charts.ts +++ b/apps/studio/data/reports/database-charts.ts @@ -34,7 +34,7 @@ export const getReportAttributes = (org: Organization, project: Project): Report }, { id: 'avg_cpu_usage', - label: 'CPU usage', + label: 'Average CPU usage', syncId: 'database-reports', format: '%', valuePrecision: 2, @@ -56,6 +56,30 @@ export const getReportAttributes = (org: Organization, project: Project): Report }, ], }, + { + id: 'max_cpu_usage', + label: 'Max CPU usage', + syncId: 'database-reports', + format: '%', + valuePrecision: 2, + availableIn: ['free', 'pro'], + hide: false, + showTooltip: false, + showLegend: false, + showMaxValue: false, + showGrid: false, + hideChartType: false, + defaultChartStyle: 'bar', + attributes: [ + { + attribute: 'max_cpu_usage', + provider: 'infra-monitoring', + label: 'Max CPU usage', + format: '%', + tooltip: 'Max CPU usage', + }, + ], + }, { id: 'disk-iops', label: 'Disk Input/Output operations per second (IOPS)', diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index 59f9bdd8c44e3..2d6fda0103554 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -1,9 +1,8 @@ -import { useConstant } from 'common' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { partition } from 'lodash' import { NextRouter } from 'next/router' -import { createContext, PropsWithChildren, ReactNode, useContext, useEffect } from 'react' +import { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useState } from 'react' import { proxy, subscribe, useSnapshot } from 'valtio' export const editorEntityTypes = { @@ -78,6 +77,7 @@ const DEFAULT_TABS_STATE = { openTabs: [] as string[], tabsMap: {} as { [key: string]: Tab }, previewTabId: undefined as string | undefined, + recentItems: [], } const TABS_STORAGE_KEY = 'supabase_studio_tabs' const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}` @@ -399,8 +399,14 @@ export type TabsState = ReturnType export const TabsStateContext = createContext(createTabsState('')) export const TabsStateContextProvider = ({ children }: PropsWithChildren) => { - const project = useSelectedProject() - const state = useConstant(() => createTabsState(project?.ref ?? '')) + const { data: project } = useSelectedProjectQuery() + const [state, setState] = useState(createTabsState(project?.ref ?? '')) + + useEffect(() => { + if (typeof window !== 'undefined' && !!project?.ref) { + setState(createTabsState(project?.ref ?? '')) + } + }, [project?.ref]) useEffect(() => { if (typeof window !== 'undefined' && project?.ref) { @@ -422,8 +428,7 @@ export const TabsStateContextProvider = ({ children }: PropsWithChildren) => { ) }) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [project?.ref, state]) return {children} } diff --git a/apps/ui-library/public/r/social-auth-tanstack.json b/apps/ui-library/public/r/social-auth-tanstack.json index 5304e77f91ae1..ccc42a24aae97 100644 --- a/apps/ui-library/public/r/social-auth-tanstack.json +++ b/apps/ui-library/public/r/social-auth-tanstack.json @@ -43,7 +43,7 @@ }, { "path": "registry/default/blocks/social-auth-tanstack/routes/auth/oauth.ts", - "content": "import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/server'\nimport { type EmailOtpType } from '@supabase/supabase-js'\nimport { createFileRoute, redirect } from '@tanstack/react-router'\nimport { createServerFn } from '@tanstack/react-start'\nimport { getWebRequest } from '@tanstack/react-start/server'\n\nconst confirmFn = createServerFn({ method: 'GET' })\n .validator((searchParams: unknown) => {\n if (\n searchParams &&\n typeof searchParams === 'object' &&\n 'token_hash' in searchParams &&\n 'type' in searchParams &&\n 'next' in searchParams\n ) {\n return searchParams\n }\n throw new Error('Invalid search params')\n })\n .handler(async (ctx) => {\n const request = getWebRequest()\n\n if (!request) {\n throw redirect({ to: `/auth/error`, search: { error: 'No request' } })\n }\n\n const searchParams = ctx.data\n const token_hash = searchParams['token_hash'] as string\n const type = searchParams['type'] as EmailOtpType | null\n const _next = (searchParams['next'] ?? '/') as string\n const next = _next?.startsWith('/') ? _next : '/'\n\n if (token_hash && type) {\n const supabase = createClient()\n\n const { error } = await supabase.auth.verifyOtp({\n type,\n token_hash,\n })\n console.log(error?.message)\n if (!error) {\n // redirect user to specified redirect URL or root of app\n throw redirect({ href: next })\n } else {\n // redirect the user to an error page with some instructions\n throw redirect({\n to: `/auth/error`,\n search: { error: error?.message },\n })\n }\n }\n\n // redirect the user to an error page with some instructions\n throw redirect({\n to: `/auth/error`,\n search: { error: 'No token hash or type' },\n })\n })\n\nexport const Route = createFileRoute('/auth/confirm')({\n preload: false,\n loader: (opts) => confirmFn({ data: opts.location.search }),\n})\n", + "content": "import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/server'\nimport { createFileRoute, redirect } from '@tanstack/react-router'\nimport { createServerFn } from '@tanstack/react-start'\nimport { getWebRequest } from '@tanstack/react-start/server'\n\nconst confirmFn = createServerFn({ method: 'GET' })\n .validator((searchParams: unknown) => {\n if (\n searchParams &&\n typeof searchParams === 'object' &&\n 'code' in searchParams &&\n 'next' in searchParams\n ) {\n return searchParams\n }\n throw new Error('Invalid search params')\n })\n .handler(async (ctx) => {\n const request = getWebRequest()\n\n if (!request) {\n throw redirect({ to: `/auth/error`, search: { error: 'No request' } })\n }\n\n const searchParams = ctx.data\n const code = searchParams['code'] as string\n const _next = (searchParams['next'] ?? '/') as string\n const next = _next?.startsWith('/') ? _next : '/'\n\n if (code) {\n const supabase = createClient()\n\n const { error } = await supabase.auth.exchangeCodeForSession(code)\n if (!error) {\n // redirect user to specified redirect URL or root of app\n throw redirect({ href: next })\n } else {\n // redirect the user to an error page with some instructions\n throw redirect({\n to: `/auth/error`,\n search: { error: error?.message },\n })\n }\n }\n\n // redirect the user to an error page with some instructions\n throw redirect({\n to: `/auth/error`,\n search: { error: 'No code found' },\n })\n })\n\nexport const Route = createFileRoute('/auth/confirm')({\n preload: false,\n loader: (opts) => confirmFn({ data: opts.location.search }),\n})\n", "type": "registry:file", "target": "routes/auth/oauth.ts" }, diff --git a/apps/ui-library/registry/default/blocks/social-auth-tanstack/routes/auth/oauth.ts b/apps/ui-library/registry/default/blocks/social-auth-tanstack/routes/auth/oauth.ts index 2996a594a653b..529a6e539ab40 100644 --- a/apps/ui-library/registry/default/blocks/social-auth-tanstack/routes/auth/oauth.ts +++ b/apps/ui-library/registry/default/blocks/social-auth-tanstack/routes/auth/oauth.ts @@ -1,5 +1,4 @@ import { createClient } from '@/registry/default/clients/tanstack/lib/supabase/server' -import { type EmailOtpType } from '@supabase/supabase-js' import { createFileRoute, redirect } from '@tanstack/react-router' import { createServerFn } from '@tanstack/react-start' import { getWebRequest } from '@tanstack/react-start/server' @@ -9,8 +8,7 @@ const confirmFn = createServerFn({ method: 'GET' }) if ( searchParams && typeof searchParams === 'object' && - 'token_hash' in searchParams && - 'type' in searchParams && + 'code' in searchParams && 'next' in searchParams ) { return searchParams @@ -25,19 +23,14 @@ const confirmFn = createServerFn({ method: 'GET' }) } const searchParams = ctx.data - const token_hash = searchParams['token_hash'] as string - const type = searchParams['type'] as EmailOtpType | null + const code = searchParams['code'] as string const _next = (searchParams['next'] ?? '/') as string const next = _next?.startsWith('/') ? _next : '/' - if (token_hash && type) { + if (code) { const supabase = createClient() - const { error } = await supabase.auth.verifyOtp({ - type, - token_hash, - }) - console.log(error?.message) + const { error } = await supabase.auth.exchangeCodeForSession(code) if (!error) { // redirect user to specified redirect URL or root of app throw redirect({ href: next }) @@ -53,7 +46,7 @@ const confirmFn = createServerFn({ method: 'GET' }) // redirect the user to an error page with some instructions throw redirect({ to: `/auth/error`, - search: { error: 'No token hash or type' }, + search: { error: 'No code found' }, }) })