Merge branch 'master' of github.com:supabase/supabase

This commit is contained in:
Terry Sutton committed 2024-04-09 09:48:47 -02:30
commit 1dd3221d02
561 files changed
+18142 -19284

No files matched your search

+3 -3
View File
@@ -19,10 +19,10 @@
"common": "*",
"config": "*",
"dayjs": "^1.11.10",
"eslint-config-next": "^14.1.0",
"eslint-config-next": "^14.1.4",
"lodash": "^4.17.21",
"lucide-react": "^0.338.0",
"next": "^14.0.3",
"next": "^14.1.4",
"openai": "^4.20.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -38,7 +38,7 @@
"@types/react": "^18.2.24",
"@types/react-dom": "^18.2.8",
"autoprefixer": "^10.4.14",
"nextjs-node-loader": "^1.1.5-alpha.0",
"nextjs-node-loader": "^1.1.5",
"postcss": "^8.4.31",
"typescript": "^5.4.3"
}
+17 -7
View File
@@ -10,7 +10,7 @@ import {
useState,
} from 'react'
import { useIsLoggedIn } from 'common'
import { type Database, useIsLoggedIn } from 'common'
import { Button, cn } from 'ui'
import { useSendFeedbackMutation } from '~/lib/fetch/feedback'
@@ -76,7 +76,7 @@ function Feedback() {
const pathname = usePathname()
const sendTelemetryEvent = useSendTelemetryEvent()
const { mutate: sendFeedbackComment } = useSendFeedbackMutation()
const supabase = useSupabaseClient()
const supabase = useSupabaseClient<Database>()
const unanswered = state.type === 'unanswered'
const isYes = 'response' in state && state.response === 'yes'
@@ -85,7 +85,13 @@ function Feedback() {
const showNo = unanswered || isNo
async function sendFeedbackVote(response: Response) {
const { error } = await supabase.from('feedback').insert({ vote: response, page: pathname })
const { error } = await supabase.from('feedback').insert({
vote: response,
page: pathname,
metadata: {
query: Object.fromEntries(new URLSearchParams(window.location.search).entries()),
},
})
if (error) console.error(error)
}
@@ -111,8 +117,14 @@ function Feedback() {
}, 100)
}
async function handleSubmit({ page, comment }: FeedbackFields) {
sendFeedbackComment({ message: comment, pathname: page })
async function handleSubmit({ page, comment, title }: FeedbackFields) {
sendFeedbackComment({
message: comment,
pathname: page,
title,
// @ts-expect-error -- can't click this button without having a state.response
isHelpful: state.response === 'yes',
})
setModalOpen(false)
refocusButton()
}
@@ -178,14 +190,12 @@ function Feedback() {
)}
>
<span className="text-foreground-light">Thanks for your feedback!</span>
{/**
<FeedbackButton
ref={feedbackButtonRef}
onClick={() => setModalOpen(true)}
isYes={isYes}
visible={!unanswered}
/>
*/}
</div>
</div>
<FeedbackModal
@@ -2,6 +2,7 @@ import { Button, Form, Input, Modal } from 'ui'
export type FeedbackFields = {
page: string
title: string
comment: string
}
@@ -21,6 +22,10 @@ function FeedbackModal({ visible, page, onCancel, onSubmit }: FeedbackModalProps
validate={(vals) => {
const errors: Partial<FeedbackFields> = {}
if (!vals.title) {
errors.title = 'Required'
}
if (!vals.comment) {
errors.comment = 'Required'
}
@@ -32,8 +37,9 @@ function FeedbackModal({ visible, page, onCancel, onSubmit }: FeedbackModalProps
>
{({ isSubmitting }: { isSubmitting: boolean }) => (
<>
<Modal.Content className="pt-4 pb-2">
<Modal.Content className="pt-4 pb-2 flex flex-col gap-2">
<Input type="hidden" id="page" name="page" value={page} />
<Input type="text" id="title" name="title" label="Title" className="mb-2" />
<Input.TextArea
label="Comment"
id="comment"
+92 -22
View File
@@ -1,5 +1,14 @@
import { FC } from 'react'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { cn, TAB_CHANGE_EVENT_NAME } from 'ui'
import { ExpandableVideo } from 'ui-patterns'
import { highlightSelectedTocItem } from '~/components/CustomHTMLElements/CustomHTMLElements.utils'
import useHash from '~/hooks/useHash'
import { useRerenderOnEvt } from '~/hooks/useManualRerender'
import { removeAnchor } from './CustomHTMLElements/CustomHTMLElements.utils'
import { Feedback } from './Feedback'
const formatSlug = (slug: string) => {
// [Joshen] We will still provide support for headers declared like this:
@@ -28,30 +37,91 @@ const formatTOCHeader = (content: string) => {
return res.join('')
}
interface TOCHeader {
id: number
level: number
text: string
link: string
}
const GuidesTableOfContents = ({
className,
overrideToc,
video,
}: {
className?: string
overrideToc?: Array<{ text: string; link: string; level: number }>
video?: string
}) => {
const [tocList, setTocList] = useState([])
const pathname = usePathname()
const [hash] = useHash()
interface Props {
list: TOCHeader[]
}
const displayedList = overrideToc ?? tocList
useEffect(() => {
if (overrideToc) return
/**
* Because we're directly querying the DOM, needs the setTimeout so the DOM
* update will happen first.
*/
const timeoutHandle = setTimeout(() => {
const headings = Array.from(
document.querySelector('#sb-docs-guide-main-article')?.querySelectorAll('h2, h3') ?? []
)
const newHeadings = headings
.filter((heading) => heading.id)
.map((heading) => {
const text = heading.textContent.replace('#', '')
const link = heading.querySelector('a').getAttribute('href')
const level = heading.tagName === 'H2' ? 2 : 3
return { text, link, level }
})
setTocList(newHeadings)
})
return () => clearTimeout(timeoutHandle)
/**
* window.location.href needed to recalculate toc when page changes,
* useRerenderOnEvt below will guarantee rerender on change
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideToc, typeof window !== 'undefined' && window.location.href])
useEffect(() => {
if (hash && displayedList.length > 0) {
highlightSelectedTocItem(hash)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hash, JSON.stringify(displayedList)])
/**
* Displayed headings may change if the tab changes, so the table of contents
* needs to rerender.
*/
useRerenderOnEvt(TAB_CHANGE_EVENT_NAME)
if (!displayedList.length) return
const tocVideoPreview = `http://img.youtube.com/vi/${video}/0.jpg`
const GuidesTableOfContents: FC<Props> = ({ list }) => {
return (
<ul className="toc-menu list-none pl-5 text-[0.8rem] grid gap-2">
{list.map((item, i) => (
<li key={`${item.level}-${i}`} className={item.level === 3 ? 'ml-4' : ''}>
<a
href={`#${formatSlug(item.link)}`}
className="text-foreground-lighter hover:text-brand-link transition-colors"
dangerouslySetInnerHTML={{ __html: formatTOCHeader(removeAnchor(item.text)) }}
/>
</li>
))}
</ul>
<div className={cn('border-l', 'thin-scrollbar overflow-y-auto', 'px-2', className)}>
{video && (
<div className="relative mb-6 pl-5">
<ExpandableVideo imgUrl={tocVideoPreview} videoId={video} />
</div>
)}
<Feedback key={pathname} />
<span className="block font-mono text-xs uppercase text-foreground px-5 mb-6">
On this page
</span>
<ul className="toc-menu list-none pl-5 text-[0.8rem] grid gap-2">
{displayedList.map((item, i) => (
<li key={`${item.level}-${i}`} className={item.level === 3 ? 'ml-4' : ''}>
<a
href={`#${formatSlug(item.link)}`}
className="text-foreground-lighter hover:text-brand-link transition-colors"
dangerouslySetInnerHTML={{ __html: formatTOCHeader(removeAnchor(item.text)) }}
/>
</li>
))}
</ul>
</div>
)
}
@@ -3,4 +3,4 @@ The next step requires a callback URL, which looks like this: `https://<project-
- Go to your [Supabase Project Dashboard](https://supabase.com/dashboard)
- Click on the `Authentication` icon in the left sidebar
- Click on [`Providers`](https://supabase.com/dashboard/project/_/auth/providers) under the Configuration section
- Click on **{props.provider}** from the accordion list to expand and you'll find your **Redirect URL**, you can click `Copy` to copy it to the clipboard
- Click on **{props.provider}** from the accordion list to expand and you'll find your **Callback URL**, you can click `Copy` to copy it to the clipboard
@@ -26,58 +26,58 @@ import {
IconMenuDevCli,
} from './HomeMenuIcons'
function getMenuIcon(menuKey: string, width: number = 16, height: number = 16) {
function getMenuIcon(menuKey: string, width: number = 16, height: number = 16, className) {
switch (menuKey) {
case 'home':
return <IconMenuHome width={width} height={height} />
return <IconMenuHome width={width} height={height} className={className} />
case 'getting-started':
return <IconMenuGettingStarted width={width} height={height} />
return <IconMenuGettingStarted width={width} height={height} className={className} />
case 'database':
return <IconMenuDatabase width={width} height={height} />
return <IconMenuDatabase width={width} height={height} className={className} />
case 'rest':
return <IconMenuRestApis width={width} height={height} />
return <IconMenuRestApis width={width} height={height} className={className} />
case 'graphql':
return <IconMenuGraphQL width={width} height={height} />
return <IconMenuGraphQL width={width} height={height} className={className} />
case 'auth':
return <IconMenuAuth width={width} height={height} />
return <IconMenuAuth width={width} height={height} className={className} />
case 'edge-functions':
return <IconMenuEdgeFunctions width={width} height={height} />
return <IconMenuEdgeFunctions width={width} height={height} className={className} />
case 'realtime':
return <IconMenuRealtime width={width} height={height} />
return <IconMenuRealtime width={width} height={height} className={className} />
case 'storage':
return <IconMenuStorage width={width} height={height} />
return <IconMenuStorage width={width} height={height} className={className} />
case 'ai':
return <IconMenuAI width={width} height={height} />
return <IconMenuAI width={width} height={height} className={className} />
case 'platform':
return <IconMenuPlatform width={width} height={height} />
return <IconMenuPlatform width={width} height={height} className={className} />
case 'resources':
return <IconMenuResources width={width} height={height} />
return <IconMenuResources width={width} height={height} className={className} />
case 'self-hosting':
return <IconMenuSelfHosting width={width} height={height} />
return <IconMenuSelfHosting width={width} height={height} className={className} />
case 'integrations':
return <IconMenuIntegrations width={width} height={height} />
return <IconMenuIntegrations width={width} height={height} className={className} />
case 'reference-javascript':
return <IconMenuJavascript width={width} height={height} />
return <IconMenuJavascript width={width} height={height} className={className} />
case 'reference-dart':
return <IconMenuFlutter width={width} height={height} />
return <IconMenuFlutter width={width} height={height} className={className} />
case 'reference-python':
return <IconMenuPython width={width} height={height} />
return <IconMenuPython width={width} height={height} className={className} />
case 'reference-csharp':
return <IconMenuCsharp width={width} height={height} />
return <IconMenuCsharp width={width} height={height} className={className} />
case 'reference-swift':
return <IconMenuSwift width={width} height={height} />
return <IconMenuSwift width={width} height={height} className={className} />
case 'reference-kotlin':
return <IconMenuKotlin width={width} height={height} />
return <IconMenuKotlin width={width} height={height} className={className} />
case 'reference-api':
return <IconMenuApi width={width} height={height} />
return <IconMenuApi width={width} height={height} className={className} />
case 'dev-cli':
return <IconMenuDevCli width={width} height={height} />
return <IconMenuDevCli width={width} height={height} className={className} />
case 'reference-cli':
return <IconMenuCli width={width} height={height} />
return <IconMenuCli width={width} height={height} className={className} />
case 'status':
return <IconMenuStatus width={width} height={height} />
return <IconMenuStatus width={width} height={height} className={className} />
default:
return <IconMenuPlatform width={width} height={height} />
return <IconMenuPlatform width={width} height={height} className={className} />
}
}
@@ -85,12 +85,14 @@ type HomeMenuIconPickerProps = {
icon: string
width?: number
height?: number
className?: string
}
export default function HomeMenuIconPicker({
icon,
width = 16,
height = 16,
className,
}: HomeMenuIconPickerProps) {
return getMenuIcon(icon, width, height)
return getMenuIcon(icon, width, height, className)
}
@@ -3,11 +3,13 @@ import { products } from 'shared-data'
type HomeMenuIcon = {
width?: number
height?: number
className?: string
}
export function IconMenuHome({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuHome({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
width={width}
height={height}
viewBox="0 0 16 16"
@@ -23,10 +25,11 @@ export function IconMenuHome({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuGraphQL({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuGraphQL({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
width={width}
height={height}
viewBox="0 0 16 16"
@@ -42,10 +45,11 @@ export function IconMenuGraphQL({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuApi({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuApi({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
width={width}
height={height}
viewBox="0 0 16 16"
@@ -77,9 +81,10 @@ export function IconMenuApi({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuAuth({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuAuth({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
viewBox="0 0 16 16"
width={width}
height={height}
@@ -96,10 +101,11 @@ export function IconMenuAuth({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuDevCli({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuDevCli({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
width={width}
height={height}
viewBox="0 0 16 16"
@@ -115,10 +121,11 @@ export function IconMenuDevCli({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuCli({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuCli({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
width={width}
height={height}
viewBox="0 0 16 16"
@@ -132,9 +139,10 @@ export function IconMenuCli({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuCsharp({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuCsharp({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
viewBox="0 0 16 16"
width={width}
height={height}
@@ -151,10 +159,11 @@ export function IconMenuCsharp({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuDatabase({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuDatabase({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
width={width}
height={width}
viewBox="0 0 16 16"
@@ -170,9 +179,10 @@ export function IconMenuDatabase({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuRestApis({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuRestApis({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
viewBox="0 0 16 16"
width={width}
height={height}
@@ -189,10 +199,11 @@ export function IconMenuRestApis({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuEdgeFunctions({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuEdgeFunctions({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
width={width}
height={height}
viewBox="0 0 16 16"
@@ -208,9 +219,10 @@ export function IconMenuEdgeFunctions({ width = 16, height = 16 }: HomeMenuIcon)
)
}
export function IconMenuExtensions({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuExtensions({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
viewBox="0 0 16 16"
width={width}
height={height}
@@ -227,9 +239,10 @@ export function IconMenuExtensions({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuFlutter({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuFlutter({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
viewBox="0 0 16 16"
width={width}
height={height}
@@ -246,10 +259,11 @@ export function IconMenuFlutter({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuGettingStarted({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuGettingStarted({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
width={width}
height={height}
viewBox="0 0 16 16"
@@ -265,10 +279,11 @@ export function IconMenuGettingStarted({ width = 16, height = 16 }: HomeMenuIcon
)
}
export function IconMenuIntegrations({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuIntegrations({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
width={width}
height={height}
viewBox="0 0 16 16"
@@ -284,9 +299,10 @@ export function IconMenuIntegrations({ width = 16, height = 16 }: HomeMenuIcon)
)
}
export function IconMenuJavascript({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuJavascript({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
width={width}
height={height}
fill="none"
@@ -303,9 +319,10 @@ export function IconMenuJavascript({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuPlatform({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuPlatform({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
viewBox="0 0 16 16"
width={width}
height={height}
@@ -322,9 +339,10 @@ export function IconMenuPlatform({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuPython({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuPython({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
viewBox="0 0 16 16"
width={width}
height={height}
@@ -341,10 +359,11 @@ export function IconMenuPython({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuRealtime({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuRealtime({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
width={width}
height={height}
viewBox="0 0 16 16"
@@ -360,10 +379,11 @@ export function IconMenuRealtime({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuResources({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuResources({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
width={width}
height={height}
viewBox="0 0 16 16"
@@ -379,9 +399,10 @@ export function IconMenuResources({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuSelfHosting({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuSelfHosting({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
viewBox="0 0 16 16"
width={width}
height={height}
@@ -398,9 +419,10 @@ export function IconMenuSelfHosting({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuStorage({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuStorage({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
viewBox="0 0 16 16"
width={width}
height={height}
@@ -417,9 +439,10 @@ export function IconMenuStorage({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuAI({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuAI({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
width={width}
height={height}
viewBox="0 0 16 16"
@@ -437,9 +460,10 @@ export function IconMenuAI({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuSwift({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuSwift({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
viewBox="0 0 16 16"
width={width}
height={height}
@@ -456,10 +480,11 @@ export function IconMenuSwift({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuKotlin({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuKotlin({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
viewBox="0 0 16 16"
width={width}
height={height}
@@ -475,9 +500,10 @@ export function IconMenuKotlin({ width = 16, height = 16 }: HomeMenuIcon) {
)
}
export function IconMenuStatus({ width = 16, height = 16 }: HomeMenuIcon) {
export function IconMenuStatus({ width = 16, height = 16, className }: HomeMenuIcon) {
return (
<svg
className={className}
width={width}
height={height}
viewBox="0 0 18 18"
@@ -794,7 +794,14 @@ export const database: NavMenuConstant = {
name: 'Query optimization',
url: '/guides/database/query-optimization',
},
{ name: 'Debugging and monitoring', url: '/guides/database/inspect' },
{
name: 'Database linter',
url: '/guides/database/database-linter',
},
{
name: 'Debugging and monitoring',
url: '/guides/database/inspect',
},
{
name: 'Debugging performance issues',
url: '/guides/database/debugging-performance',
@@ -917,10 +924,18 @@ export const database: NavMenuConstant = {
name: 'Overview',
url: '/guides/database/extensions/wrappers/overview',
},
{
name: 'Connecting to Auth0',
url: '/guides/database/extensions/wrappers/auth0',
},
{
name: 'Connecting to Airtable',
url: '/guides/database/extensions/wrappers/airtable',
},
{
name: 'Connecting to AWS Cognito',
url: '/guides/database/extensions/wrappers/cognito',
},
{
name: 'Connecting to AWS S3',
url: '/guides/database/extensions/wrappers/s3',
@@ -941,6 +956,14 @@ export const database: NavMenuConstant = {
name: 'Connecting to Logflare',
url: '/guides/database/extensions/wrappers/logflare',
},
{
name: 'Connecting to MSSQL',
url: '/guides/database/extensions/wrappers/mssql',
},
{
name: 'Connecting to Redis',
url: '/guides/database/extensions/wrappers/redis',
},
{
name: 'Connecting to Stripe',
url: '/guides/database/extensions/wrappers/stripe',
-83
View File
@@ -1,83 +0,0 @@
// [Terry]
// Delete this after we've implemented GuidesTableofContents and moved all guides
// and rename GuidesTableofContents to TableOfContents
import { FC } from 'react'
import { getAnchor, removeAnchor } from './CustomHTMLElements/CustomHTMLElements.utils'
interface TOCHeader {
id: number
lvl: number
seen: number
content: string
slug: string
}
interface Props {
toc: any
video?: string
}
const formatSlug = (slug: string) => {
// [Joshen] We will still provide support for headers declared like this:
// ## REST API {#rest-api-overview}
// At least for now, this was a docusaurus thing.
if (slug.includes('#')) return slug.split('-#')[1]
return slug
}
const formatTOCHeader = (content: string) => {
let begin = false
const res = []
for (const x of content) {
if (x === '`') {
if (!begin) {
begin = true
res.push(`<code class="text-xs border rounded bg-surface-200 border-overlay">`)
} else {
begin = false
res.push(`</code>`)
}
} else {
res.push(x)
}
}
return res.join('')
}
const TableOfContents: FC<Props> = ({ toc, video }) => {
// [Joshen] markdown-toc doesn't seem to read maxdepth from the options passed in
// Our first level headers will be H2s (H1 is ignored), and we only show up to H3
return (
<>
{video && (
<div className="video-container">
<iframe
src="https://www.youtube-nocookie.com/embed/0Fs96oZ4se0"
frameBorder="1"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
)}
<ul className="toc-menu list-none pl-4 text-[0.8rem] grid gap-2 mt-12">
{(toc.json as TOCHeader[])
.filter((item) => item.lvl !== 1 && item.lvl <= 3)
.map((item: any, i: number) => {
return (
<li key={i} id={item.lvl} style={{ marginLeft: `${(item.lvl - 2) * 1}rem` }}>
<a
href={`#${formatSlug(item.slug)}`}
className="text-foreground-lighter hover:text-brand transition-colors"
dangerouslySetInnerHTML={{ __html: formatTOCHeader(removeAnchor(item.content)) }}
/>
</li>
)
})}
</ul>
</>
)
}
export default TableOfContents
@@ -22,7 +22,7 @@ This is different from the `anon` API key which does not create a user and can b
</Admonition>
To get started, [enable anonymous sign-ins](/dashboard/project/_/auth/providers) for your Supabase Project.
To get started, [enable anonymous sign-ins](/dashboard/project/_/settings/auth) for your Supabase Project.
<Admonition type="caution">
@@ -55,6 +55,15 @@ Call the [`signInAnonymously()`](/docs/reference/javascript/auth-signinanonymous
const { data, error } = await supabase.auth.signInAnonymously()
```
</TabPanel>
<TabPanel id="kotlin" label="Kotlin">
Call the [`signInAnonymously()`](/docs/reference/kotlin/auth-signinanonymously) method:
```kotlin
supabase.auth.signInAnonymously()
```
</TabPanel>
</Tabs>
@@ -79,6 +88,17 @@ You can use the [`updateUser()`](/docs/reference/javascript/auth-updateuser) met
const { data, error } = await supabase.auth.updateUser({ email: 'example@email.com' })
```
</TabPanel>
<TabPanel id="kotlin" label="Kotlin">
You can use the [`modifyUser()`](/docs/reference/kotlin/auth-updateuser) method to link an email or phone identity to the anonymous user.
```kotlin
supabase.auth.modifyUser {
email = "example@email.com"
}
```
</TabPanel>
</Tabs>
@@ -99,6 +119,15 @@ You can use the [`linkIdentity()`](/docs/reference/javascript/auth-linkidentity)
const { data, error } = await supabase.auth.linkIdentity({ provider: 'google' })
```
</TabPanel>
<TabPanel id="kotlin" label="Kotlin">
You can use the [`linkIdentity()`](/docs/reference/kotlin/auth-linkidentity) method to link an oauth identity to the anonymous user.
```kotlin
supabase.auth.linkIdentity(Google)
```
</TabPanel>
</Tabs>
@@ -108,7 +137,7 @@ An anonymous user assumes the `authenticated` role just like a permanent user. Y
```sql
create policy "Only permanent users can post to the news feed"
on news_feed for insert
on news_feed as restrictive for insert
to authenticated
with check ((auth.jwt()->>'is_anonymous')::boolean is false );
@@ -118,6 +147,12 @@ to authenticated
using ( true );
```
<Admonition variant="note" label="Use restrictive policies">
RLS policies are permissive by default, which means that they are combined using an "OR" operator when multiple policies are applied. It is important to construct restrictive policies to ensure that the checks for an anonymous user are always enforced when combined with other policies.
</Admonition>
## Resolving identity conflicts
Depending on your application requirements, data conflicts can arise when an anonymous user is converted to a permanent user. For example, in the context of an e-commerce application, an anonymous user would be allowed to add items to the shopping cart without signing up / signing in. When they decide to sign-in to an existing account, you will need to decide how you want to resolve data conflicts in the shopping cart:
@@ -11,6 +11,20 @@ The `auth-helpers` package has been replaced with the `@supabase/ssr` package. W
</Admonition>
<Accordion
type="default"
openBehaviour="multiple"
chevronAlign="right"
justified
size="medium"
className="text-foreground-light border-b mt-8 pb-2"
>
<Accordion.Item
header="See legacy docs"
id="legacy-docs"
>
This submodule provides convenience helpers for implementing user authentication in Next.js applications using the pages directory.
> Note: As of [Next.js 13.4](https://nextjs.org/blog/next-13-4), the App Router has reached stable status. This is now the recommended path for new Next.js app. Check out our guide on using [Auth Helpers with the Next.js App Directory](/docs/guides/auth/auth-helpers/nextjs).
@@ -914,3 +928,7 @@ import { Database } from '../database.types'
const supabaseClient = useSupabaseClient<Database>()
```
</Accordion.Item>
</Accordion>
@@ -11,6 +11,20 @@ The `auth-helpers` package has been replaced with the `@supabase/ssr` package. W
</Admonition>
<Accordion
type="default"
openBehaviour="multiple"
chevronAlign="right"
justified
size="medium"
className="text-foreground-light border-b mt-8 pb-2"
>
<Accordion.Item
header="See legacy docs"
id="legacy-docs"
>
The [Next.js Auth Helpers package](https://github.com/supabase/auth-helpers) configures Supabase Auth to store the user's `session` in a `cookie`, rather than `localStorage`. This makes it available across the client and server of the App Router - [Client Components](/docs/guides/auth/auth-helpers/nextjs#client-components), [Server Components](/docs/guides/auth/auth-helpers/nextjs#server-components), [Server Actions](/docs/guides/auth/auth-helpers/nextjs#server-actions), [Route Handlers](/docs/guides/auth/auth-helpers/nextjs#route-handlers) and [Middleware](/docs/guides/auth/auth-helpers/nextjs#middleware). The `session` is automatically sent along with any requests to Supabase.
<div className="video-container">
@@ -1312,3 +1326,7 @@ export default function() {
```
For an example of creating multiple Supabase clients, check [Singleton section](/docs/guides/auth/auth-helpers/nextjs#singleton) above.
</Accordion.Item>
</Accordion>
@@ -11,6 +11,20 @@ We generally recommend using the new `@supabase/ssr` package instead of `auth-he
</Admonition>
<Accordion
type="default"
openBehaviour="multiple"
chevronAlign="right"
justified
size="medium"
className="text-foreground-light border-b mt-8 pb-2"
>
<Accordion.Item
header="See legacy docs"
id="legacy-docs"
>
This submodule provides convenience helpers for implementing user authentication in Remix applications.
<div className="video-container">
@@ -782,3 +796,7 @@ supabaseClient.auth.signUp({
},
})
```
</Accordion.Item>
</Accordion>
@@ -11,6 +11,20 @@ We generally recommend using the new `@supabase/ssr` package instead of `auth-he
</Admonition>
<Accordion
type="default"
openBehaviour="multiple"
chevronAlign="right"
justified
size="medium"
className="text-foreground-light border-b mt-8 pb-2"
>
<Accordion.Item
header="See legacy docs"
id="legacy-docs"
>
This submodule provides convenience helpers for implementing user authentication in [SvelteKit](https://kit.svelte.dev/) applications.
## Configuration
@@ -46,8 +60,6 @@ PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Create a new `hooks.server.js` file in the root of your project and populate with the following to retrieve the user session.
The session information from `getSession` is supplied to the Supabase client so it can retrieve the auth token. This is safe, since the auth token signature will be revalidated on the Auth server. But you shouldn't trust the unsigned data that is stored alongside the auth token:
<GetSessionWarning />
```js src/hooks.server.js
@@ -63,15 +75,23 @@ export const handle = async ({ event, resolve }) => {
})
/**
* a little helper that is written for convenience so that instead
* of calling `const { data: { session } } = await supabase.auth.getSession()`
* you just call this `await getSession()`
* Unlike `supabase.auth.getSession`, which is unsafe on the server because it
* doesn't validate the JWT, this function validates the JWT by first calling
* `getUser` and aborts early if the JWT signature is invalid.
*/
event.locals.getSession = async () => {
event.locals.safeGetSession = async () => {
const {
data: { user },
error,
} = await supabase.auth.getUser()
if (error) {
return { session: null, user: null }
}
const {
data: { session },
} = await event.locals.supabase.auth.getSession()
return session
return { session, user }
}
return resolve(event, {
@@ -102,15 +122,23 @@ export const handle: Handle = async ({ event, resolve }) => {
})
/**
* a little helper that is written for convenience so that instead
* of calling `const { data: { session } } = await supabase.auth.getSession()`
* you just call this `await getSession()`
* Unlike `supabase.auth.getSession`, which is unsafe on the server because it
* doesn't validate the JWT, this function validates the JWT by first calling
* `getUser` and aborts early if the JWT signature is invalid.
*/
event.locals.getSession = async () => {
event.locals.safeGetSession = async () => {
const {
data: { user },
error,
} = await supabase.auth.getUser()
if (error) {
return { session: null, user: null }
}
const {
data: { session },
} = await event.locals.supabase.auth.getSession()
return session
return { session, user }
}
return resolve(event, {
@@ -189,17 +217,18 @@ In order to get the most out of TypeScript and it's intellisense, you should imp
```ts src/app.d.ts
// src/app.d.ts
import { SupabaseClient, Session } from '@supabase/supabase-js'
import { SupabaseClient, Session, User } from '@supabase/supabase-js'
import { Database } from './DatabaseDefinitions'
declare global {
namespace App {
interface Locals {
supabase: SupabaseClient<Database>
getSession(): Promise<Session | null>
safeGetSession(): Promise<{ session: Session | null; user: User | null }>
}
interface PageData {
session: Session | null
user: User | null
}
// interface Error {}
// interface Platform {}
@@ -234,9 +263,12 @@ To make the session available across the UI, including pages and layouts, it is
```js src/routes/+layout.server.js
// src/routes/+layout.server.js
export const load = async ({ locals: { getSession } }) => {
export const load = async ({ locals: { safeGetSession } }) => {
const { session, user } = await safeGetSession()
return {
session: await getSession(),
session,
user,
}
}
```
@@ -246,9 +278,12 @@ export const load = async ({ locals: { getSession } }) => {
```ts src/routes/+layout.server.ts
// src/routes/+layout.server.ts
export const load = async ({ locals: { getSession } }) => {
export const load = async ({ locals: { safeGetSession } }) => {
const { session, user } = await safeGetSession()
return {
session: await getSession(),
session,
user,
}
}
```
@@ -284,6 +319,11 @@ export const load = async ({ fetch, data, depends }) => {
serverSession: data.session,
})
/**
* It's fine to use `getSession` here, because on the client, `getSession` is
* safe, and on the server, it reads `session` from the `LayoutData`, which
* safely checked the session using `safeGetSession`.
*/
const {
data: { session },
} = await supabase.auth.getSession()
@@ -312,6 +352,11 @@ export const load = async ({ fetch, data, depends }) => {
serverSession: data.session,
})
/**
* It's fine to use `getSession` here, because on the client, `getSession` is
* safe, and on the server, it reads `session` from the `LayoutData`, which
* safely checked the session using `safeGetSession`.
*/
const {
data: { session },
} = await supabase.auth.getSession()
@@ -532,8 +577,8 @@ Wrap an API Route to check that the user has a valid session. If they're not log
// src/routes/api/protected-route/+server.ts
import { json, error } from '@sveltejs/kit'
export const GET = async ({ locals: { supabase, getSession } }) => {
const session = await getSession()
export const GET = async ({ locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession()
if (!session) {
// the user is not signed in
throw error(401, { message: 'Unauthorized' })
@@ -555,8 +600,8 @@ Wrap an Action to check that the user has a valid session. If they're not logged
import { error, fail } from '@sveltejs/kit'
export const actions = {
createPost: async ({ request, locals: { supabase, getSession } }) => {
const session = await getSession()
createPost: async ({ request, locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession()
if (!session) {
// the user is not signed in
@@ -615,15 +660,21 @@ async function supabase({ event, resolve }) {
})
/**
* a little helper that is written for convenience so that instead
* of calling `const { data: { session } } = await supabase.auth.getSession()`
* you just call this `await getSession()`
* Unlike `supabase.auth.getSession`, which is unsafe on the server because it
* doesn't validate the JWT, this function validates the JWT by first calling
* `getUser` and aborts early if the JWT signature is invalid.
*/
event.locals.getSession = async () => {
event.locals.safeGetSession = async () => {
const {
data: { user },
error,
} = await event.locals.supabase.auth.getUser()
if (error) return { session: null, user: null }
const {
data: { session },
} = await event.locals.supabase.auth.getSession()
return session
return { session, user }
}
return resolve(event, {
@@ -636,7 +687,7 @@ async function supabase({ event, resolve }) {
async function authorization({ event, resolve }) {
// protect requests to all routes that start with /protected-routes
if (event.url.pathname.startsWith('/protected-routes') && event.request.method === 'GET') {
const session = await event.locals.getSession()
const { session } = await event.locals.safeGetSession()
if (!session) {
// the user is not signed in
redirect(303, '/')
@@ -645,7 +696,7 @@ async function authorization({ event, resolve }) {
// protect POST requests to all routes that start with /protected-posts
if (event.url.pathname.startsWith('/protected-posts') && event.request.method === 'POST') {
const session = await event.locals.getSession()
const { session } = await event.locals.safeGetSession()
if (!session) {
// the user is not signed in
throw error(303, '/')
@@ -677,15 +728,21 @@ async function supabase({ event, resolve }) {
})
/**
* a little helper that is written for convenience so that instead
* of calling `const { data: { session } } = await supabase.auth.getSession()`
* you just call this `await getSession()`
* Unlike `supabase.auth.getSession`, which is unsafe on the server because it
* doesn't validate the JWT, this function validates the JWT by first calling
* `getUser` and aborts early if the JWT signature is invalid.
*/
event.locals.getSession = async () => {
event.locals.safeGetSession = async () => {
const {
data: { user },
error,
} = await event.locals.supabase.auth.getUser()
if (error) return { session: null, user: null }
const {
data: { session },
} = await event.locals.supabase.auth.getSession()
return session
return { session, user }
}
return resolve(event, {
@@ -698,7 +755,7 @@ async function supabase({ event, resolve }) {
async function authorization({ event, resolve }) {
// protect requests to all routes that start with /protected-routes
if (event.url.pathname.startsWith('/protected-routes') && event.request.method === 'GET') {
const session = await event.locals.getSession()
const { session } = await event.locals.safeGetSession()
if (!session) {
// the user is not signed in
redirect(303, '/')
@@ -707,7 +764,7 @@ async function authorization({ event, resolve }) {
// protect POST requests to all routes that start with /protected-posts
if (event.url.pathname.startsWith('/protected-posts') && event.request.method === 'POST') {
const session = await event.locals.getSession()
const { session } = await event.locals.safeGetSession()
if (!session) {
// the user is not signed in
throw error(303, '/')
@@ -896,15 +953,21 @@ export const handle: Handle = async ({ event, resolve }) => {
})
/**
* a little helper that is written for convenience so that instead
* of calling `const { data: { session } } = await supabase.auth.getSession()`
* you just call this `await getSession()`
* Unlike `supabase.auth.getSession`, which is unsafe on the server because it
* doesn't validate the JWT, this function validates the JWT by first calling
* `getUser` and aborts early if the JWT signature is invalid.
*/
event.locals.getSession = async () => {
event.locals.safeGetSession = async () => {
const {
data: { user },
error,
} = await event.locals.supabase.auth.getUser()
if (error) return { session: null, user: null }
const {
data: { session },
} = await event.locals.supabase.auth.getSession()
return session
return { session, user }
}
return resolve(event, {
@@ -1055,17 +1118,18 @@ declare namespace App {
```ts src/app.d.ts
// src/app.d.ts
import { SupabaseClient, Session } from '@supabase/supabase-js'
import { SupabaseClient, Session, User } from '@supabase/supabase-js'
import { Database } from './DatabaseDefinitions'
declare global {
namespace App {
interface Locals {
supabase: SupabaseClient<Database>
getSession(): Promise<Session | null>
safeGetSession(): Promise<{ session: Session | null; user: User | null }>
}
interface PageData {
session: Session | null
user: User | null
}
// interface Error {}
// interface Platform {}
@@ -1196,7 +1260,7 @@ import type { RequestHandler } from './$types'
import { json, error } from '@sveltejs/kit'
export const GET: RequestHandler = async ({ locals: { supabase, getSession } }) => {
const session = await getSession()
const { session } = await getSession()
if (!session) {
// the user is not signed in
throw error(401, { message: 'Unauthorized' })
@@ -1966,3 +2030,7 @@ export const GET: RequestHandler = withAuth(async ({ session, getSupabaseClient
- [Auth Helpers Source code](https://github.com/supabase/auth-helpers)
- [SvelteKit example](https://github.com/supabase/auth-helpers/tree/main/examples/sveltekit)
</Accordion.Item>
</Accordion>
@@ -429,12 +429,6 @@ export default function Index () {
Creating a Supabase client with the `ssr` package automatically configures it to use Cookies. This means your user's session is available throughout the entire SvelteKit stack - page, layout, server, hooks.
<Admonition type="danger">
Beware when accessing the session object on the server, because it is not revalidated on every request from the client. That means the sender can tamper with unencoded data in the session object. If you need to verify the integrity of user data for server logic, call `auth.getUser` instead, which will query the Supabase Auth server for trusted user data.
</Admonition>
<Tabs
scrollable
size="small"
@@ -475,15 +469,23 @@ export const handle: Handle = async ({ event, resolve }) => {
})
/**
* a little helper that is written for convenience so that instead
* of calling `const { data: { session } } = await supabase.auth.getSession()`
* you just call this `await getSession()`
* Unlike `supabase.auth.getSession`, which is unsafe on the server because it
* doesn't validate the JWT, this function validates the JWT by first calling
* `getUser` and aborts early if the JWT signature is invalid.
*/
event.locals.getSession = async () => {
event.locals.safeGetSession = async () => {
const {
data: { user },
error,
} = await event.locals.supabase.auth.getUser()
if (error) {
return { session: null, user: null }
}
const {
data: { session },
} = await event.locals.supabase.auth.getSession()
return session
return { session, user }
}
return resolve(event, {
@@ -528,6 +530,11 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
},
})
/**
* It's fine to use `getSession` here, because on the client, `getSession` is
* safe, and on the server, it reads `session` from the `LayoutData`, which
* safely checked the session using `safeGetSession`.
*/
const {
data: { session },
} = await supabase.auth.getSession()
@@ -543,9 +550,12 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
```ts +layout.server.ts
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals: { getSession } }) => {
export const load: LayoutServerLoad = async ({ locals: { safeGetSession } }) => {
const { session, user } = await safeGetSession()
return {
session: await getSession(),
session,
user,
}
}
```
@@ -812,11 +812,11 @@ Since this is an API route, use the Supabase client from `@/utils/supabase/api.t
<CH.Code>
```ts pages/api/auth/confirm.tsx
```ts pages/api/auth/confirm.ts
import { type EmailOtpType } from '@supabase/supabase-js'
import type { NextApiRequest, NextApiResponse } from 'next'
import { createClient } from '@/utils/supabase/api'
import createClient from '@/utils/supabase/api'
function stringOrFirstString(item: string | string[] | undefined) {
return Array.isArray(item) ? item[0] : item
@@ -869,7 +869,7 @@ export default function ErrorPage() {
If you use dynamic server-side rendering, you can serve a page to authenticated users only by checking for the user data in `getServerSideProps`. Unauthenticated users will be redirected to the home page.
Since you're calling Supabse from `getServerSideProps`, use the client from `@/utils/supabase/server-props.ts`.
Since you're calling Supabase from `getServerSideProps`, use the client from `@/utils/supabase/server-props.ts`.
<Admonition type="danger">
@@ -231,7 +231,7 @@ await supabase.auth.signInWithOAuth({
<TabPanel id="server" label="Server">
In the server, you need to handle the redirect to the SSO provider's authentication endpoint. The `signInWithOAuth` method returns the endpoint URL, which you can redirect to.
In the server, you need to handle the redirect to the OAuth provider's authentication endpoint. The `signInWithOAuth` method returns the endpoint URL, which you can redirect to.
```js
const { data, error } = await supabase.auth.signInWithOAuth({
@@ -461,7 +461,17 @@ The buckets and objects themselves are rows in the storage schema so they won't
### Local logging
Local logs rely on the Supabase Analytics Server, and are available in the Studio automatically.
Local logs rely on the Supabase Analytics Server, and are not enabled by default. To enable this, edit your `supabase/config.toml` file and set `enabled` to `true`.
```bash supabase/config.toml
[analytics]
# Set enabled to true to see logs in the Logs Explorer
enabled = true
port = 54327
vector_port = 54328
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
```
<Admonition type="note">
@@ -374,3 +374,36 @@ Connection pooler logs are found [here](https://supabase.com/dashboard/project/_
- Terminating with reason :client_termination when state was :busy
This error happens when the client terminates the connection before the connection with the database is completed.
## Finding your database hostname
Your database’s hostname is crucial for establishing a direct connection. It resolves to the underlying IP address of your database. To find your hostname, navigate to your [Database Settings](/dashboard/project/_/settings/database).
Note that the pooler (connection pooler) has a different IP than your database. To reveal the database host and direct connection string, hide the pooler connection string.
Example hostname: `db.<PROJECT_REF>.supabase.co`
## Managing your IP address
To determine your current IP address, you can use an IP address [lookup website](https://whatismyipaddress.com/hostname-ip) or the terminal command:
```shell
nslookup db.<PROJECT_REF>.supabase.co
```
This command queries the domain name servers to find the IP address of the given hostname.
Example IPv6 Address: `2a05:d014:1c06:5f0c:d7a9:8616:bee2:30df`
### IPv6 address
Upon project creation, a static IPv6 address is assigned. This address might change when:
- A project is paused or resumed.
- The database is upgraded.
### IPv4 address
Opting for the static [IPv4 add-on](https://supabase.com/docs/guides/platform/ipv4-address) provides a more stable connection address. The IPv4 address remains constant unless the project is paused or resumed.
Unlike the IPv6 address, upgrading your database doesn't affect the IPv4 address.
@@ -62,11 +62,13 @@ The schedule uses the standard cron syntax, in which \* means "run every time pe
* * * * *
```
You can use [crontab.guru](https://crontab.guru/) to help validate your cron schedules.
You can use `[1-59] seconds` as the cron syntax to schedule sub-minute jobs. This is available on `pg_cron` v1.5.0+; upgrade your existing Supabase project to use this syntax.
Head over to [crontab.guru](https://crontab.guru/) to validate your cron schedules.
### Scheduling system maintenance
Be extremely careful when setting up pg_cron jobs for system maintenance tasks as they can have unintended consequences. For instance, scheduling a command to terminate idle connections with `pg_terminate_backend(pid)` can disrupt critical background processes like nightly backups. Often, there is an existing Postgres setting e.g. `idle_session_timeout` that can perform these common maintenance tasks without the risk.
Be extremely careful when setting up `pg_cron` jobs for system maintenance tasks as they can have unintended consequences. For instance, scheduling a command to terminate idle connections with `pg_terminate_backend(pid)` can disrupt critical background processes like nightly backups. Often, there is an existing Postgres setting e.g. `idle_session_timeout` that can perform these common maintenance tasks without the risk.
Reach out to [Supabase Support](https://supabase.com/support) if you're unsure if that applies to your use case.
@@ -74,7 +76,7 @@ Reach out to [Supabase Support](https://supabase.com/support) if you're unsure i
### Delete data every week
Delete old data on Saturday at 3:30am (GMT):
Delete old data every Saturday at 3:30am (GMT):
```sql
select cron.schedule (
@@ -86,21 +88,35 @@ select cron.schedule (
### Run a vacuum every day
Vacuum every day at 3:00am (GMT)
Vacuum every day at 3:00am (GMT):
```sql
select cron.schedule('nightly-vacuum', '0 3 * * *', 'VACUUM');
```
### Invoke Supabase Edge Function every minute
### Call a database function every 5 minutes
Make a POST request to a Supabase Edge Function every minute. Note: this requires the [`pg_net` extension](/docs/guides/database/extensions/pgnet) to be enabled.
Create a [`hello_world()`](https://supabase.com/docs/guides/database/functions?language=sql#simple-functions) database function and then call it every 5 minutes:
```sql
select cron.schedule('call-db-function', '*/5 * * * *', 'CALL hello_world()');
```
### Invoke Supabase Edge Function every 30 seconds
<Admonition type="note">
This requires `pg_cron` v1.5.0+ and the [`pg_net` extension](/docs/guides/database/extensions/pgnet) to be enabled.
</Admonition>
Make a POST request to a Supabase Edge Function every 30 seconds:
```sql
select
cron.schedule(
'invoke-function-every-minute',
'* * * * *', -- every minute
'invoke-function-every-half-minute',
'30 seconds',
$$
select
net.http_post(
@@ -164,4 +180,4 @@ The records in cron.job_run_details are not cleaned automatically which will tak
## Resources
- [pg_cron GitHub Repository](https://github.com/citusdata/pg_cron)
- [`pg_cron` GitHub Repository](https://github.com/citusdata/pg_cron)
@@ -4,7 +4,7 @@ title: 'PGAudit: Postgres Auditing'
description: 'Session and object auditing via PostgreSQL standard logging'
---
[PGAudit](https://www.pgaudit.org) is a PostgreSQL extension that extends PostgreSQL's built-in logging abilities. It can be used to selectively track activities within your database.
[PGAudit](https://www.pgaudit.org) extends Postgres's built-in logging abilities. It can be used to selectively track activities within your database.
This helps you with:
@@ -12,7 +12,7 @@ This helps you with:
- **Security**: Detecting suspicious database activity
- **Troubleshooting**: Identifying and fixing database issues
## Enable the Extension
## Enable the extension
<Tabs
scrollable
@@ -30,7 +30,6 @@ This helps you with:
</TabPanel>
<TabPanel id="sql" label="SQL">
{/* prettier-ignore */}
```sql
-- Enable the "pgaudit" extension
create extension pgaudit;
@@ -42,20 +41,20 @@ drop extension if exists pgaudit;
</TabPanel>
</Tabs>
## Configure the Extension
## Configure the extension
PGAudit can be configured with multiple different levels of precision.
PGAudit can be configured with different levels of precision.
**PGAudit Logging Precision:**
**PGAudit logging precision:**
- **[Session](#session-logging):** Logs activity within a connection, such as a [PSQL](https://supabase.com/docs/guides/database/connecting-to-postgres#connecting-with-psql) connection.
- **[User](#user-logging):** Logs activity by a particular database user (e.g., anon or postgres).
- **[Session](#session-logging):** Logs activity within a connection, such as a [psql](https://supabase.com/docs/guides/database/connecting-to-postgres#connecting-with-psql) connection.
- **[User](#user-logging):** Logs activity by a particular database user (for example, anon or postgres).
- **[Global](#global-logging):** Logs activity across the entire database.
- **[Object](#object-mode-settings):** Logs events related to specific database objects (for example, the auth.users table).
- **[Object](#object-logging):** Logs events related to specific database objects (for example, the auth.users table).
Although Session, User, and Global modes differ in their precision, they are all considered variants of **Session Mode** and are configured with the same input categories.
Although Session, User, and Global modes differ in their precision, they're all considered variants of **Session Mode** and are configured with the same input categories.
### Session Mode Categories
### Session mode categories
These modes can monitor predefined categories of database operations:
@@ -69,7 +68,7 @@ These modes can monitor predefined categories of database operations:
| misc | Less common commands (FETCH, CHECKPOINT) | Captures obscure actions for deeper analysis if needed. |
| all | Everything above | Comprehensive logging for complete audit trails. |
Below is a limited example of how to assign pgaudit to monitor specific categories.
Below is a limited example of how to assign PGAudit to monitor specific categories.
```sql
-- log all CREATE, ALTER, and DROP events
@@ -82,24 +81,24 @@ Below is a limited example of how to assign pgaudit to monitor specific categori
... pgaudit.log = 'none';
```
### Session Logging
### Session logging
When you are connecting in a session environment, such as a PSQL connection, you can configure pgaudit to record events initiated within the session.
When you are connecting in a session environment, such as a [psql](https://supabase.com/docs/guides/database/connecting-to-postgres#connecting-with-psql) connection, you can configure PGAudit to record events initiated within the session.
<Admonition type="note">
The [Dashboard](https://supabase.com/dashboard/project/_) is a transactional environment and will not sustain a session.
The [Dashboard](https://supabase.com/dashboard/project/_) is a transactional environment and won't sustain a session.
</Admonition>
Inside a session, by default, pgaudit will log nothing:
Inside a session, by default, PGAudit will log nothing:
```sql
-- returns 'none'
show pgaudit.log;
```
In the session, you can `SET` the `pgaudit.log` variable to record events:
In the session, you can `set` the `pgaudit.log` variable to record events:
```sql
-- log CREATE, ALTER, and DROP events
@@ -112,7 +111,7 @@ set pgaudit.log = 'read, ddl';
set pgaudit.log = 'none';
```
### User Logging
### User logging
There are some cases where you may want to monitor a database user's actions. For instance, let's say you connected your database to [Zapier](https://supabase.com/partners/integrations/zapier) and created a custom role for it to use:
@@ -130,25 +129,26 @@ To remove the settings, execute the following code:
```sql
-- disables role's log
alter role "zapier" set pgaudit.log TO 'none';
alter role "zapier" set pgaudit.log to 'none';
-- check to make sure the changes are finalized:
select rolname,
select
rolname,
rolconfig
from pg_roles
where rolname = 'zapier';
-- should return a rolconfig path with "pgaudit.log=none" present
```
### Global Logging
### Global logging
<Admonition type="caution">
Use global logging sparingly as it can generate many logs and make it difficult to find important events. Consider limiting the scope of what is logged by using user or object logging where possible.
Use global logging cautiously. It can generate many logs and make it difficult to find important events. Consider limiting the scope of what is logged by using session, user, or object logging where possible.
</Admonition>
The below SQL configures pgaudit to record all events associated with the "postgres" role. Since postgres has extensive privileges, this effectively monitors all database activity.
The below SQL configures PGAudit to record all events associated with the "postgres" role. Since it has extensive privileges, this effectively monitors all database activity.
```sql
alter role "postgres" set pgaudit.log to 'all';
@@ -171,35 +171,31 @@ To remove the settings, execute the following code:
alter role "postgres" set pgaudit.log to 'none';
```
## Object Mode Settings
### Object logging
Assigning a role to pgaudit will result in the logging of **any actions** that role is **capable** of performing, _regardless of who or what actually initiated the event_.
### Object Logging
To fine-tune what pgaudit will record, you must create a custom database role with limited permissions:
To fine-tune what object events PGAudit will record, you must create a custom database role with limited permissions:
```sql
create role "some_audit_role" noinherit;
```
No other Postgres user can assume or login via this role. It solely exists to securely define what pgaudit will record.
No other Postgres user can assume or login via this role. It solely exists to securely define what PGAudit will record.
Once the role is created, you can direct pgaudit to log by assigning it to the `pgaudit.role` variable:
Once the role is created, you can direct PGAudit to log by assigning it to the `pgaudit.role` variable:
```sql
alter role "postgres" set pgaudit.role to 'some_audit_role';
```
You can then assign the role to monitor only approved events, such as `select` statements that include a specific table:
You can then assign the role to monitor only approved object events, such as `select` statements that include a specific table:
```sql
grant select on random_table to "some_audit_role";
```
With this privilege granted, pgaudit will record all select statements against the `random_table`. Note, that this does include joins that feature the table, too. All assignignable priveleges can be viewed in the [PostgreSQL documentation](https://www.postgresql.org/docs/current/ddl-priv.html).
With this privilege granted, PGAudit will record all select statements that reference the `random_table`, regardless of _who_ or _what_ actually initiated the event. All assignable privileges can be viewed in the [Postgres documentation](https://www.postgresql.org/docs/current/ddl-priv.html).
If you would no longer like to use object logging, you will need to unnasign the `pgaudit.role` variable:
If you would no longer like to use object logging, you will need to unassign the `pgaudit.role` variable:
```sql
-- change pgaudit.role to no longer reference some_audit_role
@@ -214,27 +210,21 @@ where rolname = 'postgres';
-- should return a rolconfig path with "pgaudit.role="
```
If you are no longer using a `ROLE`, it is best to `DROP`it:
```sql
drop role "some_audit_role";
```
## Interpreting Audit Logs
pgaudit was designed for storing logs as CSV files with the following headers:
PGAudit was designed for storing logs as CSV files with the following headers:
> Referenced from the [pgaudit official docs](https://github.com/pgaudit/pgaudit/blob/master/README.md#format)
> Referenced from the [PGAudit official docs](https://github.com/pgaudit/pgaudit/blob/master/README.md#format)
| header | Description |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| AUDIT_TYPE | SESSION or OBJECT |
| STATEMENT_ID | Unique statement ID for this session. Sequential even if some statements are not logged. |
| SUBSTATEMENT_ID | Sequential ID for each sub-statement within the main statement. Continuous even if some are not logged. |
| CLASS | e.g., READ, ROLE (see pgaudit.log). |
| COMMAND | e.g., ALTER TABLE, SELECT. |
| CLASS | ..., READ, ROLE (see pgaudit.log). |
| COMMAND | ..., ALTER TABLE, SELECT. |
| OBJECT_TYPE | TABLE, INDEX, VIEW, etc. Available for SELECT, DML, and most DDL statements. |
| OBJECT_NAME | The fully qualified object name (e.g., public.account). Available for SELECT, DML, and most DDL. |
| OBJECT_NAME | The fully qualified object name (for example, public.account). Available for SELECT, DML, and most DDL. |
| STATEMENT | Statement executed on the backend. |
| PARAMETER | If pgaudit.log_parameter is set, this field contains the statement parameters as quoted CSV, or \<none\>. Otherwise, it's \<not logged\>. |
@@ -258,9 +248,9 @@ Generates the following log in the [Dashboard's Postgres Logs](https://supabase.
); <not logged>
```
## Finding and Filtering Audit Logs
## Finding and filtering audit logs
Logs generated by pgaudit can be found in [Postgres Logs](https://supabase.com/dashboard/project/_/logs/postgres-logs?s=AUDIT). To find a specific log, you can use the log explorer. Below is a basic example to extract logs referencing `CREATE TABLE` events
Logs generated by PGAudit can be found in [Postgres Logs](https://supabase.com/dashboard/project/_/logs/postgres-logs?s=AUDIT). To find a specific log, you can use the log explorer. Below is a basic example to extract logs referencing `CREATE TABLE` events
```sql
select
@@ -275,7 +265,7 @@ order by timestamp desc
limit 100;
```
## Practical Examples
## Practical examples
### Monitoring API events
@@ -296,9 +286,9 @@ alter role "authenticator" set pgaudit.log to 'write';
-- alter role "service_role" set pgaudit.log TO 'write';
```
### Monitoring the `auth.users` Table
### Monitoring the `auth.users` table
In the worst case scenario, where a privileged roles' password is exposed, you can use pgaudit to monitor if the `auth.users` table was targeted. It should be stated that API requests are already monitored in the [API Edge Network](https://supabase.com/dashboard/project/_/logs/edge-logs) and this is more about providing greater clarity about what is happening at the database level.
In the worst case scenario, where a privileged roles' password is exposed, you can use PGAudit to monitor if the `auth.users` table was targeted. It should be stated that API requests are already monitored in the [API Edge Network](https://supabase.com/dashboard/project/_/logs/edge-logs) and this is more about providing greater clarity about what is happening at the database level.
Logging `auth.user` should be done in Object Mode and requires a custom role:
@@ -312,18 +302,17 @@ grant delete on auth.users to "auth_auditor";
-- assign auth_auditor to pgaudit.role
alter role "postgres" set pgaudit.role to 'auth_auditor';
```
With the above code, any query involving reading or deleting the auth.users email column will be logged.
With the above code, any query involving reading or deleting from the auth.users table will be logged.
## Best Practices
## Best practices
### Over-logging: Help! Too many logs!
### Disabling excess logging
pgaudit, if not configured mindfully, can log all database events, including background tasks. This can generate an undesirablely large amount of logs in a few hours.
PGAudit, if not configured mindfully, can log all database events, including background tasks. This can generate an undesirably large amount of logs in a few hours.
The first step to solve this problem is to identify which database users pgaudit is observing:
The first step to solve this problem is to identify which database users PGAudit is observing:
```sql
-- find all users monitored by pgaudit
@@ -340,7 +329,7 @@ where
);
```
To prevent pgaudit from monitoring the problematic roles, you'll want to change their `pgaudit.log` values to `none` and `pgaudit.role` values to `empty quotes ''`
To prevent PGAudit from monitoring the problematic roles, you'll want to change their `pgaudit.log` values to `none` and `pgaudit.role` values to `empty quotes ''`
```sql
-- Use to disable object level logging
@@ -352,7 +341,7 @@ To prevent pgaudit from monitoring the problematic roles, you'll want to change
## FAQ
#### How can I use pgaudit to debug database functions?
#### Can I use PGAudit to debug database functions?
Technically yes, but it is not the best approach. It is better to check out our [function debugging guide](https://supabase.com/docs/guides/database/functions#general-logging) instead.
@@ -362,7 +351,7 @@ In the [Logs Dashboard](https://supabase.com/dashboard/project/_/logs/postgres-l
#### Can I log observed table rows?
By default, pgaudit records queries, but not the returned rows. You can modify this behavior with the `pgaudit.log_rows` variable:
By default, PGAudit records queries, but not the returned rows. You can modify this behavior with the `pgaudit.log_rows` variable:
```sql
--enable
@@ -372,31 +361,31 @@ alter role "postgres" set pgaudit.log_rows to 'on';
alter role "postgres" set pgaudit.log_rows to 'off';
```
You should not do this unless you are _absolutely_ certain it is necessary for your use case. It can expose sensitive values to your logs that ideally should not be preserved. Furthermore, if done in excess, it can noticably reduce database performance.
You should not do this unless you are _absolutely_ certain it is necessary for your use case. It can expose sensitive values to your logs that ideally should not be preserved. Furthermore, if done in excess, it can noticeably reduce database performance.
#### Can I log function parameters?
We don't currently support configuring pgaudit.log_parameter because it may log secrets in encrypted columns if you are using [pgsodium](https://supabase.com/docs/guides/database/extensions/pgsodium) or[Vault](https://supabase.com/docs/guides/database/vault).
We don't currently support configuring `pgaudit.log_parameter` because it may log secrets in encrypted columns if you are using [pgsodium](https://supabase.com/docs/guides/database/extensions/pgsodium) or[Vault](https://supabase.com/docs/guides/database/vault).
You can upvote this [feature request](https://github.com/orgs/supabase/discussions/20183) with your use-case if you'd like this restriction lifted.
#### Why do the official pgaudit docs differ from the Supabase one?
#### Does PGAudit support system wide configurations?
pgaudit allows settings to be applied at 3 different database scopes:
PGAudit allows settings to be applied to 3 different database scopes:
| Scope | Definition | Configuration File/Command |
| Scope | Description | Configuration File/Command |
| -------- | ------------------ | -------------------------- |
| System | Entire server | ALTER SYSTEM commands |
| Database | Specific database | ALTER DATABASE commands |
| Role | Specific user/role | ALTER ROLE commands |
Supabase limits full privileges for file system and database variables, meaning pgaudit modifications can only occur at the ROLE level. Assigning pgaudit to the 'postgres' role provides it with the role's database visibility, allowing `ROLE` level adjustments a practical alternative to DATABASE/SYSTEM level configurations.
Supabase limits full privileges for file system and database variables, meaning PGAudit modifications can only occur at the role level. Assigning PGAudit to the 'postgres' role grants it nearly complete visibility into the database, making role-level adjustments a practical alternative to configuring at the database or system level.
pgaudit's [official documentation](https://www.pgaudit.org) focuses on system and database level configs, but its docs officially supports `ROLE` level configs, too:
PGAudit's [official documentation](https://www.pgaudit.org) focuses on system and database level configs, but its docs officially supports role level configs, too.
## Resources
- Official [`PGAudit` documentation](https://www.pgaudit.org)
- [Official `PGAudit` documentation](https://www.pgaudit.org)
- [Database Function Logging](https://supabase.com/docs/guides/database/functions#general-logging)
- [Supabase Logging](https://supabase.com/docs/guides/platform/logs)
- [Self-Hosting Logs](https://supabase.com/docs/reference/self-hosting-analytics/introduction)
@@ -347,7 +347,7 @@ Good targets to log include:
#### General logging
To create custom logs in the [Dashboard's Postgres Logs](/apps/docs/public/img/database/postgres-logs.png), you can use the `raise` keyword. By default, there are 3 observed severity levels:
To create custom logs in the [Dashboard's Postgres Logs](https://supabase.com/dashboard/project/_/logs/postgres-logs), you can use the `raise` keyword. By default, there are 3 observed severity levels:
- `log`
- `warning`
@@ -177,6 +177,6 @@ import { Sheets } from 'npm:@googleapis/sheets'
- Outgoing connections to ports `25` and `587` are not allowed.
- Serving of HTML content is not supported (`GET` requests that return `text/html` will be rewritten to `text/plain`).
- Deno and Node file system APIs are not available.
- Memory limit for the function: 150 MB (heap) + 150 MB (external - array buffers/ WASM)
- Memory limit for the function: 256MB
- Wall clock limit: 400 s
- CPU Time: 2 s
@@ -78,7 +78,7 @@ Deno.serve(async (req) => {
```bash
supabase functions deploy cloudflare-turnstile
supabase secrets set CLOUDFLARE_TURNSTILE_SECRET_KEY=your_secret_key
supabase secrets set CLOUDFLARE_SECRET_KEY=your_secret_key
```
## Invoke the function from your site
@@ -117,7 +117,7 @@ hideToc: true
```dart lib/main.dart
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
const MyApp({super.key});
@override
Widget build(BuildContext context) {
@@ -171,6 +171,6 @@ hideToc: true
## Next steps
- Set up [Auth](/docs/guides/auth/server-side/overview) for your app
- Set up [Auth](/docs/guides/auth) for your app
- [Insert more data](/docs/guides/database/import-data) into your database
- Upload and serve static files using [Storage](/docs/guides/storage)
@@ -139,6 +139,6 @@ hideToc: true
## Next steps
- Set up [Auth](/docs/guides/auth/server-side/overview) for your app
- Set up [Auth](/docs/guides/auth) for your app
- [Insert more data](/docs/guides/database/import-data) into your database
- Upload and serve static files using [Storage](/docs/guides/storage)
@@ -161,6 +161,6 @@ hideToc: true
## Next steps
- Set up [Auth](/docs/guides/auth/server-side/overview) for your app
- Set up [Auth](/docs/guides/auth) for your app
- [Insert more data](/docs/guides/database/import-data) into your database
- Upload and serve static files using [Storage](/docs/guides/storage)
@@ -85,13 +85,23 @@ export const handle: Handle = async ({ event, resolve }) => {
})
/**
* A convenience helper so we can just call await getSession() instead const { data: { session } } = await supabase.auth.getSession()
* Unlike `supabase.auth.getSession`, which is unsafe on the server because it
* doesn't validate the JWT, this function validates the JWT by first calling
* `getUser` and aborts early if the JWT signature is invalid.
*/
event.locals.getSession = async () => {
event.locals.safeGetSession = async () => {
const {
data: { user },
error,
} = await supabase.auth.getUser()
if (error) {
return { session: null, user: null }
}
const {
data: { session },
} = await event.locals.supabase.auth.getSession()
return session
return { session, user }
}
return resolve(event, {
@@ -104,9 +114,7 @@ export const handle: Handle = async ({ event, resolve }) => {
<GetSessionWarning />
In this case, the session information from `getSession` is supplied to the Supabase client so it can retrieve the auth token. This is safe, since the auth token signature will be revalidated on the Auth server. But you shouldn't trust the unsigned data that is stored alongside the JWT.
If you are using TypeScript the compiler might complain about `event.locals.supabase` and `event.locals.getSession`, this can be fixed by updating your `src/app.d.ts` with the content below:
If you are using TypeScript the compiler might complain about `event.locals.supabase` and `event.locals.safeGetSession`, this can be fixed by updating your `src/app.d.ts` with the content below:
```ts src/app.d.ts
// src/app.d.ts
@@ -117,10 +125,11 @@ declare global {
namespace App {
interface Locals {
supabase: SupabaseClient
getSession(): Promise<Session | null>
safeGetSession(): Promise<{ session: Session | null; user: User | null }>
}
interface PageData {
session: Session | null
user: User | null
}
// interface Error {}
// interface Platform {}
@@ -134,9 +143,12 @@ Create a new `src/routes/+layout.server.ts` file to handle the session on the se
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals: { getSession } }) => {
export const load: LayoutServerLoad = async ({ locals: { safeGetSession } }) => {
const { session, user } = await safeGetSession()
return {
session: await getSession(),
session,
user,
}
}
```
@@ -170,6 +182,11 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
},
})
/**
* It's fine to use `getSession` here, because on the client, `getSession` is
* safe, and on the server, it reads `session` from the `LayoutData`, which
* safely checked the session using `safeGetSession`.
*/
const {
data: { session },
} = await supabase.auth.getSession()
@@ -259,8 +276,8 @@ Create a `src/routes/+page.server.ts` file that will return our website URL to b
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ url, locals: { getSession } }) => {
const session = await getSession()
export const load: PageServerLoad = async ({ url, locals: { safeGetSession } }) => {
const { session } = await safeGetSession()
// if the user is already logged in return them to the account page
if (session) {
@@ -419,8 +436,8 @@ and handle all our form actions through the `actions` object.
import { fail, redirect } from '@sveltejs/kit'
import type { Actions, PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals: { supabase, getSession } }) => {
const session = await getSession()
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession()
if (!session) {
throw redirect(303, '/')
@@ -436,14 +453,14 @@ export const load: PageServerLoad = async ({ locals: { supabase, getSession } })
}
export const actions: Actions = {
update: async ({ request, locals: { supabase, getSession } }) => {
update: async ({ request, locals: { supabase, safeGetSession } }) => {
const formData = await request.formData()
const fullName = formData.get('fullName') as string
const username = formData.get('username') as string
const website = formData.get('website') as string
const avatarUrl = formData.get('avatarUrl') as string
const session = await getSession()
const { session } = await safeGetSession()
const { error } = await supabase.from('profiles').upsert({
id: session?.user.id,
@@ -470,8 +487,8 @@ export const actions: Actions = {
avatarUrl,
}
},
signout: async ({ locals: { supabase, getSession } }) => {
const session = await getSession()
signout: async ({ locals: { supabase, safeGetSession } }) => {
const { session } = await safeGetSession()
if (session) {
await supabase.auth.signOut()
throw redirect(303, '/')
@@ -64,7 +64,7 @@ Read more on [how billing for compute works](https://supabase.com/docs/guides/pl
#### What is unified egress and how is it billed?
Unified egress refers to the total egress quota available to each organization. This quota can be utilized for various purposes such as Storage, Runtime, Auth, Functions, and Database. Each plan includes a specific egress quota, and any additional usage beyond that quota is billed accordingly.
Unified egress refers to the total egress quota available to each organization. This quota can be utilized for various purposes such as Storage, Realtime, Auth, Functions, Supavisor and Database. Each plan includes a specific egress quota, and any additional usage beyond that quota is billed accordingly.
To learn more about unified egress and its details, please refer to the information provided [here](https://supabase.com/docs/guides/platform/org-based-billing#:~:text=running%20any%20projects.-,Unified%20egress,-%23).
@@ -18,15 +18,6 @@ When you're ready to ship your changes, merge your branch to update your product
If you understand Git, you already understand Supabase Branching.
<Admonition
type="note"
label="Branching is Early Access only, and only available on the Pro Plan and above."
>
Branching will move to Public Beta in future, but only available for Pro plan and above.
</Admonition>
## How branching works
Supabase Branching works with Git. You can test changes in a separate, temporary environment without affecting your production setup. When you're ready to ship your changes, merge your branch to update your production instance with the new changes.
@@ -6,18 +6,25 @@ description: "Learn about your project's instance and additional add-ons."
Every project on the Supabase Platform comes with its own dedicated Postgres instance running inside a virtual machine (VM). The following table describes the base instance with additional compute add-ons available if you need extra performance when scaling up Supabase.
| Plan | Hourly Price USD | Monthly Price USD | CPU | Memory | Connections: Direct | Connections: Pooler | Recommended Maximum DB Size |
| ------ | ---------------- | ----------------- | ----------------------- | ------ | ------------------- | ------------------- | --------------------------- |
| Micro | $0.01344 | ~$10 | 2-core ARM (shared) | 1 GB | 60 | 200 | 10GB |
| Small | $0.0206 | ~$15 | 2-core ARM (shared) | 2 GB | 90 | 200 | 50GB |
| Medium | $0.0822 | ~$60 | 2-core ARM (shared) | 4 GB | 120 | 200 | 100GB |
| Large | $0.1517 | ~$110 | 2-core ARM (dedicated) | 8 GB | 160 | 300 | 200GB |
| XL | $0.2877 | ~$210 | 4-core ARM (dedicated) | 16 GB | 240 | 700 | 500GB |
| 2XL | $0.562 | ~$410 | 8-core ARM (dedicated) | 32 GB | 380 | 1500 | 1TB |
| 4XL | $1.32 | ~$960 | 16-core ARM (dedicated) | 64 GB | 480 | 3000 | 2TB |
| 8XL | $2.562 | ~$1,870 | 32-core ARM (dedicated) | 128 GB | 490 | 6000 | 4TB |
| 12XL | $3.836 | ~$2,800 | 48-core ARM (dedicated) | 192 GB | 500 | 9000 | 6TB |
| 16XL | $5.12 | ~$3,730 | 64-core ARM (dedicated) | 256 GB | 500 | 12,000 | 10TB |
<Admonition type="note">
Free plan resources subject to changes.
</Admonition>
| Plan | Hourly Price USD | Monthly Price USD | CPU | Memory | Connections: Direct | Connections: Pooler | Recommended Maximum DB Size |
| ----------- | ---------------- | ----------------- | ----------------------- | ------------ | ------------------- | ------------------- | --------------------------- |
| Nano (free) | $0 | $0 | Shared | Up to 0.5 GB | 60 | 200 | 500MB |
| Micro | $0.01344 | ~$10 | 2-core ARM (shared) | 1 GB | 60 | 200 | 10GB |
| Small | $0.0206 | ~$15 | 2-core ARM (shared) | 2 GB | 90 | 400 | 50GB |
| Medium | $0.0822 | ~$60 | 2-core ARM (shared) | 4 GB | 120 | 600 | 100GB |
| Large | $0.1517 | ~$110 | 2-core ARM (dedicated) | 8 GB | 160 | 800 | 200GB |
| XL | $0.2877 | ~$210 | 4-core ARM (dedicated) | 16 GB | 240 | 1,000 | 500GB |
| 2XL | $0.562 | ~$410 | 8-core ARM (dedicated) | 32 GB | 380 | 1,500 | 1TB |
| 4XL | $1.32 | ~$960 | 16-core ARM (dedicated) | 64 GB | 480 | 3,000 | 2TB |
| 8XL | $2.562 | ~$1,870 | 32-core ARM (dedicated) | 128 GB | 490 | 6,000 | 4TB |
| 12XL | $3.836 | ~$2,800 | 48-core ARM (dedicated) | 192 GB | 500 | 9,000 | 6TB |
| 16XL | $5.12 | ~$3,730 | 64-core ARM (dedicated) | 256 GB | 500 | 12,000 | 10TB |
Number of connections above are recommended values.
@@ -45,18 +52,19 @@ When considering compute upgrades, assess whether your bottlenecks are hardware-
SSD Disks are attached to your servers and the disk performance depends on the compute add-on of your instance. Disk IO refers to two metrics: throughput (Megabits per Second) and IOPS (Input/Output Operations per Second). Disk throughput is capped at 1,048 Mbps and IOPS is capped at 3,000 IOPS for all compute add-ons.
| Plan | Baseline Disk Throughput | Baseline IOPS |
| ------ | ------------------------ | ------------- |
| Micro | 87 Mbps | 500 IOPS |
| Small | 174 Mbps | 1,000 IOPS |
| Medium | 347 Mbps | 2,000 IOPS |
| Large | 630 Mbps | 3,000 IOPS |
| XL | 1,048 Mbps | 3,000 IOPS |
| 2XL | 1,048 Mbps | 3,000 IOPS |
| 4XL | 1,048 Mbps | 3,000 IOPS |
| 8XL | 1,048 Mbps | 3,000 IOPS |
| 12XL | 1,048 Mbps | 3,000 IOPS |
| 16XL | 1,048 Mbps | 3,000 IOPS |
| Plan | Baseline Disk Throughput | Baseline IOPS |
| ----------- | ------------------------ | ------------- |
| Nano (free) | 43 Mbps | 250 IOPS |
| Micro | 87 Mbps | 500 IOPS |
| Small | 174 Mbps | 1,000 IOPS |
| Medium | 347 Mbps | 2,000 IOPS |
| Large | 630 Mbps | 3,000 IOPS |
| XL | 1,048 Mbps | 3,000 IOPS |
| 2XL | 1,048 Mbps | 3,000 IOPS |
| 4XL | 1,048 Mbps | 3,000 IOPS |
| 8XL | 1,048 Mbps | 3,000 IOPS |
| 12XL | 1,048 Mbps | 3,000 IOPS |
| 16XL | 1,048 Mbps | 3,000 IOPS |
[Contact us](https://supabase.com/contact/enterprise) if you require a plan with higher disk throughput and IOPS.
@@ -74,18 +82,19 @@ If you're unsure of how much throughput or IOPS your application requires, you c
The maximum number of replication slots and WAL senders depends on your compute add-on plan, as follows:
| Plan | Max Replication Slots | Max WAL Senders |
| ------ | --------------------- | --------------- |
| Micro | 5 | 5 |
| Small | 5 | 5 |
| Medium | 5 | 5 |
| Large | 8 | 8 |
| XL | 24 | 24 |
| 2XL | 80 | 80 |
| 4XL | 80 | 80 |
| 8XL | 80 | 80 |
| 12XL | 80 | 80 |
| 16XL | 80 | 80 |
| Plan | Max Replication Slots | Max WAL Senders |
| ----------- | --------------------- | --------------- |
| Nano (free) | 5 | 5 |
| Micro | 5 | 5 |
| Small | 5 | 5 |
| Medium | 5 | 5 |
| Large | 8 | 8 |
| XL | 24 | 24 |
| 2XL | 80 | 80 |
| 4XL | 80 | 80 |
| 8XL | 80 | 80 |
| 12XL | 80 | 80 |
| 16XL | 80 | 80 |
<Admonition type="caution">
@@ -59,6 +59,12 @@ Projects on the Pro plan and above have auto-scaling Disk Storage.
Disk Storage expands automatically when the database reaches 90% of the disk size. The disk is expanded to be 50% larger (e.g., 8GB -> 12GB). Auto-scaling can only take place once every 6 hours. If within those 6 hours you reach 95% of the disk space, your project will enter read-only mode.
<Admonition type="note">
The automatic resize operation will add an additional 50% capped to a maximum of 200GB. If 50% of your current usage is more than 200GB then only 200GB will be added to your disk (e.g. a size of 1500GB will resize to 1700GB).
</Admonition>
The Disk Storage Size can also be manually expanded on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). The maximum default you can expand the Disk Storage to is 200GB. If you wish to manually expand it to any more than 200GB, please [contact us](https://supabase.com/dashboard/support/new) to discuss expanding the 200GB limit.
<Admonition type="note">
@@ -92,7 +92,7 @@ Additionally, Compute Credits are given to you not only for the first month but
## Unified egress
Each organization receives a total egress quota (eg. 250GB on the Pro plan) which can be used for anything - Storage, Realtime, Auth, Functions or Database.
Each organization receives a total egress quota (eg. 250GB on the Pro plan) which can be used for anything - Storage, Realtime, Auth, Functions, Supavisor or Database.
### Included egress quota
@@ -175,21 +175,7 @@ You can pair the information available from `pg_stat_statements` with the detail
## Optimizing the number of connections
By default, the number of connections allowed to Postgres and Supavisor is configured based on the resources available to the database.
| Compute Add-on | Postgresql connections | Supavisor connections |
| -------------- | ---------------------- | --------------------- |
| - (Free plan) | 30 | 200 |
| Micro | 60 | 200 |
| Small | 90 | 200 |
| Medium | 120 | 200 |
| Large | 160 | 300 |
| XL | 240 | 700 |
| 2XL | 380 | 1500 |
| 4XL | 480 | 3000 |
| 8XL | 490 | 6000 |
| 12XL | 500 | 9000 |
| 16XL | 500 | 12000 |
The default connection limits for Postgres and Supavisor is based on your compute size. See the default connection numbers in the [Compute Add-ons](/docs/guides/platform/compute-add-ons) section.
If the number of connections is insufficient, you will receive the following error upon connecting to the DB:
@@ -51,10 +51,11 @@ A project needs to satisfy these requirements to deploy a Read Replica:
- Support for projects on Fly.io is coming.
1. Running on at least a [Small compute add-on](/docs/guides/platform/compute-add-ons).
- Read Replicas are currently placed on the same compute instance as the database to make sure they can keep up with the Primary's activities.
1. Using [physical backups](/docs/guides/platform/backups#point-in-time-recovery)
- Physical backups are automatically enabled if using [PITR](/docs/guides/platform/backups#point-in-time-recovery)
1. Running on Postgres 15.
- For projects running on older versions of PostgreSQL, you will need to [upgrade to the latest platform version](/docs/guides/platform/migrating-and-upgrading-projects#pgupgrade).
1. Using [physical backups](/docs/guides/platform/backups#point-in-time-recovery)
- Physical backups are automatically enabled if using [PITR](/docs/guides/platform/backups#point-in-time-recovery)
- If you're not using PITR, you'll be able to switch to physical backups as part of the Read Replica setup process. Note that physical backups can't be downloaded from the dashboard in the way logical backups can.
## Getting started
+1 -1
View File
@@ -5,7 +5,7 @@ description: 'General information about enabling single sign-on (SSO) for your o
<Admonition>
Looking for docs on how to add Single Sign-On support in your Supabase project? Head on over to [Single Sign-On with SAML 2.0 for Projects](/docs/guides/auth/sso/auth-sso-saml).
Looking for docs on how to add Single Sign-On support in your Supabase project? Head on over to [Single Sign-On with SAML 2.0 for Projects](/docs/guides/auth/enterprise-sso/auth-sso-saml).
</Admonition>
@@ -150,6 +150,7 @@ Build a basic Todo List with Supabase and your favorite frontend framework:
- Supabase + Ory Cloud [Github](https://github.com/ory/examples/tree/master/supabase-ory-cloud)
- Supabase Auth with React Native + Next.js (Monorepo) [Github](https://github.com/mateoguzmana/react-native-next-supabase-auth-monorepo)
- Supabase Auth with Nuxt3 [Github](https://github.com/zackha/supaAuth)
- Supabase Auth with Hono [Github](https://github.com/CarlosZiegler/hono-supabase)
### Blog posts
@@ -31,7 +31,7 @@ Supabase Storage implements the [TUS protocol](https://tus.io/) to enable resuma
const projectId = ''
async function uploadFile(bucketName, fileName, file) {
const { data: session } = await supabase.auth.session()
const { data: { session } } = await supabase.auth.getSession()
return new Promise((resolve, reject) => {
var upload = new tus.Upload(file, {
+5 -2
View File
@@ -23,10 +23,13 @@ export const primaryLinks = [
]
export const secondaryLinks = [
{ title: 'Contributing', url: '/handbook/contributing' },
{
title: 'Contributing',
url: 'https://github.com/supabase/supabase/blob/master/apps/docs/DEVELOPERS.md',
},
{
title: 'Author Styleguide',
url: 'https://github.com/supabase/supabase/blob/master/DEVELOPERS.md',
url: 'https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md',
},
{ title: 'Open Source', url: 'https://supabase.com/open-source' },
{ title: 'SupaSquad', url: 'https://supabase.com/supasquad' },
+2 -2
View File
@@ -14,7 +14,7 @@ const useHash = () => {
return () => {
window.removeEventListener('hashchange', hashChangeHandler)
}
}, [])
}, [hashChangeHandler])
const updateHash = useCallback(
(newHash) => {
@@ -23,7 +23,7 @@ const useHash = () => {
[hash]
)
return [hash, updateHash]
return [hash, updateHash] as const
}
export default useHash
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useReducer } from 'react'
/**
* An escape hatch to manually trigger component rerenders in response to
* external events, such as fired custom events.
*/
const useRerenderOnEvt = (event: string, listeningElem?: Document | Window | HTMLElement) => {
const [, rerender] = useReducer((state) => !state, true)
useEffect(() => {
const elem = listeningElem ?? window
elem.addEventListener(event, rerender)
return () => elem.removeEventListener(event, rerender)
}, [event, listeningElem, rerender])
}
export { useRerenderOnEvt }
+1 -86
View File
@@ -1,91 +1,6 @@
import { MDXProvider } from '@mdx-js/react'
import { NextSeo } from 'next-seo'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { FC, PropsWithChildren } from 'react'
import components from '~/components'
import TableOfContents from '~/components/TableOfContents'
import { MainSkeleton } from './MainSkeleton'
interface Props {
meta: {
title: string
description?: string
hide_table_of_contents?: boolean
video?: string
tocVideo?: string
canonical?: string
}
children: any
toc?: any
menuItems: any
}
const Layout: FC<Props> = (props: Props) => {
const { asPath } = useRouter()
const hasTableOfContents =
props.toc !== undefined &&
props.toc.json.filter((item) => item.lvl !== 1 && item.lvl <= 3).length > 0
return (
<>
<Head>
<title>{asPath === '/' ? 'Supabase Docs' : `${props.meta?.title} | Supabase Docs`}</title>
<meta name="description" content={props.meta?.description} />
<meta property="og:image" content={`https://supabase.com/docs/img/supabase-og-image.png`} />
<meta
name="twitter:image"
content={`https://supabase.com/docs/img/supabase-og-image.png`}
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</Head>
<NextSeo
canonical={props.meta?.canonical ?? `https://supabase.com/docs${asPath}`}
openGraph={{
url: `https://supabase.com/docs${asPath}`,
type: 'article',
videos: props.meta?.video && [
{
// youtube based video meta
url: props.meta?.video,
width: 640,
height: 385,
type: 'application/x-shockwave-flash',
},
],
article: {
publishedTime: new Date().toISOString(),
modifiedTime: new Date().toISOString(),
authors: ['Supabase'],
},
}}
/>
<LayoutMainContent>
<div className={['relative transition-all ease-out', 'duration-150 '].join(' ')}>
<article className="prose max-w-none">
<h1>{props.meta.title}</h1>
<MDXProvider components={components}>{props.children}</MDXProvider>
</article>
</div>
{hasTableOfContents && !props.meta?.hide_table_of_contents && (
<div
className={[
'border-default bg-background table-of-contents-height border-l',
'thin-scrollbar overflow-y-auto sticky hidden md:block md:col-span-3 px-2',
].join(' ')}
>
<TableOfContents toc={props.toc} video={props.meta.video} />
</div>
)}
</LayoutMainContent>
</>
)
}
import { type FC, type PropsWithChildren } from 'react'
export const LayoutMainContent: FC<PropsWithChildren<{ className?: string }>> = ({
className,
children,
}) => <div className={['max-w-7xl px-5 mx-auto py-16', className].join(' ')}>{children}</div>
export default Layout
+2 -20
View File
@@ -6,30 +6,22 @@ import { FC } from 'react'
import components from '~/components'
import HomePageCover from '~/components/HomePageCover'
import { MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
import TableOfContents from '~/components/TableOfContents'
import { LayoutMainContent } from './DefaultLayout'
import { MainSkeleton } from './MainSkeleton'
interface Props {
meta: {
title: string
description?: string
hide_table_of_contents?: boolean
video?: string
canonical?: string
description?: string
video?: string
}
children: any
toc?: any
menuItems: any
}
const HomeLayout: FC<Props> = (props: Props) => {
const { asPath } = useRouter()
const hasTableOfContents =
props.toc !== undefined &&
props.toc.json.filter((item) => item.lvl !== 1 && item.lvl <= 3).length > 0
return (
<>
<Head>
@@ -72,16 +64,6 @@ const HomeLayout: FC<Props> = (props: Props) => {
<MDXProvider components={components}>{props.children}</MDXProvider>
</div>
</div>
{hasTableOfContents && !props.meta?.hide_table_of_contents && (
<div
className={[
'border-default bg-background table-of-contents-height border-l',
'thin-scrollbar overflow-y-auto sticky hidden md:block md:col-span-3 px-2',
].join(' ')}
>
<TableOfContents toc={props.toc} video={props.meta.video} />
</div>
)}
</LayoutMainContent>
</article>
</MainSkeleton>
+36 -35
View File
@@ -6,141 +6,142 @@ import { type CSSProperties, type PropsWithChildren, memo, useEffect } from 'rea
import { cn } from 'ui'
import Footer from '~/components/Navigation/Footer'
import HomeMenuIconPicker from '~/components/Navigation/NavigationMenu/HomeMenuIconPicker'
import NavigationMenu, { type MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
import TopNavBar from '~/components/Navigation/NavigationMenu/TopNavBar'
import { menuState, useMenuMobileOpen } from '~/hooks/useMenuState'
const levelsData = {
home: {
icon: '/docs/img/icons/menu/home',
icon: 'home',
name: 'Home',
},
gettingstarted: {
icon: '/docs/img/icons/menu/getting-started',
icon: 'getting-started',
name: 'Getting Started',
},
database: {
icon: '/docs/img/icons/menu/database',
icon: 'database',
name: 'Database',
},
api: {
icon: '/docs/img/icons/menu/rest',
icon: 'rest',
name: 'REST API',
},
graphql: {
icon: '/docs/img/icons/menu/graphql',
icon: 'graphql',
name: 'GraphQL',
},
auth: {
icon: '/docs/img/icons/menu/auth',
icon: 'auth',
name: 'Auth',
},
functions: {
icon: '/docs/img/icons/menu/functions',
icon: 'edge-functions',
name: 'Edge Functions',
},
realtime: {
icon: '/docs/img/icons/menu/realtime',
icon: 'realtime',
name: 'Realtime',
},
analytics: {
icon: '/docs/img/icons/menu/analytics',
icon: 'analytics',
name: 'Analytics',
},
storage: {
icon: '/docs/img/icons/menu/storage',
icon: 'storage',
name: 'Storage',
},
ai: {
icon: '/docs/img/icons/menu/ai',
icon: 'ai',
name: 'AI & Vectors',
},
supabase_cli: {
icon: '/docs/img/icons/menu/reference-cli',
icon: 'reference-cli',
name: 'Supabase CLI',
},
platform: {
icon: '/docs/img/icons/menu/platform',
icon: 'platform',
name: 'Platform',
},
resources: {
icon: '/docs/img/icons/menu/resources',
icon: 'resources',
name: 'Resources',
},
self_hosting: {
icon: '/docs/img/icons/menu/self-hosting',
icon: 'self-hosting',
name: 'Self-Hosting',
},
integrations: {
icon: '/docs/img/icons/menu/integrations',
icon: 'integrations',
name: 'Integrations',
},
reference_javascript_v1: {
icon: '/docs/img/icons/menu/reference-javascript',
icon: 'reference-javascript',
name: 'Javascript Reference v1.0',
},
reference_javascript_v2: {
icon: '/docs/img/icons/menu/reference-javascript',
icon: 'reference-javascript',
name: 'Javascript Reference v2.0',
},
reference_dart_v1: {
icon: '/docs/img/icons/menu/reference-dart',
icon: 'reference-dart',
name: 'Dart Reference v1.0',
},
reference_dart_v2: {
icon: '/docs/img/icons/menu/reference-dart',
icon: 'reference-dart',
name: 'Dart Reference v2.0',
},
reference_csharp_v0: {
icon: '/docs/img/icons/menu/reference-csharp',
icon: 'reference-csharp',
name: 'C# Reference v0.0',
},
reference_python_v2: {
icon: '/docs/img/icons/menu/reference-python',
icon: 'reference-python',
name: 'Python Reference v2.0',
},
reference_swift_v1: {
icon: '/docs/img/icons/menu/reference-swift',
icon: 'reference-swift',
name: 'Swift Reference v1.0',
},
reference_swift_v2: {
icon: '/docs/img/icons/menu/reference-swift',
icon: 'reference-swift',
name: 'Swift Reference v2.0',
},
reference_kotlin_v1: {
icon: '/docs/img/icons/menu/reference-kotlin',
icon: 'reference-kotlin',
name: 'Kotlin Reference v1.0',
},
reference_kotlin_v2: {
icon: '/docs/img/icons/menu/reference-kotlin',
icon: 'reference-kotlin',
name: 'Kotlin Reference v2.0',
},
reference_cli: {
icon: '/docs/img/icons/menu/reference-cli',
icon: 'reference-cli',
name: 'CLI Reference',
},
reference_api: {
icon: '/docs/img/icons/menu/reference-api',
icon: 'reference-api',
name: 'Management API Reference',
},
reference_self_hosting_auth: {
icon: '/docs/img/icons/menu/reference-auth',
icon: 'reference-auth',
name: 'Auth Server Reference',
},
reference_self_hosting_storage: {
icon: '/docs/img/icons/menu/reference-storage',
icon: 'reference-storage',
name: 'Storage Server Reference',
},
reference_self_hosting_realtime: {
icon: '/docs/img/icons/menu/reference-realtime',
icon: 'reference-realtime',
name: 'Realtime Server Reference',
},
reference_self_hosting_analytics: {
icon: '/docs/img/icons/menu/reference-analytics',
icon: 'reference-analytics',
name: 'Analytics Server Reference',
},
reference_self_hosting_functions: {
icon: '/docs/img/icons/menu/reference-functions',
icon: 'reference-functions',
name: 'Functions Server Reference',
},
}
@@ -187,8 +188,8 @@ const MobileHeader = memo(function MobileHeader({ menuId }: { menuId: MenuId })
</div>
</button>
<div className={[].join(' ')}>
<img
src={menuLevel ? levelsData[menuLevel].icon + '.svg' : levelsData['home'].icon + '.svg'}
<HomeMenuIconPicker
icon={menuLevel ? levelsData[menuLevel].icon : 'home'}
className={[
'transition-all duration-200',
mobileMenuOpen ? 'invisible w-0 h-0' : 'w-4 h-4',
+15 -63
View File
@@ -1,22 +1,18 @@
import { MDXProvider } from '@mdx-js/react'
import 'katex/dist/katex.min.css'
import { ExternalLink } from 'lucide-react'
import { NextSeo } from 'next-seo'
import { usePathname } from 'next/navigation'
import { useRouter } from 'next/router'
import { FC, useEffect, useRef, useState } from 'react'
import { type FC } from 'react'
import { IconExternalLink, cn } from 'ui'
import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
import { cn } from 'ui'
import components from '~/components'
import { highlightSelectedTocItem } from '~/components/CustomHTMLElements/CustomHTMLElements.utils'
import { FooterHelpCalloutType } from '~/components/FooterHelpCallout'
import GuidesTableOfContents from '~/components/GuidesTableOfContents'
import { type MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
import useHash from '~/hooks/useHash'
import { Feedback } from '~/components/Feedback'
import { LayoutMainContent } from '~/layouts/DefaultLayout'
import { MainSkeleton } from '~/layouts/MainSkeleton'
import 'katex/dist/katex.min.css'
interface Props {
meta: {
@@ -39,12 +35,6 @@ interface Props {
}
const Layout: FC<Props> = (props) => {
const pathname = usePathname()
const [hash] = useHash()
const articleRef = useRef()
const [tocList, setTocList] = useState([])
const { asPath } = useRouter()
const router = useRouter()
@@ -52,31 +42,6 @@ const Layout: FC<Props> = (props) => {
const EDIT_BUTTON_EXCLUDE_LIST = ['/404']
useEffect(() => {
if (hash && tocList.length > 0) {
highlightSelectedTocItem(hash as string)
}
}, [hash, JSON.stringify(tocList)])
useEffect(() => {
const articleEl = articleRef.current as HTMLElement
if (!articleRef.current) return
const headings = Array.from(articleEl.querySelectorAll('h2, h3'))
const newHeadings = headings
.filter((heading) => heading.id)
.map((heading) => {
const text = heading.textContent.replace('#', '')
const link = heading.querySelector('a').getAttribute('href')
const level = heading.tagName === 'H2' ? 2 : 3
return { text, link, level }
})
setTocList(newHeadings)
}, [pathname]) // needed to recalculate the toc when path changes
const hasTableOfContents = tocList.length > 0
const tocVideoPreview = `http://img.youtube.com/vi/${props.meta?.tocVideo}/0.jpg`
// page type, ie, Auth, Database, Storage etc
const ogPageType = asPath.split('/')[2]
// open graph image url constructor
@@ -141,10 +106,9 @@ const Layout: FC<Props> = (props) => {
<p className="text-brand tracking-wider mb-3">{props.meta.breadcrumb}</p>
)}
<article
ref={articleRef}
className={`${
props.meta?.hide_table_of_contents || !hasTableOfContents ? '' : ''
} prose max-w-none`}
// Used to get headings for the table of contents
id="sb-docs-guide-main-article"
className="prose max-w-none"
>
<h1 className="mb-0">{props.meta.title}</h1>
{props.meta?.subtitle && (
@@ -166,35 +130,23 @@ const Layout: FC<Props> = (props) => {
`}
className="text-sm transition flex items-center gap-1 text-scale-1000 hover:text-scale-1200 w-fit"
>
Edit this page on GitHub <IconExternalLink size={14} strokeWidth={1.5} />
Edit this page on GitHub <ExternalLink size={14} strokeWidth={1.5} />
</a>
</div>
</div>
)}
</article>
</div>
{!props.hideToc && hasTableOfContents && !props.meta?.hide_table_of_contents && (
<div
{!props.hideToc && !props.meta?.hide_table_of_contents && (
<GuidesTableOfContents
video={props.meta?.tocVideo}
className={cn(
'col-span-3 self-start',
'border-overlay bg-background',
'thin-scrollbar overflow-y-auto hidden md:block md:col-span-3 px-2',
'sticky top-[calc(var(--mobile-header-height,40px)+2rem)] lg:top-[calc(var(--desktop-header-height,60px)+2rem)]'
'hidden md:block md:col-span-3',
'sticky top-[calc(var(--mobile-header-height,40px)+2rem)] lg:top-[calc(var(--desktop-header-height,60px)+2rem)]',
'max-h-[calc(100vh-60px-5rem)]'
)}
>
<div className="border-l">
{props.meta?.tocVideo && !!tocVideoPreview && (
<div className="relative mb-6 pl-5">
<ExpandableVideo imgUrl={tocVideoPreview} videoId={props.meta.tocVideo} />
</div>
)}
<Feedback key={pathname} />
<span className="block font-mono text-xs uppercase text-foreground px-5 mb-6">
On this page
</span>
<GuidesTableOfContents list={tocList} />
</div>
</div>
/>
)}
</div>
</LayoutMainContent>
+10 -2
View File
@@ -134,12 +134,20 @@ const StickyHeader: FC<StickyHeader> = ({ icon, ...props }) => {
}
const Details: FC<PropsWithChildren<ISectionDetails>> = (props) => {
return <div className="relative w-full">{props.children}</div>
/**
* `min-w` is necessary because these are used as grid children, which have
* default `min-w-auto`
*/
return <div className="relative w-full min-w-full">{props.children}</div>
}
const Examples: FC<PropsWithChildren<ISectionExamples>> = (props) => {
/**
* `min-w` is necessary because these are used as grid children, which have
* default `min-w-auto`
*/
return (
<div className="w-full">
<div className="w-full min-w-full">
<div className="sticky top-24">{props.children}</div>
</div>
)
@@ -1,87 +0,0 @@
import { MDXProvider } from '@mdx-js/react'
import Head from 'next/head'
import { FC, useEffect, useState } from 'react'
import components from '~/components'
import SideBar from '~/components/Navigation/SideBar'
import TableOfContents from '~/components/TableOfContents'
interface Props {
meta: {
title: string
description?: string
hide_table_of_contents?: boolean
video?: string
tocVideo?: string
}
children: any
toc?: any
menuItems: any
currentPage: string
}
const Layout: FC<Props> = (props: Props) => {
const [active, setActive] = useState(false)
useEffect(() => {
setTimeout(function () {
setActive(true)
}, 150)
}, [])
// const contentString = renderToString(props.children)
// const content = serialize(contentString || '')
// console.log('contentString', contentString)
// const _toc = toc('#hello world', { maxdepth: 1, firsth1: false })
const hasTableOfContents =
props.toc !== undefined &&
props.toc.json.filter((item) => item.lvl !== 1 && item.lvl <= 3).length > 0
return (
<>
<Head>
<title>{props.meta?.title} | Supabase</title>
<meta name="description" content={props.meta?.description} />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/docs/favicon.ico" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content={props.meta?.title} />
<meta property="og:description" content={props.meta?.description} />
<meta property="og:title" content={props.meta?.title} />
</Head>
<div
className={[
'relative transition-all ease-out',
'duration-150',
active ? 'opacity-100 left-0' : 'opacity-0 left-10',
].join(' ')}
>
<div>
<p className="text-brand tracking-wider">Tutorials</p>
<article className={['prose', 'max-w-none'].join(' ')}>
<h1>{props.meta.title}</h1>
<div className="max-w-xs w-32 h-[1px] bg-gradient-to-r from-brand-300 to-brand my-16"></div>
<MDXProvider components={components} children={props.children} />
</article>
</div>
{hasTableOfContents && !props.meta?.hide_table_of_contents && (
<div
className={[
'border-overlay bg-background table-of-contents-height border-l',
'thin-scrollbar overflow-y-auto sticky hidden md:block md:col-span-3 px-2',
].join(' ')}
>
<TableOfContents toc={props.toc} video={props.meta.video} />
</div>
)}
</div>
</>
)
}
export default Layout
+8 -6
View File
@@ -4,17 +4,19 @@ import { type ResponseError } from '~/types/fetch'
import { post } from './fetchWrappers'
type SendFeedbackVariables = {
title: string
message: string
isHelpful: boolean
pathname?: string
}
export async function sendFeedback({ message, pathname }: SendFeedbackVariables) {
const { data, error } = await post('/platform/feedback/send', {
export async function sendFeedback({ message, pathname, title, isHelpful }: SendFeedbackVariables) {
const { data, error } = await post('/platform/feedback/docs', {
body: {
message,
category: 'Feedback',
tags: ['docs-feedback'],
pathname,
page: pathname,
isHelpful,
title,
feedback: message,
},
})
if (error) throw Error(`Couldn't send feedback`, { cause: error })
+6 -6
View File
@@ -22,15 +22,15 @@
"@code-hike/mdx": "^0.9.0",
"@mdx-js/loader": "^2.1.5",
"@mdx-js/react": "^2.3.0",
"@next/bundle-analyzer": "^13.5.6",
"@next/mdx": "^13.5.6",
"@next/bundle-analyzer": "^14.1.4",
"@next/mdx": "^14.1.4",
"@octokit/auth-app": "^6.0.3",
"@octokit/core": "^5.0.2",
"@octokit/core": "^5.1.0",
"@octokit/graphql": "^7.0.2",
"@octokit/plugin-paginate-graphql": "^4.0.0",
"@radix-ui/react-accordion": "^1.1.2",
"@supabase/auth-helpers-react": "^0.4.2",
"@supabase/supabase-js": "^2.39.3",
"@supabase/supabase-js": "^2.41.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^5.13.4",
"common": "*",
@@ -54,11 +54,11 @@
"mdx-mermaid": "2.0.0-rc3",
"mermaid": "^10.0.2",
"micromark-extension-mdxjs": "^1.0.0",
"next": "^13.4.8",
"next": "^14.1.4",
"next-mdx-remote": "^4.4.1",
"next-plugin-yaml": "^1.0.1",
"next-seo": "^6.5.0",
"next-themes": "^0.2.1",
"next-themes": "^0.3.0",
"openai": "^4.20.1",
"openapi-fetch": "^0.8.2",
"react": "^18.2.0",
+2 -1
View File
@@ -11,7 +11,7 @@ import { AuthProvider, ThemeProvider, useTelemetryProps, useThemeSandbox } from
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useState, type PropsWithChildren } from 'react'
import { PortalToast, TabsProvider } from 'ui'
import { PortalToast, PromoToast, TabsProvider } from 'ui'
import { CommandMenuProvider } from 'ui-patterns/Cmdk'
import { useConsent } from 'ui-patterns/ConsentToast'
@@ -229,6 +229,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
<div className="h-screen flex flex-col">
<SiteLayout>
<PortalToast />
<PromoToast />
<Component {...pageProps} />
</SiteLayout>
</div>
+18 -15
View File
@@ -1,12 +1,14 @@
import specFile from '~/spec/cli_v1_config.yaml' assert { type: 'yml' }
import { Parameter } from '~/lib/refGenerator/refTypes'
import ReactMarkdown from 'react-markdown'
import GuidesTableOfContents from '~/components/GuidesTableOfContents'
import { Heading } from '~/components/CustomHTMLElements'
import Head from 'next/head'
import { CodeBlock } from 'ui'
import { MainSkeleton } from '~/layouts/MainSkeleton'
import ReactMarkdown from 'react-markdown'
import { CodeBlock, cn } from 'ui'
import { Heading } from '~/components/CustomHTMLElements'
import GuidesTableOfContents from '~/components/GuidesTableOfContents'
import { MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
import { MainSkeleton } from '~/layouts/MainSkeleton'
import { Parameter } from '~/lib/refGenerator/refTypes'
import specFile from '~/spec/cli_v1_config.yaml' assert { type: 'yml' }
// Parameters are grouped on the page by tag
const tocList = []
@@ -41,14 +43,15 @@ export default function Config() {
<div>{content}</div>
</div>
</div>
<div className="md:col-span-3">
<div className="sticky overflow-auto max-h-[calc(100vh-60px-5rem)] pb-3 top-32 border-l">
<span className="block font-mono text-xs uppercase text-foreground pl-5 mb-4">
On this page
</span>
<GuidesTableOfContents list={tocList} />
</div>
</div>
<GuidesTableOfContents
overrideToc={tocList}
className={cn(
'col-span-3 self-start',
'hidden md:block md:col-span-3',
'sticky top-[calc(var(--mobile-header-height,40px)+2rem)] lg:top-[calc(var(--desktop-header-height,60px)+2rem)]',
'max-h-[calc(100vh-60px-5rem)]'
)}
/>
</div>
</MainSkeleton>
</>
@@ -0,0 +1,190 @@
import { CodeHikeConfig, remarkCodeHike } from '@code-hike/mdx'
import { Octokit } from '@octokit/core'
import { capitalize } from 'lodash'
import { GetStaticProps, InferGetStaticPropsType } from 'next'
import { MDXRemote } from 'next-mdx-remote'
import { serialize } from 'next-mdx-remote/serialize'
import remarkGfm from 'remark-gfm'
import rehypeSlug from 'rehype-slug'
import codeHikeTheme from 'config/code-hike.theme.json' assert { type: 'json' }
import { Tabs } from 'ui'
import components from '~/components'
import { Heading } from '~/components/CustomHTMLElements'
import { MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
import Layout from '~/layouts/DefaultGuideLayout'
import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform'
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
// We fetch these docs at build time from an external repo
export const org = 'supabase'
export const repo = 'splinter'
export const branch = 'main'
export const docsDir = 'docs'
const meta = {
title: 'Database Linter',
subtitle: 'Check your database for performance and security issues',
}
const editLink = 'https://github.com/supabase/splinter/tree/main/docs'
const markdownIntro = `
You can use the Database Linter to check your database for issues such as missing indexes and improperly set-up RLS policies.
## Using the linter
In the dashboard, navigate to [Database Linter](/dashboard/project/_/database/linter) under Database. The linter runs automatically. You can also manually rerun it after you've resolved issues.
`.trim()
const getBasename = (path: string) => path.split('/').at(-1).replace(/\.md$/, '')
export default function ProjectLinterDocs({
intro,
lints,
}: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<Layout menuId={MenuId.Database} meta={meta} editLink={editLink}>
<MDXRemote {...intro} components={components} />
<Heading tag="h2">Available lints</Heading>
<Tabs listClassNames="flex flex-wrap gap-2 [&>button]:!m-0" queryGroup="lint">
{lints.map((lint) => (
<Tabs.Panel
key={lint.path}
id={lint.path}
label={capitalize(getBasename(lint.path).replace(/_/g, ' '))}
>
<section id={getBasename(lint.path)}>
<MDXRemote {...lint.content} components={components} />
</section>
</Tabs.Panel>
))}
</Tabs>
</Layout>
)
}
/**
* The GitHub repo uses relative links, which don't lead to the right locations
* in docs.
*
* @param url The original link, as written in the Markdown file
* @returns The rewritten link
*/
const urlTransform: (lints: Array<{ path: string }>) => UrlTransformFunction = (lints) => (url) => {
try {
const placeholderHostname = 'placeholder'
const { hostname, pathname, hash } = new URL(url, `http://${placeholderHostname}`)
// Don't modify a url with a FQDN or a url that's only a hash
if (hostname !== placeholderHostname || pathname === '/') {
return url
}
const relativePath = getBasename(pathname)
const section = lints.find(({ path }) => path === relativePath)
if (section) {
const url = new URL(window.location.href)
url.searchParams.set('lint', relativePath)
return url.toString()
}
// If we don't have this page in our docs, link to GitHub repo
return `https://github.com/${org}/${repo}/blob/${branch}${pathname}${hash}`
} catch (err) {
console.error('Error transforming markdown URL', err)
return url
}
}
const transformMarkdown = async (
rawContent: string,
{ replacementLinks = [] }: { replacementLinks?: Array<{ path: string }> } = {}
) => {
const codeHikeOptions: CodeHikeConfig = {
theme: codeHikeTheme,
lineNumbers: true,
showCopyButton: true,
skipLanguages: [],
autoImport: false,
}
const content = await serialize(rawContent, {
scope: {
chCodeConfig: codeHikeOptions,
},
mdxOptions: {
remarkPlugins: [
remarkGfm,
remarkMkDocsAdmonition,
remarkPyMdownTabs,
[removeTitle, meta.title],
[remarkCodeHike, codeHikeOptions],
],
rehypePlugins: [[linkTransform, urlTransform(replacementLinks)], rehypeSlug],
},
})
return content
}
/**
* Fetch markdown from external repo and transform links
*/
export const getStaticProps = (async () => {
const octokit = new Octokit()
const response = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', {
owner: org,
repo: repo,
path: docsDir,
ref: branch,
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
})
if (response.status >= 400) {
throw Error(`Could not get contents of repo ${org}/${repo}`)
}
if (!Array.isArray(response.data)) {
throw Error(
`Reading a directory, not a file. Should not reach this, solely to appease Typescript.`
)
}
const [intro, ...lints] = await Promise.all([
await transformMarkdown(markdownIntro),
...response.data
.filter(({ path }) => /docs\/\d+.+\.md$/.test(path))
.map(async ({ path }, _, data) => {
const fileResponse = await fetch(
`https://raw.githubusercontent.com/${org}/${repo}/${branch}/${path}`
)
if (fileResponse.status >= 400) {
throw Error(`Could not get contents of file ${org}/${repo}/${path}`)
}
const rawContent = await fileResponse.text()
const content = await transformMarkdown(rawContent, { replacementLinks: data })
return {
path: getBasename(path),
content,
}
}),
])
return {
props: {
intro,
lints,
},
}
}) satisfies GetStaticProps
@@ -35,11 +35,11 @@ const pageMap = [
remoteFile: 'airtable.md',
},
{
slug: 's3',
slug: 'auth0',
meta: {
title: 'AWS S3',
title: 'Auth0',
},
remoteFile: 's3.md',
remoteFile: 'auth0.md',
},
{
slug: 'bigquery',
@@ -55,6 +55,13 @@ const pageMap = [
},
remoteFile: 'clickhouse.md',
},
{
slug: 'cognito',
meta: {
title: 'AWS Cognito',
},
remoteFile: 'cognito.md',
},
{
slug: 'firebase',
meta: {
@@ -69,6 +76,27 @@ const pageMap = [
},
remoteFile: 'logflare.md',
},
{
slug: 'mssql',
meta: {
title: 'MSSQL',
},
remoteFile: 'mssql.md',
},
{
slug: 'redis',
meta: {
title: 'Redis',
},
remoteFile: 'redis.md',
},
{
slug: 's3',
meta: {
title: 'AWS S3',
},
remoteFile: 's3.md',
},
{
slug: 'stripe',
meta: {
@@ -1,114 +0,0 @@
import fs from 'fs'
import matter from 'gray-matter'
import components from '~/components/index'
import { MDXRemote } from 'next-mdx-remote'
import { serialize } from 'next-mdx-remote/serialize'
import specFile from '~/spec/realtime_v0_config.yaml' assert { type: 'yml' }
import { Parameter } from '~/lib/refGenerator/refTypes'
import ReactMarkdown from 'react-markdown'
// Parameters are grouped on the page by tag
const TAGS = ['general', 'database']
export default function Config(props) {
return (
<div>
<div className="flex my-16">
<div className="w-full prose">
<h1 className="text-4xl mb-16">{specFile.info.title} Configuration</h1>
<ReactMarkdown>{specFile.info.description}</ReactMarkdown>
<div>
<div>
{props.docs
.filter((doc) => doc.introPage)
.map((item) => (
<MDXRemote {...item.content} components={components} />
))}
</div>
{TAGS.map((tag) =>
specFile.parameters
.filter((param: Parameter) => param.tags[0] === tag)
.map((parameter: Parameter, index) => (
<div>
{index === 0 && <h2 className="text-xl capitalize">{tag} Settings</h2>}
<div className="mt-8">
<div>
<h2 className="text-xl font-medium text-foreground font-mono">
<span className="mr-2">$</span>
{parameter.title}
</h2>
<div className="grid" id={parameter.id}>
<div className="border-b pb-8" key={parameter.id}>
<div className=" mb-16">
<p className="mb-4 scroll-mt-16 mt-0 text-foreground-light text-base">
<ReactMarkdown>{parameter.description}</ReactMarkdown>
</p>
<div className="grid gap-2">
<div className="flex gap-2">
Required: <code>{parameter.required.toString()}</code>
</div>
<div className="flex gap-2">
Default:
<code>
{parameter.default ? parameter.default.toString() : 'None'}
</code>
</div>
</div>
</div>
{parameter.links &&
parameter.links.map((link) => (
<div>
<h3>See also:</h3>
<li>
<a href={link.link}>{link.name}</a>
</li>
</div>
))}
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
)
}
export async function getServerSideProps() {
// an array of ids of the intro sections for this library
const introPages = ['realtime']
const pages = [...introPages]
// Grab custom markdown intro page files
const allMarkdownDocs = await Promise.all(
pages.map(async (x: any, i) => {
const pathName = `docs/ref/realtime/${x}.mdx`
const markdownExists = fs.existsSync(pathName)
const fileContents = markdownExists ? fs.readFileSync(pathName, 'utf8') : ''
const { data, content } = matter(fileContents)
return {
id: x,
title: x,
// ...content,
meta: data,
introPage: introPages.includes(x),
content: content ? await serialize(content || '') : null,
}
})
)
return {
props: {
docs: allMarkdownDocs,
},
}
}
@@ -1,167 +0,0 @@
import { CodeBlock, Tabs } from 'ui'
import specFile from '~/spec/transforms/storage_v0_openapi_deparsed.json' assert { type: 'json' }
import { gen_v3 } from '~/lib/refGenerator/helpers'
import RefSubLayout from '~/layouts/ref/RefSubLayout'
import React from 'react'
export type AcceptedValue = {
id: string
name: string
type: 'string' | 'boolean' | 'object'
description?: string
}
export type Flag = {
id: string
name: string
description: string
default_value: string
accepted_values: AcceptedValue[]
}
export type ApiParameter = {
example: string
in: string
name: string
required: boolean
schema: {
type: string
example: string
}
}
// @ts-ignore
const generatedSpec = gen_v3(specFile, 'wat', { apiUrl: 'apiv0' })
export default function Config() {
return (
<RefSubLayout>
<div className="flex my-16">
<div className="w-full">
<div className="grid gap-16">
<h1 className="text-4xl">{generatedSpec.info.title}</h1>
<p>{generatedSpec.info.description}</p>
</div>
<div className="grid gap-32 mx-auto max-w-5xl mt-24">
{generatedSpec.sections.map((section, i) => (
<React.Fragment key={i}>
<h2 className="text-3xl">{section.title}</h2>
{section.operations.map((operation: any, index) => (
<div key={index} className="border-b pb-8">
<RefSubLayout.Section
slug={operation.id}
title={operation.summary}
id={operation.operationId}
monoFont={false}
>
<RefSubLayout.Details>
<div className="mt-4">
<code className="text-md flex gap-4 text-md text-foreground-lighter break-all">
<span className="uppercase">{operation.operation}</span>
{operation.fullPath}
</code>
</div>
{/* Path Parameters */}
{operation.parameters &&
operation.parameters.filter((parameter) => parameter.in === 'path')
.length > 0 && (
<div className="mt-12">
<h2 className="border-b pb-2 text-xl">Path parameters</h2>
<ul className="mt-4">
{operation.parameters &&
operation.parameters
.filter((parameter: any) => parameter.in === 'path')
.map((parameter: any, indx: number) => (
<li key={indx} className="mt-8 border-b pb-6">
<div className="flex gap-4 items-center">
<span className="font-bold">{parameter.name}</span>
<span className="font-mono text-xs break-all">
{parameter.required && (
<div className="text-[10px] border border-amber-700 bg-amber-300 text-amber-900 px-2 tracking-wide font-mono py-0.25 rounded-full">
REQUIRED
</div>
)}
</span>
</div>
<div className="mt-4 flex gap-4 items-center">
<span>Example:</span>
<span className="font-mono text-xs break-all">
{parameter.example}
</span>
</div>
</li>
))}
</ul>
</div>
)}
{/* Header Parameters */}
{operation.parameters &&
operation.parameters.filter((parameter) => parameter.in === 'header')
.length > 0 && (
<div className="mt-12">
<h2 className="border-b pb-2 text-xl">Header parameters</h2>
<ul className="mt-4">
{operation.parameters &&
operation.parameters
.filter((parameter: any) => parameter.in === 'header')
.map((parameter: any, i: number) => (
<li key={i} className="mt-8 border-b pb-6">
<div className="flex gap-4 items-center">
<span className="font-bold">{parameter.name}</span>
<span className="font-mono text-xs break-all">
{parameter.required && 'required'}
</span>
</div>
<div className="mt-4 flex gap-4 items-center">
<span>Example:</span>
<span className="font-mono text-xs break-all">
{parameter.example}
</span>
</div>
</li>
))}
</ul>
</div>
)}
</RefSubLayout.Details>
<RefSubLayout.Examples>
<h2 className="text-xl">Responses</h2>
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId={operation.responseList[0].responseCode}
queryGroup="response-status"
>
{operation.responseList.map((response: any, i: number) => (
<Tabs.Panel
key={i}
id={response.responseCode}
label={response.responseCode}
>
<p>{response.description}</p>
{response?.content && response?.content['application/json'] && (
<div className="mt-8">
<CodeBlock language="bash" className="relative">
{JSON.stringify(response.content['application/json'], null, 2)}
</CodeBlock>
</div>
)}
</Tabs.Panel>
))}
</Tabs>
</RefSubLayout.Examples>
</RefSubLayout.Section>
</div>
))}
</React.Fragment>
))}
</div>
</div>
</div>
</RefSubLayout>
)
}
@@ -1,68 +0,0 @@
import specFile from '~/spec/storage_v0_config.yaml' assert { type: 'yml' }
import { Parameter } from '~/lib/refGenerator/refTypes'
import ReactMarkdown from 'react-markdown'
// Parameters are grouped on the page by tag
const TAGS = ['general', 'multitenant']
// console.log(specFile)
export default function Config() {
return (
<div>
<div className="flex my-16">
<div className="w-full prose">
<h1 className="text-4xl mb-16">{specFile.info.title} Configuration</h1>
<ReactMarkdown>{specFile.info.description}</ReactMarkdown>
<div>
{TAGS.map((tag) =>
specFile.parameters
.filter((param: Parameter) => param.tags[0] === tag)
.map((parameter: Parameter, index) => (
<div>
{index === 0 && <h2 className="text-xl capitalize">{tag} Settings</h2>}
<div className="mt-8">
<div>
<h2 className="text-xl font-medium text-foreground font-mono">
<span className="mr-2">$</span>
{parameter.title}
</h2>
<div className="grid" id={parameter.id}>
<div className="border-b pb-8" key={parameter.id}>
<div className=" mb-16">
<p className="mb-4 scroll-mt-16 mt-0 text-foreground-light text-base">
<ReactMarkdown>{parameter.description}</ReactMarkdown>
</p>
<div className="grid gap-2">
<div className="flex gap-2">
Required: <code>{parameter.required.toString()}</code>
</div>
<div className="flex gap-2">
Default:
<code>
{parameter.default ? parameter.default.toString() : 'None'}
</code>
</div>
</div>
</div>
{parameter.links &&
parameter.links.map((link) => (
<div>
<h3>See also:</h3>
<li>
<a href={link.link}>{link.name}</a>
</li>
</div>
))}
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
)
}
+1 -1
View File
@@ -1,2 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
<browserconfig><msapplication><tile><square70x70logo src="/favicon/ms-icon-70x70.png"/><square150x150logo src="/favicon/ms-icon-150x150.png"/><square310x310logo src="/favicon/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+6 -6
View File
@@ -7,37 +7,37 @@
"background_color": "#1C1C1C",
"icons": [
{
"src": "/android-icon-36x36.png",
"src": "/favicon/android-icon-36x36.png",
"sizes": "36x36",
"type": "image/png",
"density": "0.75"
},
{
"src": "/android-icon-48x48.png",
"src": "/favicon/android-icon-48x48.png",
"sizes": "48x48",
"type": "image/png",
"density": "1.0"
},
{
"src": "/android-icon-72x72.png",
"src": "/favicon/android-icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"density": "1.5"
},
{
"src": "/android-icon-96x96.png",
"src": "/favicon/android-icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"density": "2.0"
},
{
"src": "/android-icon-144x144.png",
"src": "/favicon/android-icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"density": "3.0"
},
{
"src": "/android-icon-192x192.png",
"src": "/favicon/android-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"density": "4.0"
+1 -1
View File
@@ -1346,7 +1346,7 @@ functions:
notes: |
- Sends an invite link to the user's email address.
- The `inviteUserByEmail()` method is typically used by administrators to invite users to join the application.
- Note that PKCE is not supported when using `inviteUserByEmail`. This is because the browser initiating the invite is often different from the browser acecpting the invite which makes it difficult to provide the security guarantees required of the PKCE flow.
- Note that PKCE is not supported when using `inviteUserByEmail`. This is because the browser initiating the invite is often different from the browser accepting the invite which makes it difficult to provide the security guarantees required of the PKCE flow.
examples:
- id: invite-a-user
name: Invite a user
+31
View File
@@ -2903,6 +2903,37 @@ functions:
password = "example-password"
}
```
- id: sign-in-anonymously
title: 'signInAnonymously()'
notes: |
- Creates an anonymous user.
- The user can be retrieved by calling `supabase.auth.currentUserOrNull()`.
- It is recommended to set up captcha for anonymous sign-ins to prevent abuse. You can pass in the captcha token in the `options` param.
params:
- name: captchaToken
isOptional: true
type: String?
description: The captcha token when having captcha enabled.
- name: data
isOptional: true
type: JsonObject? or T
description: Extra user data to pass in.
examples:
- id: sign-in-anonymously
name: Create an anonymous user
isSpotlight: true
code: |
```kotlin
supabase.auth.signInAnonymously(captchaToken = "token")
```
- id: sign-in-anonymously-with-user-metadata
name: Create an anonymous user with custom user metadata
isSpotlight: false
code: |
```kotlin
supabase.auth.signInAnonymously(data = customUserData)
```
- id: sign-in-with-password
title: 'signInWith()'
$ref: '@supabase/gotrue-js.GoTrueClient.signInWithPassword'
+2 -1
View File
@@ -40,7 +40,8 @@ functions:
code: |
```
import os
from supabase import create_client, Client, ClientOptions
from supabase import create_client, Client
from supabase.client import ClientOptions
url: str = os.environ.get("SUPABASE_URL")
key: str = os.environ.get("SUPABASE_KEY")
+25 -2
View File
@@ -129,6 +129,29 @@ functions:
redirectTo: URL(string: "https://example.com/welcome")!
)
```
- id: sign-in-anonymously
title: "signInAnonymously()"
notes: |
- Returns an anonymous user
- It is recommended to set up captcha for anonymous sign-ins to prevent abuse. You can pass in the captcha token in the `options` param.
examples:
- id: sign-in-anonymously
name: Create an anonymous user
isSpotlight: true
code: |
```swift
let session = try await supabase.auth.signInAnonymously(captchaToken: captchaToken)
```
- id: sign-in-anonymously-with-user-metadata
name: Create an anonymous user with custom user metadata
isSpotlight: false
code: |
```swift
let session = try await supabase.auth.signInAnonymously(
data: customData,
captchaToken: captchaToken
)
```
- id: sign-in-with-password
title: "signInWithPassword()"
notes: |
@@ -376,7 +399,7 @@ functions:
)
```
- id: get-user-identities
title: 'userIdentities()'
title: "userIdentities()"
notes: |
- The user needs to be signed in to call `userIdentities()`.
examples:
@@ -388,7 +411,7 @@ functions:
let identities = try await supabase.auth.userIdentities()
```
- id: send-password-reauthentication
title: 'reauthenticate()'
title: "reauthenticate()"
notes: |
- This method is used together with `update(user:)` when a user's password needs to be updated.
- If you require your user to reauthenticate before updating their password, you need to enable the **Secure password change** option in your [project's email provider settings](/dashboard/project/_/auth/providers).
+286 -61
View File
@@ -41,6 +41,14 @@ export interface paths {
/** Send exit survey to HubSpot */
post: operations['SendExitSurveyController_sendExitSurvey']
}
'/platform/feedback/upgrade': {
/** Send upgrade survey to survey_responses table */
post: operations['SendUpgradeSurveyController_sendUpgradeSurvey']
}
'/platform/feedback/docs': {
/** Send feedback on docs */
post: operations['SendDocsFeedbackController_sendDocsFeedback']
}
'/platform/signup': {
/** Sign up with email and password */
post: operations['SignUpController_signUp']
@@ -100,6 +108,10 @@ export interface paths {
/** Gets project backups */
get: operations['BackupsController_getBackups']
}
'/platform/database/{ref}/backups/enable-physical-backups': {
/** Enable usage of physical backups */
post: operations['BackupsController_enablePhysicalBackup']
}
'/platform/database/{ref}/backups/download': {
/** Download project backup */
post: operations['BackupsController_downloadBackup']
@@ -822,14 +834,6 @@ export interface paths {
/** Get GitHub connection branch */
get: operations['GitHubBranchesController_getConnectionBranch']
}
'/platform/integrations/github/pull-requests/{connectionId}': {
/** List GitHub connection pull requests */
get: operations['GitHubPullRequestsController_getConnectionPullRequests']
}
'/platform/integrations/github/pull-requests/{connectionId}/{branchName}': {
/** List GitHub pull requests for a specific branch */
get: operations['GitHubPullRequestsController_validateConnectionBranch']
}
'/platform/integrations/github/repositories': {
/** Gets GitHub repositories for user */
get: operations['GitHubRepositoriesController_listRepositories']
@@ -1020,6 +1024,10 @@ export interface paths {
/** Gets project backups */
get: operations['BackupsController_getBackups']
}
'/v0/database/{ref}/backups/enable-physical-backups': {
/** Enable usage of physical backups */
post: operations['BackupsController_enablePhysicalBackup']
}
'/v0/database/{ref}/backups/download': {
/** Download project backup */
post: operations['BackupsController_downloadBackup']
@@ -1468,6 +1476,13 @@ export interface paths {
*/
patch: operations['BranchController_updateBranch']
}
'/v1/branches/{branch_id}/reset': {
/**
* Resets a database branch
* @description Resets the specified database branch
*/
post: operations['BranchController_resetBranch']
}
'/v1/projects': {
/**
* List all projects
@@ -1640,6 +1655,18 @@ export interface paths {
/** Updates a project's auth config */
patch: operations['V1AuthConfigController_updateV1AuthConfig']
}
'/v1/projects/{ref}/config/auth/third-party-auth': {
/** Lists all third-party auth integrations */
get: operations['ThirdPartyAuthController_listTPAForProject']
/** Creates a new third-party auth integration */
post: operations['ThirdPartyAuthController_createTPAForProject']
}
'/v1/projects/{ref}/config/auth/third-party-auth/{tpa_id}': {
/** Get a third-party integration */
get: operations['ThirdPartyAuthController_getTPAForProject']
/** Removes a third-party auth integration */
delete: operations['ThirdPartyAuthController_deleteTPAForProject']
}
'/v1/projects/{ref}/config/auth/sso/providers': {
/** Lists all SSO providers */
get: operations['ProvidersController_listAllProviders']
@@ -1860,6 +1887,19 @@ export interface components {
additionalFeedback?: string
exitAction?: string
}
SendUpgradeSurveyBody: {
orgSlug?: string
prevPlan?: string
currentPlan?: string
reasons: string[]
additionalFeedback?: string
}
SendDocsFeedbackBody: {
page: string
isHelpful: boolean
title: string
feedback: string
}
SignUpBody: {
email: string
password: string
@@ -1899,11 +1939,13 @@ export interface components {
MAILER_SUBJECTS_RECOVERY: string
MAILER_SUBJECTS_EMAIL_CHANGE: string
MAILER_SUBJECTS_MAGIC_LINK: string
MAILER_SUBJECTS_REAUTHENTICATION: string
MAILER_TEMPLATES_INVITE_CONTENT: string
MAILER_TEMPLATES_CONFIRMATION_CONTENT: string
MAILER_TEMPLATES_RECOVERY_CONTENT: string
MAILER_TEMPLATES_EMAIL_CHANGE_CONTENT: string
MAILER_TEMPLATES_MAGIC_LINK_CONTENT: string
MAILER_TEMPLATES_REAUTHENTICATION_CONTENT: string
MFA_MAX_ENROLLED_FACTORS: number
URI_ALLOW_LIST: string
EXTERNAL_ANONYMOUS_USERS_ENABLED: boolean
@@ -1959,6 +2001,8 @@ export interface components {
HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: string
HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: boolean
HOOK_CUSTOM_ACCESS_TOKEN_URI: string
HOOK_SEND_SMS_ENABLED: boolean
HOOK_SEND_SMS_URI: string
EXTERNAL_APPLE_ENABLED: boolean
EXTERNAL_APPLE_CLIENT_ID: string
EXTERNAL_APPLE_SECRET: string
@@ -2042,7 +2086,9 @@ export interface components {
MAILER_SUBJECTS_RECOVERY?: string
MAILER_SUBJECTS_EMAIL_CHANGE?: string
MAILER_SUBJECTS_MAGIC_LINK?: string
MAILER_SUBJECTS_REAUTHENTICATION?: string
MAILER_TEMPLATES_INVITE_CONTENT?: string
MAILER_TEMPLATES_REAUTHENTICATION_CONTENT?: string
MAILER_TEMPLATES_CONFIRMATION_CONTENT?: string
MAILER_TEMPLATES_RECOVERY_CONTENT?: string
MAILER_TEMPLATES_EMAIL_CHANGE_CONTENT?: string
@@ -2107,6 +2153,8 @@ export interface components {
HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI?: string
HOOK_CUSTOM_ACCESS_TOKEN_ENABLED?: boolean
HOOK_CUSTOM_ACCESS_TOKEN_URI?: string
HOOK_SEND_SMS_ENABLED?: boolean
HOOK_SEND_SMS_URI?: string
EXTERNAL_APPLE_ENABLED?: boolean
EXTERNAL_APPLE_CLIENT_ID?: string
EXTERNAL_APPLE_SECRET?: string
@@ -2207,6 +2255,7 @@ export interface components {
updated_at?: string
is_sso_user?: boolean
deleted_at?: string
is_anonymous?: boolean
}
UsersResponse: {
total: number
@@ -2263,14 +2312,17 @@ export interface components {
migrated_at: string | null
}
OrganizationResponse: {
/** @enum {string|null} */
restriction_status: 'grace_period' | 'grace_period_over' | 'restricted' | null
id: number
slug: string
name: string
billing_email: string
billing_email: string | null
is_owner: boolean
stripe_customer_id: string
subscription_id?: string
stripe_customer_id: string | null
subscription_id: string | null
opt_in_tags: string[]
restriction_data: Record<string, never>
}
GetOrganizationByFlyOrganizationIdResponse: {
slug: string
@@ -2788,7 +2840,7 @@ export interface components {
payment_method_card_details?: components['schemas']['PaymentMethodCardDetails']
billing_via_partner: boolean
/** @enum {string} */
billing_partner: 'fly'
billing_partner: 'fly' | 'aws'
scheduled_plan_change: components['schemas']['ScheduledPlanChange'] | null
customer_balance: number
nano_enabled: boolean
@@ -4565,6 +4617,7 @@ export interface components {
branch_name?: string
git_branch?: string
reset_on_push?: boolean
persistent?: boolean
}
BranchResponse: {
id: string
@@ -4575,11 +4628,18 @@ export interface components {
git_branch?: string
pr_number?: number
reset_on_push: boolean
persistent: boolean
/** @enum {string} */
status: 'CREATING_PROJECT' | 'RUNNING_MIGRATIONS' | 'MIGRATIONS_PASSED' | 'MIGRATIONS_FAILED'
created_at: string
updated_at: string
}
BranchDeleteResponse: {
message: string
}
BranchResetResponse: {
message: string
}
V1DatabaseResponse: {
/** @description Database host */
host: string
@@ -4604,6 +4664,19 @@ export interface components {
*/
created_at: string
database?: components['schemas']['V1DatabaseResponse']
/** @enum {string} */
status:
| 'ACTIVE_HEALTHY'
| 'ACTIVE_UNHEALTHY'
| 'COMING_UP'
| 'GOING_DOWN'
| 'INACTIVE'
| 'INIT_FAILED'
| 'REMOVED'
| 'RESTORING'
| 'UNKNOWN'
| 'UPGRADING'
| 'PAUSING'
}
V1CreateProjectBody: {
/** @description Database password */
@@ -4925,11 +4998,13 @@ export interface components {
mailer_subjects_invite: string | null
mailer_subjects_magic_link: string | null
mailer_subjects_recovery: string | null
mailer_subjects_reauthentication: string | null
mailer_templates_confirmation_content: string | null
mailer_templates_email_change_content: string | null
mailer_templates_invite_content: string | null
mailer_templates_magic_link_content: string | null
mailer_templates_recovery_content: string | null
mailer_templates_reauthentication_content: string | null
mfa_max_enrolled_factors: number | null
password_hibp_enabled: boolean | null
password_min_length: number | null
@@ -5001,11 +5076,13 @@ export interface components {
mailer_subjects_recovery?: string
mailer_subjects_email_change?: string
mailer_subjects_magic_link?: string
mailer_subjects_reauthentication?: string
mailer_templates_invite_content?: string
mailer_templates_confirmation_content?: string
mailer_templates_recovery_content?: string
mailer_templates_email_change_content?: string
mailer_templates_magic_link_content?: string
mailer_templates_reauthentication_content?: string
mfa_max_enrolled_factors?: number
uri_allow_list?: string
external_anonymous_users_enabled?: boolean
@@ -5066,6 +5143,8 @@ export interface components {
hook_password_verification_attempt_uri?: string
hook_custom_access_token_enabled?: boolean
hook_custom_access_token_uri?: string
hook_custom_sms_provider_enabled?: boolean
hook_custom_sms_provider_uri?: string
external_apple_enabled?: boolean
external_apple_client_id?: string
external_apple_secret?: string
@@ -5131,6 +5210,22 @@ export interface components {
external_zoom_client_id?: string
external_zoom_secret?: string
}
CreateThirdPartyAuthBody: {
oidc_issuer_url?: string
jwks_url?: string
custom_jwks?: Record<string, never>
}
ThirdPartyAuth: {
id: string
type: string
oidc_issuer_url?: string | null
jwks_url?: string | null
custom_jwks?: unknown
resolved_jwks?: unknown
inserted_at: string
updated_at: string
resolved_at?: string | null
}
AttributeValue: {
default?: Record<string, never> | number | string | boolean
name?: string
@@ -5650,6 +5745,44 @@ export interface operations {
}
}
}
/** Send upgrade survey to survey_responses table */
SendUpgradeSurveyController_sendUpgradeSurvey: {
requestBody: {
content: {
'application/json': components['schemas']['SendUpgradeSurveyBody']
}
}
responses: {
201: {
content: {
'application/json': components['schemas']['SendFeedbackResponse']
}
}
/** @description Failed to send upgrade survey */
500: {
content: never
}
}
}
/** Send feedback on docs */
SendDocsFeedbackController_sendDocsFeedback: {
requestBody: {
content: {
'application/json': components['schemas']['SendDocsFeedbackBody']
}
}
responses: {
201: {
content: {
'application/json': components['schemas']['SendFeedbackResponse']
}
}
/** @description Failed to send feedback for docs */
500: {
content: never
}
}
}
/** Sign up with email and password */
SignUpController_signUp: {
requestBody: {
@@ -5870,7 +6003,13 @@ export interface operations {
parameters: {
path: {
ref: string
template: 'confirmation' | 'email-change' | 'invite' | 'magic-link' | 'recovery'
template:
| 'confirmation'
| 'email-change'
| 'invite'
| 'magic-link'
| 'recovery'
| 'reauthentication'
}
}
responses: {
@@ -5986,6 +6125,24 @@ export interface operations {
}
}
}
/** Enable usage of physical backups */
BackupsController_enablePhysicalBackup: {
parameters: {
path: {
/** @description Project ref */
ref: string
}
}
responses: {
201: {
content: never
}
/** @description Failed to enable usage of physical backups */
500: {
content: never
}
}
}
/** Download project backup */
BackupsController_downloadBackup: {
parameters: {
@@ -11079,52 +11236,6 @@ export interface operations {
}
}
}
/** List GitHub connection pull requests */
GitHubPullRequestsController_getConnectionPullRequests: {
parameters: {
query: {
pr_number: number[]
}
path: {
connectionId: number
}
}
responses: {
200: {
content: {
'application/json': Record<string, never>[]
}
}
/** @description Failed to list GitHub connection pull requests */
500: {
content: never
}
}
}
/** List GitHub pull requests for a specific branch */
GitHubPullRequestsController_validateConnectionBranch: {
parameters: {
query?: {
per_page?: number
page?: number
}
path: {
connectionId: number
branchName: string
}
}
responses: {
200: {
content: {
'application/json': Record<string, never>[]
}
}
/** @description Failed to validate GitHub connection branch */
500: {
content: never
}
}
}
/** Gets GitHub repositories for user */
GitHubRepositoriesController_listRepositories: {
responses: {
@@ -11176,7 +11287,13 @@ export interface operations {
parameters: {
path: {
ref: string
template: 'confirmation' | 'email-change' | 'invite' | 'magic-link' | 'recovery'
template:
| 'confirmation'
| 'email-change'
| 'invite'
| 'magic-link'
| 'recovery'
| 'reauthentication'
}
}
responses: {
@@ -11845,7 +11962,9 @@ export interface operations {
}
responses: {
200: {
content: never
content: {
'application/json': components['schemas']['BranchDeleteResponse']
}
}
/** @description Failed to delete database branch */
500: {
@@ -11881,6 +12000,29 @@ export interface operations {
}
}
}
/**
* Resets a database branch
* @description Resets the specified database branch
*/
BranchController_resetBranch: {
parameters: {
path: {
/** @description Branch ID */
branch_id: string
}
}
responses: {
201: {
content: {
'application/json': components['schemas']['BranchResetResponse']
}
}
/** @description Failed to reset database branch */
500: {
content: never
}
}
}
/**
* List all projects
* @description Returns a list of all projects you've previously created.
@@ -12833,6 +12975,89 @@ export interface operations {
}
}
}
/** Lists all third-party auth integrations */
ThirdPartyAuthController_listTPAForProject: {
parameters: {
path: {
/** @description Project ref */
ref: string
}
}
responses: {
200: {
content: {
'application/json': components['schemas']['ThirdPartyAuth'][]
}
}
403: {
content: never
}
}
}
/** Creates a new third-party auth integration */
ThirdPartyAuthController_createTPAForProject: {
parameters: {
path: {
/** @description Project ref */
ref: string
}
}
requestBody: {
content: {
'application/json': components['schemas']['CreateThirdPartyAuthBody']
}
}
responses: {
201: {
content: {
'application/json': components['schemas']['ThirdPartyAuth']
}
}
403: {
content: never
}
}
}
/** Get a third-party integration */
ThirdPartyAuthController_getTPAForProject: {
parameters: {
path: {
/** @description Project ref */
ref: string
tpa_id: string
}
}
responses: {
200: {
content: {
'application/json': components['schemas']['ThirdPartyAuth']
}
}
403: {
content: never
}
}
}
/** Removes a third-party auth integration */
ThirdPartyAuthController_deleteTPAForProject: {
parameters: {
path: {
/** @description Project ref */
ref: string
tpa_id: string
}
}
responses: {
200: {
content: {
'application/json': components['schemas']['ThirdPartyAuth']
}
}
403: {
content: never
}
}
}
/** Lists all SSO providers */
ProvidersController_listAllProviders: {
parameters: {
+1 -1
View File
@@ -27,7 +27,7 @@ WORKDIR /app
FROM base as turbo
COPY . .
RUN npx turbo@1.30.0 prune --scope=studio --docker
RUN npx turbo@1.13.2 prune --scope=studio --docker
# Install dev dependencies (only if needed)
FROM base as deps
@@ -38,7 +38,7 @@ const Footer = ({ isLoading, isRefetching }: FooterProps) => {
width={75}
options={['definition', 'data']}
activeOption={selectedView}
borderOverride="border-gray-500"
borderOverride="border"
onClickOption={setSelectedView}
/>
</div>
@@ -190,34 +190,30 @@ const Pagination = ({ isLoading: isLoadingRows = false }: PaginationProps) => {
<ConfirmationModal
visible={isConfirmPreviousModalOpen}
header="Confirm moving to previous page"
buttonLabel="Confirm"
onSelectCancel={() => setIsConfirmPreviousModalOpen(false)}
onSelectConfirm={() => {
title="Confirm moving to previous page"
confirmLabel="Confirm"
onCancel={() => setIsConfirmPreviousModalOpen(false)}
onConfirm={() => {
onConfirmPreviousPage()
}}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
The currently selected lines will be deselected, do you want to proceed?
</p>
</Modal.Content>
<p className="py-4 text-sm text-foreground-light">
The currently selected lines will be deselected, do you want to proceed?
</p>
</ConfirmationModal>
<ConfirmationModal
visible={isConfirmNextModalOpen}
header="Confirm moving to next page"
buttonLabel="Confirm"
onSelectCancel={() => setIsConfirmNextModalOpen(false)}
onSelectConfirm={() => {
title="Confirm moving to next page"
confirmLabel="Confirm"
onCancel={() => setIsConfirmNextModalOpen(false)}
onConfirm={() => {
onConfirmNextPage()
}}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
The currently selected lines will be deselected, do you want to proceed?
</p>
</Modal.Content>
<p className="py-4 text-sm text-foreground-light">
The currently selected lines will be deselected, do you want to proceed?
</p>
</ConfirmationModal>
</>
)}
@@ -377,7 +377,7 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
</Button>
)}
</div>
<div className="h-[20px] border-r border-gray-700" />
<div className="h-[20px] border-r border-strong" />
<div className="flex items-center gap-2">
<Button
type="primary"
@@ -12,6 +12,7 @@ import {
IconLock,
IconTrash,
IconUnlock,
Separator,
} from 'ui'
import { useDispatch, useTrackedState } from '../../store'
@@ -92,7 +93,7 @@ const ColumnMenu = ({ column, isEncrypted }: ColumnMenuProps) => {
</DropdownMenuItem>
{state.editable && onDeleteColumn !== undefined && (
<>
<Divider light />
<Separator />
<DropdownMenuItem className="space-x-2" onClick={onDeleteColumn}>
<IconTrash size="tiny" stroke="red" />
<p>Delete column</p>
@@ -77,20 +77,18 @@ const AccessTokenList = () => {
</div>
<ConfirmationModal
visible={isOpen}
danger={true}
header="Confirm to delete"
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={() => setIsOpen(false)}
onSelectConfirm={() => {
variant={'destructive'}
title="Confirm to delete"
confirmLabel="Delete"
confirmLabelLoading="Deleting"
onCancel={() => setIsOpen(false)}
onConfirm={() => {
if (token) onDeleteToken(token.id)
}}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
{`This action cannot be undone. Are you sure you want to delete "${token?.name}" token?`}
</p>
</Modal.Content>
<p className="py-4 text-sm text-foreground-light">
{`This action cannot be undone. Are you sure you want to delete "${token?.name}" token?`}
</p>
</ConfirmationModal>
</>
)
@@ -3,10 +3,11 @@ import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import { Alert, Button, IconArrowDown, IconArrowUp, IconRefreshCw } from 'ui'
import { FilterPopover, LogDetailsPanel } from 'components/interfaces/AuditLogs'
import { LogDetailsPanel } from 'components/interfaces/AuditLogs'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { DatePicker } from 'components/ui/DatePicker'
import { FilterPopover } from 'components/ui/FilterPopover'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import type { AuditLog } from 'data/organizations/organization-audit-logs-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
@@ -64,29 +64,27 @@ const FirstStep = ({ visible, name, enroll, setName, isEnrolling, onClose }: Fir
<ConfirmationModal
size="medium"
visible={visible}
header="Add a new authenticator app as a factor"
buttonLabel="Generate QR"
buttonLoadingLabel="Generating QR"
buttonDisabled={name.length === 0}
title="Add a new authenticator app as a factor"
confirmLabel="Generate QR"
confirmLabelLoading="Generating QR"
disabled={name.length === 0}
loading={isEnrolling}
onSelectCancel={onClose}
onSelectConfirm={() => {
onCancel={onClose}
onConfirm={() => {
enroll({
factorType: 'totp',
friendlyName: name,
})
}}
>
<Modal.Content>
<div className="pt-6 pb-5">
<Input
label="Provide a name to identify this app"
descriptionText="A string will be randomly generated if a name is not provided"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</Modal.Content>
<div className="pt-6 pb-5">
<Input
label="Provide a name to identify this app"
descriptionText="A string will be randomly generated if a name is not provided"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</ConfirmationModal>
)
}
@@ -152,65 +150,63 @@ const SecondStep = ({
<ConfirmationModal
size="medium"
visible={visible}
header={`Verify new factor ${factorName}`}
buttonLabel="Confirm"
buttonLoadingLabel="Confirming"
title={`Verify new factor ${factorName}`}
confirmLabel="Confirm"
confirmLabelLoading="Confirming"
loading={isVerifying}
onSelectCancel={() => {
onCancel={() => {
// If a factor has been created (but not verified), unenroll it. This will be run as a
// side effect so that it's not confusing to the user why the modal stays open while
// unenrolling.
if (factor) unenroll({ factorId: factor.id })
}}
onSelectConfirm={() => factor && challengeAndVerify({ factorId: factor.id, code })}
onConfirm={() => factor && challengeAndVerify({ factorId: factor.id, code })}
>
<Modal.Content>
<>
<div className="py-4 pb-0 text-sm">
<span>
Use an authenticator app to scan the following QR code, and provide the code from the
app to complete the enrolment.
</span>
<>
<div className="py-4 pb-0 text-sm">
<span>
Use an authenticator app to scan the following QR code, and provide the code from the
app to complete the enrolment.
</span>
</div>
{isLoading && (
<div className="pb-4 px-4">
<GenericSkeletonLoader />
</div>
{isLoading && (
<div className="pb-4 px-4">
<GenericSkeletonLoader />
)}
{factor && (
<>
<div className="flex justify-center py-6">
<div className="h-48 w-48 bg-white rounded">
<img width={190} height={190} src={factor.totp.qr_code} alt={factor.totp.uri} />
</div>
</div>
)}
{factor && (
<>
<div className="flex justify-center py-6">
<div className="h-48 w-48 bg-white rounded">
<img width={190} height={190} src={factor.totp.qr_code} alt={factor.totp.uri} />
</div>
</div>
<div>
<InformationBox
title="Unable to scan?"
description={
<Input
copy
disabled
id="ref"
size="small"
label="You can also enter this secret key into your authenticator app"
value={factor.totp.secret}
/>
}
/>
</div>
<div className="pt-2 pb-4">
<Input
label="Authentication code"
value={code}
placeholder="XXXXXX"
onChange={(e) => setCode(e.target.value)}
/>
</div>
</>
)}
</>
</Modal.Content>
<div>
<InformationBox
title="Unable to scan?"
description={
<Input
copy
disabled
id="ref"
size="small"
label="You can also enter this secret key into your authenticator app"
value={factor.totp.secret}
/>
}
/>
</div>
<div className="pt-2 pb-4">
<Input
label="Authentication code"
value={code}
placeholder="XXXXXX"
onChange={(e) => setCode(e.target.value)}
/>
</div>
</>
)}
</>
</ConfirmationModal>
)
}
@@ -34,45 +34,36 @@ const DeleteFactorModal = ({
<ConfirmationModal
size="medium"
visible={visible}
danger
header="Confirm to delete factor"
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
variant={'destructive'}
title="Confirm to delete factor"
confirmLabel="Delete"
confirmLabelLoading="Deleting"
loading={isLoading}
onSelectCancel={onClose}
onSelectConfirm={() => factorId && unenroll({ factorId })}
onCancel={onClose}
onConfirm={() => factorId && unenroll({ factorId })}
alert={{
title: lastFactorToBeDeleted
? 'Multi-factor authentication will be disabled'
: 'This action cannot be undone',
description: lastFactorToBeDeleted
? 'There are no other factors that are set up once you delete this factor, as such your account will no longer be guarded by multi-factor authentication'
: 'You will no longer be able to use this authenticator app for multi-factor authentication when signing in to the dashboard',
}}
>
<Modal.Content className="py-6">
<Alert_Shadcn_ variant="warning">
<IconAlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>
{lastFactorToBeDeleted
? 'Multi-factor authentication will be disabled'
: 'This action cannot be undone'}
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{lastFactorToBeDeleted
? 'There are no other factors that are set up once you delete this factor, as such your account will no longer be guarded by multi-factor authentication'
: 'You will no longer be able to use this authenticator app for multi-factor authentication when signing in to the dashboard'}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<div className="text-sm px-1 pt-4">
<p>Before deleting this factor, consider:</p>
<ul className="text-foreground-light py-1 list-disc mx-4 space-y-1">
{lastFactorToBeDeleted ? (
<>
<li>Adding another authenticator app as a factor prior to deleting</li>
<li>Ensure that your account does not need multi-factor authentication</li>
</>
) : (
<>
<li>Your backup authenticator app is still available to use</li>
<li>Adding another authenticator app thereafter as a backup</li>
</>
)}
</ul>
</div>
</Modal.Content>
<p className="text-sm">Before deleting this factor, consider:</p>
<ul className="text-sm text-foreground-light py-1 list-disc mx-4 space-y-1">
{lastFactorToBeDeleted ? (
<>
<li>Adding another authenticator app as a factor prior to deleting</li>
<li>Ensure that your account does not need multi-factor authentication</li>
</>
) : (
<>
<li>Your backup authenticator app is still available to use</li>
<li>Adding another authenticator app thereafter as a backup</li>
</>
)}
</ul>
</ConfirmationModal>
)
}
@@ -1,9 +1,10 @@
import { useMonaco } from '@monaco-editor/react'
import { useTheme } from 'next-themes'
import { PropsWithChildren, useEffect } from 'react'
import { PropsWithChildren, useMemo } from 'react'
import IncidentBanner from 'components/layouts/AppLayout/IncidentBanner'
import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner'
import { RestrictrionBanner } from 'components/layouts/AppLayout/RestrictionBanner'
import { getTheme } from 'components/ui/CodeEditor'
import { useFlag } from 'hooks'
@@ -14,7 +15,9 @@ const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
const ongoingIncident = useFlag('ongoingIncident')
const showNoticeBanner = useFlag('showNoticeBanner')
useEffect(() => {
// Define the supabase theme for Monaco before anything is rendered. Using useEffect would sometime load the theme
// after the editor was loaded, so it looked off. useMemo will always be run before rendering
useMemo(() => {
if (monaco && resolvedTheme) {
const mode: any = getTheme(resolvedTheme)
monaco.editor.defineTheme('supabase', mode)
@@ -26,6 +29,7 @@ const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
<div className="flex-none">
{ongoingIncident && <IncidentBanner />}
{showNoticeBanner && <NoticeBanner />}
<RestrictrionBanner />
</div>
{children}
</div>
@@ -1,2 +1 @@
export { default as FilterPopover } from './FilterPopover'
export { default as LogDetailsPanel } from './LogDetailsPanel'
@@ -24,6 +24,7 @@ const CONFIRMATION: FormSchema = {
- \`{{ .SiteURL }}\` : The URL of the site
- \`{{ .Email }}\` : The user's email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
- \`{{ .RedirectTo }}\` : The URL of \`emailRedirectTo\` passed in options
`,
},
},
@@ -58,6 +59,7 @@ const INVITE: FormSchema = {
- \`{{ .SiteURL }}\` : The URL of the site
- \`{{ .Email }}\` : The user's email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
- \`{{ .RedirectTo }}\` : The URL of \`redirectTo\` passed in options
`,
},
},
@@ -92,6 +94,7 @@ const MAGIC_LINK: FormSchema = {
- \`{{ .SiteURL }}\` : The URL of the site
- \`{{ .Email }}\` : The user's email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
- \`{{ .RedirectTo }}\` : The URL of \`emailRedirectTo\` passed in options
`,
},
},
@@ -127,6 +130,7 @@ const EMAIL_CHANGE: FormSchema = {
- \`{{ .Email }}\` : The original user's email address
- \`{{ .NewEmail }}\` : The user's new email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
- \`{{ .RedirectTo }}\` : The URL of \`emailRedirectTo\` passed in options
`,
},
},
@@ -161,6 +165,7 @@ const RECOVERY: FormSchema = {
- \`{{ .SiteURL }}\` : The URL of the site
- \`{{ .Email }}\` : The user's email address
- \`{{ .Data }}\` : The user's \`user_metadata\`
- \`{{ .RedirectTo }}\` : The URL of \`redirectTo\` passed in options
`,
},
},
@@ -83,10 +83,15 @@ export const PolicyTemplates = ({
icon={
<div className="min-w-16">
<Badge
className="!rounded font-mono"
className={cn(
'!rounded font-mono',
template.command === 'UPDATE'
? 'bg-blue-400 text-blue-900 border border-blue-800'
: ''
)}
variant={
template.command === 'ALL'
? 'default'
? 'outline'
: template.command === 'SELECT'
? 'brand'
: template.command === 'UPDATE'
@@ -22,6 +22,7 @@ interface RLSCodeEditorProps {
placeholder?: string
readOnly?: boolean
disableTabToUsePlaceholder?: boolean
lineNumberStart?: number
onChange?: () => void
onMount?: () => void
@@ -39,6 +40,7 @@ const RLSCodeEditor = ({
placeholder,
readOnly = false,
disableTabToUsePlaceholder = false,
lineNumberStart,
onChange = noop,
onMount: _onMount = noop,
@@ -50,6 +52,7 @@ const RLSCodeEditor = ({
const monaco = useMonaco()
const { project } = useProjectContext()
const placeholderId = `monaco-placeholder-${id}`
const options: editor.IStandaloneEditorConstructionOptions = {
tabSize: 2,
fontSize: 13,
@@ -70,27 +73,31 @@ const RLSCodeEditor = ({
if (monacoRef !== undefined) monacoRef.current = monaco
hasValue.current = editor.createContextKey('hasValue', false)
const placeholderEl = document.querySelector('.monaco-placeholder') as HTMLElement | null
if (placeholderEl && placeholder !== undefined) placeholderEl.style.display = 'block'
const placeholderEl = document.getElementById(placeholderId) as HTMLElement | null
if (placeholderEl && placeholder !== undefined && (value ?? '').trim().length === 0) {
placeholderEl.style.display = 'block'
}
editor.addCommand(
monaco.KeyCode.Tab,
() => {
editor.executeEdits('source', [
{
// @ts-ignore
identifier: 'add-placeholder',
range: new monaco.Range(1, 1, 1, 1),
text: (placeholder ?? '')
.split('\n\n')
.join('\n')
.replaceAll('*', '')
.replaceAll('&nbsp;', ''),
},
])
},
'!hasValue'
)
if (!disableTabToUsePlaceholder) {
editor.addCommand(
monaco.KeyCode.Tab,
() => {
editor.executeEdits('source', [
{
// @ts-ignore
identifier: 'add-placeholder',
range: new monaco.Range(1, 1, 1, 1),
text: (placeholder ?? '')
.split('\n\n')
.join('\n')
.replaceAll('*', '')
.replaceAll('&nbsp;', ''),
},
])
},
'!hasValue'
)
}
editor.focus()
@@ -100,7 +107,7 @@ const RLSCodeEditor = ({
const onChangeContent: OnChange = (value) => {
hasValue.current.set((value ?? '').length > 0)
const placeholderEl = document.querySelector('.monaco-placeholder') as HTMLElement | null
const placeholderEl = document.getElementById(placeholderId) as HTMLElement | null
if (placeholderEl) {
if (!value) {
placeholderEl.style.display = 'block'
@@ -148,6 +155,13 @@ const RLSCodeEditor = ({
}
}, [monaco])
useEffect(() => {
if (value !== undefined && value.trim().length > 0) {
const placeholderEl = document.getElementById(placeholderId) as HTMLElement | null
if (placeholderEl) placeholderEl.style.display = 'none'
}
}, [value])
return (
<>
<Editor
@@ -164,6 +178,7 @@ const RLSCodeEditor = ({
/>
{placeholder !== undefined && (
<div
id={placeholderId}
className="monaco-placeholder absolute top-[0px] left-[57px] text-sm pointer-events-none font-mono [&>div>p]:text-foreground-lighter [&>div>p]:!m-0 tracking-tighter"
style={{ display: 'none' }}
>
@@ -551,14 +551,20 @@ export const AIPolicyEditorPanel = memo(function ({
/>
<div
className={`py-1 relative ${incomingChange ? 'hidden' : 'block'}`}
className={`mt-1 relative ${incomingChange ? 'hidden' : 'block'}`}
style={{
height:
expOneContentHeight <= 100 ? `${8 + expOneContentHeight}px` : '108px',
}}
>
<RLSCodeEditor
disableTabToUsePlaceholder
id="rls-exp-one-editor"
placeholder={
command === 'insert'
? '-- Provide a SQL expression for the with check statement'
: '-- Provide a SQL expression for the using statement'
}
defaultValue={command === 'insert' ? check : using}
value={command === 'insert' ? check : using}
editorRef={editorOneRef}
@@ -608,7 +614,7 @@ export const AIPolicyEditorPanel = memo(function ({
{showCheckBlock && (
<>
<div
className={`py-1 relative ${incomingChange ? 'hidden' : 'block'}`}
className={`mt-1 relative ${incomingChange ? 'hidden' : 'block'}`}
style={{
height:
expTwoContentHeight <= 100
@@ -617,7 +623,9 @@ export const AIPolicyEditorPanel = memo(function ({
}}
>
<RLSCodeEditor
disableTabToUsePlaceholder
id="rls-exp-two-editor"
placeholder="-- Provide a SQL expression for the with check statement"
defaultValue={check}
value={check}
editorRef={editorTwoRef}
@@ -843,22 +851,20 @@ export const AIPolicyEditorPanel = memo(function ({
<ConfirmationModal
visible={isClosingPolicyEditorPanel}
header="Discard changes"
buttonLabel="Discard"
onSelectCancel={() => {
title="Discard changes"
confirmLabel="Discard"
onCancel={() => {
setIsClosingPolicyEditorPanel(false)
}}
onSelectConfirm={() => {
onConfirm={() => {
onSelectCancel()
setIsClosingPolicyEditorPanel(false)
}}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
Are you sure you want to close the editor? Any unsaved changes on your policy and
conversations with the Assistant will be lost.
</p>
</Modal.Content>
<p className="text-sm text-foreground-light">
Are you sure you want to close the editor? Any unsaved changes on your policy and
conversations with the Assistant will be lost.
</p>
</ConfirmationModal>
</>
)
@@ -187,21 +187,19 @@ const PolicyEditorModal = ({
<div>
<ConfirmationModal
visible={isClosingPolicyEditorModal}
header="Discard changes"
buttonLabel="Discard"
onSelectCancel={() => setIsClosingPolicyEditorModal(false)}
onSelectConfirm={() => {
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosingPolicyEditorModal(false)}
onConfirm={() => {
onSelectCancel()
setIsClosingPolicyEditorModal(false)
setIsDirty(false)
}}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the editor? Your changes
will be lost.
</p>
</Modal.Content>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the editor? Your changes will
be lost.
</p>
</ConfirmationModal>
{view === POLICY_MODAL_VIEWS.SELECTION ? (
<PolicySelection
@@ -277,6 +277,18 @@ const SmtpForm = () => {
}
>
<FormSectionContent loading={isLoading}>
{values['SMTP_HOST'] && values['SMTP_HOST'].endsWith('.gmail.com') && (
<Alert_Shadcn_ variant="warning">
<IconAlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>Check your SMTP provider</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Not all SMTP providers are designed for the email sending required by
Supabase Auth. It looks like the SMTP provider you entered is designed for
sending personal email messages and not for sending transactional messages.
Although you can ignore this warning, email deliverability may be impacted.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
<Input
name="SMTP_HOST"
placeholder="your.smtp.host.com"
@@ -121,7 +121,7 @@ const UserDropdown = ({
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="text" loading={isLoading} className="hover:border-gray-500 flex">
<Button type="text" loading={isLoading} className="hover:border-muted flex">
<IconMoreVertical />
</Button>
</DropdownMenuTrigger>
@@ -224,35 +224,30 @@ const UserDropdown = ({
<ConfirmationModal
visible={isDeleteModalOpen}
header="Confirm to delete"
buttonLabel="Delete"
buttonLoadingLabel="Delete"
onSelectCancel={() => setIsDeleteModalOpen(false)}
onSelectConfirm={() => {
title="Confirm to delete"
confirmLabel="Delete"
onCancel={() => setIsDeleteModalOpen(false)}
onConfirm={() => {
handleDelete()
}}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
This is permanent! Are you sure you want to delete user {user.email}?
</p>
</Modal.Content>
<p className="text-sm text-foreground-light">
This is permanent! Are you sure you want to delete user {user.email}?
</p>
</ConfirmationModal>
<ConfirmationModal
visible={isDeleteFactorsModalOpen}
header="Confirm to delete"
buttonLabel="Delete"
onSelectCancel={() => setIsDeleteFactorsModalOpen(false)}
onSelectConfirm={() => {
title="Confirm to delete"
confirmLabel="Delete"
onCancel={() => setIsDeleteFactorsModalOpen(false)}
onConfirm={() => {
handleDeleteFactors()
}}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
This is permanent! Are you sure you want to delete the user's MFA factors?
</p>
</Modal.Content>
<p className="text-sm text-foreground-light">
This is permanent! Are you sure you want to delete the user's MFA factors?
</p>
</ConfirmationModal>
</>
)
@@ -40,7 +40,9 @@ const UserListItem = ({
<span className="text-foreground">{!user.phone ? '-' : user.phone}</span>
</Table.td>
<Table.td className="table-cell">
<span className="capitalize text-foreground">{user?.raw_app_meta_data?.provider}</span>
<span className="capitalize text-foreground">
{user.is_anonymous ? 'Anonymous' : user?.raw_app_meta_data?.provider}
</span>
</Table.td>
<Table.td className="table-cell">
<span className="text-foreground">{createdAt?.format('DD MMM, YYYY HH:mm')}</span>
@@ -27,7 +27,7 @@ const UsersListItemSkeleton = () => {
<ShimmeringLoader className="h-[20px] py-0 w-20" />
</Table.td>
<Table.td className="table-cell">
<Button type="text" disabled={true} className="hover:border-gray-500 flex">
<Button type="text" disabled={true} className="hover:border-muted flex">
<IconMoreHorizontal />
</Button>
</Table.td>
@@ -344,44 +344,28 @@ const BranchManagement = () => {
/>
<ConfirmationModal
danger
variant={'destructive'}
size="medium"
loading={isDisabling}
visible={showDisableBranching}
header="Confirm disable branching for project"
buttonLabel="Confirm disable branching"
buttonLoadingLabel="Disabling branching..."
onSelectConfirm={() => onConfirmDisableBranching()}
onSelectCancel={() => setShowDisableBranching(false)}
title="Confirm disable branching for project"
confirmLabel="Confirm disable branching"
confirmLabelLoading="Disabling branching..."
onConfirm={() => onConfirmDisableBranching()}
onCancel={() => setShowDisableBranching(false)}
alert={{
title: 'This action cannot be undone',
description:
'All database preview branches will be removed upon disabling branching. You may still re-enable branching again thereafter, but your existing preview branches will not be restored.',
}}
>
<Modal.Content>
<div className="py-6">
<Alert_Shadcn_ variant="warning">
<IconAlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>This action cannot be undone</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
All database preview branches will be removed upon disabling branching. You may
still re-enable branching again thereafter, but your existing preview branches will
not be restored.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<ul className="mt-4 space-y-5">
<li className="flex gap-3">
<div>
<strong className="text-sm">Before you disable branching, consider:</strong>
<ul className="space-y-2 mt-2 text-sm text-foreground-light">
<li className="list-disc ml-6">
Your project no longer requires database previews.
</li>
<li className="list-disc ml-6">
None of your database previews are currently being used in any app.
</li>
</ul>
</div>
</li>
</ul>
</div>
</Modal.Content>
<p className="text-sm">Before you disable branching, consider:</p>
<ul className="space-y-2 mt-2 text-sm text-foreground-light">
<li className="list-disc ml-6">Your project no longer requires database previews.</li>
<li className="list-disc ml-6">
None of your database previews are currently being used in any app.
</li>
</ul>
</ConfirmationModal>
<CreateBranchModal visible={showCreateBranch} onClose={() => setShowCreateBranch(false)} />
@@ -1,12 +1,14 @@
import { useParams } from 'common'
import dayjs from 'dayjs'
import { GitPullRequest } from 'lucide-react'
import { GitPullRequest, RefreshCw, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { PropsWithChildren, ReactNode } from 'react'
import { PropsWithChildren, ReactNode, useState } from 'react'
import toast from 'react-hot-toast'
import { useInView } from 'react-intersection-observer'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useBranchQuery } from 'data/branches/branch-query'
import { useBranchResetMutation } from 'data/branches/branch-reset-mutation'
import type { Branch } from 'data/branches/branches-query'
import {
Badge,
@@ -19,8 +21,9 @@ import {
IconExternalLink,
IconMoreVertical,
IconShield,
IconTrash,
Modal,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import BranchStatusBadge from './BranchStatusBadge'
interface BranchManagementSectionProps {
@@ -111,6 +114,23 @@ export const BranchRow = ({
}
)
const [showConfirmResetModal, setShowConfirmResetModal] = useState(false)
const { mutate, isLoading: isResetting } = useBranchResetMutation({
onSuccess() {
toast.success('Success! Please allow a few seconds for the branch to reset.')
setShowConfirmResetModal(false)
},
})
function onConfirmReset() {
if (!projectRef) {
throw new Error('Invalid project reference')
}
mutate({ id: branch.id, projectRef })
}
return (
<div className="w-full flex items-center justify-between px-6 py-2.5" ref={ref}>
<div className="flex items-center gap-x-4">
@@ -204,16 +224,40 @@ export const BranchRow = ({
<Button type="text" icon={<IconMoreVertical />} className="px-1" />
</DropdownMenuTrigger>
<DropdownMenuContent className="p-0 w-56" side="bottom" align="end">
<DropdownMenuItem
className="gap-x-2"
onSelect={() => setShowConfirmResetModal(true)}
onClick={() => setShowConfirmResetModal(true)}
>
<RefreshCw size={14} />
Reset Branch
</DropdownMenuItem>
<DropdownMenuItem
className="gap-x-2"
onSelect={() => onSelectDeleteBranch?.()}
onClick={() => onSelectDeleteBranch?.()}
>
<IconTrash size="tiny" />
<p>Delete branch</p>
<Trash2 size={14} />
Delete branch
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmationModal
variant={'destructive'}
visible={showConfirmResetModal}
confirmLabel="Reset branch"
title="Confirm branch reset"
loading={isResetting}
onCancel={() => {
setShowConfirmResetModal(false)
}}
onConfirm={onConfirmReset}
>
<p className="text-sm text-foreground-light">
Are you sure you want to reset the "{branch.name}" branch? All data will be deleted.
</p>
</ConfirmationModal>
</div>
)}
</div>
@@ -2,7 +2,7 @@ import { IconInfo } from 'ui'
const BackupsEmpty = () => {
return (
<div className="block w-full rounded border border-gray-400 border-opacity-50 bg-gray-300 p-3">
<div className="block w-full rounded border border-muted border-opacity-50 bg-gray-300 p-3">
<div className="flex space-x-3">
<IconInfo size={20} strokeWidth={1.5} />
<p className="text-sm">No backups created yet - check again tomorrow.</p>
@@ -107,26 +107,22 @@ const BackupsList = () => {
</div>
<ConfirmationModal
size="medium"
buttonLabel="Confirm restore"
buttonLoadingLabel="Restoring"
confirmLabel="Confirm restore"
confirmLabelLoading="Restoring"
visible={selectedBackup !== undefined}
header="Confirm to restore from backup"
title="Confirm to restore from backup"
loading={isRestoring || isSuccessBackup}
onSelectCancel={() => setSelectedBackup(undefined)}
onSelectConfirm={() => {
onCancel={() => setSelectedBackup(undefined)}
onConfirm={() => {
if (selectedBackup === undefined) return console.error('Backup required')
restoreFromBackup({ ref: projectRef, backup: selectedBackup })
}}
>
<Modal.Content>
<div className="pt-6 pb-5">
<p>
Are you sure you want to restore from
{dayjs(selectedBackup?.inserted_at).format('DD MMM YYYY')}? This will destroy any new
data written since this backup was made.
</p>
</div>
</Modal.Content>
<p>
Are you sure you want to restore from
{dayjs(selectedBackup?.inserted_at).format('DD MMM YYYY')}? This will destroy any new data
written since this backup was made.
</p>
</ConfirmationModal>
</>
)
@@ -46,40 +46,32 @@ const DeleteEnumeratedTypeModal = ({
return (
<ConfirmationModal
danger
variant={'destructive'}
size="medium"
loading={isDeleting}
visible={visible}
header={
title={
<>
Confirm to delete enumerated type{' '}
<code className="text-sm">{selectedEnumeratedType?.name}</code>
</>
}
buttonLabel="Confirm delete"
buttonLoadingLabel="Deleting..."
onSelectConfirm={() => onConfirmDeleteType()}
onSelectCancel={onClose}
confirmLabel="Confirm delete"
confirmLabelLoading="Deleting..."
onCancel={onClose}
onConfirm={() => onConfirmDeleteType()}
alert={{
title: 'This action cannot be undone',
description:
'You will need to re-create the enumerated type if you want to revert the deletion.',
}}
>
<Modal.Content>
<div className="py-6">
<Alert_Shadcn_ variant="warning">
<IconAlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>This action cannot be undone</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
You will need to re-create the enumerated type if you want to revert the deletion.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<div className="mt-4">
<strong className="text-sm">Before deleting this enumerated type, consider:</strong>
<ul className="space-y-2 mt-2 text-sm text-foreground-light">
<li className="list-disc ml-6">
This enumerated type is no longer in use in any tables or functions
</li>
</ul>
</div>
</div>
</Modal.Content>
<p className="text-sm">Before deleting this enumerated type, consider:</p>
<ul className="space-y-2 mt-2 text-sm text-foreground-light">
<li className="list-disc ml-6">
This enumerated type is no longer in use in any tables or functions
</li>
</ul>
</ConfirmationModal>
)
}
@@ -139,17 +139,15 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => {
/>
<ConfirmationModal
visible={isDisableModalOpen}
header="Confirm to disable extension"
buttonLabel="Disable"
buttonLoadingLabel="Disabling"
onSelectCancel={() => setIsDisableModalOpen(false)}
onSelectConfirm={() => onConfirmDisable()}
title="Confirm to disable extension"
confirmLabel="Disable"
confirmLabelLoading="Disabling"
onCancel={() => setIsDisableModalOpen(false)}
onConfirm={() => onConfirmDisable()}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
Are you sure you want to turn OFF the "{extension.name}" extension?
</p>
</Modal.Content>
<p className="text-sm text-foreground-light">
Are you sure you want to turn OFF the "{extension.name}" extension?
</p>
</ConfirmationModal>
</>
)
@@ -405,20 +405,18 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => {
) : null}
<ConfirmationModal
visible={isClosingPanel}
header="Discard changes"
buttonLabel="Discard"
onSelectCancel={() => setIsClosingPanel(false)}
onSelectConfirm={() => {
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosingPanel(false)}
onConfirm={() => {
setIsClosingPanel(false)
setVisible(!visible)
}}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will
be lost.
</p>
</Modal.Content>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will
be lost.
</p>
</ConfirmationModal>
</SheetContent>
</Sheet>
@@ -310,21 +310,19 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
</SidePanel>
<ConfirmationModal
visible={isClosingPanel}
header="Discard changes"
buttonLabel="Discard"
onSelectCancel={() => setIsClosingPanel(false)}
onSelectConfirm={() => {
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosingPanel(false)}
onConfirm={() => {
setIsClosingPanel(false)
setIsEdited(false)
onClose()
}}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will
be lost.
</p>
</Modal.Content>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
</>
)
@@ -7,7 +7,6 @@ import { Button, IconExternalLink, IconSearch, Input } from 'ui'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AlertError from 'components/ui/AlertError'
import { FormHeader } from 'components/ui/Forms'
import NoSearchResults from 'components/ui/NoSearchResults'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useDatabaseHooksQuery } from 'data/database-triggers/database-triggers-query'
@@ -45,95 +44,87 @@ const HooksList = ({ createHook = noop, editHook = noop, deleteHook = noop }: Ho
const isPermissionsLoaded = usePermissionsLoaded()
return (
<>
<div className="w-full space-y-4">
<div className="flex items-center justify-between">
<FormHeader
title="Database Webhooks"
description="Send real-time data from your database to another system whenever a table event occurs"
<Input
placeholder="Search for a webhook"
size="small"
icon={<IconSearch size="tiny" />}
value={filterString}
className="w-64"
onChange={(e) => setFilterString(e.target.value)}
/>
</div>
<div className="w-full space-y-4">
<div className="flex items-center justify-between">
<Input
placeholder="Search for a webhook"
size="small"
icon={<IconSearch size="tiny" />}
value={filterString}
className="w-64"
onChange={(e) => setFilterString(e.target.value)}
/>
<div className="flex items-center space-x-2">
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/database/webhooks"
target="_blank"
rel="noreferrer"
<div className="flex items-center space-x-2">
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/database/webhooks"
target="_blank"
rel="noreferrer"
>
Documentation
</Link>
</Button>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
disabled={!isPermissionsLoaded || !canCreateWebhooks}
onClick={() => createHook()}
>
Documentation
</Link>
</Button>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
disabled={!isPermissionsLoaded || !canCreateWebhooks}
onClick={() => createHook()}
>
Create a new hook
</Button>
</Tooltip.Trigger>
{isPermissionsLoaded && !canCreateWebhooks && (
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">
You need additional permissions to create webhooks
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
)}
</Tooltip.Root>
</div>
Create a new hook
</Button>
</Tooltip.Trigger>
{isPermissionsLoaded && !canCreateWebhooks && (
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">
You need additional permissions to create webhooks
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
)}
</Tooltip.Root>
</div>
{isLoading && (
<div className="py-4">
<GenericSkeletonLoader />
</div>
)}
{isError && <AlertError error={error} subject="Failed to retrieve database webhooks" />}
{isSuccess &&
(hooks.length <= 0 ? (
<HooksListEmpty />
) : (
<>
{filteredHooks.length <= 0 && (
<NoSearchResults
searchString={filterString}
onResetFilter={() => setFilterString('')}
/>
)}
{filteredHookSchemas.map((schema: any) => (
<SchemaTable
key={schema}
filterString={filterString}
schema={schema}
editHook={editHook}
deleteHook={deleteHook}
/>
))}
</>
))}
</div>
</>
{isLoading && (
<div className="py-4">
<GenericSkeletonLoader />
</div>
)}
{isError && <AlertError error={error} subject="Failed to retrieve database webhooks" />}
{isSuccess &&
(hooks.length <= 0 ? (
<HooksListEmpty />
) : (
<>
{filteredHooks.length <= 0 && (
<NoSearchResults
searchString={filterString}
onResetFilter={() => setFilterString('')}
/>
)}
{filteredHookSchemas.map((schema: any) => (
<SchemaTable
key={schema}
filterString={filterString}
schema={schema}
editHook={editHook}
deleteHook={deleteHook}
/>
))}
</>
))}
</div>
)
}
@@ -2,6 +2,7 @@ import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { SelectValue } from '@ui/components/shadcn/ui/select'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { CodeEditor } from 'components/ui/CodeEditor'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
@@ -10,8 +11,19 @@ import { useSchemasQuery } from 'data/database/schemas-query'
import { useTableColumnsQuery } from 'data/database/table-columns-query'
import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query'
import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { Button, Input, Listbox, SidePanel } from 'ui'
import {
Button,
Input,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectSeparator_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Select_Shadcn_,
SidePanel,
} from 'ui'
import MultiSelect, { MultiSelectOption } from 'ui-patterns/MultiSelect'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { INDEX_TYPES } from './Indexes.constants'
interface CreateIndexSidePanelProps {
@@ -22,7 +34,7 @@ interface CreateIndexSidePanelProps {
const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) => {
const { project } = useProjectContext()
const [selectedSchema, setSelectedSchema] = useState('public')
const [selectedEntity, setSelectedEntity] = useState('---')
const [selectedEntity, setSelectedEntity] = useState<string | undefined>(undefined)
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
const [selectedIndexType, setSelectedIndexType] = useState<string>(INDEX_TYPES[0].value)
@@ -92,14 +104,14 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
useEffect(() => {
if (visible) {
setSelectedSchema('public')
setSelectedEntity('---')
setSelectedEntity('')
setSelectedColumns([])
setSelectedIndexType(INDEX_TYPES[0].value)
}
}, [visible])
useEffect(() => {
setSelectedEntity('---')
setSelectedEntity('')
setSelectedColumns([])
setSelectedIndexType(INDEX_TYPES[0].value)
}, [selectedSchema])
@@ -109,6 +121,8 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
setSelectedIndexType(INDEX_TYPES[0].value)
}, [selectedEntity])
const isSelectEntityDisabled = entityTypes.length === 0
return (
<SidePanel
size="large"
@@ -121,49 +135,61 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
>
<div className="py-6 space-y-6">
<SidePanel.Content className="space-y-6">
<Listbox
size="small"
label="Select a schema"
value={selectedSchema}
onChange={setSelectedSchema}
>
{(schemas ?? []).map((schema) => (
<Listbox.Option key={schema.name} value={schema.name} label={schema.name}>
{schema.name}
</Listbox.Option>
))}
</Listbox>
{entityTypes.length === 0 ? (
<div className="space-y-2">
<p className="text-sm text-foreground-light leading-4">Select a table</p>
<Input
disabled
placeholder="No tables available in schema"
descriptionText="Create a table in this schema via the Table or SQL editor first"
/>
</div>
) : (
<Listbox
size="small"
label="Select a table"
value={selectedEntity}
onChange={setSelectedEntity}
<FormItemLayout label="Select a schema" name="select-schema" isReactForm={false}>
<Select_Shadcn_
value={selectedSchema}
onValueChange={setSelectedSchema}
name="select-schema"
>
<Listbox.Option key="default-option-table" value="---" label="---">
---
</Listbox.Option>
{(entityTypes ?? []).map((entity) => (
<Listbox.Option key={entity.name} value={entity.name} label={entity.name}>
{entity.name}
</Listbox.Option>
))}
</Listbox>
)}
<SelectTrigger_Shadcn_ size="small">
<SelectValue_Shadcn_>{selectedSchema}</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{(schemas ?? []).map((schema) => (
<SelectItem_Shadcn_ key={schema.name} value={schema.name}>
{schema.name}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormItemLayout>
{selectedEntity !== '---' && (
<div>
<p className="text-sm text-foreground-light mb-2">Select up to 32 columns</p>
<FormItemLayout
label="Select a table"
name="select-table"
description={
isSelectEntityDisabled &&
'Create a table in this schema via the Table or SQL editor first'
}
isReactForm={false}
>
<Select_Shadcn_
disabled={isSelectEntityDisabled}
value={selectedEntity}
onValueChange={setSelectedEntity}
name="select-table"
>
<SelectTrigger_Shadcn_ size="small">
<SelectValue
placeholder={
isSelectEntityDisabled ? 'No tables available in schema' : 'Choose a table'
}
>
{selectedEntity}
</SelectValue>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{(entityTypes ?? []).map((entity) => (
<SelectItem_Shadcn_ key={entity.name} value={entity.name}>
{entity.name}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormItemLayout>
{selectedEntity && (
<FormItemLayout label="Select up to 32 columns" isReactForm={false}>
{isLoadingTableColumns && <ShimmeringLoader className="py-4" />}
{isSuccessTableColumns && (
<MultiSelect
@@ -174,7 +200,7 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
onChange={setSelectedColumns}
/>
)}
</div>
</FormItemLayout>
)}
</SidePanel.Content>
@@ -182,21 +208,43 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp
<>
<SidePanel.Separator />
<SidePanel.Content className="space-y-6">
<Listbox
size="small"
<FormItemLayout
label="Select an index type"
value={selectedIndexType}
onChange={setSelectedIndexType}
name="selected-index-type"
isReactForm={false}
>
{INDEX_TYPES.map((index) => (
<Listbox.Option key={index.name} value={index.value} label={index.name}>
<p>{index.name}</p>
{index.description.split('\n').map((x, idx) => (
<p key={`${index.value}-description-${idx}`}>{x}</p>
<Select_Shadcn_
value={selectedIndexType}
onValueChange={setSelectedIndexType}
name="selected-index-type"
>
<SelectTrigger_Shadcn_ size={'small'}>
<SelectValue_Shadcn_ className="font-mono">
{selectedIndexType}
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{INDEX_TYPES.map((index, i) => (
<>
<SelectItem_Shadcn_ key={index.name} value={index.value}>
<div className="flex flex-col gap-0.5">
<span>{index.name}</span>
{index.description.split('\n').map((x, idx) => (
<span
className="text-foreground-lighter group-focus:text-foreground-light group-data-[state=checked]:text-foreground-light"
key={`${index.value}-description-${idx}`}
>
{x}
</span>
))}
</div>
</SelectItem_Shadcn_>
{i < INDEX_TYPES.length - 1 && <SelectSeparator_Shadcn_ />}
</>
))}
</Listbox.Option>
))}
</Listbox>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormItemLayout>
</SidePanel.Content>
<SidePanel.Separator />
<SidePanel.Content>
@@ -1,23 +1,10 @@
import { partition, sortBy } from 'lodash'
import Link from 'next/link'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
IconAlertCircle,
IconAlertTriangle,
IconExternalLink,
IconSearch,
IconTrash,
Input,
Modal,
SidePanel,
} from 'ui'
import { Button, IconAlertCircle, IconSearch, IconTrash, Input, SidePanel } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
@@ -32,13 +19,15 @@ import ProtectedSchemaWarning from '../ProtectedSchemaWarning'
import CreateIndexSidePanel from './CreateIndexSidePanel'
const Indexes = () => {
const { project } = useProjectContext()
const { schema: urlSchema, table } = useParams()
const [search, setSearch] = useState('')
const [selectedSchema, setSelectedSchema] = useState('public')
const [showCreateIndex, setShowCreateIndex] = useState(false)
const [selectedIndex, setSelectedIndex] = useState<DatabaseIndex>()
const [selectedIndexToDelete, setSelectedIndexToDelete] = useState<DatabaseIndex>()
const { project } = useProjectContext()
const {
data: allIndexes,
refetch: refetchIndexes,
@@ -93,38 +82,20 @@ const Indexes = () => {
})
}
useEffect(() => {
if (urlSchema !== undefined) {
const schema = schemas?.find((s) => s.name === urlSchema)
if (schema !== undefined) setSelectedSchema(schema.name)
}
}, [urlSchema, isSuccessSchemas])
useEffect(() => {
if (table !== undefined) setSearch(table)
}, [table])
return (
<>
<div className="pb-8">
<div className="flex items-center justify-between mb-6">
<div className="space-y-1">
<h3 className="text-xl text-foreground">Database Indexes</h3>
<div className="text-sm text-foreground-lighter">
Improve query performance against your database
</div>
</div>
<div className="flex items-center space-x-2">
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/database/query-optimization"
target="_blank"
rel="noreferrer"
>
Documentation
</Link>
</Button>
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/database/extensions/index_advisor"
target="_blank"
rel="noreferrer"
>
Optimization with index_advisor
</Link>
</Button>
</div>
</div>
<div className="flex flex-col gap-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
@@ -264,48 +235,41 @@ const Indexes = () => {
<CreateIndexSidePanel visible={showCreateIndex} onClose={() => setShowCreateIndex(false)} />
<ConfirmationModal
danger
variant="warning"
size="medium"
loading={isExecuting}
visible={selectedIndexToDelete !== undefined}
header={
title={
<>
Confirm to delete index <code className="text-sm">{selectedIndexToDelete?.name}</code>
</>
}
buttonLabel="Confirm delete"
buttonLoadingLabel="Deleting..."
onSelectConfirm={() =>
confirmLabel="Confirm delete"
confirmLabelLoading="Deleting..."
onConfirm={() =>
selectedIndexToDelete !== undefined ? onConfirmDeleteIndex(selectedIndexToDelete) : {}
}
onSelectCancel={() => setSelectedIndexToDelete(undefined)}
onCancel={() => setSelectedIndexToDelete(undefined)}
alert={{
title: 'This action cannot be undone',
description:
'Deleting an index that is still in use will cause queries to slow down, and in some cases causing significant performance issues.',
}}
>
<Modal.Content>
<div className="py-6">
<Alert_Shadcn_ variant="warning">
<IconAlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>This action cannot be undone</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Deleting an index that is still in use will cause queries to slow down, and in some
cases causing significant performance issues.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<ul className="mt-4 space-y-5">
<li className="flex gap-3">
<div>
<strong className="text-sm">Before deleting this index, consider:</strong>
<ul className="space-y-2 mt-2 text-sm text-foreground-light">
<li className="list-disc ml-6">This index is no longer in use</li>
<li className="list-disc ml-6">
The table which the index is on is not currently in use, as dropping an index
requires a short exclusive access lock on the table.
</li>
</ul>
</div>
</li>
</ul>
</div>
</Modal.Content>
<ul className="mt-4 space-y-5">
<li className="flex gap-3">
<div>
<strong className="text-sm">Before deleting this index, consider:</strong>
<ul className="space-y-2 mt-2 text-sm text-foreground-light">
<li className="list-disc ml-6">This index is no longer in use</li>
<li className="list-disc ml-6">
The table which the index is on is not currently in use, as dropping an index
requires a short exclusive access lock on the table.
</li>
</ul>
</div>
</li>
</ul>
</ConfirmationModal>
</>
)
@@ -1,7 +1,7 @@
import dayjs from 'dayjs'
import Link from 'next/link'
import { useState } from 'react'
import { Alert, Button, IconExternalLink, IconSearch, Input, SidePanel } from 'ui'
import { Alert, Button, IconSearch, Input, SidePanel } from 'ui'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import Table from 'components/to-be-cleaned/Table'
@@ -28,131 +28,109 @@ const Migrations = () => {
return (
<>
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="mb-1 text-xl text-foreground">Database Migrations</h3>
<div className="text-sm text-foreground-lighter">
History of migrations that have been run on your database
</div>
</div>
<div className="flex items-center space-x-2">
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/getting-started/local-development#database-migrations"
target="_blank"
rel="noreferrer"
>
Documentation
</Link>
</Button>
</div>
{isLoading && (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{isLoading && (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
<div>
{isError && (
<Alert
withIcon
variant="warning"
title="Failed to retrieve migration history for database"
actions={[
<Button key="contact-support" asChild type="default" className="ml-4">
<Link
href={`/support/new?ref=${project?.ref}&category=dashboard_bug&subject=Unable%20to%20view%20database%20migrations`}
>
Contact support
</Link>
</Button>,
]}
>
<p className="mb-1">
Try refreshing your browser, but if the issue persists, please reach out to us via
support.
</p>
<p>Error: {(error as any)?.message ?? 'Unknown'}</p>
</Alert>
)}
{isSuccess && (
<div>
{data.result.length <= 0 && <MigrationsEmptyState />}
{data.result.length > 0 && (
<>
<div className="w-80 mb-4">
<Input
size="small"
placeholder="Search for a migration"
value={search}
onChange={(e: any) => setSearch(e.target.value)}
icon={<IconSearch size="tiny" />}
/>
</div>
<Table
head={[
<Table.th key="version" style={{ width: '180px' }}>
Version
</Table.th>,
<Table.th key="version">Name</Table.th>,
<Table.th key="version">Inserted at (UTC)</Table.th>,
<Table.th key="buttons"></Table.th>,
]}
body={
migrations.length > 0 ? (
migrations.map((migration) => {
// [Joshen] LEFT OFF HERE
const insertedAt = dayjs(migration.version, 'YYYYMMDDHHmmss').format(
'DD MMM YYYY, HH:mm:ss'
)
return (
<Table.tr key={migration.version}>
<Table.td>{migration.version}</Table.td>
<Table.td
className={
(migration?.name ?? '').length === 0
? '!text-foreground-lighter'
: ''
}
>
{migration?.name ?? 'Name not available'}
</Table.td>
<Table.td>{insertedAt}</Table.td>
<Table.td align="right">
<Button
type="default"
onClick={() => setSelectedMigration(migration)}
>
View migration SQL
</Button>
</Table.td>
</Table.tr>
)
})
) : (
<Table.tr>
<Table.td colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{search}" did not return any results
</p>
</Table.td>
</Table.tr>
)
}
/>
</>
)}
</div>
)}
<div>
{isError && (
<Alert
withIcon
variant="warning"
title="Failed to retrieve migration history for database"
actions={[
<Button key="contact-support" asChild type="default" className="ml-4">
<Link
href={`/support/new?ref=${project?.ref}&category=dashboard_bug&subject=Unable%20to%20view%20database%20migrations`}
>
Contact support
</Link>
</Button>,
]}
>
<p className="mb-1">
Try refreshing your browser, but if the issue persists, please reach out to us via
support.
</p>
<p>Error: {(error as any)?.message ?? 'Unknown'}</p>
</Alert>
)}
{isSuccess && (
<div>
{data.result.length <= 0 && <MigrationsEmptyState />}
{data.result.length > 0 && (
<>
<div className="w-80 mb-4">
<Input
size="small"
placeholder="Search for a migration"
value={search}
onChange={(e: any) => setSearch(e.target.value)}
icon={<IconSearch size="tiny" />}
/>
</div>
<Table
head={[
<Table.th key="version" style={{ width: '180px' }}>
Version
</Table.th>,
<Table.th key="version">Name</Table.th>,
<Table.th key="version">Inserted at (UTC)</Table.th>,
<Table.th key="buttons"></Table.th>,
]}
body={
migrations.length > 0 ? (
migrations.map((migration) => {
// [Joshen] LEFT OFF HERE
const insertedAt = dayjs(migration.version, 'YYYYMMDDHHmmss').format(
'DD MMM YYYY, HH:mm:ss'
)
return (
<Table.tr key={migration.version}>
<Table.td>{migration.version}</Table.td>
<Table.td
className={
(migration?.name ?? '').length === 0
? '!text-foreground-lighter'
: ''
}
>
{migration?.name ?? 'Name not available'}
</Table.td>
<Table.td>{insertedAt}</Table.td>
<Table.td align="right">
<Button
type="default"
onClick={() => setSelectedMigration(migration)}
>
View migration SQL
</Button>
</Table.td>
</Table.tr>
)
})
) : (
<Table.tr>
<Table.td colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{search}" did not return any results
</p>
</Table.td>
</Table.tr>
)
}
/>
</>
)}
</div>
)}
</div>
</div>
<SidePanel
@@ -173,21 +173,19 @@ const PublicationsList = ({ onSelectPublication = noop }: PublicationsListProps)
<ConfirmationModal
visible={toggleListenEventValue !== null}
header={`Confirm to toggle sending ${toggleListenEventValue?.event.event.toLowerCase()} events`}
buttonLabel="Confirm"
buttonLoadingLabel="Updating"
onSelectCancel={() => setToggleListenEventValue(null)}
onSelectConfirm={() => {
title={`Confirm to toggle sending ${toggleListenEventValue?.event.event.toLowerCase()} events`}
confirmLabel="Confirm"
confirmLabelLoading="Updating"
onCancel={() => setToggleListenEventValue(null)}
onConfirm={() => {
toggleListenEvent()
}}
>
<Modal.Content>
<p className="py-4 text-sm text-foreground-light">
Are you sure you want to {toggleListenEventValue?.currentStatus ? 'stop' : 'start'}{' '}
sending {toggleListenEventValue?.event.event.toLowerCase()} events for{' '}
{toggleListenEventValue?.publication.name}?
</p>
</Modal.Content>
<p className="text-sm text-foreground-light">
Are you sure you want to {toggleListenEventValue?.currentStatus ? 'stop' : 'start'}{' '}
sending {toggleListenEventValue?.event.event.toLowerCase()} events for{' '}
{toggleListenEventValue?.publication.name}?
</p>
</ConfirmationModal>
</>
)
@@ -1,11 +1,11 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { partition, sortBy } from 'lodash'
import { Plus, Search, X } from 'lucide-react'
import { useState } from 'react'
import { Badge, Button, IconPlus, IconSearch, IconX, Input } from 'ui'
import { Badge, Button, Input } from 'ui'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { FormHeader } from 'components/ui/Forms'
import NoSearchResults from 'components/ui/NoSearchResults'
import SparkBar from 'components/ui/SparkBar'
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
@@ -59,135 +59,128 @@ const RolesList = () => {
return (
<>
<div>
<div className="flex items-center justify-between">
<FormHeader
title="Database Roles"
description="Manage access control to your database through users, groups, and permissions"
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center space-x-4">
<Input
size="small"
placeholder="Search for a role"
icon={<Search size={12} />}
value={filterString}
onChange={(event: any) => setFilterString(event.target.value)}
actions={
filterString && (
<Button
size="tiny"
type="text"
onClick={() => setFilterString('')}
className="px-1 mr-1"
>
<X size={12} strokeWidth={2} />
</Button>
)
}
/>
</div>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center space-x-4">
<Input
size="small"
placeholder="Search for a role"
icon={<IconSearch size="tiny" />}
value={filterString}
onChange={(event: any) => setFilterString(event.target.value)}
actions={
filterString && (
<Button
size="tiny"
type="text"
onClick={() => setFilterString('')}
className="px-1 mr-1"
>
<IconX size={12} strokeWidth={2} />
</Button>
)
}
/>
<div className="flex items-center border border-strong rounded-full w-min h-[34px]">
<button
className={[
'text-xs w-[90px] h-full text-center rounded-l-full flex items-center justify-center transition',
filterType === 'all'
? 'bg-overlay-hover text-foreground'
: 'hover:bg-surface-200 text-foreground-light',
].join(' ')}
onClick={() => setFilterType('all')}
>
All roles
</button>
<div className="h-full w-[1px] border-r border-strong"></div>
<button
className={[
'text-xs w-[90px] h-full text-center rounded-r-full flex items-center justify-center transition',
filterType === 'active'
? 'bg-overlay-hover text-foreground'
: 'hover:bg-surface-200 text-foreground-light',
].join(' ')}
onClick={() => setFilterType('active')}
>
Active roles
</button>
</div>
<div className="flex items-center border border-strong rounded-full w-min h-[34px]">
<button
className={[
'text-xs w-[90px] h-full text-center rounded-l-full flex items-center justify-center transition',
filterType === 'all'
? 'bg-overlay-hover text-foreground'
: 'hover:bg-surface-200 text-foreground-light',
].join(' ')}
onClick={() => setFilterType('all')}
>
All roles
</button>
<div className="h-full w-[1px] border-r border-strong"></div>
<button
className={[
'text-xs w-[90px] h-full text-center rounded-r-full flex items-center justify-center transition',
filterType === 'active'
? 'bg-overlay-hover text-foreground'
: 'hover:bg-surface-200 text-foreground-light',
].join(' ')}
onClick={() => setFilterType('active')}
>
Active roles
</button>
</div>
<div className="flex items-center space-x-6">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<div className="w-42">
<SparkBar
type="horizontal"
// if the maxConnectionLimit is undefined, set totalActiveConnections so that
// the width of the bar is set to 100%
max={maxConnectionLimit || totalActiveConnections}
value={totalActiveConnections}
barClass={
maxConnectionLimit === 0 || maxConnectionLimit === undefined
? 'bg-control'
: totalActiveConnections > 0.9 * maxConnectionLimit
? 'bg-red-800'
: totalActiveConnections > 0.75 * maxConnectionLimit
? 'bg-amber-900'
: 'bg-green-800'
}
labelTop={
Number.isInteger(maxConnectionLimit)
? `${totalActiveConnections}/${maxConnectionLimit}`
: `${totalActiveConnections}`
}
labelBottom="Active connections"
/>
</div>
</Tooltip.Trigger>
</div>
<div className="flex items-center space-x-6">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<div className="w-42">
<SparkBar
type="horizontal"
// if the maxConnectionLimit is undefined, set totalActiveConnections so that
// the width of the bar is set to 100%
max={maxConnectionLimit || totalActiveConnections}
value={totalActiveConnections}
barClass={
maxConnectionLimit === 0 || maxConnectionLimit === undefined
? 'bg-foreground'
: totalActiveConnections > 0.9 * maxConnectionLimit
? 'bg-destructive'
: totalActiveConnections > 0.75 * maxConnectionLimit
? 'bg-warning'
: undefined
}
labelTop={
Number.isInteger(maxConnectionLimit)
? `${totalActiveConnections}/${maxConnectionLimit}`
: `${totalActiveConnections}`
}
labelBottom="Active connections"
/>
</div>
</Tooltip.Trigger>
<Tooltip.Content align="start" side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background space-y-1',
].join(' ')}
>
<p className="text-xs text-foreground-light pr-2">Connections by roles:</p>
{rolesWithActiveConnections.map((role) => (
<div key={role.id} className="text-xs text-foreground">
{role.name}: {role.active_connections}
</div>
))}
</div>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
type="primary"
disabled={!canUpdateRoles}
icon={<Plus size={12} />}
onClick={() => setIsCreatingRole(true)}
>
Add role
</Button>
</Tooltip.Trigger>
{!canUpdateRoles && (
<Tooltip.Content align="start" side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background space-y-1',
'border border-background text-xs',
].join(' ')}
>
<p className="text-xs text-foreground-light pr-2">Connections by roles:</p>
{rolesWithActiveConnections.map((role) => (
<div key={role.id} className="text-xs text-foreground">
{role.name}: {role.active_connections}
</div>
))}
You need additional permissions to add a new role
</div>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
type="primary"
disabled={!canUpdateRoles}
icon={<IconPlus size="tiny" />}
onClick={() => setIsCreatingRole(true)}
>
Add role
</Button>
</Tooltip.Trigger>
{!canUpdateRoles && (
<Tooltip.Content align="start" side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background text-xs',
].join(' ')}
>
You need additional permissions to add a new role
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
</div>
)}
</Tooltip.Root>
</div>
</div>
<div className="space-y-4">
<div className="space-y-4">
{supabaseRoles.length > 0 && (
<div>
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t flex items-center space-x-4">
<p className="text-sm text-foreground-light">Roles managed by Supabase</p>
@@ -196,7 +189,7 @@ const RolesList = () => {
{isLoading
? Array.from({ length: 5 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
: supabaseRoles.map((role, i: number) => (
: supabaseRoles.map((role) => (
<RoleRow
disabled
key={role.id}
@@ -205,7 +198,9 @@ const RolesList = () => {
/>
))}
</div>
)}
{otherRoles.length > 0 && (
<div>
<div className="bg-surface-100 border border-default px-6 py-3 rounded-t">
<p className="text-sm text-foreground-light">Other database roles</p>
@@ -213,7 +208,7 @@ const RolesList = () => {
{isLoading
? Array.from({ length: 3 }).map((_, i) => <RoleRowSkeleton key={i} index={i} />)
: otherRoles.map((role, i: number) => (
: otherRoles.map((role) => (
<RoleRow
key={role.id}
disabled={!canUpdateRoles}
@@ -222,13 +217,13 @@ const RolesList = () => {
/>
))}
</div>
</div>
{filterString.length > 0 && filteredRoles.length === 0 && (
<NoSearchResults searchString={filterString} onResetFilter={() => setFilterString('')} />
)}
</div>
{filterString.length > 0 && filteredRoles.length === 0 && (
<NoSearchResults searchString={filterString} onResetFilter={() => setFilterString('')} />
)}
<CreateRolePanel visible={isCreatingRole} onClose={() => setIsCreatingRole(false)} />
<DeleteRoleModal
Loaded 100 of 561 files, more files were not shown because too many files have changed in this diff. Show more