diff --git a/.github/workflows/autofix_linters.yml b/.github/workflows/autofix_linters.yml new file mode 100644 index 00000000000..ca8882d7e04 --- /dev/null +++ b/.github/workflows/autofix_linters.yml @@ -0,0 +1,58 @@ +name: Autofix Linting Checks + +on: + pull_request: + types: + - labeled + +# Cancel old builds on new commit for same workflow + branch/PR +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + autofix: + runs-on: ubuntu-latest + permissions: + contents: write + if: ${{ github.event_name == 'pull_request' && (github.event.label.name == 'autofix') }} + steps: + - name: Calculate number of commits + run: echo "PR_FETCH_DEPTH=$(( ${{ github.event.pull_request.commits }} + 1 ))" >> "${GITHUB_ENV}" + + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + sparse-checkout: apps + fetch-depth: ${{ env.PR_FETCH_DEPTH }} + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + # Installing all dependencies takes up to three minutes, hacking around to only installing prettier+deps + - name: Install required dependencies + run: | + rm package.json + rm package-lock.json + npm i prettier@4.0.0-alpha.8 prettier-plugin-sql-cst + + - name: Run Prettier in fix mode + run: | + npx prettier --write 'apps/**/*.{js,jsx,ts,tsx,css,md,mdx,json}' + + - name: Commit changes and push to existing branch + run: | + git checkout -- package.json + git checkout -- package-lock.json + git config --global user.name 'github-tidy-bot' + git config --global user.email 'github-tidy-bot@supabase.com' + if [[ `git status --porcelain` ]]; then + echo "[bot] Changes detected, committing." + + echo "[bot] Running in non-squash mode." + git commit -am "ci: Autofix updates from GitHub workflow" + git push + else + echo "[bot] No changes detected, nothing to commit." + fi diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index 379248ed601..886a2d41b40 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -27,7 +27,7 @@ jobs: run: | rm package.json rm package-lock.json - npm i prettier@2 prettier-plugin-sql-cst + npm i prettier@4.0.0-alpha.8 prettier-plugin-sql-cst - name: Run prettier run: |- npx prettier -c 'apps/**/*.{js,jsx,ts,tsx,css,md,mdx,json}' @@ -50,7 +50,7 @@ jobs: run: | rm package.json rm package-lock.json - npm i prettier@2 prettier-plugin-sql-cst + npm i prettier@4.0.0-alpha.8 prettier-plugin-sql-cst - name: Run prettier run: |- npx prettier -c 'i18n/**/*.{js,jsx,ts,tsx,css,md,mdx,json}' @@ -63,13 +63,14 @@ jobs: with: sparse-checkout: | apps/docs/pages + apps/docs/content # Installing all dependencies takes up to three minutes, hacking around to only installing prettier+deps - name: Download dependencies run: | rm package.json rm package-lock.json - npm i prettier@2 prettier-plugin-sql-cst + npm i prettier@4.0.0-alpha.8 prettier-plugin-sql-cst - name: Run prettier run: |- # Check mdx files which contain sql code blocks - grep -lr '```sql' apps/docs/pages/**/*.mdx | xargs npx prettier -c + grep -lr '```sql' apps/docs/{pages,content}/**/*.mdx | xargs npx prettier -c diff --git a/.github/workflows/search.yml b/.github/workflows/search.yml index 2fc6b8cd510..b1dd9b1d57a 100644 --- a/.github/workflows/search.yml +++ b/.github/workflows/search.yml @@ -38,6 +38,7 @@ jobs: with: sparse-checkout: | apps/docs + apps/www/.env.local.example supabase - name: Setup node @@ -54,6 +55,10 @@ jobs: - name: Run migrations run: npx supabase db push + # Need the miscellaneous use API, which is available publicly (by design) in www + - name: Copy environment variables + run: cp apps/www/.env.local.example apps/docs/.env + - name: Update embeddings working-directory: ./apps/docs if: ${{ !inputs.refresh }} diff --git a/.prettierrc b/.prettierrc index c60d3ab91ef..5609026b6f4 100644 --- a/.prettierrc +++ b/.prettierrc @@ -7,5 +7,11 @@ "endOfLine": "lf", "sqlKeywordCase": "lower", "pluginSearchDirs": false, - "plugins": ["prettier-plugin-sql-cst"] + "plugins": ["prettier-plugin-sql-cst"], + "overrides": [ + { + "files": "**/*.json", + "options": { "parser": "json" } + } + ] } diff --git a/README.md b/README.md index 069f098bdde..a3cd29f08a3 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,6 @@ To see how to Contribute, visit [Getting Started](./DEVELOPERS.md) - [Email Support](https://supabase.com/docs/support#business-support). Best for: problems with your database or infrastructure. - [Discord](https://discord.supabase.com). Best for: sharing your applications and hanging out with the community. - ## How it works Supabase is a combination of open source tools. We’re building the features of Firebase using enterprise-grade, open source products. If the tools and communities exist, with an MIT, Apache 2, or equivalent open license, we will use and support that tool. If the tool doesn't exist, we build and open source it ourselves. Supabase is not a 1-to-1 mapping of Firebase. Our aim is to give developers a Firebase-like developer experience using open source tools. @@ -49,7 +48,7 @@ Supabase is a combination of open source tools. We’re building the features of Supabase is a [hosted platform](https://supabase.com/dashboard). You can sign up and start using Supabase without installing anything. You can also [self-host](https://supabase.com/docs/guides/hosting/overview) and [develop locally](https://supabase.com/docs/guides/local-development). -![Architecture](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png) +![Architecture](apps/docs/public/img/supabase-architecture.svg) - [Postgres](https://www.postgresql.org/) is an object-relational database system with over 30 years of active development that has earned it a strong reputation for reliability, feature robustness, and performance. - [Realtime](https://github.com/supabase/realtime) is an Elixir server that allows you to listen to PostgreSQL inserts, updates, and deletes using websockets. Realtime polls Postgres' built-in replication functionality for database changes, converts changes to JSON, then broadcasts the JSON over websockets to authorized clients. diff --git a/apps/database-new/app/[threadId]/[runId]/UserChat.tsx b/apps/database-new/app/[threadId]/[runId]/UserChat.tsx index 8abc0683a6e..f1457b55ff6 100644 --- a/apps/database-new/app/[threadId]/[runId]/UserChat.tsx +++ b/apps/database-new/app/[threadId]/[runId]/UserChat.tsx @@ -138,8 +138,8 @@ const UserChat = ({ message, isLatest, times, run }: UserChatProps) => { {replyDuration !== undefined ? ` with ${replyDuration}s response` : isLoading - ? ', generating response...' - : ''} + ? ', generating response...' + : ''}

)} diff --git a/apps/database-new/app/globals.css b/apps/database-new/app/globals.css index bbe375023cc..11c393d6b2d 100644 --- a/apps/database-new/app/globals.css +++ b/apps/database-new/app/globals.css @@ -8,56 +8,64 @@ @font-face { font-family: 'circular'; - src: url(/fonts/custom/CustomFont-Book.woff2) format('woff2'), + src: + url(/fonts/custom/CustomFont-Book.woff2) format('woff2'), url(/fonts/custom/CustomFont-Book.woff) format('woff'); font-weight: 400; font-style: normal; } @font-face { font-family: 'circular'; - src: url(/fonts/custom/CustomFont-BookItalic.woff2) format('woff2'), + src: + url(/fonts/custom/CustomFont-BookItalic.woff2) format('woff2'), url(/fonts/custom/CustomFont-BookItalic.woff) format('woff'); font-weight: 400; font-style: italic; } @font-face { font-family: 'circular'; - src: url(/fonts/custom/CustomFont-Medium.woff2) format('woff2'), + src: + url(/fonts/custom/CustomFont-Medium.woff2) format('woff2'), url(/fonts/custom/CustomFont-Medium.woff) format('woff'); font-weight: 500; font-style: normal; } @font-face { font-family: 'circular'; - src: url(/fonts/custom/CustomFont-MediumItalic.woff2) format('woff2'), + src: + url(/fonts/custom/CustomFont-MediumItalic.woff2) format('woff2'), url(/fonts/custom/CustomFont-MediumItalic.woff) format('woff'); font-weight: 500; font-style: italic; } @font-face { font-family: 'circular'; - src: url(/fonts/custom/CustomFont-Bold.woff2) format('woff2'), + src: + url(/fonts/custom/CustomFont-Bold.woff2) format('woff2'), url(/fonts/custom/CustomFont-Bold.woff) format('woff'); font-weight: 700; font-style: 600; } @font-face { font-family: 'circular'; - src: url(/fonts/custom/CustomFont-BoldItalic.woff2) format('woff2'), + src: + url(/fonts/custom/CustomFont-BoldItalic.woff2) format('woff2'), url(/fonts/custom/CustomFont-BoldItalic.woff) format('woff'); font-style: 600; font-style: italic; } @font-face { font-family: 'circular'; - src: url(/fonts/custom/CustomFont-Black.woff2) format('woff2'), + src: + url(/fonts/custom/CustomFont-Black.woff2) format('woff2'), url(/fonts/custom/CustomFont-Black.woff) format('woff'); font-weight: 800; font-style: normal; } @font-face { font-family: 'circular'; - src: url(/fonts/custom/CustomFont-BlackItalic.woff2) format('woff2'), + src: + url(/fonts/custom/CustomFont-BlackItalic.woff2) format('woff2'), url(/fonts/custom/CustomFont-BlackItalic.woff) format('woff'); font-weight: 800; font-style: italic; @@ -66,7 +74,8 @@ @font-face { font-family: 'source code pro'; src: url('/fonts/source-code-pro/SourceCodePro-Regular.eot'); - src: url('/fonts/source-code-pro/SourceCodePro-Regular.woff2') format('woff2'), + src: + url('/fonts/source-code-pro/SourceCodePro-Regular.woff2') format('woff2'), url('/fonts/source-code-pro/SourceCodePro-Regular.woff') format('woff'), url('/fonts/source-code-pro/SourceCodePro-Regular.ttf') format('truetype'), url('/fonts/source-code-pro/SourceCodePro-Regular.svg#SourceCodePro-Regular') format('svg'); diff --git a/apps/database-new/components/Header/ThemeSwitcher.tsx b/apps/database-new/components/Header/ThemeSwitcher.tsx index 6ae241f5823..02f72e47752 100644 --- a/apps/database-new/components/Header/ThemeSwitcher.tsx +++ b/apps/database-new/components/Header/ThemeSwitcher.tsx @@ -1,5 +1,5 @@ 'use client' -import ThemeToggle from '@ui/components/ThemeProvider/ThemeToggle' +import { ThemeToggle } from 'ui-patterns/ThemeToggle' export default function ThemeSwitcher() { return diff --git a/apps/database-new/next.config.js b/apps/database-new/next.config.js index 5bbe7d31982..9de7ef8a94c 100644 --- a/apps/database-new/next.config.js +++ b/apps/database-new/next.config.js @@ -1,6 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - transpilePackages: ['ui'], + transpilePackages: ['ui', 'ui-patterns'], webpack: (config, { dev, isServer, webpack, nextRuntime }) => { config.module.rules.push({ test: /\.node$/, diff --git a/apps/database-new/package.json b/apps/database-new/package.json index db88150b75c..a57afb3a814 100644 --- a/apps/database-new/package.json +++ b/apps/database-new/package.json @@ -17,7 +17,7 @@ "common": "*", "config": "*", "dayjs": "^1.11.10", - "eslint-config-next": "^14.0.3", + "eslint-config-next": "^14.1.0", "lodash": "^4.17.21", "lucide-react": "^0.292.0", "next": "^14.0.3", @@ -27,6 +27,7 @@ "react-flow": "^1.0.3", "sql-formatter": "^13.1.0", "ui": "*", + "ui-patterns": "*", "valtio": "^1.12.0", "zod": "^3.22.4" }, diff --git a/apps/database-new/tailwind.config.js b/apps/database-new/tailwind.config.js index 394569fad16..f64d449ed98 100644 --- a/apps/database-new/tailwind.config.js +++ b/apps/database-new/tailwind.config.js @@ -5,8 +5,8 @@ export default config({ './pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', - './src/**/*.{ts,tsx}', '../../packages/ui/src/**/*.{tsx,ts,js}', + '../../packages/ui-patterns/**/*.{tsx,ts,js}', ], theme: { extend: { diff --git a/apps/database-new/types/supabase.ts b/apps/database-new/types/supabase.ts index 6a883759f1d..7050f57386e 100644 --- a/apps/database-new/types/supabase.ts +++ b/apps/database-new/types/supabase.ts @@ -46,7 +46,7 @@ export interface Database { columns: ['user_id'] referencedRelation: 'users' referencedColumns: ['id'] - } + }, ] } responses_ai: { @@ -92,7 +92,7 @@ export interface Database { columns: ['user_id'] referencedRelation: 'users' referencedColumns: ['id'] - } + }, ] } threads: { @@ -132,7 +132,7 @@ export interface Database { columns: ['user_id'] referencedRelation: 'users' referencedColumns: ['id'] - } + }, ] } } diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index 155cb496ae3..b18b47afe4b 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -23,7 +23,3 @@ yarn-error.log* **/*/generated/**/* !**/*/generated/.gitkeep !**/*/generated/**/.gitkeep - -# Temporary to avoid storing a lot of duplicate Markdown -# before finalizing the codemod -/content diff --git a/apps/docs/components/AppleSecretGenerator.tsx b/apps/docs/components/AppleSecretGenerator/AppleSecretGenerator.tsx similarity index 100% rename from apps/docs/components/AppleSecretGenerator.tsx rename to apps/docs/components/AppleSecretGenerator/AppleSecretGenerator.tsx diff --git a/apps/docs/components/AppleSecretGenerator/index.tsx b/apps/docs/components/AppleSecretGenerator/index.tsx new file mode 100644 index 00000000000..ea66eadd23a --- /dev/null +++ b/apps/docs/components/AppleSecretGenerator/index.tsx @@ -0,0 +1,11 @@ +import { lazy, Suspense } from 'react' + +const Generator = lazy(() => import('./AppleSecretGenerator')) + +export function AppleSecretGenerator() { + return ( + + + + ) +} diff --git a/apps/docs/components/AuthProviders.tsx b/apps/docs/components/AuthProviders.tsx index ec9b59a1e3f..1c1950ff87c 100644 --- a/apps/docs/components/AuthProviders.tsx +++ b/apps/docs/components/AuthProviders.tsx @@ -1,5 +1,5 @@ +import { IconPanel } from 'ui-patterns/IconPanel' import providers from '../data/authProviders' -import { IconPanel } from 'ui' import Link from 'next/link' export default function AuthProviders({ type }: { type: string }) { diff --git a/apps/docs/components/Extensions.tsx b/apps/docs/components/Extensions.tsx index d8ca49e3c69..7a1e76e5537 100644 --- a/apps/docs/components/Extensions.tsx +++ b/apps/docs/components/Extensions.tsx @@ -1,7 +1,8 @@ import Link from 'next/link' import React, { useState } from 'react' import { extensions } from 'shared-data' -import { GlassPanel, IconX, Input } from 'ui' +import { IconX, Input } from 'ui' +import { GlassPanel } from 'ui-patterns/GlassPanel' type Extension = { name: string diff --git a/apps/docs/components/HomePageCover.tsx b/apps/docs/components/HomePageCover.tsx index d9a603be207..d9ee30ad170 100644 --- a/apps/docs/components/HomePageCover.tsx +++ b/apps/docs/components/HomePageCover.tsx @@ -1,7 +1,8 @@ import Link from 'next/link' -import { IconBackground, IconPanel, IconPlay } from 'ui' +import { IconBackground, IconPlay } from 'ui' import { useBreakpoint } from 'common' import DocsCoverLogo from './DocsCoverLogo' +import { IconPanel } from 'ui-patterns/IconPanel' const HomePageCover = (props) => { const isXs = useBreakpoint(639) @@ -74,7 +75,7 @@ const HomePageCover = (props) => {
-

Getting Started

diff --git a/apps/docs/components/Mermaid/Mermaid.tsx b/apps/docs/components/Mermaid/Mermaid.tsx new file mode 100644 index 00000000000..e2607613ccf --- /dev/null +++ b/apps/docs/components/Mermaid/Mermaid.tsx @@ -0,0 +1,2 @@ +import { Mermaid } from 'mdx-mermaid/lib/Mermaid' +export default Mermaid diff --git a/apps/docs/components/Mermaid/index.tsx b/apps/docs/components/Mermaid/index.tsx new file mode 100644 index 00000000000..b73a5d8033d --- /dev/null +++ b/apps/docs/components/Mermaid/index.tsx @@ -0,0 +1,11 @@ +import { Suspense, lazy } from 'react' + +const MermaidHeavy = lazy(() => import('./Mermaid')) + +export function Mermaid(props) { + return ( + + + + ) +} diff --git a/apps/docs/components/NavData.tsx b/apps/docs/components/NavData.tsx new file mode 100644 index 00000000000..8fb29b5fa23 --- /dev/null +++ b/apps/docs/components/NavData.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from 'react' + +import { navDataForMdx } from './Navigation/NavigationMenu/NavigationMenu.constants' + +export function NavData({ + data, + children, +}: { + data: keyof typeof navDataForMdx + children: (navItems: (typeof navDataForMdx)[keyof typeof navDataForMdx]) => ReactNode +}) { + const navItems = navDataForMdx[data] + return children(navItems) +} diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index ec26db6379d..381cb046e77 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -614,6 +614,10 @@ export const auth = { url: undefined, items: [ { name: 'Overview', url: '/guides/auth/server-side/overview' }, + { + name: 'Next.js guide', + url: '/guides/auth/server-side/nextjs', + }, { name: 'Creating a client', url: '/guides/auth/server-side/creating-a-client', @@ -653,20 +657,20 @@ export const auth = { items: [ { name: 'Part One: JWTs', - url: '/learn/auth-deep-dive/auth-deep-dive-jwts', + url: '/guides/auth/auth-deep-dive/auth-deep-dive-jwts', }, { name: 'Part Two: Row Level Security', - url: '/learn/auth-deep-dive/auth-row-level-security', + url: '/guides/auth/auth-deep-dive/auth-row-level-security', }, { name: 'Part Three: Policies', - url: '/learn/auth-deep-dive/auth-policies', + url: '/guides/auth/auth-deep-dive/auth-policies', }, - { name: 'Part Four: GoTrue', url: '/learn/auth-deep-dive/auth-gotrue' }, + { name: 'Part Four: GoTrue', url: '/guides/auth/auth-deep-dive/auth-gotrue' }, { name: 'Part Five: Google OAuth', - url: '/learn/auth-deep-dive/auth-google-oauth', + url: '/guides/auth/auth-deep-dive/auth-google-oauth', }, ], }, @@ -751,6 +755,10 @@ export const database: NavMenuConstant = { url: '/guides/database/postgres/roles', }, { name: 'Managing secrets with Vault', url: '/guides/database/vault' }, + { + name: 'Superuser Access and Unsupported Operations', + url: '/guides/database/postgres/roles-superuser', + }, ], }, { @@ -938,6 +946,10 @@ export const database: NavMenuConstant = { name: 'Print PostgreSQL Version', url: '/guides/database/postgres/which-version-of-postgres', }, + { + name: 'Replicating from Supabase to External Postgres', + url: '/guides/database/postgres/setup-replication-external', + }, ], }, ], @@ -1622,6 +1634,10 @@ export const platform: NavMenuConstant = { name: 'Enterprise Billing', url: '/guides/platform/enterprise-billing', }, + { + name: 'Billing FAQ', + url: '/guides/platform/billing-faq', + }, ], }, { @@ -2110,3 +2126,9 @@ export const references = [ ], }, ] + +export const navDataForMdx = { + nativeMobileLoginItems: NativeMobileLoginItems, + phoneLoginsItems: PhoneLoginsItems, + socialLoginItems: SocialLoginItems, +} diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index c5727f3b2a6..5ac5557f0d9 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -4,8 +4,7 @@ import Link from 'next/link' import { FC } from 'react' import { Button, IconCommand, IconGitHub, IconSearch, SearchButton } from 'ui' import { useIsLoggedIn, useIsUserLoading } from 'common' - -import ThemeToggle from '@ui/components/ThemeProvider/ThemeToggle' +import { ThemeToggle } from 'ui-patterns/ThemeToggle' const TopNavBar: FC = () => { const isLoggedIn = useIsLoggedIn() diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx index fa68260238a..5f2047470bd 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.ComboBox.tsx @@ -64,8 +64,8 @@ export function ComboBox({ {isLoading ? 'Loading...' : options.length === 0 - ? `No ${name} found` - : selectedOptionDisplayName ?? `Select a ${name}...`} + ? `No ${name} found` + : selectedOptionDisplayName ?? `Select a ${name}...`} diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx index dfe36c3b34d..b316a6cdbb8 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.tsx @@ -98,14 +98,14 @@ function OrgProjectSelector() { const stateSummary: ProjectOrgDataState = isUserLoading ? 'userLoading' : !isLoggedIn - ? 'loggedOut' - : anyIsPending - ? 'loggedIn.dataPending' - : anyIsError - ? 'loggedIn.dataError' - : projects?.length === 0 - ? 'loggedIn.dataSuccess.hasNoData' - : 'loggedIn.dataSuccess.hasData' + ? 'loggedOut' + : anyIsPending + ? 'loggedIn.dataPending' + : anyIsError + ? 'loggedIn.dataError' + : projects?.length === 0 + ? 'loggedIn.dataSuccess.hasNoData' + : 'loggedIn.dataSuccess.hasData' const formattedData: ComboBoxOption[] = useMemo( () => @@ -194,16 +194,16 @@ function BranchSelector() { const stateSummary: BranchesDataState = userLoading ? 'userLoading' : !isLoggedIn - ? 'loggedOut' - : !hasBranches - ? 'loggedIn.noBranches' - : isPending - ? 'loggedIn.branches.dataPending' - : isError - ? 'loggedIn.branches.dataError' - : data.length === 0 - ? 'loggedIn.branches.dataSuccess.noData' - : 'loggedIn.branches.dataSuccess.hasData' + ? 'loggedOut' + : !hasBranches + ? 'loggedIn.noBranches' + : isPending + ? 'loggedIn.branches.dataPending' + : isError + ? 'loggedIn.branches.dataError' + : data.length === 0 + ? 'loggedIn.branches.dataSuccess.noData' + : 'loggedIn.branches.dataSuccess.hasData' const formattedData: ComboBoxOption[] = stateSummary !== 'loggedIn.branches.dataSuccess.hasData' @@ -282,14 +282,14 @@ function VariableView({ variable, className }: { variable: Variable; className?: const stateSummary: VariableDataState = isUserLoading ? 'userLoading' : !isLoggedIn - ? 'loggedOut' - : !ref - ? 'loggedIn.noSelectedProject' - : isPending - ? 'loggedIn.selectedProject.dataPending' - : isError - ? 'loggedIn.selectedProject.dataError' - : 'loggedIn.selectedProject.dataSuccess' + ? 'loggedOut' + : !ref + ? 'loggedIn.noSelectedProject' + : isPending + ? 'loggedIn.selectedProject.dataPending' + : isError + ? 'loggedIn.selectedProject.dataError' + : 'loggedIn.selectedProject.dataSuccess' let variableValue: string = null if (stateSummary === 'loggedIn.selectedProject.dataSuccess') { @@ -319,8 +319,8 @@ function VariableView({ variable, className }: { variable: Variable; className?: stateSummary === 'loggedIn.selectedProject.dataPending' ? 'Loading...' : stateSummary === 'loggedIn.selectedProject.dataSuccess' - ? variableValue - : `YOUR ${prettyFormatVariable[variable].toUpperCase()}` + ? variableValue + : `YOUR ${prettyFormatVariable[variable].toUpperCase()}` } /> diff --git a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.utils.ts b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.utils.ts index a0659cf8aca..519c40c25e0 100644 --- a/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.utils.ts +++ b/apps/docs/components/ProjectConfigVariables/ProjectConfigVariables.utils.ts @@ -8,6 +8,26 @@ export type Branch = BranchesData[number] export type Variable = 'url' | 'anonKey' +function removeDoubleQuotes(str: string) { + return str.replaceAll('"', '') +} + +function escapeDoubleQuotes(str: string) { + return str.replaceAll('"', '\\"') +} + +function unescapeDoubleQuotes(str: string) { + /** + * Regex matches a backslash followed by a double quote, except when the + * backslash is escaped. + * + * Negative lookbehind: + * \\(?:\\\\)* -> An odd number of backslashes (means the next backslash is escaped) + * (?:^|[^\\]) -> Backslash group is at start or follows non-backslash + */ + return str.replace(/(? = { url: 'Project URL', anonKey: 'Anon key', @@ -18,15 +38,19 @@ export function toDisplayNameOrgProject(org: Org, project: Project) { } export function toOrgProjectValue(org: Org, project: Project) { - // @ts-ignore -- problem in OpenAPI spec -- project has ref property - return JSON.stringify([org.id, project.ref, toDisplayNameOrgProject(org, project)]) + return escapeDoubleQuotes( + // @ts-ignore -- problem in OpenAPI spec -- project has ref property + JSON.stringify([org.id, project.ref, removeDoubleQuotes(toDisplayNameOrgProject(org, project))]) + ) } export function fromOrgProjectValue( maybeOrgProject: string ): [string, string, string] | [null, null, null] { try { - const data = JSON.parse(maybeOrgProject) + // not restoring the double quotes on the display name is fine because it's only used for + // command fuzzy search, not for exact/literal matching + const data = JSON.parse(unescapeDoubleQuotes(maybeOrgProject)) if (!Array.isArray(data) || data.length !== 3) { throw Error("Shape of parsed JSON doesn't match form of org and project value") } @@ -37,12 +61,14 @@ export function fromOrgProjectValue( } export function toBranchValue(branch: Branch) { - return JSON.stringify([branch.id, branch.name]) + return escapeDoubleQuotes(JSON.stringify([branch.id, removeDoubleQuotes(branch.name)])) } export function fromBranchValue(maybeBranch: string): [string, string] | [null, null] { try { - const data = JSON.parse(maybeBranch) + // not restoring the double quotes on the branch name is fine because it's only used for + // command fuzzy search, not for exact/literal matching + const data = JSON.parse(unescapeDoubleQuotes(maybeBranch)) if (!Array.isArray(data) || data.length !== 2) { throw Error("Shape of parsed JSON doesn't match form of branch value") } diff --git a/apps/docs/components/RealtimeLimitsEstimater.tsx b/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx similarity index 98% rename from apps/docs/components/RealtimeLimitsEstimater.tsx rename to apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx index d8e153c2d3b..822c95f5a83 100644 --- a/apps/docs/components/RealtimeLimitsEstimater.tsx +++ b/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx @@ -158,8 +158,8 @@ export default function RealtimeLimitsEstimater({}) { {computeAddOn === 'micro' ? 'Micro' : computeAddOn === 'small' - ? 'Small to medium' - : 'Large to 16XL'} + ? 'Small to medium' + : 'Large to 16XL'} diff --git a/apps/docs/components/RealtimeLimitsEstimator/index.tsx b/apps/docs/components/RealtimeLimitsEstimator/index.tsx new file mode 100644 index 00000000000..34fe3d20a42 --- /dev/null +++ b/apps/docs/components/RealtimeLimitsEstimator/index.tsx @@ -0,0 +1,11 @@ +import { lazy, Suspense } from 'react' + +const Estimator = lazy(() => import('./RealtimeLimitsEstimator')) + +export function RealtimeLimitsEstimator() { + return ( + + + + ) +} diff --git a/apps/docs/components/index.tsx b/apps/docs/components/index.tsx index bddb5268caf..a36aea45605 100644 --- a/apps/docs/components/index.tsx +++ b/apps/docs/components/index.tsx @@ -1,80 +1,94 @@ +/** + * This entire file could do with a cleanup and better code-splitting, but one thing at a time. + */ + +// Basic UI things import Link from 'next/link' -import { Alert, Button, CodeBlock, GlassPanel, markdownComponents, Tabs } from 'ui' -import StepHikeCompact from '~/components/StepHikeCompact' +import { Accordion, Admonition, Alert, Button, CodeBlock, markdownComponents, Tabs } from 'ui' +import { GlassPanel } from 'ui-patterns/GlassPanel' +import { IconPanel } from 'ui-patterns/IconPanel' +import { ThemeImage } from 'ui-patterns/ThemeImage' + // Common components +import { CH } from '@code-hike/mdx/components' +import StepHikeCompact from '~/components/StepHikeCompact' import ButtonCard from './ButtonCard' +import { Heading } from './CustomHTMLElements' + +// Reference guide specific +// [Charis] I think we can factor these out so they aren't in the bundle for absolutely everything +import CliGlobalFlagsHandler from '~/components/reference/enrichments/cli/CliGlobalFlagsHandler' +import RefSubLayout from '~/layouts/ref/RefSubLayout' +import RefHeaderSection from './reference/RefHeaderSection' // Other components -import RefSubLayout from '~/layouts/ref/RefSubLayout' -import { Heading } from './CustomHTMLElements' +import AuthProviders from '~/components/AuthProviders' +import Options from '~/components/Options' +import Param from '~/components/Params' +import { ProjectConfigVariables } from './ProjectConfigVariables' + +// Data wrappers +import { NavData } from './NavData' + +// Partials import DatabaseSetup from './MDX/database_setup.mdx' +import GetSessionWarning from './MDX/get_session_warning.mdx' +import HuggingFaceDeployment from './MDX/ai/quickstart_hf_deployment.mdx' +import MigrationWarnings from './MDX/migration_warnings.mdx' import ProjectSetup from './MDX/project_setup.mdx' import QuickstartIntro from './MDX/quickstart_intro.mdx' import SocialProviderSettingsSupabase from './MDX/social_provider_settings_supabase.mdx' import SocialProviderSetup from './MDX/social_provider_setup.mdx' import StorageManagement from './MDX/storage_management.mdx' -import MigrationWarnings from './MDX/migration_warnings.mdx' -import GetSessionWarning from './MDX/get_session_warning.mdx' -import { CH } from '@code-hike/mdx/components' -import RefHeaderSection from './reference/RefHeaderSection' -import { ProjectConfigVariables } from './ProjectConfigVariables' -// Ref version specific -import CliGlobalFlagsHandler from '~/components/reference/enrichments/cli/CliGlobalFlagsHandler' - -import Options from '~/components/Options' -import Param from '~/components/Params' - -import { Admonition, ThemeImage } from 'ui' +// Icons import { - IconMenuJavascript, - IconMenuHome, - IconMenuGettingStarted, - IconMenuDatabase, - IconMenuRestApis, - IconMenuAuth, - IconMenuEdgeFunctions, - IconMenuRealtime, - IconMenuStorage, - IconMenuPlatform, - IconMenuResources, - IconMenuSelfHosting, - IconMenuIntegrations, - IconMenuFlutter, - IconMenuPython, - IconMenuCsharp, - IconMenuSwift, - IconMenuKotlin, IconMenuApi, + IconMenuAuth, IconMenuCli, + IconMenuCsharp, + IconMenuDatabase, + IconMenuEdgeFunctions, + IconMenuFlutter, + IconMenuGettingStarted, + IconMenuHome, + IconMenuIntegrations, + IconMenuJavascript, + IconMenuKotlin, + IconMenuPlatform, + IconMenuPython, + IconMenuRealtime, + IconMenuResources, + IconMenuRestApis, + IconMenuSelfHosting, + IconMenuStorage, + IconMenuSwift, } from './Navigation/NavigationMenu/HomeMenuIcons' +// Heavy/rare (lazy-loaded) +import { AppleSecretGenerator } from './AppleSecretGenerator' +import { Mermaid } from './Mermaid' +import { RealtimeLimitsEstimator } from './RealtimeLimitsEstimator' + const components = { ...markdownComponents, + Accordion, Admonition, - Button, - ButtonCard, - CH, - CodeBlock, - GlassPanel, - Link, - QuickstartIntro, - DatabaseSetup, - ProjectSetup, - MigrationWarnings, - GetSessionWarning, - SocialProviderSetup, - SocialProviderSettingsSupabase, - StepHikeCompact, - ProjectConfigVariables, - StorageManagement, Alert: (props: any) => ( {props.children} ), - Tabs: (props: any) => , - TabPanel: (props: any) => {props.children}, + AppleSecretGenerator, + AuthProviders, + Button, + ButtonCard, + CH, + CliGlobalFlagsHandler: () => , + CodeBlock, + DatabaseSetup, + GetSessionWarning, + GlassPanel, h2: (props: any) => ( {props.children} @@ -90,32 +104,47 @@ const components = { {props.children} ), + HuggingFaceDeployment, + IconMenuApi, + IconMenuAuth, + IconMenuCli, + IconMenuCsharp, + IconMenuDatabase, + IconMenuEdgeFunctions, + IconMenuFlutter, + IconMenuGettingStarted, + IconMenuHome, + IconMenuIntegrations, + IconMenuJavascript, + IconMenuKotlin, + IconMenuPlatform, + IconMenuPython, + IconMenuRealtime, + IconMenuResources, + IconMenuRestApis, + IconMenuSelfHosting, + IconMenuStorage, + IconMenuSwift, + IconPanel, Image: (props: any) => , - RefSubLayout, - RefHeaderSection: (props: any) => , - CliGlobalFlagsHandler: () => , + Link, + Mermaid, + MigrationWarnings, + NavData, Options, Param, - IconMenuJavascript, - IconMenuHome, - IconMenuGettingStarted, - IconMenuDatabase, - IconMenuRestApis, - IconMenuAuth, - IconMenuEdgeFunctions, - IconMenuRealtime, - IconMenuStorage, - IconMenuPlatform, - IconMenuResources, - IconMenuSelfHosting, - IconMenuIntegrations, - IconMenuFlutter, - IconMenuPython, - IconMenuCsharp, - IconMenuKotlin, - IconMenuSwift, - IconMenuApi, - IconMenuCli, + ProjectConfigVariables, + ProjectSetup, + QuickstartIntro, + RealtimeLimitsEstimator, + RefHeaderSection: (props: any) => , + RefSubLayout, + SocialProviderSettingsSupabase, + SocialProviderSetup, + StepHikeCompact, + StorageManagement, + TabPanel: (props: any) => {props.children}, + Tabs: (props: any) => , } export default components diff --git a/apps/docs/components/reference/RefFunctionSection.tsx b/apps/docs/components/reference/RefFunctionSection.tsx index 733e07030f0..19d14023f54 100644 --- a/apps/docs/components/reference/RefFunctionSection.tsx +++ b/apps/docs/components/reference/RefFunctionSection.tsx @@ -126,14 +126,14 @@ const RefFunctionSection: React.FC = (props) => { const codeBlockLang = example?.code?.startsWith('```js') ? 'js' : example?.code?.startsWith('```ts') - ? 'ts' - : example?.code?.startsWith('```dart') - ? 'dart' - : example?.code?.startsWith('```c#') - ? 'csharp' - : example?.code?.startsWith('```kotlin') - ? 'kotlin' - : 'js' + ? 'ts' + : example?.code?.startsWith('```dart') + ? 'dart' + : example?.code?.startsWith('```c#') + ? 'csharp' + : example?.code?.startsWith('```kotlin') + ? 'kotlin' + : 'js' // ` // import { createClient } from '@supabase/supabase-js' diff --git a/apps/docs/pages/guides/ai.mdx b/apps/docs/content/guides/ai.mdx similarity index 73% rename from apps/docs/pages/guides/ai.mdx rename to apps/docs/content/guides/ai.mdx index a1abf0d2ba8..66c7ffbedae 100644 --- a/apps/docs/pages/guides/ai.mdx +++ b/apps/docs/content/guides/ai.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai', - title: 'AI & Vectors', - description: 'The best vector database is the database you already have.', - subtitle: 'The best vector database is the database you already have.', - sidebar_label: 'Overview', -} +--- +id: 'ai' +title: 'AI & Vectors' +description: 'The best vector database is the database you already have.' +subtitle: 'The best vector database is the database you already have.' +hideToc: true +--- Supabase provides an open source toolkit for developing AI applications using Postgres and pgvector. Use the Supabase client libraries to store, index, and query your vector embeddings at scale. @@ -109,7 +107,26 @@ export const integrations = [ ## Case studies
- {customers.map((x) => ( + {[ + { + name: 'Berri AI Boosts Productivity by Migrating from AWS RDS to Supabase with pgvector', + description: + 'Learn how Berri AI overcame challenges with self-hosting their vector database on AWS RDS and successfully migrated to Supabase.', + href: 'https://supabase.com/customers/berriai', + }, + { + name: 'Mendable switches from Pinecone to Supabase for PostgreSQL vector embeddings', + description: + 'How Mendable boosts efficiency and accuracy of chat powered search for documentation using Supabase with pgvector', + href: 'https://supabase.com/customers/mendableai', + }, + { + name: 'Markprompt: GDPR-Compliant AI Chatbots for Docs and Websites', + description: + "AI-powered chatbot platform, Markprompt, empowers developers to deliver efficient and GDPR-compliant prompt experiences on top of their content, by leveraging Supabase's secure and privacy-focused database and authentication solutions", + href: 'https://supabase.com/customers/markprompt', + }, + ].map((x) => (
{x.description} @@ -117,28 +134,3 @@ export const integrations = [
))}
- -export const customers = [ - { - name: 'Berri AI Boosts Productivity by Migrating from AWS RDS to Supabase with pgvector', - description: - 'Learn how Berri AI overcame challenges with self-hosting their vector database on AWS RDS and successfully migrated to Supabase.', - href: 'https://supabase.com/customers/berriai', - }, - { - name: 'Mendable switches from Pinecone to Supabase for PostgreSQL vector embeddings', - description: - 'How Mendable boosts efficiency and accuracy of chat powered search for documentation using Supabase with pgvector', - href: 'https://supabase.com/customers/mendableai', - }, - { - name: 'Markprompt: GDPR-Compliant AI Chatbots for Docs and Websites', - description: - "AI-powered chatbot platform, Markprompt, empowers developers to deliver efficient and GDPR-compliant prompt experiences on top of their content, by leveraging Supabase's secure and privacy-focused database and authentication solutions", - href: 'https://supabase.com/customers/markprompt', - }, -] - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/choosing-compute-addon.mdx b/apps/docs/content/guides/ai/choosing-compute-addon.mdx similarity index 97% rename from apps/docs/pages/guides/ai/choosing-compute-addon.mdx rename to apps/docs/content/guides/ai/choosing-compute-addon.mdx index b76b416a5d7..fec64e9167f 100644 --- a/apps/docs/pages/guides/ai/choosing-compute-addon.mdx +++ b/apps/docs/content/guides/ai/choosing-compute-addon.mdx @@ -1,14 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import { Tabs } from 'ui' -export const TabPanel = Tabs.Panel - -export const meta = { - id: 'ai-choosing-compute-addon', - title: 'Choosing your Compute Add-on', - description: 'Choosing the right Compute Add-on for your vector workload.', - subtitle: 'Choosing the right Compute Add-on for your vector workload.', - sidebar_label: 'Choosing Compute Add-on', -} +--- +id: 'ai-choosing-compute-addon' +title: 'Choosing your Compute Add-on' +description: 'Choosing the right Compute Add-on for your vector workload.' +subtitle: 'Choosing the right Compute Add-on for your vector workload.' +sidebar_label: 'Choosing Compute Add-on' +--- You have two options for scaling your vector workload: @@ -339,7 +335,3 @@ We follow techniques outlined in the [ANN Benchmarks](https://github.com/erikber Each test is run for a minimum of 30-40 minutes. They include a series of experiments executed at different concurrency levels to measure the engine's performance under different load types. The results are then averaged. As a general recommendation, we suggest using a concurrency level of 5 or more for most workloads and 30 or more for high-load workloads. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/concepts.mdx b/apps/docs/content/guides/ai/concepts.mdx similarity index 91% rename from apps/docs/pages/guides/ai/concepts.mdx rename to apps/docs/content/guides/ai/concepts.mdx index 27d71515f33..b1265c97885 100644 --- a/apps/docs/pages/guides/ai/concepts.mdx +++ b/apps/docs/content/guides/ai/concepts.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-concepts', - title: 'Concepts', - description: 'Learn about embeddings within AI and vector applications.', - sidebar_label: 'Concepts', -} +--- +id: 'ai-concepts' +title: 'Concepts' +description: 'Learn about embeddings within AI and vector applications.' +sidebar_label: 'Concepts' +--- Embeddings are core to many AI and vector applications. This guide covers these concepts. If you prefer to get started right away, see our guide on [Generating Embeddings](/docs/guides/ai/quickstarts/generate-text-embeddings). @@ -61,7 +59,3 @@ Why is this useful? Once we have generated embeddings on multiple texts, it is t ## See also - [Structured and Unstructured embeddings](/docs/guides/ai/structured-unstructured) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/engineering-for-scale.mdx b/apps/docs/content/guides/ai/engineering-for-scale.mdx similarity index 91% rename from apps/docs/pages/guides/ai/engineering-for-scale.mdx rename to apps/docs/content/guides/ai/engineering-for-scale.mdx index c0e02ebe320..17a4a27f9c3 100644 --- a/apps/docs/pages/guides/ai/engineering-for-scale.mdx +++ b/apps/docs/content/guides/ai/engineering-for-scale.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-engineering-for-scale', - title: 'Engineering for Scale', - description: 'Building an enterprise-grade vector architecture', - subtitle: 'Building an enterprise-grade vector architecture.', - sidebar_label: 'Engineering for Scale', -} +--- +id: 'ai-engineering-for-scale' +title: 'Engineering for Scale' +description: 'Building an enterprise-grade vector architecture' +subtitle: 'Building an enterprise-grade vector architecture.' +sidebar_label: 'Engineering for Scale' +--- Content sources for vectors can be extremely large. As you grow you should run your Vector workloads across several secondary databases (sometimes called "pods"), which allows each collection to scale independently. @@ -145,7 +143,3 @@ This diagram provides an example architecture that allows you to access the coll }} /> - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/examples/building-chatgpt-plugins.mdx b/apps/docs/content/guides/ai/examples/building-chatgpt-plugins.mdx similarity index 95% rename from apps/docs/pages/guides/ai/examples/building-chatgpt-plugins.mdx rename to apps/docs/content/guides/ai/examples/building-chatgpt-plugins.mdx index 39a81955b67..a37988c88c2 100644 --- a/apps/docs/pages/guides/ai/examples/building-chatgpt-plugins.mdx +++ b/apps/docs/content/guides/ai/examples/building-chatgpt-plugins.mdx @@ -1,11 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import StepHikeCompact from '~/components/StepHikeCompact' - -export const meta = { - title: 'Building ChatGPT plugins', - subtitle: 'Use Supabase as a Retrieval Store for your ChatGPT plugin.', - breadcrumb: 'AI Examples', -} +--- +title: 'Building ChatGPT plugins' +subtitle: 'Use Supabase as a Retrieval Store for your ChatGPT plugin.' +breadcrumb: 'AI Examples' +--- ChatGPT recently released [Plugins](https://openai.com/blog/chatgpt-plugins) which help ChatGPT access up-to-date information, run computations, or use third-party services. If you're building a plugin for ChatGPT, you'll probably want to answer questions from a specific source. We can solve this with “retrieval plugins”, which allow ChatGPT to access information from a database. @@ -151,6 +148,3 @@ And after ChatGPT receives a response from the plugin it will answer your questi - ChatGPT Retrieval Plugin: [github.com/openai/chatgpt-retrieval-plugin](https://github.com/openai/chatgpt-retrieval-plugin) - ChatGPT Plugins: [official documentation](https://platform.openai.com/docs/plugins/introduction) - -export const Page = ({ children }) => -export default Page diff --git a/apps/docs/pages/guides/ai/examples/headless-vector-search.mdx b/apps/docs/content/guides/ai/examples/headless-vector-search.mdx similarity index 90% rename from apps/docs/pages/guides/ai/examples/headless-vector-search.mdx rename to apps/docs/content/guides/ai/examples/headless-vector-search.mdx index 9267986b634..f24c71283f0 100644 --- a/apps/docs/pages/guides/ai/examples/headless-vector-search.mdx +++ b/apps/docs/content/guides/ai/examples/headless-vector-search.mdx @@ -1,12 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import StepHikeCompact from '~/components/StepHikeCompact' - -export const meta = { - title: 'Adding generative Q&A for your documentation', - subtitle: - 'Learn how to build a ChatGPT-style doc search powered using our headless search toolkit.', - breadcrumb: 'AI Examples', -} +--- +title: 'Adding generative Q&A for your documentation' +subtitle: 'Learn how to build a ChatGPT-style doc search powered using our headless search toolkit.' +breadcrumb: 'AI Examples' +--- Supabase provides a [Headless Search Toolkit](https://github.com/supabase/headless-vector-search) for adding "Generative Q&A" to your documentation. The toolkit is "headless", so that you can integrate it into your existing website and style it to match your website theme. @@ -37,7 +33,7 @@ There are 3 steps to build similarity search inside your documentation: ### Prepare your database -To prepare, create a [new Supabase project](https://database.new) and store the database and API credentials, which you can find in the project [settings](https://supabase.com/dashboard/_/settings). +To prepare, create a [new Supabase project](https://database.new) and store the database and API credentials, which you can find in the project [settings](https://supabase.com/dashboard/project/_/settings). Now we can use the [Headless Vector Search](https://github.com/supabase/headless-vector-search#set-up) instructions to set up the database: @@ -119,6 +115,3 @@ const onSubmit = (e: Event) => { - Read about how we built [ChatGPT for the Supabase Docs](https://supabase.com/blog/chatgpt-supabase-docs). - Read the pgvector Docs for [Embeddings and vector similarity](/docs/guides/database/extensions/pgvector) - See how to build something like this from scratch [using Next.js](/docs/guides/ai/examples/nextjs-vector-search). - -export const Page = ({ children }) => -export default Page diff --git a/apps/docs/pages/guides/ai/examples/huggingface-image-captioning.mdx b/apps/docs/content/guides/ai/examples/huggingface-image-captioning.mdx similarity index 86% rename from apps/docs/pages/guides/ai/examples/huggingface-image-captioning.mdx rename to apps/docs/content/guides/ai/examples/huggingface-image-captioning.mdx index 29dfde30de1..64012a95fea 100644 --- a/apps/docs/pages/guides/ai/examples/huggingface-image-captioning.mdx +++ b/apps/docs/content/guides/ai/examples/huggingface-image-captioning.mdx @@ -1,14 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - title: 'Generate image captions using Hugging Face', - description: - 'Use the Hugging Face Inference API to make calls to 100,000+ Machine Learning models from Supabase Edge Functions.', - subtitle: - 'Use the Hugging Face Inference API to make calls to 100,000+ Machine Learning models from Supabase Edge Functions.', - video: 'https://www.youtube.com/v/OgnYxRkxEUw', - tocVideo: 'OgnYxRkxEUw', -} +--- +title: 'Generate image captions using Hugging Face' +description: 'Use the Hugging Face Inference API to make calls to 100,000+ Machine Learning models from Supabase Edge Functions.' +subtitle: 'Use the Hugging Face Inference API to make calls to 100,000+ Machine Learning models from Supabase Edge Functions.' +video: 'https://www.youtube.com/v/OgnYxRkxEUw' +tocVideo: 'OgnYxRkxEUw' +--- We can combine Hugging Face with [Supabase Storage](https://supabase.com/storage) and [Database Webhooks](https://supabase.com/docs/guides/database/webhooks) to automatically caption for any image we upload to a storage bucket. @@ -93,7 +89,3 @@ serve(async (req) => { return new Response('ok') }) ``` - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/examples/image-search-openai-clip.mdx b/apps/docs/content/guides/ai/examples/image-search-openai-clip.mdx similarity index 93% rename from apps/docs/pages/guides/ai/examples/image-search-openai-clip.mdx rename to apps/docs/content/guides/ai/examples/image-search-openai-clip.mdx index 40f9859aa26..27e446a32d5 100644 --- a/apps/docs/pages/guides/ai/examples/image-search-openai-clip.mdx +++ b/apps/docs/content/guides/ai/examples/image-search-openai-clip.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'examples-image-search-python', - title: 'Image Search with OpenAI CLIP', - description: 'Implement image search with the OpenAI CLIP Model and Supabase Vector.', - subtitle: 'Implement image search with the OpenAI CLIP Model and Supabase Vector.', -} +--- +id: 'examples-image-search-python' +title: 'Image Search with OpenAI CLIP' +description: 'Implement image search with the OpenAI CLIP Model and Supabase Vector.' +subtitle: 'Implement image search with the OpenAI CLIP Model and Supabase Vector.' +--- The [OpenAI CLIP Model](https://github.com/openai/CLIP) was trained on a variety of (image, text)-pairs. You can use the CLIP model for: @@ -171,7 +169,3 @@ That's it, go ahead and test it out by running `poetry run search` and you will ## Conclusion With just a couple of lines of Python you are able to implement image search as well as reverse image search using OpenAI's CLIP model and Supabase Vector. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/examples/nextjs-vector-search.mdx b/apps/docs/content/guides/ai/examples/nextjs-vector-search.mdx similarity index 97% rename from apps/docs/pages/guides/ai/examples/nextjs-vector-search.mdx rename to apps/docs/content/guides/ai/examples/nextjs-vector-search.mdx index 2d6986fb92c..2cd43c7944b 100644 --- a/apps/docs/pages/guides/ai/examples/nextjs-vector-search.mdx +++ b/apps/docs/content/guides/ai/examples/nextjs-vector-search.mdx @@ -1,14 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import StepHikeCompact from '~/components/StepHikeCompact' - -export const meta = { - title: 'Vector search with Next.js and OpenAI', - subtitle: - 'Learn how to build a ChatGPT-style doc search powered by Next.js, OpenAI, and Supabase.', - breadcrumb: 'AI Examples', - video: 'https://www.youtube.com/v/xmfNUCjszh4', - tocVideo: 'xmfNUCjszh4', -} +--- +title: 'Vector search with Next.js and OpenAI' +subtitle: 'Learn how to build a ChatGPT-style doc search powered by Next.js, OpenAI, and Supabase.' +breadcrumb: 'AI Examples' +video: 'https://www.youtube.com/v/xmfNUCjszh4' +tocVideo: 'xmfNUCjszh4' +--- While our [Headless Vector search](/docs/guides/ai/examples/headless-vector-search) provides a toolkit for generative Q&A, in this tutorial we'll go more in-depth, build a custom ChatGPT-like search experience from the ground-up using Next.js. You will: @@ -543,6 +539,3 @@ Want to learn more about the awesome tech that is powering this? allowFullScreen > - -export const Page = ({ children }) => -export default Page diff --git a/apps/docs/pages/guides/ai/examples/openai.mdx b/apps/docs/content/guides/ai/examples/openai.mdx similarity index 85% rename from apps/docs/pages/guides/ai/examples/openai.mdx rename to apps/docs/content/guides/ai/examples/openai.mdx index 7bf0c3b07ae..b437530179c 100644 --- a/apps/docs/pages/guides/ai/examples/openai.mdx +++ b/apps/docs/content/guides/ai/examples/openai.mdx @@ -1,13 +1,11 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'examples-openai', - title: 'Generating OpenAI GPT3 completions', - description: 'Generate GPT text completions using OpenAI and Supabase Edge Functions.', - subtitle: 'Generate GPT text completions using OpenAI and Supabase Edge Functions.', - video: 'https://www.youtube-nocookie.com/v/29p8kIqyU_Y', - tocVideo: '29p8kIqyU_Y', -} +--- +id: 'examples-openai' +title: 'Generating OpenAI GPT3 completions' +description: 'Generate GPT text completions using OpenAI and Supabase Edge Functions.' +subtitle: 'Generate GPT text completions using OpenAI and Supabase Edge Functions.' +video: 'https://www.youtube-nocookie.com/v/29p8kIqyU_Y' +tocVideo: '29p8kIqyU_Y' +--- OpenAI provides a [completions API](https://platform.openai.com/docs/api-reference/completions) that allows you to use their generative GPT models in your own applications. @@ -37,7 +35,7 @@ We'll design the function to take your user's query (via POST request) and forwa import OpenAI from 'https://deno.land/x/openai@v4.24.0/mod.ts' Deno.serve(async (req) => { - const query = await req.json() + const { query } = await req.json() const apiKey = Deno.env.get('OPENAI_API_KEY') const openai = new OpenAI({ apiKey: apiKey, @@ -112,7 +110,3 @@ If you're interesting in learning how to use this to build your own ChatGPT, rea allowFullScreen > - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/going-to-prod.mdx b/apps/docs/content/guides/ai/going-to-prod.mdx similarity index 95% rename from apps/docs/pages/guides/ai/going-to-prod.mdx rename to apps/docs/content/guides/ai/going-to-prod.mdx index bf133b70b10..010748a695e 100644 --- a/apps/docs/pages/guides/ai/going-to-prod.mdx +++ b/apps/docs/content/guides/ai/going-to-prod.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-going-to-prod', - title: 'Going to Production', - description: 'Checklist for going to production with your AI application.', - subtitle: 'Going to production checklist for AI applications.', - sidebar_label: 'Going to Production', -} +--- +id: 'ai-going-to-prod' +title: 'Going to Production' +description: 'Checklist for going to production with your AI application.' +subtitle: 'Going to production checklist for AI applications.' +sidebar_label: 'Going to Production' +--- This guide will help you to prepare your application for production. We'll provide actionable steps to help you scale your application, ensure that it is reliable, can handle the load, and provide optimal accuracy for your use case. @@ -125,7 +123,3 @@ Or take a look at our [pgvector 0.5.0 performance](https://supabase.com/blog/inc }} /> - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/google-colab.mdx b/apps/docs/content/guides/ai/google-colab.mdx similarity index 85% rename from apps/docs/pages/guides/ai/google-colab.mdx rename to apps/docs/content/guides/ai/google-colab.mdx index ef9cb7325f5..ddd4c76177f 100644 --- a/apps/docs/pages/guides/ai/google-colab.mdx +++ b/apps/docs/content/guides/ai/google-colab.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-google-colab', - title: 'Google Colab', - description: 'Use Google Colab to manage your Supabase Vector store.', - subtitle: 'Use Google Colab to manage your Supabase Vector store.', - sidebar_label: 'Google Colab', -} +--- +id: 'ai-google-colab' +title: 'Google Colab' +description: 'Use Google Colab to manage your Supabase Vector store.' +subtitle: 'Use Google Colab to manage your Supabase Vector store.' +sidebar_label: 'Google Colab' +--- - -export default Page diff --git a/apps/docs/pages/guides/ai/hugging-face.mdx b/apps/docs/content/guides/ai/hugging-face.mdx similarity index 95% rename from apps/docs/pages/guides/ai/hugging-face.mdx rename to apps/docs/content/guides/ai/hugging-face.mdx index 0b36104c8e8..ee902d128ee 100644 --- a/apps/docs/pages/guides/ai/hugging-face.mdx +++ b/apps/docs/content/guides/ai/hugging-face.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-hugging-face', - title: 'Hugging Face Inference API', - description: 'Learn how to integrate hugging face models with Supabase', - sidebar_label: 'Hugging Face', -} +--- +id: 'ai-hugging-face' +title: 'Hugging Face Inference API' +description: 'Learn how to integrate hugging face models with Supabase' +sidebar_label: 'Hugging Face' +--- [Hugging Face](https://huggingface.co) is an open source hub for AI/ML models and tools. With over 100,000 machine learning models available, Hugging Face provides a great way to integrate specialized AI & ML tasks into your application. @@ -170,7 +168,3 @@ Try running some other [AI tasks](#ai-tasks). - Official [Hugging Face site](https://huggingface.co/). - Official [Hugging Face JS docs](https://huggingface.co/docs/huggingface.js). - [Generate image captions](/docs/guides/ai/examples/huggingface-image-captioning) using Hugging Face. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/integrations/llamaindex.mdx b/apps/docs/content/guides/ai/integrations/llamaindex.mdx similarity index 70% rename from apps/docs/pages/guides/ai/integrations/llamaindex.mdx rename to apps/docs/content/guides/ai/integrations/llamaindex.mdx index b1aa7f80b2a..af85a7b8cd9 100644 --- a/apps/docs/pages/guides/ai/integrations/llamaindex.mdx +++ b/apps/docs/content/guides/ai/integrations/llamaindex.mdx @@ -1,13 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-integration-llamaindex', - title: - 'Learn how to integrate Supabase with LlamaIndex, a data framework for your LLM applications.', - subtitle: - 'Learn how to integrate Supabase with LlamaIndex, a data framework for your LLM applications.', - breadcrumb: 'AI Integrations', -} +--- +id: 'ai-integration-llamaindex' +title: 'Learn how to integrate Supabase with LlamaIndex, a data framework for your LLM applications.' +subtitle: 'Learn how to integrate Supabase with LlamaIndex, a data framework for your LLM applications.' +breadcrumb: 'AI Integrations' +--- This guide will walk you through a basic example using the LlamaIndex [SupabaseVectorStore](https://github.com/supabase/supabase/blob/master/examples/ai/llamaindex/llamaindex.ipynb). @@ -46,7 +42,19 @@ DB_CONNECTION = "postgresql://:@:/" vx = vecs.create_client(DB_CONNECTION) ``` -Replace the `DB_CONNECTION` with your own connection string for your database, which you set up in first step of this guide. +Replace the `DB_CONNECTION` with your own connection string for your database. You can find the Postgres connection string in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database) of your Supabase project. + + + +SQLAlchemy requires the connection string to start with `postgresql://` (instead of `postgres://`). Don't forget to rename this after copying the string from the dashboard. + + + + + +You must use the "connection pooling" string (domain ending in `*.pooler.supabase.com`) with Google Colab since Colab does not support IPv6. + + ## Stepping through the notebook @@ -60,7 +68,3 @@ You can view the inserted items in the [Table Editor](https://supabase.com/dashb - Visit the LlamaIndex + SupabaseVectorStore [docs](https://gpt-index.readthedocs.io/en/latest/examples/vector_stores/SupabaseVectorIndexDemo.html) - Visit the official LlamaIndex [repo](https://github.com/jerryjliu/llama_index/) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/integrations/roboflow.mdx b/apps/docs/content/guides/ai/integrations/roboflow.mdx similarity index 95% rename from apps/docs/pages/guides/ai/integrations/roboflow.mdx rename to apps/docs/content/guides/ai/integrations/roboflow.mdx index 568fd734608..a388b4fbe68 100644 --- a/apps/docs/pages/guides/ai/integrations/roboflow.mdx +++ b/apps/docs/content/guides/ai/integrations/roboflow.mdx @@ -1,12 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-integration-roboflow', - title: 'Roboflow', - subtitle: - 'Learn how to integrate Supabase with Roboflow, a tool for running fine-tuned and foundation vision models.', - breadcrumb: 'AI Integrations', -} +--- +id: 'ai-integration-roboflow' +title: 'Roboflow' +subtitle: 'Learn how to integrate Supabase with Roboflow, a tool for running fine-tuned and foundation vision models.' +breadcrumb: 'AI Integrations' +--- In this guide, we will walk through two examples of using [Roboflow Inference](https://inference.roboflow.com) to run fine-tuned and foundation models. We will run inference and save predictions using an object detection model and [CLIP](https://github.com/openai/CLIP). @@ -238,7 +235,3 @@ print(result[0]) - [Roboflow Inference documentation](https://inference.roboflow.com) - [Roboflow Getting Started guide](https://blog.roboflow.com/getting-started-with-roboflow/) - [How to Build a Semantic Image Search Engine with Supabase and OpenAI CLIP](https://blog.roboflow.com/how-to-use-semantic-search-supabase-openai-clip/) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/langchain.mdx b/apps/docs/content/guides/ai/langchain.mdx similarity index 94% rename from apps/docs/pages/guides/ai/langchain.mdx rename to apps/docs/content/guides/ai/langchain.mdx index 967bf23e14e..df6a2b80dde 100644 --- a/apps/docs/pages/guides/ai/langchain.mdx +++ b/apps/docs/content/guides/ai/langchain.mdx @@ -1,14 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import { Tabs } from 'ui' -export const TabPanel = Tabs.Panel - -export const meta = { - id: 'ai-lang-chain', - title: 'LangChain', - description: - 'Learn how to integrate Supabase with LangChain, a popular framework for composing AI, Vectors, and embeddings', - sidebar_label: 'LangChain', -} +--- +id: 'ai-lang-chain' +title: 'LangChain' +description: 'Learn how to integrate Supabase with LangChain, a popular framework for composing AI, Vectors, and embeddings' +sidebar_label: 'LangChain' +--- [LangChain](https://langchain.com/) is a popular framework for working with AI, Vectors, and embeddings. LangChain supports using Supabase as a [vector store](https://js.langchain.com/docs/modules/indexes/vector_stores/integrations/supabase), using the `pgvector` extension. @@ -230,7 +225,3 @@ You can install the LangChain Hybrid Search function though our [database.dev pa - Official [LangChain site](https://langchain.com/). - Official [LangChain docs](https://js.langchain.com/docs/modules/indexes/vector_stores/integrations/supabase). - Supabase [Hybrid Search](https://js.langchain.com/docs/modules/indexes/retrievers/supabase-hybrid). - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/python-clients.mdx b/apps/docs/content/guides/ai/python-clients.mdx similarity index 58% rename from apps/docs/pages/guides/ai/python-clients.mdx rename to apps/docs/content/guides/ai/python-clients.mdx index 6e6695d3592..189ad97ec24 100644 --- a/apps/docs/pages/guides/ai/python-clients.mdx +++ b/apps/docs/content/guides/ai/python-clients.mdx @@ -1,18 +1,18 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-python-clients', - title: 'Choosing a Client', - description: 'Learn how to manage vectors using Python', - sidebar_label: 'Choosing a Client', -} +--- +id: 'ai-python-clients' +title: 'Choosing a Client' +description: 'Learn how to manage vectors using Python' +sidebar_label: 'Choosing a Client' +--- As described in [Structured & Unstructured Embeddings](/docs/guides/ai/structured-unstructured), AI workloads come in many forms. For data science or ephemeral workloads, the [Supabase Vecs](https://supabase.github.io/vecs/) client gets you started quickly. All you need is a connection string and vecs handles setting up your database to store and query vectors with associated metadata. + + +You can get your connection string from the [**Database Settings**](https://supabase.com/dashboard/project/_/settings/database) page in your dashboard. Make sure to check **Use connection pooling**, then copy the URI. Also, change the URI scheme from `postgres` to `postgresql`. `vecs` uses SQLAlchemy under the hood, which only supports `postgresql` as a dialect. + + + For production python applications with version controlled migrations, we recommend adding first class vector support to your toolchain by [registering the vector type with your ORM](https://github.com/pgvector/pgvector-python). pgvector provides bindings for the most commonly used SQL drivers/libraries including Django, SQLAlchemy, SQLModel, psycopg, asyncpg and Peewee. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/quickstarts/face-similarity.mdx b/apps/docs/content/guides/ai/quickstarts/face-similarity.mdx similarity index 74% rename from apps/docs/pages/guides/ai/quickstarts/face-similarity.mdx rename to apps/docs/content/guides/ai/quickstarts/face-similarity.mdx index 160ed479b6d..496e068c3ee 100644 --- a/apps/docs/pages/guides/ai/quickstarts/face-similarity.mdx +++ b/apps/docs/content/guides/ai/quickstarts/face-similarity.mdx @@ -1,11 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-vecs-python-client', - title: 'Face similarity search', - subtitle: 'Identify the celebrities who look most similar to you using Supabase Vecs.', - breadcrumb: 'AI Quickstarts', -} +--- +id: 'ai-vecs-python-client' +title: 'Face similarity search' +subtitle: 'Identify the celebrities who look most similar to you using Supabase Vecs.' +--- This guide will walk you through a ["Face Similarity Search"](https://github.com/supabase/supabase/blob/master/examples/ai/face_similarity.ipynb) example using Colab and Supabase Vecs. You will be able to identify the celebrities who look most similar to you (or any other person). You will: @@ -43,7 +40,19 @@ DB_CONNECTION = "postgresql://:@:/" vx = vecs.create_client(DB_CONNECTION) ``` -Replace the `DB_CONNECTION` with your own connection string for your database, which you set up in first step of this guide. +Replace the `DB_CONNECTION` with your own connection string for your database. You can find the Postgres connection string in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database) of your Supabase project. + + + +SQLAlchemy requires the connection string to start with `postgresql://` (instead of `postgres://`). Don't forget to rename this after copying the string from the dashboard. + + + + + +You must use the "connection pooling" string (domain ending in `*.pooler.supabase.com`) with Google Colab since Colab does not support IPv6. + + ## Stepping through the notebook @@ -56,7 +65,3 @@ You can view the inserted items in the [Table Editor](https://supabase.com/dashb ## Next steps You can now start building your own applications with Vecs. Check our [examples](/docs/guides/ai#examples) for ideas. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/quickstarts/generate-text-embeddings.mdx b/apps/docs/content/guides/ai/quickstarts/generate-text-embeddings.mdx similarity index 95% rename from apps/docs/pages/guides/ai/quickstarts/generate-text-embeddings.mdx rename to apps/docs/content/guides/ai/quickstarts/generate-text-embeddings.mdx index ae9e4b2b3e9..840fd114122 100644 --- a/apps/docs/pages/guides/ai/quickstarts/generate-text-embeddings.mdx +++ b/apps/docs/content/guides/ai/quickstarts/generate-text-embeddings.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-generating-embeddings', - title: 'Generate Embeddings', - subtitle: 'Generate text embeddings using Edge Functions + Transformer.js.', - breadcrumb: 'AI Quickstarts', -} +--- +id: 'ai-generating-embeddings' +title: 'Generate Embeddings' +subtitle: 'Generate text embeddings using Edge Functions + Transformer.js.' +breadcrumb: 'AI Quickstarts' +--- This guide will walk you through how to generate high quality text embeddings in [Edge Functions](/docs/guides/functions) using Transformers.js. Inference is performed directly in Edge Functions using open source Hugging Face models, so no external API is required. @@ -205,7 +203,3 @@ Let's build an Edge Function that will accept an input string and generate an em - Learn more about [embedding concepts](/docs/guides/ai/concepts) - [Store your embeddings](/docs/guides/ai/vector-columns) in a database - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/quickstarts/hello-world.mdx b/apps/docs/content/guides/ai/quickstarts/hello-world.mdx similarity index 72% rename from apps/docs/pages/guides/ai/quickstarts/hello-world.mdx rename to apps/docs/content/guides/ai/quickstarts/hello-world.mdx index d612ed23d86..d9944a06b01 100644 --- a/apps/docs/pages/guides/ai/quickstarts/hello-world.mdx +++ b/apps/docs/content/guides/ai/quickstarts/hello-world.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-vecs-python-client', - title: 'Creating and managing collections', - subtitle: 'Connecting to your database with Colab.', - breadcrumb: 'AI Quickstarts', -} +--- +id: 'ai-vecs-python-client' +title: 'Creating and managing collections' +subtitle: 'Connecting to your database with Colab.' +breadcrumb: 'AI Quickstarts' +--- This guide will walk you through a basic ["Hello World"](https://github.com/supabase/supabase/blob/master/examples/ai/vector_hello_world.ipynb) example using Colab and Supabase Vecs. You'll learn how to: @@ -43,7 +41,19 @@ DB_CONNECTION = "postgresql://:@:/" vx = vecs.create_client(DB_CONNECTION) ``` -Replace the `DB_CONNECTION` with your own connection string for your database, which you set up in first step of this guide. +Replace the `DB_CONNECTION` with your own connection string for your database. You can find the Postgres connection string in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database) of your Supabase project. + + + +SQLAlchemy requires the connection string to start with `postgresql://` (instead of `postgres://`). Don't forget to rename this after copying the string from the dashboard. + + + + + +You must use the "connection pooling" string (domain ending in `*.pooler.supabase.com`) with Google Colab since Colab does not support IPv6. + + ## Stepping through the notebook @@ -56,7 +66,3 @@ You can view the inserted items in the [Table Editor](https://supabase.com/dashb ## Next steps You can now start building your own applications with Vecs. Check our [examples](/docs/guides/ai#examples) for ideas. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/quickstarts/text-deduplication.mdx b/apps/docs/content/guides/ai/quickstarts/text-deduplication.mdx similarity index 75% rename from apps/docs/pages/guides/ai/quickstarts/text-deduplication.mdx rename to apps/docs/content/guides/ai/quickstarts/text-deduplication.mdx index d15cbbd1289..e603bafea03 100644 --- a/apps/docs/pages/guides/ai/quickstarts/text-deduplication.mdx +++ b/apps/docs/content/guides/ai/quickstarts/text-deduplication.mdx @@ -1,12 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import HuggingFaceDeployment from '~/components/MDX/ai/quickstart_hf_deployment.mdx' - -export const meta = { - id: 'ai-vecs-python-client', - title: 'Semantic Text Deduplication', - subtitle: 'Finding duplicate movie reviews with Supabase Vecs.', - breadcrumb: 'AI Quickstarts', -} +--- +id: 'ai-vecs-python-client' +title: 'Semantic Text Deduplication' +subtitle: 'Finding duplicate movie reviews with Supabase Vecs.' +breadcrumb: 'AI Quickstarts' +--- This guide will walk you through a ["Semantic Text Deduplication"](https://github.com/supabase/supabase/blob/master/examples/ai/semantic_text_deduplication.ipynb) example using Colab and Supabase Vecs. You'll learn how to find similar movie reviews using embeddings, and remove any that seem like duplicates. You will: @@ -44,7 +41,19 @@ DB_CONNECTION = "postgresql://:@:/" vx = vecs.create_client(DB_CONNECTION) ``` -Replace the `DB_CONNECTION` with your own connection string for your database, which you set up in first step of this guide. +Replace the `DB_CONNECTION` with your own connection string for your database. You can find the Postgres connection string in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database) of your Supabase project. + + + +SQLAlchemy requires the connection string to start with `postgresql://` (instead of `postgres://`). Don't forget to rename this after copying the string from the dashboard. + + + + + +You must use the "connection pooling" string (domain ending in `*.pooler.supabase.com`) with Google Colab since Colab does not support IPv6. + + ## Stepping through the notebook @@ -59,7 +68,3 @@ You can view the inserted items in the [Table Editor](https://supabase.com/dashb ## Next steps You can now start building your own applications with Vecs. Check our [examples](/docs/guides/ai#examples) for ideas. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/rag-with-permissions.mdx b/apps/docs/content/guides/ai/rag-with-permissions.mdx similarity index 96% rename from apps/docs/pages/guides/ai/rag-with-permissions.mdx rename to apps/docs/content/guides/ai/rag-with-permissions.mdx index c5b27f20d42..6b10cf85a4f 100644 --- a/apps/docs/pages/guides/ai/rag-with-permissions.mdx +++ b/apps/docs/content/guides/ai/rag-with-permissions.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-rag-with-permissions', - title: 'RAG with Permissions', - subtitle: 'Fine-grain access control with Retrieval Augmented Generation.', - description: 'Implement fine-grain access control with retrieval augmented generation', - sidebar_label: 'RAG with Permissions', -} +--- +id: 'ai-rag-with-permissions' +title: 'RAG with Permissions' +subtitle: 'Fine-grain access control with Retrieval Augmented Generation.' +description: 'Implement fine-grain access control with retrieval augmented generation' +sidebar_label: 'RAG with Permissions' +--- Since pgvector is built on top of Postgres, you can implement fine-grain access control on your vector database using [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security). This means you can restrict which documents are returned during a vector similarity search to users that have access to them. Supabase also supports [Foreign Data Wrappers (FDW)](/docs/guides/database/extensions/wrappers/overview) which means you can use an external database or data source to determine these permissions if your user data doesn't exist in Supabase. @@ -272,7 +270,3 @@ order by document_sections.embedding <#> embedding; There are endless approaches to this problem based on the complexities of each system. Luckily Postgres comes with all the primitives needed to provide access control in the way that works best for your project. If the examples above didn't fit your use case or you need to adjust them slightly to better fit your existing system, feel free to reach out to [support](https://supabase.com/dashboard/support/new) and we'll be happy to assist you. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/structured-unstructured.mdx b/apps/docs/content/guides/ai/structured-unstructured.mdx similarity index 86% rename from apps/docs/pages/guides/ai/structured-unstructured.mdx rename to apps/docs/content/guides/ai/structured-unstructured.mdx index 6bd3795d35f..3b722be9605 100644 --- a/apps/docs/pages/guides/ai/structured-unstructured.mdx +++ b/apps/docs/content/guides/ai/structured-unstructured.mdx @@ -1,14 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'structured-unstructured-embeddings', - title: 'Structured and Unstructured', - description: - 'Supabase is flexible enough to associate structured and unstructured metadata with embeddings.', - subtitle: - 'Supabase is flexible enough to associate structured and unstructured metadata with embeddings.', - sidebar_label: 'Structured and unstructured embeddings', -} +--- +id: 'structured-unstructured-embeddings' +title: 'Structured and Unstructured' +description: 'Supabase is flexible enough to associate structured and unstructured metadata with embeddings.' +subtitle: 'Supabase is flexible enough to associate structured and unstructured metadata with embeddings.' +sidebar_label: 'Structured and unstructured embeddings' +--- Most vector stores treat metadata associated with embeddings like NoSQL, unstructured data. Supabase is flexible enough to store unstructured and structured metadata. @@ -108,7 +104,3 @@ Both approaches create a table where you can store your embeddings and some meta - Unstructured metadata is best when fields are unknown/user-defined or when working with data interactively e.g. exploratory research Both approaches are valid, and the one you should choose depends on your use-case. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/vecs-python-client.mdx b/apps/docs/content/guides/ai/vecs-python-client.mdx similarity index 84% rename from apps/docs/pages/guides/ai/vecs-python-client.mdx rename to apps/docs/content/guides/ai/vecs-python-client.mdx index 071da52f43d..616dfa5b11a 100644 --- a/apps/docs/pages/guides/ai/vecs-python-client.mdx +++ b/apps/docs/content/guides/ai/vecs-python-client.mdx @@ -1,12 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import StepHikeCompact from '~/components/StepHikeCompact' - -export const meta = { - id: 'ai-vecs-python-client', - title: 'Python client', - subtitle: 'Manage unstructured vector stores in PostgreSQL.', - breadcrumb: 'AI Quickstarts', -} +--- +id: 'ai-vecs-python-client' +title: 'Python client' +subtitle: 'Manage unstructured vector stores in PostgreSQL.' +breadcrumb: 'AI Quickstarts' +--- Supabase provides a Python client called [`vecs`](https://github.com/supabase/vecs) for managing unstructured vector stores. This client provides a set of useful tools for creating and querying collections in PostgreSQL using the [pgvector](/docs/guides/database/extensions/pgvector) extension. @@ -85,7 +82,3 @@ For a more in-depth guide on `vecs` collections, see [API](/docs/guides/ai/pytho - Official Vecs Documentation: https://supabase.github.io/vecs/api - Source Code: https://github.com/supabase/vecs - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/vector-columns.mdx b/apps/docs/content/guides/ai/vector-columns.mdx similarity index 95% rename from apps/docs/pages/guides/ai/vector-columns.mdx rename to apps/docs/content/guides/ai/vector-columns.mdx index c5d9cb18e9d..870f303fc44 100644 --- a/apps/docs/pages/guides/ai/vector-columns.mdx +++ b/apps/docs/content/guides/ai/vector-columns.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-vector-columns', - title: 'Vector columns', - description: 'Learn how to use vectors within your own Postgres tables', - sidebar_label: 'Vector columns', -} +--- +id: 'ai-vector-columns' +title: 'Vector columns' +description: 'Learn how to use vectors within your own Postgres tables' +sidebar_label: 'Vector columns' +--- Supabase offers a number of different ways to store and query vectors within Postgres. The SQL included in this guide is applicable for clients in all programming languages. If you are a Python user see your [Python client options](/docs/guides/ai/python-clients) after reading the `Learn` section. @@ -166,7 +164,3 @@ Vectors and embeddings can be used for much more than search. Learn more about e ### Indexes Once your vector table starts to grow, you will likely want to add an index to speed up queries. See [Vector indexes](/docs/guides/ai/vector-indexes) to learn how vector indexes work and how to create them. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/vector-indexes.mdx b/apps/docs/content/guides/ai/vector-indexes.mdx similarity index 84% rename from apps/docs/pages/guides/ai/vector-indexes.mdx rename to apps/docs/content/guides/ai/vector-indexes.mdx index 2bb3a4e310e..03883a0b5c9 100644 --- a/apps/docs/pages/guides/ai/vector-indexes.mdx +++ b/apps/docs/content/guides/ai/vector-indexes.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-vector-indexes', - title: 'Vector indexes', - description: 'Understanding vector indexes', - sidebar_label: 'Vector indexes', -} +--- +id: 'ai-vector-indexes' +title: 'Vector indexes' +description: 'Understanding vector indexes' +sidebar_label: 'Vector indexes' +--- Once your vector table starts to grow, you will likely want to add an index to speed up queries. Without indexes, you'll be performing a sequential scan which can be a resource-intensive operation when you have many records. @@ -33,7 +31,3 @@ Currently vectors with up to 2,000 dimensions can be indexed. ## Resources Read more about indexing on `pgvector`'s [GitHub page](https://github.com/pgvector/pgvector#indexing). - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/vector-indexes/hnsw-indexes.mdx b/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx similarity index 94% rename from apps/docs/pages/guides/ai/vector-indexes/hnsw-indexes.mdx rename to apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx index 38ffab8acfc..2a1823ad175 100644 --- a/apps/docs/pages/guides/ai/vector-indexes/hnsw-indexes.mdx +++ b/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-hnsw-indexes', - title: 'HNSW indexes', - description: 'Understanding HNSW indexes in pgvector', - sidebar_label: 'HNSW indexes', -} +--- +id: 'ai-hnsw-indexes' +title: 'HNSW indexes' +description: 'Understanding HNSW indexes in pgvector' +sidebar_label: 'HNSW indexes' +--- HNSW is an algorithm for approximate nearest neighbor search. It is a frequently used index type that can improve performance when querying highly-dimensional vectors, like those representing embeddings. @@ -92,7 +90,3 @@ Unlike IVFFlat indexes, you are safe to build an HNSW index immediately after th ## Resources Read more about indexing on `pgvector`'s [GitHub page](https://github.com/pgvector/pgvector#indexing). - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/ai/vector-indexes/ivf-indexes.mdx b/apps/docs/content/guides/ai/vector-indexes/ivf-indexes.mdx similarity index 92% rename from apps/docs/pages/guides/ai/vector-indexes/ivf-indexes.mdx rename to apps/docs/content/guides/ai/vector-indexes/ivf-indexes.mdx index 46795777c68..3262a6c5843 100644 --- a/apps/docs/pages/guides/ai/vector-indexes/ivf-indexes.mdx +++ b/apps/docs/content/guides/ai/vector-indexes/ivf-indexes.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'ai-ivf-indexes', - title: 'IVFFlat indexes', - description: 'Understanding IVFFlat indexes in pgvector', - sidebar_label: 'IVFFlat indexes', -} +--- +id: 'ai-ivf-indexes' +title: 'IVFFlat indexes' +description: 'Understanding IVFFlat indexes in pgvector' +sidebar_label: 'IVFFlat indexes' +--- IVFFlat is a type of vector index for approximate nearest neighbor search. It is a frequently used index type that can improve performance when querying highly-dimensional vectors, like those representing embeddings. @@ -96,7 +94,3 @@ One important note with IVF indexes is that nearest neighbor search is approxima ## Resources Read more about indexing on `pgvector`'s [GitHub page](https://github.com/pgvector/pgvector#indexing). - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth.mdx b/apps/docs/content/guides/auth.mdx similarity index 92% rename from apps/docs/pages/guides/auth.mdx rename to apps/docs/content/guides/auth.mdx index c3cf0065265..1b0017c0165 100644 --- a/apps/docs/pages/guides/auth.mdx +++ b/apps/docs/content/guides/auth.mdx @@ -1,13 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import AuthProviders from '~/components/AuthProviders' - -export const meta = { - id: 'auth', - title: 'Auth', - description: 'Use Supabase to Authenticate and Authorize your users.', - subtitle: 'Use Supabase to authenticate and authorize your users.', - video: 'https://www.youtube.com/v/6ow_jW4epf8', -} +--- +id: 'auth' +title: 'Auth' +description: 'Use Supabase to Authenticate and Authorize your users.' +subtitle: 'Use Supabase to authenticate and authorize your users.' +video: 'https://www.youtube.com/v/6ow_jW4epf8' +--- There are two parts to every Auth system: @@ -149,7 +146,3 @@ When users sign up, Supabase assigns them a unique ID. You can reference this ID type="video/mp4" /> - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-captcha.mdx b/apps/docs/content/guides/auth/auth-captcha.mdx similarity index 92% rename from apps/docs/pages/guides/auth/auth-captcha.mdx rename to apps/docs/content/guides/auth/auth-captcha.mdx index a61bf41c38c..956c962f32a 100644 --- a/apps/docs/pages/guides/auth/auth-captcha.mdx +++ b/apps/docs/content/guides/auth/auth-captcha.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-captcha', - title: 'Enable Captcha Protection', - description: 'Add Captcha Protection to your Supabase project', - video: 'https://www.youtube.com/v/em1cpOAXknM', -} +--- +id: 'auth-captcha' +title: 'Enable Captcha Protection' +description: 'Add Captcha Protection to your Supabase project' +video: 'https://www.youtube.com/v/em1cpOAXknM' +--- Supabase provides you with the option of adding captcha to your sign-in, sign-up, and password reset forms. This keeps your website safe from bots and malicious scripts. Supabase authentication has support for [hCaptcha](https://www.hcaptcha.com/) and [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/). @@ -99,7 +97,9 @@ We will pass it the sitekey we copied from the hCaptcha website as a property al ```jsx { setCaptchaToken(token) }} + onVerify={(token) => { + setCaptchaToken(token) + }} /> ``` @@ -176,7 +176,9 @@ We will pass it the sitekey we copied from the Cloudflare website as a property ```jsx { setCaptchaToken(token) }} + onSuccess={(token) => { + setCaptchaToken(token) + }} /> ``` @@ -196,7 +198,3 @@ To test locally, you will need to add localhost to the domain whitelist as per t
Run the application and you should now be provided with a captcha challenge. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/learn/auth-deep-dive/auth-deep-dive-jwts.mdx b/apps/docs/content/guides/auth/auth-deep-dive/auth-deep-dive-jwts.mdx similarity index 96% rename from apps/docs/pages/learn/auth-deep-dive/auth-deep-dive-jwts.mdx rename to apps/docs/content/guides/auth/auth-deep-dive/auth-deep-dive-jwts.mdx index da53b86c49f..c1f4c2816a9 100644 --- a/apps/docs/pages/learn/auth-deep-dive/auth-deep-dive-jwts.mdx +++ b/apps/docs/content/guides/auth/auth-deep-dive/auth-deep-dive-jwts.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-deep-dive-jwts', - title: 'Part One: JWTs', - description: 'Supabase Auth Deep Dive Part 1 - JWTs', - video: 'https://www.youtube.com/v/v3Exg5YpJvE', -} +--- +id: 'auth-deep-dive-jwts' +title: 'Part One: JWTs' +description: 'Supabase Auth Deep Dive Part 1 - JWTs' +video: 'https://www.youtube.com/v/v3Exg5YpJvE' +--- ### About @@ -192,7 +190,3 @@ Now that you understand what JWTs are and where they're used in Supabase, you ca - [Part Four: GoTrue](../../learn/auth-deep-dive/auth-gotrue) - [Part Five: Google Oauth](../../learn/auth-deep-dive/auth-google-oauth) - Sign up for Supabase: [supabase.com/dashboard](https://supabase.com/dashboard) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/learn/auth-deep-dive/auth-google-oauth.mdx b/apps/docs/content/guides/auth/auth-deep-dive/auth-google-oauth.mdx similarity index 93% rename from apps/docs/pages/learn/auth-deep-dive/auth-google-oauth.mdx rename to apps/docs/content/guides/auth/auth-deep-dive/auth-google-oauth.mdx index ec4143db846..7a51bb6eb39 100644 --- a/apps/docs/pages/learn/auth-deep-dive/auth-google-oauth.mdx +++ b/apps/docs/content/guides/auth/auth-deep-dive/auth-google-oauth.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-google-oauth', - title: 'Part Five: Google OAuth', - description: 'Supabase Deep Dive Part 5: Google OAuth Provider', - video: 'https://www.youtube.com/v/_XM9ziOzWk4', -} +--- +id: 'auth-google-oauth' +title: 'Part Five: Google OAuth' +description: 'Supabase Deep Dive Part 5: Google OAuth Provider' +video: 'https://www.youtube.com/v/_XM9ziOzWk4' +--- ### About @@ -105,7 +103,3 @@ For any support please get in touch at beta at [supabase.com](https://supabase.c - Watch [Part Four: GoTrue](../../learn/auth-deep-dive/auth-gotrue) {/* */} - Sign up for Supabase: [supabase.com/dashboard](https://supabase.com/dashboard) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/learn/auth-deep-dive/auth-gotrue.mdx b/apps/docs/content/guides/auth/auth-deep-dive/auth-gotrue.mdx similarity index 92% rename from apps/docs/pages/learn/auth-deep-dive/auth-gotrue.mdx rename to apps/docs/content/guides/auth/auth-deep-dive/auth-gotrue.mdx index d8b970c8d4f..6df29f37dc2 100644 --- a/apps/docs/pages/learn/auth-deep-dive/auth-gotrue.mdx +++ b/apps/docs/content/guides/auth/auth-deep-dive/auth-gotrue.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-gotrue', - title: 'Part Four: GoTrue', - description: 'Supabase Deep Dive Part 4: GoTrue Overview', -} +--- +id: 'auth-gotrue' +title: 'Part Four: GoTrue' +description: 'Supabase Deep Dive Part 4: GoTrue Overview' +--- ### About @@ -79,7 +77,3 @@ In the next guide we'll be looking at how to setup external OAuth providers: Wat {/* */} - Watch [Part Five: Google OAuth](../../learn/auth-deep-dive/auth-google-oauth) - Sign up for Supabase: [supabase.com/dashboard](https://supabase.com/dashboard) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/learn/auth-deep-dive/auth-policies.mdx b/apps/docs/content/guides/auth/auth-deep-dive/auth-policies.mdx similarity index 95% rename from apps/docs/pages/learn/auth-deep-dive/auth-policies.mdx rename to apps/docs/content/guides/auth/auth-deep-dive/auth-policies.mdx index 01c1ea5fbaa..651a5347365 100644 --- a/apps/docs/pages/learn/auth-deep-dive/auth-policies.mdx +++ b/apps/docs/content/guides/auth/auth-deep-dive/auth-policies.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-policies', - title: 'Part Three: Policies', - description: 'Supabase Auth Deep Dive Part 3: User Based Access Policies', - video: 'https://www.youtube.com/v/0LvCOlELs5U', -} +--- +id: 'auth-policies' +title: 'Part Three: Policies' +description: 'Supabase Auth Deep Dive Part 3: User Based Access Policies' +video: 'https://www.youtube.com/v/0LvCOlELs5U' +--- ### About @@ -186,7 +184,3 @@ You can get as creative as you like with these policies. - Watch [Part Four: GoTrue](../../learn/auth-deep-dive/auth-gotrue) - Watch [Part Five: Google Oauth](../../learn/auth-deep-dive/auth-google-oauth) - Sign up for Supabase: [supabase.com/dashboard](https://supabase.com/dashboard) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/learn/auth-deep-dive/auth-row-level-security.mdx b/apps/docs/content/guides/auth/auth-deep-dive/auth-row-level-security.mdx similarity index 93% rename from apps/docs/pages/learn/auth-deep-dive/auth-row-level-security.mdx rename to apps/docs/content/guides/auth/auth-deep-dive/auth-row-level-security.mdx index a53badb6fc5..39a98539b45 100644 --- a/apps/docs/pages/learn/auth-deep-dive/auth-row-level-security.mdx +++ b/apps/docs/content/guides/auth/auth-deep-dive/auth-row-level-security.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-row-level-security', - title: 'Part Two: Row Level Security', - description: 'Supabase Auth Deep Dive Part Two - Row Level Security', - video: 'https://www.youtube.com/v/qY_iQ10IUhs', -} +--- +id: 'auth-row-level-security' +title: 'Part Two: Row Level Security' +description: 'Supabase Auth Deep Dive Part Two - Row Level Security' +video: 'https://www.youtube.com/v/qY_iQ10IUhs' +--- ### About @@ -135,7 +133,3 @@ In the next guide we will look at using Policies in combination with User Accoun - Watch [Part Four: GoTrue](../../learn/auth-deep-dive/auth-gotrue) - Watch [Part Five: Google Oauth](../../learn/auth-deep-dive/auth-google-oauth) - Sign up for Supabase: [supabase.com/dashboard](https://supabase.com/dashboard) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-email-templates.mdx b/apps/docs/content/guides/auth/auth-email-templates.mdx similarity index 81% rename from apps/docs/pages/guides/auth/auth-email-templates.mdx rename to apps/docs/content/guides/auth/auth-email-templates.mdx index 60fbcc04013..475224f6a16 100644 --- a/apps/docs/pages/guides/auth/auth-email-templates.mdx +++ b/apps/docs/content/guides/auth/auth-email-templates.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - title: 'Email Templates', - description: 'Learn how to manage the email templates in Supabase.', - subtitle: 'Learn how to manage the email templates in Supabase.', -} +--- +title: 'Email Templates' +description: 'Learn how to manage the email templates in Supabase.' +subtitle: 'Learn how to manage the email templates in Supabase.' +--- You can customize the email messages used for the authentication flows. You can edit the following email templates: @@ -18,14 +16,14 @@ You can customize the email messages used for the authentication flows. You can The templating system provides the following variables for use: -| Name | Description | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `{{ .ConfirmationURL }}` | Contains the confirmation URL. For example, a signup confirmation URL would look like: `https://project-ref.supabase.co/auth/v1/verify?token={{ .TokenHash }}&type=signup&redirect_to=https://example.com/path` . | -| `{{ .Token }}` | Contains a 6-digit One-Time-Password (OTP) that can be used instead of the `{{. ConfirmationURL }}` . | -| `{{ .TokenHash }}` | Contains a hashed version of the `{{ .Token }}`. This is useful for constructing your own email link in the email template. | -| `{{ .SiteURL }}` | Contains your application's Site URL. This can be configured in your project's [authentication settings](/dashboard/project/_/auth/url-configuration). | -| `{{ .RedirectTo }}` | Contains the redirect URL passed when `signUp`, `signInWithOtp`, `signInWithOAuth`, `resetPasswordForEmail` or `inviteUserByEmail` is called. The redirect URL allow list can be configured in your project's [authentication settings](/dashboard/project/_/auth/url-configuration). | -| `{{ .Data }}` | Contains metadata from `auth.users.user_metadata`. Use this to personalize the email message. +| Name | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `{{ .ConfirmationURL }}` | Contains the confirmation URL. For example, a signup confirmation URL would look like: `https://project-ref.supabase.co/auth/v1/verify?token={{ .TokenHash }}&type=signup&redirect_to=https://example.com/path` . | +| `{{ .Token }}` | Contains a 6-digit One-Time-Password (OTP) that can be used instead of the `{{. ConfirmationURL }}` . | +| `{{ .TokenHash }}` | Contains a hashed version of the `{{ .Token }}`. This is useful for constructing your own email link in the email template. | +| `{{ .SiteURL }}` | Contains your application's Site URL. This can be configured in your project's [authentication settings](/dashboard/project/_/auth/url-configuration). | +| `{{ .RedirectTo }}` | Contains the redirect URL passed when `signUp`, `signInWithOtp`, `signInWithOAuth`, `resetPasswordForEmail` or `inviteUserByEmail` is called. The redirect URL allow list can be configured in your project's [authentication settings](/dashboard/project/_/auth/url-configuration). | +| `{{ .Data }}` | Contains metadata from `auth.users.user_metadata`. Use this to personalize the email message. | ## Limitations @@ -59,19 +57,23 @@ If you intend to use [Server-side rendering](/docs/guides/auth/server-side-rende You can customize the email link in the email template to redirect the user to a server-side endpoint successfully. For example: ```html -Accept the invite - +Accept the invite + ``` + When the user clicks on the link, the request will hit `https://api.example.com/v1/authenticate` and you can grab the `token_hash`, `type` and `redirect_to` query parameters from the URL. Then, you can call the [`verifyOtp`](/docs/reference/javascript/auth-verifyotp) method to get back an authenticated session before redirecting the user back to the client. Since the `verifyOtp` method makes a `POST` request to Supabase Auth to verify the user, the session will be returned in the response body, which can be read by the server. For example: ```js const { token_hash, type } = Object.fromEntries(new URLSearchParams(window.location.search)) -const { data: { session }, error } = await supabase.auth.verifyOtp({ token_hash, type }) +const { + data: { session }, + error, +} = await supabase.auth.verifyOtp({ token_hash, type }) // subsequently redirect the user back to the client using the redirect_to param // ... - ``` ## Customization @@ -98,7 +100,3 @@ Billy Team

Billy Team

{{ end }} ``` - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-email.mdx b/apps/docs/content/guides/auth/auth-email.mdx similarity index 90% rename from apps/docs/pages/guides/auth/auth-email.mdx rename to apps/docs/content/guides/auth/auth-email.mdx index 42198103b2e..38eb93095c6 100644 --- a/apps/docs/pages/guides/auth/auth-email.mdx +++ b/apps/docs/content/guides/auth/auth-email.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-email', - title: 'Login With Email', - description: 'Use Supabase to Authenticate and Authorize your users using email.', -} +--- +id: 'auth-email' +title: 'Login With Email' +description: 'Use Supabase to Authenticate and Authorize your users using email.' +--- Set up Email Logins for your Supabase application. @@ -20,7 +18,8 @@ See the [self-hosting docs](/docs/guides/hosting/overview#configuration) for det -## Sign up the user +## Sign up the user + signOut() async { - When your user signs out, call [logout()](/docs/reference/kotlin/auth-signout) to remove them from the browser session and any objects from localStorage: ```kotlin @@ -179,7 +177,3 @@ suspend fun logout() { - [Supabase JS Client](https://github.com/supabase/supabase-js) - [Supabase Flutter Client](https://github.com/supabase/supabase-flutter) - [Supabase Kotlin Client](https://github.com/supabase-community/supabase-kt) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-helpers.mdx b/apps/docs/content/guides/auth/auth-helpers.mdx similarity index 84% rename from apps/docs/pages/guides/auth/auth-helpers.mdx rename to apps/docs/content/guides/auth/auth-helpers.mdx index e8b59ff4a37..055aae41db7 100644 --- a/apps/docs/pages/guides/auth/auth-helpers.mdx +++ b/apps/docs/content/guides/auth/auth-helpers.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'index', - title: 'Auth Helpers', - description: 'Server-Side Auth guides and utilities for working with Supabase.', - sidebar_label: 'Overview', -} +--- +id: 'index' +title: 'Auth Helpers' +description: 'Server-Side Auth guides and utilities for working with Supabase.' +sidebar_label: 'Overview' +--- @@ -52,7 +50,3 @@ The Auth Helpers are in `beta`. They are usable in their current state, but it's - [Source code](https://github.com/supabase/auth-helpers) - [Known bugs and issues](https://github.com/supabase/auth-helpers/issues) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-helpers/auth-ui.mdx b/apps/docs/content/guides/auth/auth-helpers/auth-ui.mdx similarity index 97% rename from apps/docs/pages/guides/auth/auth-helpers/auth-ui.mdx rename to apps/docs/content/guides/auth/auth-helpers/auth-ui.mdx index b832b27be0c..2ce5f9fff37 100644 --- a/apps/docs/pages/guides/auth/auth-helpers/auth-ui.mdx +++ b/apps/docs/content/guides/auth/auth-helpers/auth-ui.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-ui', - title: 'Auth UI', - description: 'A prebuilt, customizable React component for authenticating users.', -} +--- +id: 'auth-ui' +title: 'Auth UI' +description: 'A prebuilt, customizable React component for authenticating users.' +--- Auth UI is a pre-built React component for authenticating users. It supports custom themes and extensible styles to match your brand and aesthetic. @@ -472,7 +470,3 @@ Setting `showLinks` to `false` will hide the following links: - Already have an account? Sign in - Send a magic link email - Forgot your password? - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-helpers/flutter-auth-ui.mdx b/apps/docs/content/guides/auth/auth-helpers/flutter-auth-ui.mdx similarity index 89% rename from apps/docs/pages/guides/auth/auth-helpers/flutter-auth-ui.mdx rename to apps/docs/content/guides/auth/auth-helpers/flutter-auth-ui.mdx index fe5b99f2786..a9549822959 100644 --- a/apps/docs/pages/guides/auth/auth-helpers/flutter-auth-ui.mdx +++ b/apps/docs/content/guides/auth/auth-helpers/flutter-auth-ui.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'flutter-auth-ui', - title: 'Flutter Auth UI', - description: 'Prebuilt, customizable Flutter widgets for authenticating users.', -} +--- +id: 'flutter-auth-ui' +title: 'Flutter Auth UI' +description: 'Prebuilt, customizable Flutter widgets for authenticating users.' +--- Flutter Auth UI is a Flutter package containing pre-built widgets for authenticating users. It is unstyled and can match your brand and aesthetic. @@ -124,7 +122,3 @@ SupaSocialsAuth( ### Theming This package uses plain Flutter components allowing you to control the appearance of the components using your own theme. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-helpers/nextjs-pages.mdx b/apps/docs/content/guides/auth/auth-helpers/nextjs-pages.mdx similarity index 97% rename from apps/docs/pages/guides/auth/auth-helpers/nextjs-pages.mdx rename to apps/docs/content/guides/auth/auth-helpers/nextjs-pages.mdx index 87e06bf3010..abdd657a63c 100644 --- a/apps/docs/pages/guides/auth/auth-helpers/nextjs-pages.mdx +++ b/apps/docs/content/guides/auth/auth-helpers/nextjs-pages.mdx @@ -1,16 +1,13 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'nextjs-pages', - title: 'Supabase Auth with Next.js Pages Directory', - description: - 'Authentication helpers for Next.js API routes, middleware, and SSR in the Pages Directory.', - sidebar_label: 'Next.js (pages)', -} +--- +id: 'nextjs-pages' +title: 'Supabase Auth with Next.js Pages Directory' +description: 'Authentication helpers for Next.js API routes, middleware, and SSR in the Pages Directory.' +sidebar_label: 'Next.js (pages)' +--- -We generally recommend using the new `@supabase/ssr` package instead of `auth-helpers`. `@supabase/ssr` takes the core concepts of the Auth Helpers package and makes them available to any server language or framework. Check out the [migration doc](/docs/guides/auth/server-side/migrating-to-ssr-from-auth-helpers) to learn more. +The `auth-helpers` package has been replaced with the `@supabase/ssr` package. We recommend setting up Auth for your Next.js app with `@supabase/ssr` instead. See the [Next.js Server-Side Auth guide](/docs/guides/auth/server-side/nextjs?router=pages) to learn how. @@ -917,7 +914,3 @@ import { Database } from '../database.types' const supabaseClient = useSupabaseClient() ``` - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-helpers/nextjs.mdx b/apps/docs/content/guides/auth/auth-helpers/nextjs.mdx similarity index 98% rename from apps/docs/pages/guides/auth/auth-helpers/nextjs.mdx rename to apps/docs/content/guides/auth/auth-helpers/nextjs.mdx index c5cfb0c434c..2b478b4251d 100644 --- a/apps/docs/pages/guides/auth/auth-helpers/nextjs.mdx +++ b/apps/docs/content/guides/auth/auth-helpers/nextjs.mdx @@ -1,16 +1,13 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'nextjs', - title: 'Supabase Auth with the Next.js App Router', - description: - 'Authentication and Authorization helpers for creating an authenticated Supabase client with the Next.js 13 App Router.', - sidebar_label: 'Next.js', -} +--- +id: 'nextjs' +title: 'Supabase Auth with the Next.js App Router' +description: 'Authentication and Authorization helpers for creating an authenticated Supabase client with the Next.js 13 App Router.' +sidebar_label: 'Next.js' +--- -We generally recommend using the new `@supabase/ssr` package instead of `auth-helpers`. `@supabase/ssr` takes the core concepts of the Auth Helpers package and makes them available to any server framework. Check out the [migration doc](/docs/guides/auth/server-side/migrating-to-ssr-from-auth-helpers) to learn more. +The `auth-helpers` package has been replaced with the `@supabase/ssr` package. We recommend setting up Auth for your Next.js app with `@supabase/ssr` instead. See the [Next.js Server-Side Auth guide](/docs/guides/auth/server-side/nextjs) to learn how. @@ -1315,7 +1312,3 @@ export default function() { ``` For an example of creating multiple Supabase clients, check [Singleton section](/docs/guides/auth/auth-helpers/nextjs#singleton) above. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-helpers/remix.mdx b/apps/docs/content/guides/auth/auth-helpers/remix.mdx similarity index 98% rename from apps/docs/pages/guides/auth/auth-helpers/remix.mdx rename to apps/docs/content/guides/auth/auth-helpers/remix.mdx index 86a3f5d7111..4a3a4dc7307 100644 --- a/apps/docs/pages/guides/auth/auth-helpers/remix.mdx +++ b/apps/docs/content/guides/auth/auth-helpers/remix.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'remix', - title: 'Supabase Auth with Remix', - description: 'Authentication helpers for loaders and actions in Remix.', - sidebar_label: 'Remix', -} +--- +id: 'remix' +title: 'Supabase Auth with Remix' +description: 'Authentication helpers for loaders and actions in Remix.' +sidebar_label: 'Remix' +--- @@ -784,7 +782,3 @@ supabaseClient.auth.signUp({ }, }) ``` - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-helpers/sveltekit.mdx b/apps/docs/content/guides/auth/auth-helpers/sveltekit.mdx similarity index 99% rename from apps/docs/pages/guides/auth/auth-helpers/sveltekit.mdx rename to apps/docs/content/guides/auth/auth-helpers/sveltekit.mdx index 03cc1c47222..97f9641eb10 100644 --- a/apps/docs/pages/guides/auth/auth-helpers/sveltekit.mdx +++ b/apps/docs/content/guides/auth/auth-helpers/sveltekit.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'sveltekit', - title: 'Supabase Auth with SvelteKit', - description: 'Convenience helpers for implementing user authentication in SvelteKit.', - sidebar_label: 'SvelteKit', -} +--- +id: 'sveltekit' +title: 'Supabase Auth with SvelteKit' +description: 'Convenience helpers for implementing user authentication in SvelteKit.' +sidebar_label: 'SvelteKit' +--- @@ -1968,7 +1966,3 @@ 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) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-hooks.mdx b/apps/docs/content/guides/auth/auth-hooks.mdx similarity index 93% rename from apps/docs/pages/guides/auth/auth-hooks.mdx rename to apps/docs/content/guides/auth/auth-hooks.mdx index 2dea224ee4d..f0d7b34fe29 100644 --- a/apps/docs/pages/guides/auth/auth-hooks.mdx +++ b/apps/docs/content/guides/auth/auth-hooks.mdx @@ -1,12 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import { CodeBlock, Admonition, Accordion } from 'ui' -import Image from 'next/image' - -export const meta = { - id: 'auth-hooks', - title: 'Auth Hooks', - description: 'Use Supabase Postgres Functions to customize your authentication flow', -} +--- +id: 'auth-hooks' +title: 'Auth Hooks' +description: 'Use Supabase Postgres Functions to customize your authentication flow' +--- Supabase allows you to use Postgres functions to alter the default Supabase Auth flow. Developers can use hooks to add custom behavior that's not supported natively. @@ -35,12 +31,14 @@ A hook is a [Postgres Function](https://www.postgresql.org/docs/current/sql-crea This function is invoked by Supabase Auth at its point in the flow by providing an event object. The object returned by the function gives instructions on how Supabase Auth should continue processing. -To access properties of the `event` argument, you can use the [JSON operators and functions](https://www.postgresql.org/docs/current/functions-json.html). +To access properties of the `event` argument, you can use the [JSON operators and functions](https://www.postgresql.org/docs/current/functions-json.html). There are no restrictions as to what language can be used to write Auth Hooks. If [PL/pgSQL](https://www.postgresql.org/docs/current/plpgsql.html) is too difficult consider using the [plv8](/docs/guides/database/extensions/plv8) extension which lets you use JavaScript to define functions. - If you're using the Supabase SQL Editor, there's an issue when using the `?` (*Does the string exist as a top-level key within the JSON value?*) operator. Use a direct connection to the database if you need to use it when defining a function. + If you're using the Supabase SQL Editor, there's an issue when using the `?` (*Does the string + exist as a top-level key within the JSON value?*) operator. Use a direct connection to the + database if you need to use it when defining a function. Here is an example hook signature: @@ -73,6 +71,12 @@ grant execute to supabase_auth_admin; ``` +You also need to grant usage to `supabase_auth_admin`: + +```sql +grant usage on schema public to supabase_auth_admin; +``` + Also revoke permissions from the `authenticated` and `anon` roles to ensure the function is not accessible by Supabase Serverless APIs. ```sql @@ -414,6 +418,7 @@ Next, store the API key of our email API provider: ```sql select vault.create_secret('my_api_key', 'my_api_key_name', 'description_of_my_api_key'); ``` + Create the hook: ```sql @@ -567,20 +572,20 @@ as $$ is_admin boolean; begin -- Check if the user is marked as admin in the profiles table - select is_admin into is_admin from profiles where user_id = event->>'user_id'; + select is_admin into is_admin from profiles where user_id = event->>'user_id'::uuid; -- Proceed only if the user is an admin if is_admin then claims := event->'claims'; - -- Check if 'user_metadata' exists in claims - if jsonb_typeof(claims->'user_metadata') is null then - -- If 'user_metadata' does not exist, create an empty object - claims := jsonb_set(claims, '{user_metadata}', '{}'); + -- Check if 'app_metadata' exists in claims + if jsonb_typeof(claims->'app_metadata') is null then + -- If 'app_metadata' does not exist, create an empty object + claims := jsonb_set(claims, '{app_metadata}', '{}'); end if; -- Set a claim of 'admin' - claims := jsonb_set(claims, '{user_metadata, admin}', 'true'); + claims := jsonb_set(claims, '{app_metadata, admin}', 'true'); -- Update the 'claims' object in the original event event := jsonb_set(event, '{claims}', claims); @@ -691,5 +696,20 @@ revoke execute -export const Page = ({ children }) => -export default Page +## Local Development + +Unlike the hosted platform, the local development dashboard does not have a user interface for linking a Hook. Instead, edit `config.toml` to link an Auth Hook and run it with your local setup. Modify the `auth.hook.` field and set `uri` to a value of `pg-functions://postgres//` + +For instance, the `config.toml` for a Custom Access Token Hook which uses the function `public.custom_access_token_hook`: + +``` +... + +[auth.hook.custom_access_token] +enabled = true +uri = "pg-functions://postgres/public/custom_access_token_hook" + +... +``` + +Save your Auth Hook as a migration in order to version the Auth Hook and share it with other team members. Run [`supabase migration new`](https://supabase.com/docs/reference/cli/supabase-migration-new) to create a migration. diff --git a/apps/docs/pages/guides/auth/auth-identity-linking.mdx b/apps/docs/content/guides/auth/auth-identity-linking.mdx similarity index 94% rename from apps/docs/pages/guides/auth/auth-identity-linking.mdx rename to apps/docs/content/guides/auth/auth-identity-linking.mdx index 921599ea1a4..befba001809 100644 --- a/apps/docs/pages/guides/auth/auth-identity-linking.mdx +++ b/apps/docs/content/guides/auth/auth-identity-linking.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-identity-linking', - title: 'Identity Linking', - description: 'Manage the identities associated with your user', - subtitle: 'Manage the identities associated with your user', -} +--- +id: 'auth-identity-linking' +title: 'Identity Linking' +description: 'Manage the identities associated with your user' +subtitle: 'Manage the identities associated with your user' +--- ## The user identity @@ -20,9 +18,9 @@ The user identity object contains the following attributes: | identity_data | `object` | The identity metadata. | | identity_id | `string` | The unique id of the identity. | | provider | `string` | The provider name. | -| created_at | `string` | The timestamp that the identity was created. | -| last_sign_in_at | `string` | The timestamp that the identity was last used to sign in. | -| updated_at | `string` | The timestamp that the identity was last updated. | +| created_at | `string` | The timestamp that the identity was created. | +| last_sign_in_at | `string` | The timestamp that the identity was last used to sign in. | +| updated_at | `string` | The timestamp that the identity was last updated. | ## Identity linking strategies @@ -64,6 +62,7 @@ Supabase Auth allows a user to initiate identity linking with a different email ```kotlin supabase.auth.linkIdentity(Google) ``` +
@@ -110,9 +109,6 @@ val googleIdentity = identities.first { it.provider == "google" } //unlink the google identity from the user supabase.auth.unlinkIdentity(googleIdentity.identityId!!) ``` + - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-mfa.mdx b/apps/docs/content/guides/auth/auth-mfa.mdx similarity index 96% rename from apps/docs/pages/guides/auth/auth-mfa.mdx rename to apps/docs/content/guides/auth/auth-mfa.mdx index ef761f58271..f6082fbb8b3 100644 --- a/apps/docs/pages/guides/auth/auth-mfa.mdx +++ b/apps/docs/content/guides/auth/auth-mfa.mdx @@ -1,13 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import { Mermaid } from 'mdx-mermaid/lib/Mermaid' - -export const meta = { - id: 'auth-mfa', - title: 'Multi-Factor Authentication', - description: - 'Add an additional layer of security to your apps with Supabase Auth multi-factor authentication.', -} - +--- +id: 'auth-mfa' +title: 'Multi-Factor Authentication' +description: 'Add an additional layer of security to your apps with Supabase Auth multi-factor authentication.' +--- Multi-factor authentication (MFA), sometimes called two-factor authentication (2FA), adds an additional layer of security to your application by verifying their identity through additional verification steps. @@ -117,7 +112,6 @@ Enrolling a factor for use with MFA takes three steps: immediately becomes active for the user account. If not, you should repeat steps 2 and 3. - #### Example: React Below is an example that creates a new `EnrollMFA` component that illustrates the important pieces of the MFA enrollment flow. @@ -223,7 +217,6 @@ An unenrollment flow provides a UI for users to manage and unenroll factors link When a user unenrolls a factor, call `supabase.auth.mfa.unenroll()` with the ID of the factor. For example, call `supabase.auth.mfa.unenroll({factorId: "d30fd651-184e-4748-a928-0a4b9be1d429"})` to unenroll a factor with ID `d30fd651-184e-4748-a928-0a4b9be1d429`. - #### Example: React Below is an example that creates a new `UnenrollMFA` component that illustrates the important pieces of the MFA enrollment flow. Note that users can only unenroll a factor after completing the enrollment flow and obtaining an `aal2` JWT claim. Here are some points of note: @@ -266,26 +259,22 @@ export function UnenrollMFA() { <> {error &&
{error}
}
- - - - - - {factors.map(factor => ( - - - - + + + - ))} + {factors.map((factor) => ( + + + + + + + ))} - setFactorId(e.target.value.trim())} - /> - + setFactorId(e.target.value.trim())} /> + ) } @@ -581,6 +570,10 @@ TOTP is not going to be the only MFA factor Supabase Auth is going to support in The TOTP QR code encodes a URI with the `otpauth` scheme. It was [initially introduced by Google Authenticator](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) but is now universally accepted by all authenticator apps. +### How long is the TOTP code valid for? + +In our TOTP implementation, each generated code remains valid for one interval, which spans 30 seconds. To account for minor time discrepancies, we allow for a one-interval clock skew. This ensures that users can successfully authenticate within this timeframe, even if there are slight variations in system clocks. + ### How do I check _when_ a user went through MFA? Access tokens issued by Supabase Auth contain an `amr` (Authentication Methods Reference) claim. It is an array of objects that indicate what authentication methods the user has used so far. @@ -629,13 +622,10 @@ Currently recognized authentication methods are: - `sso/saml` - any Single Sign On (SAML) method. The following additional claims are available when using PKCE flow: + - `invite` - any sign in via an invitation. - `magiclink` - any sign in via magic link. Excludes logins resulting from invocation of `signUp`. - `email/signup` - any login resulting from an email signup. - `email_change` - any login resulting from a change in email. More authentication methods will be added over time as we increase the number of authentication methods supported by Supabase. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/auth-smtp.mdx b/apps/docs/content/guides/auth/auth-smtp.mdx similarity index 84% rename from apps/docs/pages/guides/auth/auth-smtp.mdx rename to apps/docs/content/guides/auth/auth-smtp.mdx index 918e20b144d..ed80f9780cd 100644 --- a/apps/docs/pages/guides/auth/auth-smtp.mdx +++ b/apps/docs/content/guides/auth/auth-smtp.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-smtp', - title: 'Configure a Custom SMTP', - description: 'Moving towards production: Configuring a custom SMTP provider', -} +--- +id: 'auth-smtp' +title: 'Configure a Custom SMTP' +description: 'Moving towards production: Configuring a custom SMTP provider' +--- ## Auth SMTP @@ -38,6 +36,3 @@ You can use Supabase Auth with any major SMTP provider of your choosing. Some SM - [Twilio SendGrid](https://docs.sendgrid.com/for-developers/sending-email/integrating-with-the-smtp-api) - [AWS SES](https://docs.aws.amazon.com/ses/latest/dg/send-email-smtp.html) - [Resend](https://resend.com/docs/dashboard/emails/introduction) - -export const Page = ({ children }) => -export default Page diff --git a/apps/docs/pages/guides/auth/auth-user-management.mdx b/apps/docs/content/guides/auth/auth-user-management.mdx similarity index 95% rename from apps/docs/pages/guides/auth/auth-user-management.mdx rename to apps/docs/content/guides/auth/auth-user-management.mdx index edc65197f44..1e24c2e142e 100644 --- a/apps/docs/pages/guides/auth/auth-user-management.mdx +++ b/apps/docs/content/guides/auth/auth-user-management.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-user-management', - title: 'User Management', - description: 'Manage your users with Supabase Auth', - subtitle: 'Manage your users with Supabase Auth', -} +--- +id: 'auth-user-management' +title: 'User Management' +description: 'Manage your users with Supabase Auth' +subtitle: 'Manage your users with Supabase Auth' +--- ## The user object @@ -81,7 +79,3 @@ Supabase Auth provides these [configuration options](/dashboard/project/_/settin - **Confirm Email (Deprecated)**: Users will need to confirm their email address before signing in for the first time. - Having **Confirm Email** disabled assumes that the user's email does not need to be verified in order to login and implicitly confirms the user's email in the database. - If you previously relied on this config to autoconfirm a user's email address, you can switch to use **Allow unverified email sign in** instead. This new option allows the user to sign in with an unverified email which you can keep track of through the user object. It provides more versatility if you require your users to verify their email address in the future since you can structure your RLS policies to check the user's `email_verified` field. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/column-level-security.mdx b/apps/docs/content/guides/auth/column-level-security.mdx similarity index 85% rename from apps/docs/pages/guides/auth/column-level-security.mdx rename to apps/docs/content/guides/auth/column-level-security.mdx index 942c7ff8bcc..ac2155ca023 100644 --- a/apps/docs/pages/guides/auth/column-level-security.mdx +++ b/apps/docs/content/guides/auth/column-level-security.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'column-level-security', - title: 'Column Level Security', - description: 'Secure your data using Postgres Column Level Security.', -} +--- +id: 'column-level-security' +title: 'Column Level Security' +description: 'Secure your data using Postgres Column Level Security.' +--- PostgreSQL's [Row Level Security (RLS)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) gives you granular control over who can access rows of data. However, it doesn't give you control over which columns they can access within rows. Sometimes you want to restrict access to specific columns in your database. Column Level Privileges allows you to do just that. @@ -118,30 +116,28 @@ supabase migration new create_posts_table ```sql + create table + posts ( + id bigint primary key generated always as identity, + user_id text, + title text, + content text, + created_at timestamptz default now() + updated_at timestamptz default now() + ); -create table -posts ( -id bigint primary key generated always as identity, -user_id text, -title text, -content text, -created_at timestamptz default now() -updated_at timestamptz default now() -); + -- Add row-level security + create a policy "Allow update for owners" on posts for + update + using (auth.uid () = user_id); --- Add row-level security -create a policy "Allow update for owners" on posts for -update -using (auth.uid () = user_id); - --- Add column-level security -revoke -update -(title) on table public.posts -from -authenticated; - -``` + -- Add column-level security + revoke + update + (title) on table public.posts + from + authenticated; + ``` @@ -149,10 +145,6 @@ authenticated; ## Considerations when using column-level privileges + - If you turn off a column privilege you won't be able to use that column at all. - All operations (insert, update, delete) as well as using `select *` will fail. - - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/concepts/redirect-urls.mdx b/apps/docs/content/guides/auth/concepts/redirect-urls.mdx similarity index 94% rename from apps/docs/pages/guides/auth/concepts/redirect-urls.mdx rename to apps/docs/content/guides/auth/concepts/redirect-urls.mdx index 2ffa7186bd8..2fcd240acd8 100644 --- a/apps/docs/pages/guides/auth/concepts/redirect-urls.mdx +++ b/apps/docs/content/guides/auth/concepts/redirect-urls.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'redirect-urls', - title: 'Redirect URLs', - description: 'Set up redirect urls with Supabase Auth.', - subtitle: 'Set up redirect urls with Supabase Auth.', -} +--- +id: 'redirect-urls' +title: 'Redirect URLs' +description: 'Set up redirect urls with Supabase Auth.' +subtitle: 'Set up redirect urls with Supabase Auth.' +--- ## Overview @@ -83,7 +81,3 @@ const { data, error } = await supabase.auth.signInWithOAuth({ For mobile applications you can use deep linking URIs. For example, for your `SITE_URL` you can specify something like `com.supabase://login-callback/` and for additional redirect URLs something like `com.supabase.staging://login-callback/` if needed. Read more about deep linking and find code examples for different frameworks [here](/docs/guides/auth/native-mobile-deep-linking). - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/content/guides/auth/enterprise-sso.mdx b/apps/docs/content/guides/auth/enterprise-sso.mdx new file mode 100644 index 00000000000..16390c486f4 --- /dev/null +++ b/apps/docs/content/guides/auth/enterprise-sso.mdx @@ -0,0 +1,6 @@ +--- +title: 'Enterprise Single Sign-On' +description: 'Learn about Single Sign-On support in Supabase Auth for enterprise applications' +--- + +Supabase Auth supports building enterprise applications that require Single Sign-On (SSO) authentication [with SAML 2.0](/docs/guides/auth/sso/auth-sso-saml). diff --git a/apps/docs/pages/guides/auth/managing-user-data.mdx b/apps/docs/content/guides/auth/managing-user-data.mdx similarity index 96% rename from apps/docs/pages/guides/auth/managing-user-data.mdx rename to apps/docs/content/guides/auth/managing-user-data.mdx index 2735f05f837..dd73f0757ac 100644 --- a/apps/docs/pages/guides/auth/managing-user-data.mdx +++ b/apps/docs/content/guides/auth/managing-user-data.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'managing-user-data', - title: 'Managing User Data', - description: 'Securing your user data with Row Level Security.', -} +--- +id: 'managing-user-data' +title: 'Managing User Data' +description: 'Securing your user data with Row Level Security.' +--- For security purposes, the `auth` schema is not exposed on the auto-generated API. @@ -241,7 +239,3 @@ create trigger on_auth_user_created after insert on auth.users for each row execute procedure public.handle_new_user(); ``` - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/native-mobile-deep-linking.mdx b/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx similarity index 95% rename from apps/docs/pages/guides/auth/native-mobile-deep-linking.mdx rename to apps/docs/content/guides/auth/native-mobile-deep-linking.mdx index f1eb1425e3d..7017d7a779b 100644 --- a/apps/docs/pages/guides/auth/native-mobile-deep-linking.mdx +++ b/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx @@ -1,13 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import { IconPanel } from 'ui' -import Link from 'next/link' -import { NativeMobileLoginItems } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants' - -export const meta = { - title: 'Native Mobile Deep Linking', - description: 'Learn how to handle deep linking for OAuth and email based login.', - tocVideo: '8TZ6O1C8ujE', -} +--- +title: 'Native Mobile Deep Linking' +description: 'Learn how to handle deep linking for OAuth and email based login.' +tocVideo: '8TZ6O1C8ujE' +--- In certain auth scenarios you will need to handle linking back into your application to finish the user's sign in. @@ -115,6 +110,7 @@ In certain auth scenarios you will need to handle linking back into your applica ``` For the best user experience it is recommended to use universal links which require a more elaborate setup. You can find the detailed setup instructions in the [Expo docs](https://docs.expo.dev/guides/deep-linking/). + \*Currently supabase_flutter supports deep links on Android, iOS, Web, MacOS and Windows. @@ -266,7 +262,7 @@ In certain auth scenarios you will need to handle linking back into your applica ... ``` - At this point, you can register your own scheme. + At this point, you can register your own scheme. On Windows, URL protocols are setup in the Windows registry. This package won't do it for you. @@ -306,6 +302,7 @@ In certain auth scenarios you will need to handle linking back into your applica + @@ -344,10 +341,6 @@ In certain auth scenarios you will need to handle linking back into your applica ``` + - - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/native-mobile-login.mdx b/apps/docs/content/guides/auth/native-mobile-login.mdx similarity index 71% rename from apps/docs/pages/guides/auth/native-mobile-login.mdx rename to apps/docs/content/guides/auth/native-mobile-login.mdx index c5883ab7bad..95acd9e583a 100644 --- a/apps/docs/pages/guides/auth/native-mobile-login.mdx +++ b/apps/docs/content/guides/auth/native-mobile-login.mdx @@ -1,12 +1,7 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import { IconPanel } from 'ui' -import Link from 'next/link' -import { NativeMobileLoginItems } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants' - -export const meta = { - title: 'Native Mobile Login', - description: 'Logging in with native mobile identity providers like Apple & Google.', -} +--- +title: 'Native Mobile Login' +description: 'Logging in with native mobile identity providers like Apple & Google.' +--- Some native mobile operating systems, like iOS and Android, offer a built-in identity provider for convenient user authentication. @@ -25,21 +20,28 @@ There are several reasons why you might want to add social login to your applica ## Set up a native social login with Supabase Auth
- {NativeMobileLoginItems.map((item) => ( - - - {item.description} - - - -))} - + + {(data) => + data.map((item) => ( + + + {item.description} + + + )) + } +
## Provider tokens @@ -47,7 +49,3 @@ There are several reasons why you might want to add social login to your applica Just like with Oauth, you will receive a copy of the provider token in case you need to use it further. For example, you can use the Google provider token to access Google APIs on behalf of your user. Provider tokens are intentionally not stored in your project's database, however. This is because provider tokens give access to potentially sensitive user data in third-party systems. Different applications have different needs, and one application's OAuth scopes may be significantly more permissive than another. If you do want to use the provider token outside of the browser that completed the OAuth flow, you will have to send it manually to a secure server under your control. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/passwordless-login.mdx b/apps/docs/content/guides/auth/passwordless-login.mdx similarity index 65% rename from apps/docs/pages/guides/auth/passwordless-login.mdx rename to apps/docs/content/guides/auth/passwordless-login.mdx index cd8a76cd7f6..cb8d308ecb2 100644 --- a/apps/docs/pages/guides/auth/passwordless-login.mdx +++ b/apps/docs/content/guides/auth/passwordless-login.mdx @@ -1,12 +1,7 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import { IconPanel, GlassPanel, IconMail } from 'ui' -import Link from 'next/link' -import { PhoneLoginsItems } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants' - -export const meta = { - title: 'Passwordless Login', - description: 'Sign in your users without passwords via magic link email, or one-time passwords sent via email, SMS, or WhatsApp.', -} +--- +title: 'Passwordless Login' +description: 'Sign in your users without passwords via magic link email, or one-time passwords sent via email, SMS, or WhatsApp.' +--- Supabase supports various forms of passwordless authentication: @@ -35,23 +30,22 @@ There are several reasons why you might want to add passwordless login to your a Supabase supports [Phone Login](/docs/guides/auth/phone-login) (SMS & WhatsApp) with several communications platforms. Follow the guides below to set up a provider with Supabase Auth. -
- {PhoneLoginsItems.map((item) => ( - - - {item.linkDescription} - - -))} - +
+ + {(data) => + data.map((item) => ( + + + {item.linkDescription} + + + )) + } +
- -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/passwordless-login/auth-email-otp.mdx b/apps/docs/content/guides/auth/passwordless-login/auth-email-otp.mdx similarity index 83% rename from apps/docs/pages/guides/auth/passwordless-login/auth-email-otp.mdx rename to apps/docs/content/guides/auth/passwordless-login/auth-email-otp.mdx index 38857d78245..820f70e4b5c 100644 --- a/apps/docs/pages/guides/auth/passwordless-login/auth-email-otp.mdx +++ b/apps/docs/content/guides/auth/passwordless-login/auth-email-otp.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-email-otp', - title: 'Login With Email OTP', - description: 'Use Supabase to authenticate your users using one-time password emails.', -} +--- +id: 'auth-email-otp' +title: 'Login With Email OTP' +description: 'Use Supabase to authenticate your users using one-time password emails.' +--- Email one-time passwords (OTP) are a form of passwordless login where users key in a six digit code sent to their email address to log in to their accounts. By default, a user can only request an OTP once every 60 seconds and they expire after 24 hours. @@ -86,6 +84,15 @@ If the request was successful, you receive a response with `error: null` and a ` ## Verify OTP to create session + + + Provide an input field for the user to key in the one-time code. To verify the code and complete the user's sign in, call [verifyOtp()](/docs/reference/javascript/auth-verifyotp) with their email address, the code, and `type: "email"`: ```js @@ -99,6 +106,18 @@ const { }) ``` + + + +Provide an input field for the user to key in the one-time code. To verify the code and complete the user's sign in, call [verifyEmailOtp()](/docs/reference/kotlin/auth-verifyotp) with their email address, the code, and `OtpType.Email.EMAIL` as the type: + +```kotlin +supabase.auth.verifyEmailOtp(type = OtpType.Email.EMAIL, email = "email", token = "151345") +``` + + + + If successful the user will now be logged in and you should receive a valid session like: ```json @@ -117,7 +136,3 @@ If successful the user will now be logged in and you should receive a valid sess - [Phone SMS & WhatsApp OTP](/docs/guides/auth/phone-login) - [Supabase JS Client](https://github.com/supabase/supabase-js) - [Supabase Flutter Client](https://github.com/supabase/supabase-flutter) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/passwordless-login/auth-magic-link.mdx b/apps/docs/content/guides/auth/passwordless-login/auth-magic-link.mdx similarity index 93% rename from apps/docs/pages/guides/auth/passwordless-login/auth-magic-link.mdx rename to apps/docs/content/guides/auth/passwordless-login/auth-magic-link.mdx index 9ca6be133c5..4b6ffdfdc87 100644 --- a/apps/docs/pages/guides/auth/passwordless-login/auth-magic-link.mdx +++ b/apps/docs/content/guides/auth/passwordless-login/auth-magic-link.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-magic-link', - title: 'Login With Magic Link', - description: 'Use Supabase to authenticate your users using magic links.', -} +--- +id: 'auth-magic-link' +title: 'Login With Magic Link' +description: 'Use Supabase to authenticate your users using magic links.' +--- Magic links are a form of passwordless logins where users click on a link sent to their email address to log in to their accounts. Magic links only work with email addresses and are one-time use only. By default, a user can only request a magic link once every 60 seconds and they expire after 24 hours. @@ -140,7 +138,3 @@ suspend fun logout() { - [Supabase Account - Free Plan OK](https://supabase.com) - [Supabase JS Client](https://github.com/supabase/supabase-js) - [Supabase Flutter Client](https://github.com/supabase/supabase-flutter) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/passwords.mdx b/apps/docs/content/guides/auth/passwords.mdx similarity index 76% rename from apps/docs/pages/guides/auth/passwords.mdx rename to apps/docs/content/guides/auth/passwords.mdx index 41d85695f4b..25877dad288 100644 --- a/apps/docs/pages/guides/auth/passwords.mdx +++ b/apps/docs/content/guides/auth/passwords.mdx @@ -1,11 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import { Accordion } from 'ui' - -export const meta = { - id: 'auth-passwords', - title: 'Passwords', - description: 'How to work with passwords in Supabase Auth', -} +--- +id: 'auth-passwords' +title: 'Passwords' +description: 'How to work with passwords in Supabase Auth' +--- Using passwords to authenticate users is a tried-and-tested method to give your users access to your application. Supabase Auth provides you with secure configuration options and uses best practices to store and verify your user's passwords. @@ -52,28 +49,28 @@ Strong passwords are difficult to remember, so Supabase Auth provides you with A To add a secure password reset flow to your application: 1. Build the **password reset page**: - - Should be publicly accessible and contain a form asking for the user's email address. - - Use the [`supabase.auth.resetPasswordForEmail`](/docs/reference/javascript/auth-resetpasswordforemail) API to request a password reset link for a user's email address. - - Specify the `redirectTo` parameter when calling this API to point to the URL of the **change password page.** + - Should be publicly accessible and contain a form asking for the user's email address. + - Use the [`supabase.auth.resetPasswordForEmail`](/docs/reference/javascript/auth-resetpasswordforemail) API to request a password reset link for a user's email address. + - Specify the `redirectTo` parameter when calling this API to point to the URL of the **change password page.** 2. Build the **password change page**: - - Should be accessible only to authenticated users. - - Add its URL to the allowed [Redirect URLs](/dashboard/project/_/auth/url-configuration) settings. - - Show a form or other prompt asking the user to choose a new password. - - Call the [`supabase.auth.updateUser`](/docs/reference/javascript/auth-updateuser) API to set a new password for the user. + - Should be accessible only to authenticated users. + - Add its URL to the allowed [Redirect URLs](/dashboard/project/_/auth/url-configuration) settings. + - Show a form or other prompt asking the user to choose a new password. + - Call the [`supabase.auth.updateUser`](/docs/reference/javascript/auth-updateuser) API to set a new password for the user. The email link sent to your users works in the same way as [passwordless authentication](/docs/guides/auth/passwordless-login): 1. After a user visits the **reset password page**, they are sent a reset password link. - - If you use PKCE (default), this link only works on the device or browser where the original reset request was made. Display a message to the user to make sure they don't change devices or browsers. - - If you use the implicit grant flow, the link can be opened on any device. + - If you use PKCE (default), this link only works on the device or browser where the original reset request was made. Display a message to the user to make sure they don't change devices or browsers. + - If you use the implicit grant flow, the link can be opened on any device. 2. A user clicks the link. They are taken to Supabase Auth, which validates the link. - - If the link is valid, it redirects to the **change password page**, which you specified in the `redirectTo` parameter. - - If the **change password page's** URL is not properly registered in the [Redirect URLs](/dashboard/project/_/auth/url-configuration) configuration, the user is taken to the default Site URL page. + - If the link is valid, it redirects to the **change password page**, which you specified in the `redirectTo` parameter. + - If the **change password page's** URL is not properly registered in the [Redirect URLs](/dashboard/project/_/auth/url-configuration) configuration, the user is taken to the default Site URL page. 3. Supabase Auth redirects to the **change password page** including [session](/docs/guides/auth/sessions) information in the URL. - - If you used PKCE (default), the redirect contains the `code` query param. - - If you are not using official Supabase libraries, or have a custom setup, extract the `code` from the URL and call the [`supabase.auth.exchangeCodeForSession`](/docs/reference/javascript/auth-exchangecodeforsession) API. - - Official Supabase libraries handle code the code exchange for you. - - If you used the implicit flow, the redirect contains a URL fragment encoding the user's [session](/docs/guides/auth/sessions). + - If you used PKCE (default), the redirect contains the `code` query param. + - If you are not using official Supabase libraries, or have a custom setup, extract the `code` from the URL and call the [`supabase.auth.exchangeCodeForSession`](/docs/reference/javascript/auth-exchangecodeforsession) API. + - Official Supabase libraries handle code the code exchange for you. + - If you used the implicit flow, the redirect contains a URL fragment encoding the user's [session](/docs/guides/auth/sessions). ### Example: Request a password reset email @@ -153,7 +150,3 @@ The hash is stored in the `encrypted_password` column of the `auth.users` table. ### How will strengthened password requirements affect current users? Existing users can still sign in with their current password even if it doesn't meet the new, strengthened password requirements. However, if their password falls short of these updated standards, they will encounter a `WeakPasswordError` during the `signInWithPassword` process, explaining why it's considered weak. This change is also applicable to new users and existing users changing their passwords, ensuring everyone adheres to the enhanced security standards. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/content/guides/auth/phone-login.mdx b/apps/docs/content/guides/auth/phone-login.mdx new file mode 100644 index 00000000000..a608bcb26eb --- /dev/null +++ b/apps/docs/content/guides/auth/phone-login.mdx @@ -0,0 +1,291 @@ +--- +title: 'Phone Login' +description: 'Learn about logging in to your platform using SMS one-time passwords.' +--- + + + +Authenticating users via SMS can become expensive. Adjust your project's rate limits and [configure CAPTCHA](/docs/guides/auth/auth-captcha) to control the bill. Read more about this in the [Production Checklist](/docs/guides/platform/going-into-prod). + + + +Phone Login is a method of authentication that allows users to log in to a website or application without using a password. Instead of entering a password, the user provides another form of authentication through a one-time code sent via SMS. + +## Benefits + +There are several reasons why you might want to add phone login to your applications: + +- **Improved user experience**: By eliminating the need for users to remember and enter complex passwords, phone login can make it easier and more convenient for users to log in to your application. This can improve the overall user experience and make it more enjoyable for users to interact with your application. + +- **Increased security**: Phone login can improve the security of your application by reducing the risk of password-related security breaches, such as password reuse and weak passwords. By using alternative forms of authentication, such as one-time codes or biometric factors, you can make it more difficult for unauthorized users to access your application. + +- **Reduced support burden**: Phone login can also help reduce the support burden for your team by eliminating the need to handle password recovery flows or deal with other password-related issues. This can free up your team to focus on other important tasks and improve the efficiency of your operation. + +## Set up a provider with Supabase Auth + +To use Phone Login, you first need to set up a Phone provider. Supabase supports Phone Login with several communications platforms. Follow the guides below to set up your provider. + +
+ + {(data) => + data.map((item) => ( + + + {item.linkDescription} + + + )) + } + +
+ +## Using Phone Login + +You can use Phone Login to: + +- [Sign up a user with phone number and password](/docs/guides/auth/phone-login#sign-up-a-user-with-phone-number-and-password) +- [Sign in a user with a One Time Password (OTP)](/docs/guides/auth/phone-login#sign-in-a-user-with-otp) +- [Update a user's phone number](/docs/guides/auth/phone-login#update-a-users-phone-number) + +Each of these flows sends the user an SMS containing a six-digit PIN, which you must [verify](/docs/guides/auth/phone-login#verify-a-user) to complete the flow. + +### Sign up a user with phone number and password + +You can use a user's mobile phone number, instead of an email address, when they sign up with a password. + +This practice is usually discouraged because phone networks often recycle mobile phone numbers. Anyone receiving a recycled phone number gets access to the original user's account. To mitigate this risk, [implement MFA](/docs/guides/auth/auth-mfa). + +To sign up the user, call [`signUp()`](/docs/reference/javascript/auth-signup) with their phone number and password: + + + + + +```js +const { data, error } = await supabase.auth.signUp({ + phone: '+13334445555', + password: 'some-password', +}) +``` + + + + +```kotlin +supabase.auth.signUpWith(Phone) { + phone = "+13334445555" + password = "some-password" +} +``` + + + + +```bash +curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/signup' \ +-H "apikey: SUPABASE_KEY" \ +-H "Content-Type: application/json" \ +-d '{ + "phone": "+13334445555", + "password": "some-password" +}' +``` + + + + +The user receives an SMS with a 6-digit pin that you must [verify](/docs/guides/auth/phone-login#verify-a-user) within 60 seconds. + +After their phone number is verified, the user can use their phone number and password to sign in with needing to reverify each time: + + + + +```js +const { user, error } = await supabase.auth.signInWithPassword({ + phone: '+13334445555', + password: 'some-password', +}) +``` + + + + +```kotlin +supabase.auth.signInWith(Phone) { + phone = "+13334445555" + password = "some-password" +} +``` + + + + +```bash +curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/token?grant_type=password' \ +-H "apikey: SUPABASE_KEY" \ +-H "Content-Type: application/json" \ +-d '{ + "phone": "+13334445555", + "password": "some-password" +}' +``` + + + + +### Sign in a user with OTP + +With OTP, a user can sign in without setting a password on their account. They need to verify their phone number each time they sign in. + + + + +```js +const { data, error } = await supabase.auth.signInWithOtp({ + phone: '+13334445555', +}) +``` + + + + +```kotlin +supabase.auth.signInWith(OTP) { + phone = "+13334445555" +} +``` + + + + +```bash +curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/otp' \ +-H "apikey: SUPABASE_KEY" \ +-H "Content-Type: application/json" \ +-d '{ + "phone": "+13334445555" +}' +``` + + + + +The user receives an SMS with a 6-digit pin that you must [verify](/docs/guides/auth/phone-login#verify-a-user) within 60 seconds. + +### Update a user's phone number + +To update a user's phone number, the user must be logged in. Call [`updateUser()`](/docs/reference/javascript/auth-updateuser) with their phone number: + + + + +```js +const { data, error } = await supabase.auth.updateUser({ + phone: '123456789', +}) +``` + + + + +The user receives an SMS with a 6-digit pin that you must [verify](/docs/guides/auth/phone-login#verify-a-user) within 60 seconds. + +### Verify a user + +To verify the one-time password (OTP) sent to the user's phone number, call [`verifyOtp()`](/docs/reference/javascript/auth-verifyotp) with the phone number and OTP: + + + + +You should present a form to the user so they can input the 6 digit pin, then send it along with the phone number to `verifyOtp`: + +```js +const { + data: { session }, + error, +} = await supabase.auth.verifyOtp({ + phone: '+13334445555', + token: '123456', + type: 'sms', +}) +``` + + + + +You should present a form to the user so they can input the 6 digit pin, then send it along with the phone number to `verifyPhoneOtp`: + +```kotlin +supabase.auth.verifyPhoneOtp( + type = OtpType.Phone.SMS, + phone = "+13334445555", + token = "123456" +) +``` + + + + +```bash +curl -X POST 'https://.supabase.co/auth/v1/verify' \ +-H "apikey: " \ +-H "Content-Type: application/json" \ +-d '{ + "type": "sms", + "phone": "+13334445555", + "token": "123456" +}' +``` + + + + +If successful the user will now be logged in and you should receive a valid session like: + +```json +{ + "access_token": "", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "" +} +``` + +The access token can be sent in the Authorization header as a Bearer token for any CRUD operations on supabase-js. See our guide on [Row Level Security](/docs/guides/auth#row-level-security) for more info on restricting access on a user basis. diff --git a/apps/docs/content/guides/auth/phone-login/messagebird.mdx b/apps/docs/content/guides/auth/phone-login/messagebird.mdx new file mode 100644 index 00000000000..27d882780ef --- /dev/null +++ b/apps/docs/content/guides/auth/phone-login/messagebird.mdx @@ -0,0 +1,69 @@ +--- +id: 'auth-messagebird' +title: 'Phone Auth with MessageBird' +description: 'How to set up and use Mobile OTP with MessageBird and Supabase.' +--- + + + +Authenticating users via SMS can become expensive. Adjust your project's rate limits and [configure CAPTCHA](/docs/guides/auth/auth-captcha) to control the bill. Read more about this in the [Production Checklist](/docs/guides/platform/going-into-prod). + + + +## Prerequisites + +You'll need: + +- A MessageBird account (sign up [here](https://dashboard.messagebird.com/en/sign-up)) +- A Supabase project (create one [here](https://supabase.com/dashboard)) +- A mobile phone capable of receiving SMS + +## Set up MessageBird as your SMS provider + +Start by logging into your MessageBird account and verify the mobile number you'll be using to test with [here](https://dashboard.messagebird.com/en/getting-started/sms). This is the number that will be receiving the SMS OTPs. + +![Verify your own phone number](/docs/img/guides/auth-messagebird/1.png) + +![Get your API Keys](/docs/img/guides/auth-messagebird/2.png) + +Navigate to the [dashboard settings](https://dashboard.messagebird.com/en/settings/sms) to set the default originator. The messagebird originator is the name or number from which the message is sent. For more information, you can refer to the messagebird article on choosing an originator [here](https://support.messagebird.com/hc/en-us/articles/115002628665-Choosing-an-originator) + +![Set the default originator](/docs/img/guides/auth-messagebird/3.png) + +You will need the following values to get started: + +- Live API Key / Test API Key +- MessageBird originator + +Now go to the [Auth > Providers](https://supabase.com/dashboard/project/_/auth/providers) page in the Supabase dashboard and select "Phone" from the Auth Providers list. + +You should see an option to enable the Phone provider. + +Toggle it on, and copy the 2 values over from the Messagebird dashboard. Click save. + + + +If you use the Test API Key, the OTP will not be delivered to the mobile number specified but messagebird will log the response in the dashboard. If the Live API Key is used instead, the OTP will be delivered and there will be a deduction in your free credits. + + + +Plugin MessageBird credentials + +Now the backend should be setup, we can proceed to add our client-side code! + +#### SMS custom template + +The SMS message sent to a phone containing an OTP code can be customized. This is useful if you need to mention a brand name or display a website address. + +Go to [Auth > Templates](https://supabase.com/dashboard/project/_/auth/templates) page in the Supabase dashboard. + +Use the variable `.Code` in the template to display the code. + +## Next steps + +To implement Phone Login, see the docs on [using Phone Login](/docs/guides/auth/phone-login#using-phone-login). + +## Resources + +- [MessageBird Signup](https://dashboard.messagebird.com/en/sign-up) +- [Supabase Dashboard](https://supabase.com/dashboard) diff --git a/apps/docs/content/guides/auth/phone-login/twilio.mdx b/apps/docs/content/guides/auth/phone-login/twilio.mdx new file mode 100644 index 00000000000..86bbd8bae8b --- /dev/null +++ b/apps/docs/content/guides/auth/phone-login/twilio.mdx @@ -0,0 +1,151 @@ +--- +id: 'auth-twilio' +title: 'Phone Auth with Twilio' +description: 'How to set up and use Mobile OTP with Twilio and Supabase.' +video: 'https://www.youtube.com/v/akScoPO01bc' +--- + + + +Authenticating users via SMS can become expensive. Adjust your project's rate limits and [configure CAPTCHA](/docs/guides/auth/auth-captcha) to control the bill. Read more about this in the [Production Checklist](/docs/guides/platform/going-into-prod). + + + +## Preqrequisites + +You'll need: + +- Twilio account ([sign up](https://www.twilio.com/try-twilio)) +- Supabase project (create one [here](https://supabase.com/dashboard)) +- Mobile phone capable of receiving SMS + +SMS Authentication can be done with either Twilio Verify or Twilio Programmable Messaging. [Twilio Verify](https://www.twilio.com/en-us/trusted-activation/verify) is a specialized OTP solution and is recommended for most developers that need over-the-phone authentication. Alternatively you can use [Twilio Programmable Messaging](https://www.twilio.com/docs/messaging) which offers generic SMS sending support. + +## Twilio Verify + +To set up Twilio Verify, you will need to: + +1. Create a new [verification service](https://support.twilio.com/hc/en-us/articles/360033309133-Getting-Started-with-Twilio-Verify-V2) in the Twilio dashboard. +2. [Switch Phone Provider to Twilio Verify](https://supabase.com/dashboard/project/_/auth/providers) +3. Configure the Twilio Verify Service ID field using the Verification Service ID obtained in 1. + +When using Twilio Verify, OTPs are generated by Twilio. This means that: + +- Unlike other providers, the OTP expiry duration and message content fields are not configurable via the Supabase dashboard. Please head to Twilio Verify to configure these settings. +- The token remains the same during its validity period until the verification is successful. This means if your user make another request within that period, they will receive the same token. +- Twilio Verify has a separate set of rate limits that apply. Visit Twilio's [Rate Limit and Timeouts page](https://www.twilio.com/docs/verify/api/rate-limits-and-timeouts) to find out more. + + + +At this time, Twilio Verify is only supported on the `whatsapp` and `sms` channels. + + + +## Twilio (Programmable Messaging) + +In this section, you'll set up Twilio as an SMS provider: + +What you'll need: + +- A Twilio account (sign up here: https://www.twilio.com/try-twilio) +- A Supabase project (create one here: https://supabase.com/dashboard) +- A mobile phone capable of receiving SMS + +
+ +
+ +### Setting up your Twilio credentials + +Start by logging into your Twilio account and starting a new project: https://www.twilio.com/console/projects/create + +Give your project a name and verify the mobile number you'll be using to test with. This is the number that will be receiving the SMS OTPs. + +![Name your twilio project](/docs/img/guides/auth-twilio/1.png) +![verify your own phone number](/docs/img/guides/auth-twilio/2.png) + +Select 'SMS', 'Identity & Verification', and 'With code' as options on the welcome form. + +![Form Fields](/docs/img/guides/auth-twilio/3.png) + +When you're back on the [Twilio console screen](https://www.twilio.com/console), you need to scroll down and click 'Get a trial phone number' - this is the number that you'll be sending SMSs from. + +![Get a trial phone number](/docs/img/guides/auth-twilio/4.png) + +![Successful phone number](/docs/img/guides/auth-twilio/5.png) + +You should now be able to see all three values you'll need to get started: + +- Account SID +- Auth Token +- Sender Phone Number + +![All the credentials you'll need](/docs/img/guides/auth-twilio/6.png) + +Now go to the [Auth > Providers](https://supabase.com/dashboard/project/_/auth/providers) page in the Supabase dashboard and select "Phone" from the Auth Providers list. + +You should see an option to enable the Phone provider. + +Toggle it on, and copy the 3 values over from the Twilio dashboard. Click save. + +Note: for "Twilio Message Service SID" you can use the Sender Phone Number generated above. + +![Plug in Twilio credentials](/docs/img/guides/auth-twilio/8.png) + +Now the backend should be setup, we can proceed to add our client-side code! + + + The `Twilio Content SID` field is specific to Twilio Programmable Messaging (WhatsApp) senders. + Disregard this field if you are only sending SMS messages. Refer to the section on WhatsApp OTP + Logins for further details. + + +#### SMS custom template + +The SMS message sent to a phone containing an OTP code can be customized. This is useful if you need to mention a brand name or display a website address. + +Go to the [Auth > Providers](https://supabase.com/dashboard/project/_/auth/providers) page in the Supabase dashboard and select "Phone" from the Auth Providers list. Scroll to the very bottom of the "Phone" section to the "SMS Message" input - you can customize the SMS message here. + +Use the variable `.Code` in the template to display the OTP code. Here's an example in the SMS template. + +![example in the SMS template](/docs/img/guides/auth-twilio/9.png) + +## WhatsApp OTP logins + +In some cases, you may wish to use WhatsApp as a delivery channel instead. Here are some examples our users have cited: + +- You want higher deliverability +- You wish for a secure channel +- Your users mostly use WhatsApp as a messaging platform + +Twilio Verify can be used with WhatsApp OTPs with no additional configuration. + +Complete the following steps to use WhatsApp OTP with Twilio Programmable Messaging: + +1. Go through the [Twilio self sign up guide for WhatsApp](https://www.twilio.com/docs/whatsapp/self-sign-up). +2. Register a Twilio Content Template via the [API](https://www.twilio.com/docs/content/whatsappauthentication) and note the corresponding Twilio Content SID. You can also use the Template Builder on the Twilio dashboard to create a Content Template + ![Twilio Content SID Image](/docs/img/guides/auth-twilio/twilio_content_sid.png) +3. Register the Twilio Content SID on the Supabase dashboard under Authentication > Providers > Phone > Twilio Content SID. Ensure you have Twilio selected as your phone provider. + + + +You may only use one Twilio Content SID at a time. Supabase Auth will use the Content Template over the `SMS Message` field when sending WhatsApp messages. Use Twilio Verify if you need to use more than one message template. + + + +## Next steps + +To implement Phone Login, see the docs on [using Phone Login](/docs/guides/auth/phone-login#using-phone-login). + +## Resources + +- [Reasons to use Twilio Verify](https://www.twilio.com/blog/9-reasons-to-use-the-verify-api) +- [Twilio Verify Documentation](https://www.twilio.com/docs/verify) +- [Twilio Signup](https://www.twilio.com/try-twilio) +- [Supabase Dashboard](https://supabase.com/dashboard) +- [Supabase Row Level Security](/docs/guides/auth#row-level-security) diff --git a/apps/docs/content/guides/auth/phone-login/vonage.mdx b/apps/docs/content/guides/auth/phone-login/vonage.mdx new file mode 100644 index 00000000000..046f0b85d2a --- /dev/null +++ b/apps/docs/content/guides/auth/phone-login/vonage.mdx @@ -0,0 +1,66 @@ +--- +id: 'auth-vonage' +title: 'Phone Auth with Vonage' +description: 'How to set up and use Mobile OTP with Vonage and Supabase.' +--- + + + +Authenticating users via SMS can become expensive. Adjust your project's rate limits and [configure CAPTCHA](/docs/guides/auth/auth-captcha) to control the bill. Read more about this in the [Production Checklist](/docs/guides/platform/going-into-prod). + + + +## Prerequisites + +You'll need: + +- A Vonage account (sign up here: https://dashboard.nexmo.com/sign-up) +- A Supabase project (create one here: https://supabase.com/dashboard) +- A mobile phone capable of receiving SMS + +## Set up Vonage as an SMS provider + +### Getting your Vonage credentials + +Start by logging into your Vonage Dashboard at https://dashboard.nexmo.com/ + +You will see you API Key and API Secret here, which is actually all you need to get started. + +In most countries, a phone number is actually optional and you can also use any Alphanumeric Sender ID of up to 11 characters length (8 for India) as a Sender ID (from). This means you do not need a number to test with in most cases. + +To find out more about supported countries for Alphanumeric Sender ID, check this overview: https://help.nexmo.com/hc/en-us/articles/115011781468-SMS-Features-Overview-Outbound-only- + +Hint: Some countries might need a Sender ID Registration to allow sending with an Alphanumeric Sender ID. You can find this information in the help article as well. If Alpha Sender IDs are not supported, you will need to buy a phone number. + +### Getting a phone number (optional) + +If you want a phone number to send SMS from, you can buy one from the Vonage Dashboard under Numbers > Buy Numbers (https://dashboard.nexmo.com/buy-numbers). + +Select the country you want a number for. You will need a mobile phone number with SMS or SMS+Voice capability. After you have bought the number, you will be able to send SMS from it. + +### Configure Supabase + +Now go to the [Auth > Providers](https://supabase.com/dashboard/project/_/auth/providers) page in the Supabase dashboard and select "Phone" from the Auth Providers list. + +You should see an option to enable the Phone provider. + +Toggle it on, and copy the API key, API secret and phone number values over from the Vonage dashboard. Click save. + +Now the backend should be setup, we can proceed to add our client-side code! + +#### SMS custom template + +The SMS message sent to a phone containing an OTP code can be customized. This is useful if you need to mention a brand name or display a website address. + +Go to [Auth > Templates](https://supabase.com/dashboard/project/_/auth/templates) page in the Supabase dashboard. + +Use the variable `.Code` in the template to display the code. + +## Next steps + +To implement Phone Login, see the docs on [using Phone Login](/docs/guides/auth/phone-login#using-phone-login). + +## Resources + +- [Vonage Signup](https://dashboard.nexmo.com/sign-up) +- [Supabase Dashboard](https://supabase.com/dashboard) diff --git a/apps/docs/pages/guides/auth/quickstarts/nextjs.mdx b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx similarity index 85% rename from apps/docs/pages/guides/auth/quickstarts/nextjs.mdx rename to apps/docs/content/guides/auth/quickstarts/nextjs.mdx index 05ff464107c..33bb93927d5 100644 --- a/apps/docs/pages/guides/auth/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import StepHikeCompact from '~/components/StepHikeCompact' - -export const meta = { - title: 'Use Supabase Auth with Next.js', - subtitle: 'Learn how to configure Supabase Auth for the Next.js App Router.', - breadcrumb: 'Auth Quickstarts', -} +--- +title: 'Use Supabase Auth with Next.js' +subtitle: 'Learn how to configure Supabase Auth for the Next.js App Router.' +breadcrumb: 'Auth Quickstarts' +hideToc: true +--- @@ -88,7 +86,3 @@ export const meta = { - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/quickstarts/react-native.mdx b/apps/docs/content/guides/auth/quickstarts/react-native.mdx similarity index 95% rename from apps/docs/pages/guides/auth/quickstarts/react-native.mdx rename to apps/docs/content/guides/auth/quickstarts/react-native.mdx index 87ab5ddbff2..ff35d7283e3 100644 --- a/apps/docs/pages/guides/auth/quickstarts/react-native.mdx +++ b/apps/docs/content/guides/auth/quickstarts/react-native.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import StepHikeCompact from '~/components/StepHikeCompact' - -export const meta = { - title: 'Use Supabase Auth with React Native', - subtitle: 'Learn how to use Supabase Auth with React Native', - breadcrumb: 'Auth Quickstarts', -} +--- +title: 'Use Supabase Auth with React Native' +subtitle: 'Learn how to use Supabase Auth with React Native' +breadcrumb: 'Auth Quickstarts' +hideToc: true +--- @@ -268,7 +266,3 @@ export const meta = { - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/quickstarts/react.mdx b/apps/docs/content/guides/auth/quickstarts/react.mdx similarity index 90% rename from apps/docs/pages/guides/auth/quickstarts/react.mdx rename to apps/docs/content/guides/auth/quickstarts/react.mdx index 8a852e454fd..ca3e45a75bd 100644 --- a/apps/docs/pages/guides/auth/quickstarts/react.mdx +++ b/apps/docs/content/guides/auth/quickstarts/react.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import StepHikeCompact from '~/components/StepHikeCompact' - -export const meta = { - title: 'Use Supabase Auth with React', - subtitle: 'Learn how to use Supabase Auth with React.js.', - breadcrumb: 'Auth Quickstarts', -} +--- +title: 'Use Supabase Auth with React' +subtitle: 'Learn how to use Supabase Auth with React.js.' +breadcrumb: 'Auth Quickstarts' +hideToc: true +--- @@ -132,7 +130,3 @@ export const meta = { - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/row-level-security.mdx b/apps/docs/content/guides/auth/row-level-security.mdx similarity index 94% rename from apps/docs/pages/guides/auth/row-level-security.mdx rename to apps/docs/content/guides/auth/row-level-security.mdx index d8c1b1b53ba..f7fb52a63df 100644 --- a/apps/docs/pages/guides/auth/row-level-security.mdx +++ b/apps/docs/content/guides/auth/row-level-security.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'row-level-security', - title: 'Row Level Security', - description: 'Using Row Level Security with Supabase Auth.', - subtitle: 'Using Row Level Security with Supabase Auth.', - tocVideo: 'Ow_Uzedfohk', -} +--- +id: 'row-level-security' +title: 'Row Level Security' +description: 'Using Row Level Security with Supabase Auth.' +subtitle: 'Using Row Level Security with Supabase Auth.' +tocVideo: 'Ow_Uzedfohk' +--- [Postgres Row Level Security](/docs/guides/database/postgres/row-level-security) (RLS) is a feature of Postgres that allows you to control which users are permitted to perform SELECT/INSERT/UPDATE/DELETE statements on specific rows within tables and views. For example, you could restrict a `blog_post` table such that the current user is only allowed to UPDATE rows where their user id is set in the table's `author_id` column. @@ -38,10 +36,11 @@ using ( auth.uid() = user_id ); ```sql select * from todos -where auth.uid() = todos.user_id; -- Policy is implicitly added. +where auth.uid() = todos.user_id; +-- Policy is implicitly added. ``` -## Authenticated and anonymous roles +## Authenticated and anonymous roles Supabase Auth maps every request to one of the roles: @@ -56,7 +55,7 @@ on profiles for select to authenticated, anon using ( true ); --- OR +-- OR create policy "Public profiles are viewable only by authenticated users" on profiles for select @@ -64,7 +63,6 @@ to authenticated using ( true ); ``` - ## Helper functions Supabase provides some helper functions that make it easier to write Policies. @@ -82,8 +80,8 @@ Returns the JWT of the user making the request. Anything that you store in the u The `auth.jwt()` function is extremely versatile. For example, if you store some team data inside `app_metadata`, you can use it to determine whether a particular user belongs to a team. For example, if this was an array of IDs: -```sql -create policy "User is in team" +```sql +create policy "User is in team" on my_table to authenticated using ( team_id in (select auth.jwt() -> 'app_metadata' -> 'teams')); @@ -91,7 +89,7 @@ using ( team_id in (select auth.jwt() -> 'app_metadata' -> 'teams')); -Keep in mind that a JWT is not always "fresh". In the example above, even if you remove a user from a team and update the `app_metadata` field, that will not be reflected using `auth.jwt()` until the user's JWT is refreshed. +Keep in mind that a JWT is not always "fresh". In the example above, even if you remove a user from a team and update the `app_metadata` field, that will not be reflected using `auth.jwt()` until the user's JWT is refreshed. Also, if you are using Cookies for Auth, then you must be mindful of the JWT size. Some browsers are limited to 4096 bytes for each cookie, and so the total size of your JWT should be small enough to fit inside this limitation. @@ -125,7 +123,7 @@ using ( true ); If you want to use another authorization method for your applications that's also fine. Supabase is "just Postgres", so if your application works with Postgres, then it also works with Supabase. If you take this path, don't put your tables in the `public` schema - instead create a new schema for your tables and functions: -```sql +```sql create schema private; create table private.employees ( @@ -147,10 +145,9 @@ alter table profiles enable row level security; This makes the tables inaccessible via the [APIs](/docs/guides/api). - ## Usage -Row Level Security is extremely versatile, since it simply uses SQL to express access rules for your data. +Row Level Security is extremely versatile, since it simply uses SQL to express access rules for your data. ### Using functions @@ -291,7 +288,3 @@ to authenticated using ( - [Testing your database](/docs/guides/database/testing) - Community repo on testing RLS using [pgTAP and dbdev](https://github.com/usebasejump/supabase-test-helpers/tree/main) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/server-side-rendering.mdx b/apps/docs/content/guides/auth/server-side-rendering.mdx similarity index 97% rename from apps/docs/pages/guides/auth/server-side-rendering.mdx rename to apps/docs/content/guides/auth/server-side-rendering.mdx index 4d01f8e1605..78e0d7e31c4 100644 --- a/apps/docs/pages/guides/auth/server-side-rendering.mdx +++ b/apps/docs/content/guides/auth/server-side-rendering.mdx @@ -1,11 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import { Accordion } from 'ui' - -export const meta = { - id: 'server-side-rendering', - title: 'Server-Side Rendering', - description: 'Render pages with user information on the server.', -} +--- +id: 'server-side-rendering' +title: 'Server-Side Rendering' +description: 'Render pages with user information on the server.' +--- Single-page apps with server-side rendering (SSR) is a popular way to optimize rendering performance and leverage advanced caching strategies. @@ -68,7 +65,7 @@ Supabase Auth supports two authentication flows: **Implicit** and **PKCE**. The Web browsers do not send the URL fragment to the server they're making the request to. Since you may not be hosting the single-page app on a server under your direct control (such as on GitHub Pages or other freemium hosting providers), we want to prevent hosting services from getting access to your user's authorization credentials by default. - + Even if the server is under your direct control, `GET` requests and their full URLs are often logged. This approach also avoids leaking credentials in request or access logs. If you wish to obtain the `access_token` and `refresh_token` on a server, please consider using the PKCE flow. @@ -80,7 +77,7 @@ Supabase Auth supports two authentication flows: **Implicit** and **PKCE**. The header={PKCE} id={`ssr-pkce-flow`} > - + When using the PKCE flow, a redirect URL will be returned with the following structure: ``` https://yourapp.com/...?code=<...> @@ -90,7 +87,7 @@ Supabase Auth supports two authentication flows: **Implicit** and **PKCE**. The For security purposes, the code has a validity of 5 minutes and can only be exchanged for an access token once. You will need to restart the authentication flow from scratch if you wish to obtain a new access token. - + As the flow is run server side, `localStorage` may not be available. You may configure the client library to use a custom storage adapter an alternate backing storage such as cookies by setting the `storage` option to an object with the following methods: @@ -143,6 +140,7 @@ Supabase Auth supports two authentication flows: **Implicit** and **PKCE**. The
+ ## Bringing it together @@ -240,7 +238,3 @@ Also be sure you set proper cache control headers. We recommend invalidating cac ### Which authentication flows have PKCE support? At present, PKCE is supported on the Magic Link, OAuth, Sign Up, and Password Recovery routes. These correspond to the `signInWithOtp`, `signInWithOAuth`, `signUp`, and `resetPasswordForEmail` methods on the Supabase client library. When using PKCE with Phone and Email OTPs, there is no behavior change with respect to the implicit flow - an access token will be returned in the body when a request is successful. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/server-side/creating-a-client.mdx b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx similarity index 96% rename from apps/docs/pages/guides/auth/server-side/creating-a-client.mdx rename to apps/docs/content/guides/auth/server-side/creating-a-client.mdx index f0e4ef72106..b8917844105 100644 --- a/apps/docs/pages/guides/auth/server-side/creating-a-client.mdx +++ b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - title: 'Creating a Supabase client for SSR', - description: 'Configure Supabase client to use Cookies', - sidebar_label: 'Creating a client', -} +--- +title: 'Creating a Supabase client for SSR' +description: 'Configure Supabase client to use Cookies' +sidebar_label: 'Creating a client' +--- ### Install Supabase packages @@ -98,6 +96,12 @@ dotenv.config() 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 Next.js stack - client, server, App Router, Pages Router. It just works! + + +For the client utility functions, it is recommended to create them in a separate `/utils/supabase` folder. That way, you can import them as `client` into any component or page that needs access to Supabase. For further information, you can look at the [Next.js Auth Guide](/docs/guides/auth/server-side/nextjs). + + + { * will replicate previous/standard behaviour (https://kit.svelte.dev/docs/types#public-types-cookies) */ set: (key, value, options) => { - event.cookies.set(key, value, { ...options, path: '' }) + event.cookies.set(key, value, { ...options, path: '/' }) }, remove: (key, options) => { - event.cookies.delete(key, { ...options, path: '' }) + event.cookies.delete(key, { ...options, path: '/' }) }, }, }) @@ -862,7 +866,3 @@ app.post("/hello-world", async function (req, res, next) { - Implement [Authentication using Email and Password](/docs/guides/auth/server-side/email-based-auth-with-pkce-flow-for-ssr) - Implement [Authentication using OAuth](/docs/guides/auth/server-side/oauth-with-pkce-flow-for-ssr) - [Learn more about SSR](/docs/guides/auth/server-side-rendering) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/server-side/email-based-auth-with-pkce-flow-for-ssr.mdx b/apps/docs/content/guides/auth/server-side/email-based-auth-with-pkce-flow-for-ssr.mdx similarity index 93% rename from apps/docs/pages/guides/auth/server-side/email-based-auth-with-pkce-flow-for-ssr.mdx rename to apps/docs/content/guides/auth/server-side/email-based-auth-with-pkce-flow-for-ssr.mdx index 0eea3cf2723..02fe242a0a0 100644 --- a/apps/docs/pages/guides/auth/server-side/email-based-auth-with-pkce-flow-for-ssr.mdx +++ b/apps/docs/content/guides/auth/server-side/email-based-auth-with-pkce-flow-for-ssr.mdx @@ -1,12 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - title: 'Email Auth with PKCE flow for SSR', - description: - 'Learn how to configure email authentication in your server-side rendering (SSR) application to work with the PKCE flow.', - subtitle: - 'Learn how to configure email authentication in your server-side rendering (SSR) application to work with the PKCE flow.', -} +--- +title: 'Email Auth with PKCE flow for SSR' +description: 'Learn how to configure email authentication in your server-side rendering (SSR) application to work with the PKCE flow.' +subtitle: 'Learn how to configure email authentication in your server-side rendering (SSR) application to work with the PKCE flow.' +--- ### Setting up SSR client @@ -277,7 +273,7 @@ Let's update the URL in our email templates to point to our new confirmation end

Magic Link

Follow this link to login:

-

Log In

+

Log In

``` **Change Email Address template** @@ -306,7 +302,3 @@ Let's update the URL in our email templates to point to our new confirmation end >

``` - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/server-side/migrating-to-ssr-from-auth-helpers.mdx b/apps/docs/content/guides/auth/server-side/migrating-to-ssr-from-auth-helpers.mdx similarity index 78% rename from apps/docs/pages/guides/auth/server-side/migrating-to-ssr-from-auth-helpers.mdx rename to apps/docs/content/guides/auth/server-side/migrating-to-ssr-from-auth-helpers.mdx index 4ff96642f8b..63de85f80bb 100644 --- a/apps/docs/pages/guides/auth/server-side/migrating-to-ssr-from-auth-helpers.mdx +++ b/apps/docs/content/guides/auth/server-side/migrating-to-ssr-from-auth-helpers.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - title: 'Migrating to the SSR package from Auth Helpers', - description: 'Step-by-step guide to migrating your app to the new SSR package', - sidebar_label: 'Migrating to SSR from Auth Helpers', -} +--- +title: 'Migrating to the SSR package from Auth Helpers' +description: 'Step-by-step guide to migrating your app to the new SSR package' +sidebar_label: 'Migrating to SSR from Auth Helpers' +--- The new `ssr` package takes the core concepts of the Auth Helpers and makes them available to any server language or framework. This page will guide you through migrating from the Auth Helpers package to `ssr`. @@ -50,7 +48,3 @@ Check out the [Creating a client](/docs/guides/auth/server-side/creating-a-clien - Implement [Authentication using Email and Password](/docs/guides/auth/server-side/email-based-auth-with-pkce-flow-for-ssr) - Implement [Authentication using OAuth](/docs/guides/auth/server-side/oauth-with-pkce-flow-for-ssr) - [Learn more about SSR](/docs/guides/auth/server-side-rendering) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/content/guides/auth/server-side/nextjs.mdx b/apps/docs/content/guides/auth/server-side/nextjs.mdx new file mode 100644 index 00000000000..99b0cd126e0 --- /dev/null +++ b/apps/docs/content/guides/auth/server-side/nextjs.mdx @@ -0,0 +1,1010 @@ +--- +title: 'Setting up Server-Side Auth for Next.js' +sidebar_label: 'Next.js guide' +hideToc: true +--- + +Next.js comes in two flavors: the [App Router](https://nextjs.org/docs/app) and the [Pages Router](https://nextjs.org/docs/pages). You can set up Server-Side Auth with either strategy. You can even use both in the same application. + + + + + + + + + + + +Install the `@supabase/supabase-js` package and the helper `@supabase/ssr` package. + + + + + +```sh +npm install @supabase/supabase-js @supabase/ssr +``` + + + + + + + + + +Create a `.env.local` file in your project root directory. + +Fill in your `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY`: + + + + + + + + +```txt .env.local +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +``` + + + + + + + + + +To access Supabase from your Next.js app, you need 2 types of Supabase clients: + +1. **Client Component client** - To access Supabase from Client Components, which run in the browser. +1. **Server Component client** - To access Supabase from Server Components, Server Actions, and Route Handlers, which run only on the server. + +Create a `utils/supabase` folder with a file for each type of client. Then copy the utility functions for each client type. + + +
+ What does the `cookies` object do?} + id="utility-cookies" + > + + The cookies object lets the Supabase client know how to access the cookies, so it can read and write the user session data. To make `@supabase/ssr` framework-agnostic, the cookies methods aren't hard-coded. These utility functions adapt `@supabase/ssr`'s cookie handling for Next.js. + + The `set` and `remove` methods for the server client need error handlers, because Next.js throws an error if cookies are set from Server Components. You can safely ignore this error because you'll set up middleware in the next step to write refreshed cookies to storage. + + + +
+
+ Do I need to create a new client for every route?} + id="client-deduplication" + > + + Yes! Creating a Supabase client is lightweight. + + - On the server, it basically configures a `fetch` call. You need to reconfigure the fetch call anew for every request to your server, because you need the cookies from the request. + - On the client, `createBrowserClient` already uses a singleton pattern, so you only ever create one instance, no matter how many times you call your `createClient` function. + + + +
+
+ +
+ + + + + +```ts utils/supabase/client.ts +import { createBrowserClient } from '@supabase/ssr' + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) +} +``` + +```ts utils/supabase/server.ts +import { createServerClient, type CookieOptions } from '@supabase/ssr' +import { type cookies } from 'next/headers' + +export function createClient(cookieStore: ReturnType) { + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return cookieStore.get(name)?.value + }, + set(name: string, value: string, options: CookieOptions) { + try { + cookieStore.set({ name, value, ...options }) + } catch (error) { + // The `set` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + remove(name: string, options: CookieOptions) { + try { + cookieStore.set({ name, value: '', ...options }) + } catch (error) { + // The `delete` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + }, + } + ) +} +``` + + + + + +
+ + + + + +Create a `middleware.ts` file at the root of your project. + +Since Server Components can't write cookies, you need middleware to refresh expired Auth tokens and store them. + +The middleware is responsible for: + +1. Refreshing the Auth token (by calling `supabase.auth.getUser`). +1. Passing the refreshed Auth token to Server Components, so they don't attempt to refresh the same token themselves. This is accomplished with `request.cookies.set`. +1. Passing the refreshed Auth token to the browser, so it replaces the old token. This is accomplished with `response.cookies.set`. + +Copy the middleware code for your app. + +Add a [matcher](https://nextjs.org/docs/app/building-your-application/routing/middleware#matching-paths) so the middleware doesn't run on routes that don't access Supabase. + + + +Be careful when protecting pages. The server gets the user session from the cookies, which can be spoofed by anyone. + +Always use `supabase.auth.getUser()` to protect pages and user data. + +_Never_ trust `supabase.auth.getSession()` inside server code such as middleware. It isn't guaranteed to revalidate the Auth token. + +It's safe to trust `getUser()` because it sends a request to the Supabase Auth server every time to revalidate the Auth token. + + + + + + + +```ts middleware.ts +import { createServerClient, type CookieOptions } from '@supabase/ssr' +import { NextResponse, type NextRequest } from 'next/server' + +export async function middleware(request: NextRequest) { + let response = NextResponse.next({ + request: { + headers: request.headers, + }, + }) + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return request.cookies.get(name)?.value + }, + set(name: string, value: string, options: CookieOptions) { + request.cookies.set({ + name, + value, + ...options, + }) + response = NextResponse.next({ + request: { + headers: request.headers, + }, + }) + response.cookies.set({ + name, + value, + ...options, + }) + }, + remove(name: string, options: CookieOptions) { + request.cookies.set({ + name, + value: '', + ...options, + }) + response = NextResponse.next({ + request: { + headers: request.headers, + }, + }) + response.cookies.set({ + name, + value: '', + ...options, + }) + }, + }, + } + ) + + await supabase.auth.getUser() + + return response +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * Feel free to modify this pattern to include more paths. + */ + '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], +} +``` + + + + + + + + + +Create a login page for your app. Use a Server Action to call the Supabase signup function. + +Since Supabase is being called from an Action, use the client defined in `@/utils/supabase/server.ts`. + + + +Note that `cookies` is called before any calls to Supabase, which opts fetch calls out of Next.js's caching. This is important for authenticated data fetches, to ensure that users get access only to their own data. + +See the Next.js docs to learn more about [opting out of data caching](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching). + + + + + + + + + +```ts app/login/page.tsx +import { login, signup } from './actions' + +export default function LoginPage() { + return ( +
+ + + + + + + + ) +} +``` + +```ts app/login/actions.ts +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { redirect } from 'next/navigation' + +import { createClient } from '@/utils/supabase/server' + +export async function login(formData: FormData) { + const cookieStore = cookies() + const supabase = createClient(cookieStore) + + // type-casting here for convenience + // in practice, you should validate your inputs + const data = { + email: formData.get('email') as string, + password: formData.get('password') as string, + } + + const { error } = await supabase.auth.signInWithPassword(data) + + if (error) { + redirect('/error') + } + + revalidatePath('/', 'layout') + redirect('/') +} + +export async function signup(formData: FormData) { + const cookieStore = cookies() + const supabase = createClient(cookieStore) + + // type-casting here for convenience + // in practice, you should validate your inputs + const data = { + email: formData.get('email') as string, + password: formData.get('password') as string, + } + + const { error } = await supabase.auth.signUp(data) + + if (error) { + redirect('/error') + } + + revalidatePath('/', 'layout') + redirect('/') +} +``` + +```ts app/error/page.tsx +export default function ErrorPage() { + return

Sorry, something went wrong

+} +``` + +
+ +
+ +
+ + + + + +If you have email confirmation turned on (the default), a new user will receive an email confirmation after signing up. + +Change the email template to support a server-side authentication flow. + +Go to the [Auth templates](https://supabase.com/dashboard/project/_/auth/templates) page in your dashboard. In each email template, change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=signup`. + + + + + + + + + +Create a Route Handler for `auth/confirm`. When a user clicks their confirmation email link, exchange their secure code for an Auth token. + +Since this is a Router Handler, use the Supabase client from `@/utils/supabase/server.ts`. + + + + + +```ts app/auth/confirm/route.ts +import { type EmailOtpType } from '@supabase/supabase-js' +import { cookies } from 'next/headers' +import { type NextRequest, NextResponse } from 'next/server' + +import { createClient } from '@/utils/supabase/server' + +export async function GET(request: NextRequest) { + const cookieStore = cookies() + + const { searchParams } = new URL(request.url) + const token_hash = searchParams.get('token_hash') + const type = searchParams.get('type') as EmailOtpType | null + const next = searchParams.get('next') ?? '/' + + const redirectTo = request.nextUrl.clone() + redirectTo.pathname = next + redirectTo.searchParams.delete('token_hash') + redirectTo.searchParams.delete('type') + + if (token_hash && type) { + const supabase = createClient(cookieStore) + + const { error } = await supabase.auth.verifyOtp({ + type, + token_hash, + }) + if (!error) { + redirectTo.searchParams.delete('next') + return NextResponse.redirect(redirectTo) + } + } + + // return the user to an error page with some instructions + redirectTo.pathname = '/error' + return NextResponse.redirect(redirectTo) +} +``` + + + + + + + + + +Server Components can read cookies, so you can get the Auth status and user info. + +Since you're calling Supabase from a Server Component, use the client created in `@/utils/supabase/server.ts`. + +Create a `private` page that users can only access if they're logged in. The page displays their email. + + + +Be careful when protecting pages. The server gets the user session from the cookies, which can be spoofed by anyone. + +Always use `supabase.auth.getUser()` to protect pages and user data. + +_Never_ trust `supabase.auth.getSession()` inside Server Components. It isn't guaranteed to revalidate the Auth token. + +It's safe to trust `getUser()` because it sends a request to the Supabase Auth server every time to revalidate the Auth token. + + + + + + + +```ts app/private/page.tsx +import { cookies } from 'next/headers' +import { redirect } from 'next/navigation' + +import { createClient } from '@/utils/supabase/server' + +export default async function PrivatePage() { + const cookieStore = cookies() + const supabase = createClient(cookieStore) + + const { data, error } = await supabase.auth.getUser() + if (error || !data?.user) { + redirect('/') + } + + return

Hello {data.user.email}

+} +``` + +
+ +
+ +
+ +## Congratulations + +You're done! To recap, you've successfully: + +- Called Supabase from a Server Action. +- Called Supabase from a Server Component. +- Set up a Supabase client utility to call Supabase from a Client Component. You can use this if you need to call Supabase from a Client Component, for example to set up a realtime subscription. +- Set up middleware to automatically refresh the Supabase Auth session. + +You can now use any Supabase features from your client or server code! + +## Troubleshooting + + +
+ Getting the error DynamicServerError: Dynamic server usage: cookies} + id="troubleshooting-error-dynamic-cookies" + > + This means Next.js has failed to detect that the route is dynamic until the `cookies` function is called. To fix this, make sure you call the `cookies` function directly in your Server Component, instead of abstracting it out. + + Alternatively, you can mark the route as dynamic using `export const dynamic = 'force-dynamic'`. + + +
+
+ +
+ + + + + + + + + +Install the `@supabase/supabase-js` package and the helper `@supabase/ssr` package. + + + + + +```sh +npm install @supabase/supabase-js @supabase/ssr +``` + + + + + + + + + +Create a `.env.local` file in your project root directory. + +Fill in your `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY`: + + + + + + + + +```txt .env.local +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +``` + + + + + + + + + +To access Supabase from your Next.js app, you need 4 types of Supabase clients: + +1. **`getServerSideProps` client** - To access Supabase from `getServerSideProps`. +1. **`getStaticProps` client** - To access Supabase from `getStaticProps`. +1. **Component client** - To access Supabase from within components. +1. **API route client** - To access Supabase from API route handlers. + +Create a `utils/supabase` folder with a file for each type of client. Then copy the utility functions for each client type. + + +
+ Why do I need so many types of clients?} + id="nextjs-clients" + > + + A Supabase client reads and sets cookies in order to access and update the user session. Depending on where the client is used, it needs to interact with cookies in a different way: + + - **`getServerSideProps`** - Runs on the server. Reads cookies from the request, which is passed through from `GetServerSidePropsContext`. + - **`getStaticProps`** - Runs at build time, where there is no user, session, or cookies. + - **Component** - Runs on the client. Reads cookies from browser storage. Behind the scenes, `createBrowserClient` reuses the same client instance if called multiple times, so don't worry about deduplicating the client yourself. + - **API route** - Runs on the server. Reads cookies from the request, which is passed through from `NextApiRequest`. + + + +
+
+ What does the `cookies` object do?} + id="client-storage-cookies" + > + + The cookies object lets the Supabase client know how to access the cookies, so it can read and write the user session. To make `@supabase/ssr` framework-agnostic, the cookies methods aren't hard-coded. But you only need to set them up once. You can then reuse your utility functions whenever you need a Supabase client. + + + +
+
+ +
+ + + + + +```ts utils/supabase/server-props.ts +import { createServerClient, type CookieOptions, serialize } from '@supabase/ssr' +import { type GetServerSidePropsContext } from 'next' + +export function createClient(context: GetServerSidePropsContext) { + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return context.req.cookies[name] + }, + set(name: string, value: string, options: CookieOptions) { + context.res.appendHeader('Set-Cookie', serialize(name, value, options)) + }, + remove(name: string, options: CookieOptions) { + context.res.appendHeader('Set-Cookie', serialize(name, '', options)) + }, + }, + } + ) + + return supabase +} +``` + +```ts utils/supabase/static-props.ts +import { createClient as createClientPrimitive } from '@supabase/supabase-js' + +export function createClient() { + const supabase = createClientPrimitive( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) + + return supabase +} +``` + +```ts utils/supabase/component.ts +import { createBrowserClient } from '@supabase/ssr' + +export function createClient() { + const supabase = createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) + + return supabase +} +``` + +```ts utils/supabase/api.ts +import { createServerClient, type CookieOptions, serialize } from '@supabase/ssr' +import { type NextApiRequest, type NextApiResponse } from 'next' + +export default function createClient(req: NextApiRequest, res: NextApiResponse) { + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return req.cookies[name] + }, + set(name: string, value: string, options: CookieOptions) { + res.appendHeader('Set-Cookie', serialize(name, value, options)) + }, + remove(name: string, options: CookieOptions) { + res.appendHeader('Set-Cookie', serialize(name, '', options)) + }, + }, + } + ) + + return supabase +} +``` + + + + + +
+ + + + + +Create a login page for your app. + +Since Supabase is being called from a component, use the client defined in `@/utils/supabase/component.ts`. + + + + + + + +```ts pages/login.tsx +import { useRouter } from 'next/router' +import { useState } from 'react' + +import { createClient } from '@/utils/supabase/component' + +export default function LoginPage() { + const router = useRouter() + const supabase = createClient() + + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + + async function logIn() { + const { error } = await supabase.auth.signInWithPassword({ email, password }) + if (error) { + console.error(error) + } + router.push('/') + } + + async function signUp() { + const { error } = await supabase.auth.signUp({ email, password }) + if (error) { + console.error(error) + } + router.push('/') + } + + return ( +
+
+ + setEmail(e.target.value)} /> + + setPassword(e.target.value)} + /> + + + +
+ ) +} +``` + +
+ +
+ +
+ + + + + +If you have email confirmation turned on (the default), a new user will receive an email confirmation after signing up. + +Change the email template to support a server-side authentication flow. + +Go to the [Auth templates](https://supabase.com/dashboard/project/_/auth/templates) page in your dashboard. In each email template, change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/api/auth/confirm?token_hash={{ .TokenHash }}&type=signup`. + + + + + + + + + +Create an API route for `api/auth/confirm`. When a user clicks their confirmation email link, exchange their secure code for an Auth token. + +Since this is an API route, use the Supabase client from `@/utils/supabase/api.ts`. + + + + + + + +```ts pages/api/auth/confirm.tsx +import { type EmailOtpType } from '@supabase/supabase-js' +import type { NextApiRequest, NextApiResponse } from 'next' + +import { createClient } from '@/utils/supabase/api' + +function stringOrFirstString(item: string | string[] | undefined) { + return Array.isArray(item) ? item[0] : item +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'GET') { + res.status(405).appendHeader('Allow', 'GET').end() + return + } + + const queryParams = req.query + const token_hash = stringOrFirstString(queryParams.token_hash) + const type = stringOrFirstString(queryParams.type) + + let next = '/error' + + if (token_hash && type) { + const supabase = createClient(req, res) + const { error } = await supabase.auth.verifyOtp({ + type: type as EmailOtpType, + token_hash, + }) + if (error) { + console.error(error) + } else { + next = stringOrFirstString(queryParams.next) || '/' + } + } + + res.redirect(next) +} +``` + +```tsx pages/error.tsx +export default function ErrorPage() { + return

Sorry, something went wrong

+} +``` + +
+ +
+ +
+ + + + + +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`. + + + +Be careful when protecting pages. The server gets the user session from the cookies, which can be spoofed by anyone. + +Always use `supabase.auth.getUser()` to protect pages and user data. + +_Never_ trust `supabase.auth.getSession()` inside server code. It isn't guaranteed to revalidate the Auth token. + +It's safe to trust `getUser()` because it sends a request to the Supabase Auth server every time to revalidate the Auth token. + + + + + + + +```ts pages/private.tsx +import type { User } from '@supabase/supabase-js' +import type { GetServerSidePropsContext } from 'next' + +import { createClient } from '@/utils/supabase/server-props' + +export default function PrivatePage({ user }: { user: User }) { + return

Hello, {user.email || 'user'}!

+} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const supabase = createClient(context) + + const { data, error } = await supabase.auth.getUser() + + if (error || !data) { + return { + redirect: { + destination: '/', + permanent: false, + }, + } + } + + return { + props: { + user: data.user, + }, + } +} +``` + +
+ +
+ + + + + +You can also fetch static data at build time using Supabase. Note that there's no session or user at build time, so the data will be the same for everyone who sees the page. + +Add some countries data to your database by running the [Countries Quickstart](https://supabase.com/dashboard/project/_/sql/quickstarts) in the dashboard. + +Then fetch the countries data using `getStaticProps` with the client from `@/utils/supabase/static-props.ts`. + + + + + +```ts pages/public.tsx +import { createClient } from '@/utils/supabase/static-props' + +export default function PublicPage({ data }: { data?: any[] }) { + return
{data && JSON.stringify(data, null, 2)}
+} + +export async function getStaticProps() { + const supabase = createClient() + + const { data, error } = await supabase.from('countries').select() + + if (error || !data) { + return { props: {} } + } + + return { props: { data } } +} +``` + +
+ +
+ +
+ +## Congratulations + +You're done! To recap, you've successfully: + +- Called Supabase from a component +- Called Supabase from an API route +- Called Supabase from `getServerSideProps` +- Called Supabase from `getStaticProps` + +You can now use any Supabase features from your client or server code! + +
+ + + +You can use both the App and Pages Routers together. + +Follow the instructions for both the App and Pages Routers. Whenever you need to connect to Supabase, import the `createClient` utility that you need: + +| Router | Code location | Which `createClient` to use | +| ------------ | ------------------------------------------------- | --------------------------- | +| App Router | Server Component, Server Action, or Route Handler | `server.ts` | +| | Client Component | `client.ts` | +| Pages Router | `getServerSideProps` | `server-props.ts` | +| | `getStaticProps` | `static-props.ts` | +| | Component | `component.ts` | +| | API route | `api.ts` | + +Remember to create the `middleware.ts` file for the App Router so the session refreshes for App Router pages. + + + +
diff --git a/apps/docs/pages/guides/auth/server-side/oauth-with-pkce-flow-for-ssr.mdx b/apps/docs/content/guides/auth/server-side/oauth-with-pkce-flow-for-ssr.mdx similarity index 92% rename from apps/docs/pages/guides/auth/server-side/oauth-with-pkce-flow-for-ssr.mdx rename to apps/docs/content/guides/auth/server-side/oauth-with-pkce-flow-for-ssr.mdx index 884460b3c92..9ff80b227e0 100644 --- a/apps/docs/pages/guides/auth/server-side/oauth-with-pkce-flow-for-ssr.mdx +++ b/apps/docs/content/guides/auth/server-side/oauth-with-pkce-flow-for-ssr.mdx @@ -1,12 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - title: 'OAuth with PKCE flow for SSR', - description: - 'Learn how to configure OAuth authentication in your server-side rendering (SSR) application to work with the PKCE flow.', - subtitle: - 'Learn how to configure OAuth authentication in your server-side rendering (SSR) application to work with the PKCE flow.', -} +--- +title: 'OAuth with PKCE flow for SSR' +description: 'Learn how to configure OAuth authentication in your server-side rendering (SSR) application to work with the PKCE flow.' +subtitle: 'Learn how to configure OAuth authentication in your server-side rendering (SSR) application to work with the PKCE flow.' +--- ### Setting up SSR client @@ -219,7 +215,3 @@ await supabase.auth.signInWithOAuth({ }, }) ``` - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/server-side/overview.mdx b/apps/docs/content/guides/auth/server-side/overview.mdx similarity index 58% rename from apps/docs/pages/guides/auth/server-side/overview.mdx rename to apps/docs/content/guides/auth/server-side/overview.mdx index 18e4c186f9a..eb2d1c1c9d8 100644 --- a/apps/docs/pages/guides/auth/server-side/overview.mdx +++ b/apps/docs/content/guides/auth/server-side/overview.mdx @@ -1,21 +1,27 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'index', - title: 'Server-Side Auth Overview', - description: 'Configure Supabase Auth to use Cookies', - sidebar_label: 'Overview', -} +--- +id: 'index' +title: 'Server-Side Auth Overview' +description: 'Configure Supabase Auth to use Cookies' +sidebar_label: 'Overview' +--- When using Supabase with server-side languages and frameworks - such as Next.js, SvelteKit and Remix - it is important to configure your Supabase client to use cookies for storing user sessions. We have developed an `@supabase/ssr` package to make this process as simple as possible. This package is currently in `beta`. Adoption is recommended but be aware that the API is still unstable and may have breaking changes in the future. ## Framework quickstarts -
- {quickstarts.map((item) => { +
+ {[ + { + title: 'Next.js', + href: '/guides/auth/server-side/nextjs', + description: + 'Automatically configure Supabase to use cookies, making your user and their session available on the client and server.', + icon: '/docs/img/icons/nextjs-icon', + }, + ].map((item) => { return ( - - + + {item.description} @@ -35,17 +41,3 @@ If you're currently using the [Auth Helpers package](https://github.com/supabase - [Email Auth with PKCE flow for SSR](/docs/guides/auth/server-side/email-based-auth-with-pkce-flow-for-ssr) - [OAuth with PKCE flow for SSR](/docs/guides/auth/server-side/oauth-with-pkce-flow-for-ssr) - [Learn more about SSR](/docs/guides/auth/server-side-rendering) - -export const quickstarts = [ - { - title: 'Next.js', - href: '/guides/auth/quickstarts/nextjs', - description: - 'Automatically configure Supabase to use cookies, making your user and their session available on the client and server.', - icon: '/docs/img/icons/nextjs-icon', - }, -] - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/sessions.mdx b/apps/docs/content/guides/auth/sessions.mdx similarity index 90% rename from apps/docs/pages/guides/auth/sessions.mdx rename to apps/docs/content/guides/auth/sessions.mdx index 5e8f5fe32d0..15484293d0d 100644 --- a/apps/docs/pages/guides/auth/sessions.mdx +++ b/apps/docs/content/guides/auth/sessions.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'sessions', - title: 'User Sessions', - description: 'Control and manage user sessions.', -} +--- +id: 'sessions' +title: 'User Sessions' +description: 'Control and manage user sessions.' +--- Supabase Auth provides fine-grained control over your user's sessions. @@ -16,9 +14,9 @@ A session is created when a user signs in. By default, it lasts indefinitely and A session is represented by the Supabase Auth access token in the form of a JWT, and a refresh token which is a unique string. -Access tokens are designed to be short lived, usually between 5 minutes and 1 hour while refresh tokens never expire but can only be used once. You can exchange a refresh token only once to get a new access and refresh token pair. +Access tokens are designed to be short lived, usually between 5 minutes and 1 hour while refresh tokens never expire but can only be used once. You can exchange a refresh token only once to get a new access and refresh token pair. -This process is called **refreshing the session.** +This process is called **refreshing the session.** A session terminates, depending on configuration, when: @@ -54,7 +52,7 @@ Upon sign out, all refresh tokens and potentially other database objects related -Access tokens of revoked sessions remain valid until their expiry time, encoded in the `exp` claim. The user won't be immediately logged out and will only be logged out when the access token expires. +Access tokens of revoked sessions remain valid until their expiry time, encoded in the `exp` claim. The user won't be immediately logged out and will only be logged out when the access token expires. @@ -74,7 +72,7 @@ There are three ways to limit the lifetime of a session: - Time-boxed sessions, which terminate after a fixed amount of time. - Set an inactivity timeout, which terminates sessions that haven't been refreshed within the timeout duration. -- Enforce a single-session per user, which only keeps the most recently active session. +- Enforce a single-session per user, which only keeps the most recently active session. To make sure that users are required to re-authenticate periodically, you can set a positive value for the **Time-box user sessions** option in the [Auth settings](/dashboard/project/_/settings/auth) for your project. @@ -82,7 +80,7 @@ To make sure that sessions expire after a period of inactivity, you can set a po You can also enforce only one active session per user per device or browser. When this is enabled, the session from the most recent sign in will remain active, while the rest are terminated. Enable this via the _Single session per user_ option in the [Auth settings](/dashboard/project/_/settings/auth). -Sessions are not proactively destroyed when you change these settings, but rather the check is enforced whenever a session is refreshed next. This can confuse developers because the actual druation of a session is the configured timeout plus the JWT expiration time. For single session per user, the effect will only be noticed at intervals of the JWT expiration time. Please make sure you adjust this setting depending on your needs. We do not recommend going below 5 minutes for the JWT expiration time. +Sessions are not proactively destroyed when you change these settings, but rather the check is enforced whenever a session is refreshed next. This can confuse developers because the actual duration of a session is the configured timeout plus the JWT expiration time. For single session per user, the effect will only be noticed at intervals of the JWT expiration time. Please make sure you adjust this setting depending on your needs. We do not recommend going below 5 minutes for the JWT expiration time. Otherwise sessions are progressively deleted from the database 24 hours after they expire, which prevents you from causing a high load on your project by accident and allows you some freedom to undo changes without adversely affecting all users. @@ -108,11 +106,11 @@ As your users continue using your app, refresh tokens are being constantly excha The general rule is that a refresh token can only be used once. However, strictly enforcing this can cause certain issues to arise. There are two exceptions to this design to prevent the early and unexpected termination of user's sessions: - A refresh token can be used more than once within a defined reuse interval. By default this is 10 seconds and we do not recommend changing this value. This exception is granted for legitimate situations such as: - - Using server-side rendering where the same refresh token needs to be reused on the server and soon after on the client - - To allow some leeway for bugs or issues with serializing access to the refresh token request + - Using server-side rendering where the same refresh token needs to be reused on the server and soon after on the client + - To allow some leeway for bugs or issues with serializing access to the refresh token request - If the parent of the currently active refresh token for the user's session is being used, the active token will be returned. This exception solves an important and often common situation: - - All clients such as browsers, mobile or desktop apps, and even some servers are inherently unreliable due to network issues. A request does not indicate that they received a response or even processed the response they received. - - If a refresh token is revoked after being used only once, and the response wasn't received and processed by the client, when the client comes back online, it will attempt to use the refresh token that was already used. Since this might happen outside of the reuse interval, it can cause sudden and unexpected session termination. + - All clients such as browsers, mobile or desktop apps, and even some servers are inherently unreliable due to network issues. A request does not indicate that they received a response or even processed the response they received. + - If a refresh token is revoked after being used only once, and the response wasn't received and processed by the client, when the client comes back online, it will attempt to use the refresh token that was already used. Since this might happen outside of the reuse interval, it can cause sudden and unexpected session termination. Should the reuse attempt not fall under these two exceptions, the whole session is regarded as terminated and all refresh tokens belonging to it are marked as revoked. You can disable this behavior in the Advanced Settings of the [Auth settings](/dashboard/project/_/settings/auth) page, though it is generally not recommended. @@ -161,7 +159,3 @@ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { The `customStorageObject` should implement the `getItem`, `setItem`, and `removeItem` methods from the [`Storage` interface](https://developer.mozilla.org/en-US/docs/Web/API/Storage). Async versions of these methods are also supported. When using cookies to store access and refresh tokens, make sure that the [`Expires` or `Max-Age` attributes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes) of the cookies is set to a timestamp very far into the future. Browsers will clear the cookies, but the session will remain active in Supabase Auth. Therefore it's best to let Supabase Auth control the validity of these tokens and instruct the browser to always store the cookies indefinitely. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login.mdx b/apps/docs/content/guides/auth/social-login.mdx similarity index 77% rename from apps/docs/pages/guides/auth/social-login.mdx rename to apps/docs/content/guides/auth/social-login.mdx index f5e9b16a66c..73b1f211573 100644 --- a/apps/docs/pages/guides/auth/social-login.mdx +++ b/apps/docs/content/guides/auth/social-login.mdx @@ -1,12 +1,7 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import { IconPanel } from 'ui' -import Link from 'next/link' -import { SocialLoginItems } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants' - -export const meta = { - title: 'Social Login', - description: 'Logging in with social accounts', -} +--- +title: 'Social Login' +description: 'Logging in with social accounts' +--- Social Login (OAuth) is an open standard for authentication that allows users to log in to one website or application using their credentials from another website or application. OAuth allows users to grant third-party applications access to their online accounts without sharing their passwords. OAuth is commonly used for things like logging in to a social media account from a third-party app. It is a secure and convenient way to authenticate users and share information between applications. @@ -26,32 +21,37 @@ There are several reasons why you might want to add social login to your applica Supabase supports a suite of social providers. Follow these guides to configure a social provider for your platform.
- {SocialLoginItems.map((item) => ( - - - {item.description} - - -))} - + + {(data) => + data.map((item) => ( + + + {item.description} + + + )) + } +
## Provider tokens - -You can use the provider token and provider refresh token returned to make API calls to the OAuth provider. For example, you can use the Google provider token to access Google APIs on behalf of your user. + +You can use the provider token and provider refresh token returned to make API calls to the OAuth provider. For example, you can use the Google provider token to access Google APIs on behalf of your user. Supabase Auth does not manage refreshing the provider token for the user. Your application will need to use the provider refresh token to obtain a new provider token. If no provider refresh token is returned, then it could mean one of the following: + - The OAuth provider does not return a refresh token - Additional scopes need to be specified in order for the OAuth provider to return a refresh token. - + Provider tokens are intentionally not stored in your project's database. This is because provider tokens give access to potentially sensitive user data in third-party systems. Different applications have different needs, and one application's OAuth scopes may be significantly more permissive than another. If you want to use the provider token outside of the browser that completed the OAuth flow, it is recommended to send it to a trusted and secure server you control. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-apple.mdx b/apps/docs/content/guides/auth/social-login/auth-apple.mdx similarity index 83% rename from apps/docs/pages/guides/auth/social-login/auth-apple.mdx rename to apps/docs/content/guides/auth/social-login/auth-apple.mdx index d121be46da3..ade47b772e3 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-apple.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-apple.mdx @@ -1,12 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import AppleSecretGenerator from '~/components/AppleSecretGenerator' - -export const meta = { - id: 'auth-apple', - title: 'Login with Apple', - description: 'Use Sign in with Apple with Supabase', - tocVideo: '-tpcZzTdvN0', -} +--- +id: 'auth-apple' +title: 'Login with Apple' +description: 'Use Sign in with Apple with Supabase' +tocVideo: '-tpcZzTdvN0' +--- Supabase Auth supports using [Sign in with Apple](https://developer.apple.com/sign-in-with-apple/) on the web and in native apps for iOS, macOS, watchOS or tvOS. @@ -200,9 +197,9 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app, - ## Using native sign in with Apple in Flutter + ## Apple sign in on iOS and macOS - You can perform Apple sign in using the [sign_in_with_apple](https://pub.dev/packages/sign_in_with_apple) package on Flutter. + You can perform Apple sign in using the [sign_in_with_apple](https://pub.dev/packages/sign_in_with_apple) package on Flutter apps running on iOS or macOS. Follow the instructions in the package README to set up native Apple sign in on iOS and macOS. Once the setup is complete on the Flutter app, add the bundle ID of your app to your Supabase dashboard in `Authentication -> Providers -> Apple` in order to register your app with Supabase. @@ -249,6 +246,32 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app, + ## Apple sign in on Android, Web, Windows and Linux + + For platforms that doesn't support native Apple sign in, you can use the `signInWithOAuth()` method to perform the Apple sign in. + This method of signing in is web based, and will open a browser window to perform the sign in. For non-web platforms, the user is brought back to the app via [deep linking](/docs/guides/auth/native-mobile-deep-linking?platform=flutter). + + ```dart + supabase.auth.signInWithOAuth( + OAuthProvider.apple, + redirectTo: 'my-scheme://login-callback', + ); + ``` + + This call takes the user to Apple's consent screen. Once the flow ends, the user's profile information is exchanged and validated with Supabase Auth before it redirects back to your Flutter application with an access and refresh token representing the user's session. + + ### Configuration [#flutter-configuration-web] + + You will require the following information: + + 1. Your Apple Developer account's **Team ID**, which is an alphanumeric string of 10 characters that uniquely identifies the developer of the app. It's often easily accessible in the upper right-side menu on the Apple Developer Console. + 2. Register email sources for _Sign in with Apple for Email Communication_ which can be found in the [Services](https://developer.apple.com/account/resources/services/list) section of the Apple Developer Console. + 3. An **App ID** which uniquely identifies the app you are building. You can create a new App ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/bundleId) section in the Apple Developer Console (use the filter menu in the upper right side to see all App IDs). These usually are a reverse domain name string, for example `com.example.app`. Make sure you configure Sign in with Apple once you create an App ID in the Capabilities list. At this time Supabase Auth does not support Server-to-Server notification endpoints, so you should leave that setting blank. (In the past App IDs were referred to as _bundle IDs._) + 4. A **Services ID** which uniquely identifies the web services provided by the app you registered in the previous step. You can create a new Services ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/serviceId) section in the Apple Developer Console (use the filter menu in the upper right side to see all Services IDs). These usually are a reverse domain name string, for example `com.example.app.web`. + 5. Configure Website URLs for the newly created **Services ID**. The web domain you should use is the domain your Supabase project is hosted on. This is usually `.supabase.co` while the redirect URL is `https://.supabase.co/auth/v1/callback`. + 6. Create a signing **Key** in the [Keys](https://developer.apple.com/account/resources/authkeys/list) section of the Apple Developer Console. You can use this key to generate a secret key using the tool below, which is added to your Supabase project's Auth configuration. Make sure you safely store the `AuthKey_XXXXXXXXXX.p8` file. If you ever lose access to it, or make it public accidentally please revoke it from the Apple Developer Console and create a new one immediately. You will have to generate a new secret key using this file every 6 months, so make sure you schedule a recurring reminder in your calendar! + 7. Finally, add the information you configured above to the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers). + @@ -352,7 +375,3 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app, - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-azure.mdx b/apps/docs/content/guides/auth/social-login/auth-azure.mdx similarity index 96% rename from apps/docs/pages/guides/auth/social-login/auth-azure.mdx rename to apps/docs/content/guides/auth/social-login/auth-azure.mdx index 1fde454fc36..df8831ccc13 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-azure.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-azure.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-azure', - title: 'Login with Azure (Microsoft)', - description: 'Add Azure (Microsoft) OAuth to your Supabase project', -} +--- +id: 'auth-azure' +title: 'Login with Azure (Microsoft)' +description: 'Add Azure (Microsoft) OAuth to your Supabase project' +--- To enable Azure (Microsoft) Auth for your project, you need to set up an Azure OAuth application and add the application credentials to your Supabase Dashboard. @@ -103,6 +101,8 @@ A Microsoft Entra tenant is the directory of users who are allowed to access you By default, Supabase Auth uses the _common_ Microsoft tenant (`https://login.microsoftonline.com/common`) which generally allows any Microsoft account to sign in to your project. Microsoft Entra further limits what accounts can access your project depending on the type of OAuth application you registered. +If your app is registered as _Personal Microsoft accounts only_ for the _Supported account types_ set Microsoft tenant to _consumers_ (`https://login.microsoftonline.com/consumers`). + If your app is registered as _My organization only_ for the _Supported account types_ you may want to configure Supabase Auth with the organization's tenant URL. This will use the tenant's authorization flows instead, and will limit access at the Supabase Auth level to Microsoft accounts arising from only the specified tenant. Configure this by storing a value under _Azure Tenant URL_ in the Supabase Auth provider configuration page for Azure that has the following format `https://login.microsoftonline.com/`. @@ -227,7 +227,3 @@ suspend fun signInWithAzure() { - [Azure Developer Account](https://portal.azure.com) - [GitHub Discussion](https://github.com/supabase/gotrue/pull/54#issuecomment-757043573) - [Potential Risk of Privilege Escalation in Azure AD Applications](https://msrc.microsoft.com/blog/2023/06/potential-risk-of-privilege-escalation-in-azure-ad-applications/) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-bitbucket.mdx b/apps/docs/content/guides/auth/social-login/auth-bitbucket.mdx similarity index 91% rename from apps/docs/pages/guides/auth/social-login/auth-bitbucket.mdx rename to apps/docs/content/guides/auth/social-login/auth-bitbucket.mdx index a0284543d47..1cdde00d182 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-bitbucket.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-bitbucket.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-bitbucket', - title: 'Login with Bitbucket', - description: 'Add Bitbucket OAuth to your Supabase project', -} +--- +id: 'auth-bitbucket' +title: 'Login with Bitbucket' +description: 'Add Bitbucket OAuth to your Supabase project' +--- To enable Bitbucket Auth for your project, you need to set up a BitBucket OAuth application and add the application credentials to your Supabase Dashboard. @@ -117,7 +115,3 @@ suspend fun signOut() { - [Supabase Account - Free Plan OK](https://supabase.com) - [Supabase JS Client](https://github.com/supabase/supabase-js) - [Bitbucket Account](https://bitbucket.org) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-discord.mdx b/apps/docs/content/guides/auth/social-login/auth-discord.mdx similarity index 91% rename from apps/docs/pages/guides/auth/social-login/auth-discord.mdx rename to apps/docs/content/guides/auth/social-login/auth-discord.mdx index 043234a76e5..1c15e17249e 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-discord.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-discord.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-discord', - title: 'Login with Discord', - description: 'Add Discord OAuth to your Supabase project', -} +--- +id: 'auth-discord' +title: 'Login with Discord' +description: 'Add Discord OAuth to your Supabase project' +--- To enable Discord Auth for your project, you need to set up a Discord Application and add the Application OAuth credentials to your Supabase Dashboard. @@ -119,7 +117,3 @@ suspend fun signOut() { - [Supabase JS Client](https://github.com/supabase/supabase-js) - [Discord Account](https://discord.com) - [Discord Developer Portal](https://discord.com/developers) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-facebook.mdx b/apps/docs/content/guides/auth/social-login/auth-facebook.mdx similarity index 93% rename from apps/docs/pages/guides/auth/social-login/auth-facebook.mdx rename to apps/docs/content/guides/auth/social-login/auth-facebook.mdx index 46e9d05ccb7..b195edf22d2 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-facebook.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-facebook.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-facebook', - title: 'Login with Facebook', - description: 'Add Facebook OAuth to your Supabase project', -} +--- +id: 'auth-facebook' +title: 'Login with Facebook' +description: 'Add Facebook OAuth to your Supabase project' +--- To enable Facebook Auth for your project, you need to set up a Facebook OAuth application and add the application credentials to your Supabase Dashboard. @@ -136,7 +134,3 @@ You can read more about App Review [here](https://developers.facebook.com/docs/a - [Supabase Account - Free Plan OK](https://supabase.com) - [Supabase JS Client](https://github.com/supabase/supabase-js) - [Facebook Developers Dashboard](https://developers.facebook.com/) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-figma.mdx b/apps/docs/content/guides/auth/social-login/auth-figma.mdx similarity index 91% rename from apps/docs/pages/guides/auth/social-login/auth-figma.mdx rename to apps/docs/content/guides/auth/social-login/auth-figma.mdx index 6e9633a8313..0db6671df23 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-figma.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-figma.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-figma', - title: 'Login with Figma', - description: 'Add Figma OAuth to your Supabase project', -} +--- +id: 'auth-figma' +title: 'Login with Figma' +description: 'Add Figma OAuth to your Supabase project' +--- To enable Figma Auth for your project, you need to set up a Figma OAuth application and add the application credentials to your Supabase Dashboard. @@ -117,7 +115,3 @@ suspend fun signOut() { - [Supabase Account - Free Plan OK](https://supabase.com) - [Supabase JS Client](https://github.com/supabase/supabase-js) - [Figma Developers page](https://www.figma.com/developers) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-github.mdx b/apps/docs/content/guides/auth/social-login/auth-github.mdx similarity index 92% rename from apps/docs/pages/guides/auth/social-login/auth-github.mdx rename to apps/docs/content/guides/auth/social-login/auth-github.mdx index aba15f2401d..c0ffc5e8f04 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-github.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-github.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-github', - title: 'Login with GitHub', - description: 'Add GitHub OAuth to your Supabase project', -} +--- +id: 'auth-github' +title: 'Login with GitHub' +description: 'Add GitHub OAuth to your Supabase project' +--- To enable GitHub Auth for your project, you need to set up a GitHub OAuth application and add the application credentials to your Supabase Dashboard. @@ -128,7 +126,3 @@ suspend fun signOut() { - [Supabase Account - Free Plan OK](https://supabase.com) - [Supabase JS Client](https://github.com/supabase/supabase-js) - [GitHub Developer Settings](https://github.com/settings/developers) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-gitlab.mdx b/apps/docs/content/guides/auth/social-login/auth-gitlab.mdx similarity index 91% rename from apps/docs/pages/guides/auth/social-login/auth-gitlab.mdx rename to apps/docs/content/guides/auth/social-login/auth-gitlab.mdx index c08e3c33ab7..6dd71afa9be 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-gitlab.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-gitlab.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-gitlab', - title: 'Login with GitLab', - description: 'Add GitLab OAuth to your Supabase project', -} +--- +id: 'auth-gitlab' +title: 'Login with GitLab' +description: 'Add GitLab OAuth to your Supabase project' +--- To enable GitLab Auth for your project, you need to set up a GitLab OAuth application and add the application credentials to your Supabase Dashboard. @@ -114,7 +112,3 @@ suspend fun signOut() { - [Supabase Account - Free Plan OK](https://supabase.com) - [Supabase JS Client](https://github.com/supabase/supabase-js) - [GitLab Account](https://gitlab.com) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-google.mdx b/apps/docs/content/guides/auth/social-login/auth-google.mdx similarity index 98% rename from apps/docs/pages/guides/auth/social-login/auth-google.mdx rename to apps/docs/content/guides/auth/social-login/auth-google.mdx index 09556ea8ba4..38f6936b717 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-google.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-google.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-google', - title: 'Login with Google', - description: 'Use Sign in with Google on the web, in native apps or with Chrome extensions', - tocVideo: 'vojHmGUGUGc', -} +--- +id: 'auth-google' +title: 'Login with Google' +description: 'Use Sign in with Google on the web, in native apps or with Chrome extensions' +tocVideo: 'vojHmGUGUGc' +--- Supabase Auth supports Sign in with Google on the web, native Android applications and Chrome extensions. @@ -424,7 +422,3 @@ Before you can use Sign in with Google, you need to obtain a [Google Cloud Platf - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-kakao.mdx b/apps/docs/content/guides/auth/social-login/auth-kakao.mdx similarity index 93% rename from apps/docs/pages/guides/auth/social-login/auth-kakao.mdx rename to apps/docs/content/guides/auth/social-login/auth-kakao.mdx index c80537d176b..f838a83641e 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-kakao.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-kakao.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-kakao', - title: 'Login with Kakao', - description: 'Add Kakao OAuth to your Supabase project', -} +--- +id: 'auth-kakao' +title: 'Login with Kakao' +description: 'Add Kakao OAuth to your Supabase project' +--- To enable Kakao Auth for your project, you need to set up an Kakao OAuth application and add the application credentials to your Supabase Dashboard. @@ -137,7 +135,3 @@ suspend fun signOut() { ## Resources - [Kakao Developers Portal](https://developers.kakao.com). - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-keycloak.mdx b/apps/docs/content/guides/auth/social-login/auth-keycloak.mdx similarity index 94% rename from apps/docs/pages/guides/auth/social-login/auth-keycloak.mdx rename to apps/docs/content/guides/auth/social-login/auth-keycloak.mdx index 7826c312ad5..96cde43b5a3 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-keycloak.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-keycloak.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-keycloak', - title: 'Login with Keycloak', - description: 'Add Keycloak OAuth to your Supabase project', -} +--- +id: 'auth-keycloak' +title: 'Login with Keycloak' +description: 'Add Keycloak OAuth to your Supabase project' +--- To enable Keycloak Auth for your project, you need to set up an Keycloak OAuth application and add the application credentials to your Supabase Dashboard. @@ -136,7 +134,3 @@ suspend fun signOut() { - You can find the keycloak openid endpoint configuration under the realm settings. ![Keycloak OpenID Endpoint Configuration](/docs/img/guides/auth-keycloak/keycloak-openid-endpoint-config.png) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-linkedin.mdx b/apps/docs/content/guides/auth/social-login/auth-linkedin.mdx similarity index 94% rename from apps/docs/pages/guides/auth/social-login/auth-linkedin.mdx rename to apps/docs/content/guides/auth/social-login/auth-linkedin.mdx index 1383fccdb1e..f6ed6125535 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-linkedin.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-linkedin.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-linkedin', - title: 'Login with LinkedIn', - description: 'Add LinkedIn OAuth to your Supabase project', -} +--- +id: 'auth-linkedin' +title: 'Login with LinkedIn' +description: 'Add LinkedIn OAuth to your Supabase project' +--- To enable LinkedIn Auth for your project, you need to set up a LinkedIn OAuth application and add the application credentials to your Supabase Dashboard. @@ -140,7 +138,3 @@ Do reach out to support if you have any concerns around this change. - [Supabase Account - Free Plan OK](https://supabase.com) - [Supabase JS Client](https://github.com/supabase/supabase-js) - [LinkedIn Developer Dashboard](https://api.LinkedIn.com/apps) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-notion.mdx b/apps/docs/content/guides/auth/social-login/auth-notion.mdx similarity index 92% rename from apps/docs/pages/guides/auth/social-login/auth-notion.mdx rename to apps/docs/content/guides/auth/social-login/auth-notion.mdx index 800e5682792..0c414ab4fdb 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-notion.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-notion.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-notion', - title: 'Login with Notion', - description: 'Add Notion OAuth to your Supabase project', -} +--- +id: 'auth-notion' +title: 'Login with Notion' +description: 'Add Notion OAuth to your Supabase project' +--- To enable Notion Auth for your project, you need to set up a Notion Application and add the Application OAuth credentials to your Supabase Dashboard. @@ -118,7 +116,3 @@ suspend fun signOut() { - [Supabase JS Client](https://github.com/supabase/supabase-js) - [Notion Account](https://notion.so) - [Notion Developer Portal](https://www.notion.so/my-integrations) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-slack.mdx b/apps/docs/content/guides/auth/social-login/auth-slack.mdx similarity index 93% rename from apps/docs/pages/guides/auth/social-login/auth-slack.mdx rename to apps/docs/content/guides/auth/social-login/auth-slack.mdx index 2b9e34f2da7..238b394b9d3 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-slack.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-slack.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-slack', - title: 'Login with Slack', - description: 'Add Slack OAuth to your Supabase project', -} +--- +id: 'auth-slack' +title: 'Login with Slack' +description: 'Add Slack OAuth to your Supabase project' +--- To enable Slack Auth for your project, you need to set up a Slack OAuth application and add the application credentials to your Supabase Dashboard. @@ -135,7 +133,3 @@ suspend fun signOut() { - [Supabase Account - Free Plan OK](https://supabase.com) - [Supabase JS Client](https://github.com/supabase/supabase-js) - [Slack Developer Dashboard](https://api.slack.com/apps) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-spotify.mdx b/apps/docs/content/guides/auth/social-login/auth-spotify.mdx similarity index 83% rename from apps/docs/pages/guides/auth/social-login/auth-spotify.mdx rename to apps/docs/content/guides/auth/social-login/auth-spotify.mdx index a4647b8c554..c74d07bb473 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-spotify.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-spotify.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-spotify', - title: 'Login with Spotify', - description: 'Add Spotify OAuth to your Supabase project', -} +--- +id: 'auth-spotify' +title: 'Login with Spotify' +description: 'Add Spotify OAuth to your Supabase project' +--- To enable Spotify Auth for your project, you need to set up a Spotify OAuth application and add the application credentials to your Supabase Dashboard. @@ -52,6 +50,14 @@ Under `Redirect URIs`: ## Add login code to your client app +The following outlines the steps to sign in using Spotify with Supabase Auth. + +1. Call the signin method from the client library. +1. The user is redirected to the Spotify login page. +1. After completing the sign-in process, the user will be redirected to your app with an error that says the email address needs to be confirmed. Simultaneously the user receives a confirmation email from Supabase Auth. +1. The user clicks the confirmation link in the email. +1. The user is brought back to the app and is now signed in. + - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-twitch.mdx b/apps/docs/content/guides/auth/social-login/auth-twitch.mdx similarity index 92% rename from apps/docs/pages/guides/auth/social-login/auth-twitch.mdx rename to apps/docs/content/guides/auth/social-login/auth-twitch.mdx index 0db9cf7070c..405477b5da4 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-twitch.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-twitch.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-twitch', - title: 'Login with Twitch', - description: 'Add Twitch OAuth to your Supabase project', -} +--- +id: 'auth-twitch' +title: 'Login with Twitch' +description: 'Add Twitch OAuth to your Supabase project' +--- To enable Twitch Auth for your project, you need to set up a Twitch Application and add the Application OAuth credentials to your Supabase Dashboard. @@ -133,7 +131,3 @@ suspend fun signOut() { - [Supabase JS Client](https://github.com/supabase/supabase-js) - [Twitch Account](https://twitch.tv) - [Twitch Developer Console](https://dev.twitch.tv/console) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-twitter.mdx b/apps/docs/content/guides/auth/social-login/auth-twitter.mdx similarity index 88% rename from apps/docs/pages/guides/auth/social-login/auth-twitter.mdx rename to apps/docs/content/guides/auth/social-login/auth-twitter.mdx index f23283d9417..749029d3c9f 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-twitter.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-twitter.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' +--- +id: 'auth-twitter' +title: 'Login with Twitter' +description: 'Add Twitter OAuth to your Supabase project' +--- -export const meta = { - id: 'auth-twitter', - title: 'Login with Twitter', - description: 'Add Twitter OAuth to your Supabase project', -} - -To enable Twitter Auth for your project, you need to set up a Twitter OAuth application with [elevated access](https://developer.twitter.com/en/portal/products/elevated) and add the application credentials in the Supabase Dashboard. +To enable Twitter Auth for your project, you need to set up a Twitter OAuth application and add the application credentials in the Supabase Dashboard. ## Overview @@ -123,7 +121,3 @@ suspend fun signOut() { - [Supabase Account - Free Plan OK](https://supabase.com) - [Supabase JS Client](https://github.com/supabase/supabase-js) - [Twitter Developer Dashboard](https://developer.twitter.com/en/portal/dashboard) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-workos.mdx b/apps/docs/content/guides/auth/social-login/auth-workos.mdx similarity index 93% rename from apps/docs/pages/guides/auth/social-login/auth-workos.mdx rename to apps/docs/content/guides/auth/social-login/auth-workos.mdx index 95ff6c4617e..77ed925c241 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-workos.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-workos.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-workos', - title: 'Login with WorkOS', - description: 'Add WorkOS OAuth to your Supabase project', -} +--- +id: 'auth-workos' +title: 'Login with WorkOS' +description: 'Add WorkOS OAuth to your Supabase project' +--- To enable WorkOS Auth for your project, you need to set up WorkOS OAuth application and add the application credentials to your Supabase Dashboard. @@ -134,7 +132,3 @@ suspend fun signOut() { ## Resources - [WorkOS Documentation](https://workos.com/docs/sso/guide) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/social-login/auth-zoom.mdx b/apps/docs/content/guides/auth/social-login/auth-zoom.mdx similarity index 91% rename from apps/docs/pages/guides/auth/social-login/auth-zoom.mdx rename to apps/docs/content/guides/auth/social-login/auth-zoom.mdx index 9fd1ae7cdd4..37d6d07a8e8 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-zoom.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-zoom.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-zoom', - title: 'Login with Zoom', - description: 'Add Zoom OAuth to your Supabase project', -} +--- +id: 'auth-zoom' +title: 'Login with Zoom' +description: 'Add Zoom OAuth to your Supabase project' +--- To enable Zoom Auth for your project, you need to set up a Zoom OAuth application and add the application credentials to your Supabase Dashboard. @@ -130,7 +128,3 @@ suspend fun signOut() { - [Supabase Account - Free Plan OK](https://supabase.com) - [Supabase JS Client](https://github.com/supabase/supabase-js) - [Zoom App Marketplace](https://marketplace.zoom.us/) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/sso/auth-sso-saml.mdx b/apps/docs/content/guides/auth/sso/auth-sso-saml.mdx similarity index 98% rename from apps/docs/pages/guides/auth/sso/auth-sso-saml.mdx rename to apps/docs/content/guides/auth/sso/auth-sso-saml.mdx index c4b36288765..48a16a52779 100644 --- a/apps/docs/pages/guides/auth/sso/auth-sso-saml.mdx +++ b/apps/docs/content/guides/auth/sso/auth-sso-saml.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-sso-saml', - title: 'Single Sign-On with SAML 2.0 for Projects', - description: 'Use Single Sign-On (SSO) authentication on your project with SAML 2.0', - video: 'https://www.youtube.com/v/em1cpOAXknM', -} +--- +id: 'auth-sso-saml' +title: 'Single Sign-On with SAML 2.0 for Projects' +description: 'Use Single Sign-On (SSO) authentication on your project with SAML 2.0' +video: 'https://www.youtube.com/v/em1cpOAXknM' +--- @@ -406,7 +404,3 @@ Should you run into this problem, it is most likely a misconfiguration issue **o At this time it is not possible to extract the RSA private key used by your project's Supabase Auth server. This is done to keep the private key as secure as possible, given that SAML does not offer an easy way to rotate keys without disrupting service. (Please use a SAML 2.0 Metadata URL whenever possible for this reason!) If you really need access to the key, please [open a support ticket](https://supabase.com/dashboard/support/new) and we'll try to support you as best as possible. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/content/guides/functions.mdx b/apps/docs/content/guides/functions.mdx new file mode 100644 index 00000000000..4889ef00ef0 --- /dev/null +++ b/apps/docs/content/guides/functions.mdx @@ -0,0 +1,170 @@ +--- +id: 'functions' +title: 'Edge Functions' +description: 'Globally distributed TypeScript functions.' +subtitle: 'Globally distributed TypeScript functions.' +sidebar_label: 'Overview' +hideToc: true +--- + +Edge Functions are server-side TypeScript functions, distributed globally at the edge—close to your users. They can be used for listening to webhooks or integrating your Supabase project with third-parties [like Stripe](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/stripe-webhooks). Edge Functions are developed using [Deno](https://deno.com), which offers a few benefits to you as a developer: + +- It is open source. +- It is portable. Supabase Edge Functions run locally, and on any other Deno-compatible platform (including self-hosted infrastructure). +- It is TypeScript first and supports WASM. +- Edge Functions are globally distributed for low-latency. + +
+ +
+ +## Examples + +Check out the [Edge Function Examples](https://github.com/supabase/supabase/tree/master/examples/edge-functions) in our GitHub repository. + +
+ {[ + { + name: 'With supabase-js', + description: 'Use the Supabase client inside your Edge Function.', + href: '/guides/functions/auth', + }, + { + name: 'Type-Safe SQL with Kysely', + description: + 'Combining Kysely with Deno Postgres gives you a convenient developer experience for interacting directly with your Postgres database.', + href: '/guides/functions/kysely-postgres', + }, + { + name: 'With CORS headers', + description: 'Send CORS headers for invoking from the browser.', + href: '/guides/functions/cors', + }, + { + name: 'React Native with Stripe', + description: 'Full example for using Supabase and Stripe, with Expo.', + href: 'https://github.com/supabase-community/expo-stripe-payments-with-supabase-functions', + }, + { + name: 'Flutter with Stripe', + description: 'Full example for using Supabase and Stripe, with Flutter.', + href: 'https://github.com/supabase-community/flutter-stripe-payments-with-supabase-functions', + }, + { + name: 'Building a RESTful Service API', + description: + 'Learn how to use HTTP methods and paths to build a RESTful service for managing tasks.', + href: 'https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/restful-tasks/index.ts', + }, + { + name: 'Working with Supabase Storage', + description: 'An example on reading a file from Supabase Storage.', + href: 'https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/read-storage/index.ts', + }, + { + name: 'Open Graph Image Generation', + description: 'Generate Open Graph images with Deno and Supabase Edge Functions.', + href: '/guides/functions/examples/og-image', + }, + { + name: 'OG Image Generation & Storage CDN Caching', + description: 'Cache generated images with Supabase Storage CDN.', + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/og-image-with-storage-cdn', + }, + { + name: 'Get User Location', + description: `Get user location data from user's IP address.`, + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/location', + }, + { + name: 'Cloudflare Turnstile', + description: `Protecting Forms with Cloudflare Turnstile.`, + href: '/guides/functions/examples/cloudflare-turnstile', + }, + { + name: 'Connect to Postgres', + description: `Connecting to Postgres from Edge Functions.`, + href: '/guides/functions/connect-to-postgres', + }, + { + name: 'Github Actions', + description: `Deploying Edge Functions with GitHub Actions.`, + href: '/guides/functions/examples/github-actions', + }, + { + name: 'Oak Server Middleware', + description: `Request Routing with Oak server middleware.`, + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/oak-server', + }, + { + name: 'Hugging Face', + description: `Access 100,000+ Machine Learning models.`, + href: '/guides/ai/examples/huggingface-image-captioning', + }, + { + name: 'OpenAI', + description: `Using OpenAI in Edge Functions.`, + href: '/guides/ai/examples/openai', + }, + { + name: 'Stripe Webhooks', + description: `Handling signed Stripe Webhooks with Edge Functions.`, + href: '/guides/functions/examples/stripe-webhooks', + }, + { + name: 'Send emails', + description: `Send emails in Edge Functions with Resend.`, + href: '/guides/functions/examples/send-emails', + }, + { + name: 'Web Stream', + description: `Server-Sent Events in Edge Functions.`, + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/streams', + }, + { + name: 'Puppeteer', + description: `Generate screenshots with Puppeteer.`, + href: '/guides/functions/examples/screenshots', + }, + { + name: 'Discord Bot', + description: `Building a Slash Command Discord Bot with Edge Functions.`, + href: '/guides/functions/examples/discord-bot', + }, + { + name: 'Telegram Bot', + description: `Building a Telegram Bot with Edge Functions.`, + href: '/guides/functions/examples/telegram-bot', + }, + { + name: 'Upload File', + description: `Process multipart/form-data.`, + href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/file-upload-storage', + }, + { + name: 'Upstash Redis', + description: `Build an Edge Functions Counter with Upstash Redis.`, + href: '/guides/functions/examples/upstash-redis', + }, + { + name: 'Rate Limiting', + description: `Rate Limiting Edge Functions with Upstash Redis.`, + href: '/guides/functions/examples/rate-limiting', + }, + { + name: 'Slack Bot Mention Edge Function', + description: `Slack Bot handling Slack mentions in Edge Function`, + href: '/guides/functions/examples/slack-bot-mention', + }, + ].map((x) => ( +
+ + + {x.description} + + +
+ ))} +
diff --git a/apps/docs/pages/guides/functions/auth.mdx b/apps/docs/content/guides/functions/auth.mdx similarity index 88% rename from apps/docs/pages/guides/functions/auth.mdx rename to apps/docs/content/guides/functions/auth.mdx index 963c7af0be7..609f18a46de 100644 --- a/apps/docs/pages/guides/functions/auth.mdx +++ b/apps/docs/content/guides/functions/auth.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth', - title: 'Integrating With Supabase Auth', - description: 'Supabase Edge Functions and Auth.', - subtitle: 'Supabase Edge Functions and Auth.', -} +--- +id: 'auth' +title: 'Integrating With Supabase Auth' +description: 'Supabase Edge Functions and Auth.' +subtitle: 'Supabase Edge Functions and Auth.' +--- Edge Functions work seamlessly with [Supabase Auth](/docs/guides/auth). @@ -86,7 +84,3 @@ Deno.serve(async (req: Request) => { ## Example code See a full [example on GitHub](https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/select-from-table-with-auth-rls/index.ts). - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/functions/cicd-workflow.mdx b/apps/docs/content/guides/functions/cicd-workflow.mdx similarity index 84% rename from apps/docs/pages/guides/functions/cicd-workflow.mdx rename to apps/docs/content/guides/functions/cicd-workflow.mdx index 05347734533..76cc5d3cafb 100644 --- a/apps/docs/pages/guides/functions/cicd-workflow.mdx +++ b/apps/docs/content/guides/functions/cicd-workflow.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'cicd-workflow', - title: 'Deploying with CI / CD pipelines', - description: 'Use GitHub Actions, Bitbucket, and GitLab CI to deploy your Edge Functions.', - subtitle: 'Use GitHub Actions, Bitbucket, and GitLab CI to deploy your Edge Functions.', - tocVideo: '6OMVWiiycLs', -} +--- +id: 'cicd-workflow' +title: 'Deploying with CI / CD pipelines' +description: 'Use GitHub Actions, Bitbucket, and GitLab CI to deploy your Edge Functions.' +subtitle: 'Use GitHub Actions, Bitbucket, and GitLab CI to deploy your Edge Functions.' +tocVideo: '6OMVWiiycLs' +--- You can use popular CI / CD tools like GitHub Actions, Bitbucket, and GitLab CI to automate Edge Function deployments. @@ -116,7 +114,3 @@ verify_jwt = false ## Resources - See the [example on GitHub](https://github.com/supabase/supabase/blob/master/examples/edge-functions/.github/workflows/deploy.yaml). - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/functions/connect-to-postgres.mdx b/apps/docs/content/guides/functions/connect-to-postgres.mdx similarity index 89% rename from apps/docs/pages/guides/functions/connect-to-postgres.mdx rename to apps/docs/content/guides/functions/connect-to-postgres.mdx index b10c1f7c3ee..ac1da710b40 100644 --- a/apps/docs/pages/guides/functions/connect-to-postgres.mdx +++ b/apps/docs/content/guides/functions/connect-to-postgres.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'examples-postgres-on-the-edge', - title: 'Connecting directly to Postgres', - description: 'Connecting to Postgres from Edge Functions.', - subtitle: 'Connecting to Postgres from Edge Functions.', - tocVideo: 'cl7EuF1-RsY', -} +--- +id: 'examples-postgres-on-the-edge' +title: 'Connecting directly to Postgres' +description: 'Connecting to Postgres from Edge Functions.' +subtitle: 'Connecting to Postgres from Edge Functions.' +tocVideo: 'cl7EuF1-RsY' +--- Connect to your Postgres database from an Edge Function by using the `supabase-js` client. You can also use other Postgres clients like [Deno Postgres](https://deno.land/x/postgres) @@ -115,7 +113,3 @@ DENO_TLS_CA_STORE=mozilla,system allowFullScreen >
- -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/functions/cors.mdx b/apps/docs/content/guides/functions/cors.mdx similarity index 80% rename from apps/docs/pages/guides/functions/cors.mdx rename to apps/docs/content/guides/functions/cors.mdx index 758b90cce3d..dc357aae63d 100644 --- a/apps/docs/pages/guides/functions/cors.mdx +++ b/apps/docs/content/guides/functions/cors.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'functions-cors', - title: 'CORS (Cross-Origin Resource Sharing) support for Invoking from the browser', - description: 'Add CORS headers to invoke Edge Functions from the browser.', -} +--- +id: 'functions-cors' +title: 'CORS (Cross-Origin Resource Sharing) support for Invoking from the browser' +description: 'Add CORS headers to invoke Edge Functions from the browser.' +--- To invoke edge functions from the browser, you need to handle [CORS Preflight](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) requests. @@ -52,7 +50,3 @@ Deno.serve(async (req) => { } }) ``` - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/functions/dart-edge.mdx b/apps/docs/content/guides/functions/dart-edge.mdx similarity index 94% rename from apps/docs/pages/guides/functions/dart-edge.mdx rename to apps/docs/content/guides/functions/dart-edge.mdx index 66b8c7513a8..32f89e469a4 100644 --- a/apps/docs/pages/guides/functions/dart-edge.mdx +++ b/apps/docs/content/guides/functions/dart-edge.mdx @@ -1,10 +1,8 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'functions-dart-edge', - title: 'Dart Edge', - description: 'Write your functions using Dart.', -} +--- +id: 'functions-dart-edge' +title: 'Dart Edge' +description: 'Write your functions using Dart.' +--- [Dart Edge](https://docs.dartedge.dev/), a project built and maintained by [Invertase](https://invertase.io/), enables Dart code to be run on different edge environments. This guide will walk you through how you can start using Dart Edge to write your backend code using Dart and run it on Supabase. @@ -156,7 +154,3 @@ supabase functions deploy dart_edge ``` You will be asked to provide the project reference of your Supabase instance to which you want to deploy the function. You can [link your local project to your Supabase instance](https://supabase.com/docs/guides/getting-started/local-development#link-your-project) to avoid having to provide the Supabase reference every time you deploy. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/functions/debugging.mdx b/apps/docs/content/guides/functions/debugging.mdx similarity index 96% rename from apps/docs/pages/guides/functions/debugging.mdx rename to apps/docs/content/guides/functions/debugging.mdx index afcd52d6e3a..c9bccc5c505 100644 --- a/apps/docs/pages/guides/functions/debugging.mdx +++ b/apps/docs/content/guides/functions/debugging.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'functions-debugging', - title: 'Debugging Edge Functions', - description: 'Debugging tips and Edge Function limitations.', - subtitle: 'Debugging tips and Edge Function limitations.', -} +--- +id: 'functions-debugging' +title: 'Debugging Edge Functions' +description: 'Debugging tips and Edge Function limitations.' +subtitle: 'Debugging tips and Edge Function limitations.' +--- ## Logs & debugging @@ -166,7 +164,3 @@ deno info --import-map=/path/to/import_map.json /path/to/function/index.ts - Memory limit for the function: 150 MB (heap) + 150 MB (external - array buffers/ WASM) - Wall clock limit: 400 s - CPU Time: 2 s - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/functions/deploy.mdx b/apps/docs/content/guides/functions/deploy.mdx similarity index 86% rename from apps/docs/pages/guides/functions/deploy.mdx rename to apps/docs/content/guides/functions/deploy.mdx index e285c74f8e9..9e4c6eebef1 100644 --- a/apps/docs/pages/guides/functions/deploy.mdx +++ b/apps/docs/content/guides/functions/deploy.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'functions-deploy', - title: 'Deploy to Production', - description: 'Deploy your Edge Functions to your remote Supabase Project.', - subtitle: 'Deploy your Edge Functions to your remote Supabase Project.', - tocVideo: 'rzglqRdZUQE', -} +--- +id: 'functions-deploy' +title: 'Deploy to Production' +description: 'Deploy your Edge Functions to your remote Supabase Project.' +subtitle: 'Deploy your Edge Functions to your remote Supabase Project.' +tocVideo: '5OWH9c4u68M' +--- Once you have developed your Edge Functions locally, you can deploy them to your Supabase project. @@ -101,7 +99,3 @@ const { data, error } = await supabase.functions.invoke('hello-world', { You should receive the response `{ "message":"Hello Functions!" }`. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/functions/examples/cloudflare-turnstile.mdx b/apps/docs/content/guides/functions/examples/cloudflare-turnstile.mdx similarity index 86% rename from apps/docs/pages/guides/functions/examples/cloudflare-turnstile.mdx rename to apps/docs/content/guides/functions/examples/cloudflare-turnstile.mdx index c1c0b694e14..2e6b1bd1355 100644 --- a/apps/docs/pages/guides/functions/examples/cloudflare-turnstile.mdx +++ b/apps/docs/content/guides/functions/examples/cloudflare-turnstile.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'examples-cloudflare-turnstile', - title: 'CAPTCHA support with Cloudflare Turnstile', - description: 'Protecting Forms with Cloudflare Turnstile.', - video: 'https://www.youtube.com/v/OwW0znboh60', -} +--- +id: 'examples-cloudflare-turnstile' +title: 'CAPTCHA support with Cloudflare Turnstile' +description: 'Protecting Forms with Cloudflare Turnstile.' +video: 'https://www.youtube.com/v/OwW0znboh60' +---
- -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/realtime/subscribing-to-database-changes.mdx b/apps/docs/content/guides/realtime/subscribing-to-database-changes.mdx similarity index 85% rename from apps/docs/pages/guides/realtime/subscribing-to-database-changes.mdx rename to apps/docs/content/guides/realtime/subscribing-to-database-changes.mdx index 95af82a7e66..05878e66d6e 100644 --- a/apps/docs/pages/guides/realtime/subscribing-to-database-changes.mdx +++ b/apps/docs/content/guides/realtime/subscribing-to-database-changes.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'subscribing-to-database-changes', - title: 'Subscribing to Database Changes', - description: 'Listen to database changes in real-time from your website or application.', - sidebar_label: 'Videos', -} +--- +id: 'subscribing-to-database-changes' +title: 'Subscribing to Database Changes' +description: 'Listen to database changes in real-time from your website or application.' +sidebar_label: 'Videos' +--- Supabase allows you to subscribe to real-time changes on your database from your client application. @@ -97,7 +95,3 @@ const channel = supabase - [Flutter](/docs/reference/dart/stream) - [Python](/docs/reference/python/subscribe) - [C#](/docs/reference/csharp/subscribe) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage.mdx b/apps/docs/content/guides/storage.mdx similarity index 68% rename from apps/docs/pages/guides/storage.mdx rename to apps/docs/content/guides/storage.mdx index 49274f6ca0d..fd840fdec13 100644 --- a/apps/docs/pages/guides/storage.mdx +++ b/apps/docs/content/guides/storage.mdx @@ -1,12 +1,11 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage', - title: 'Storage', - description: 'Use Supabase to store and serve files.', - subtitle: 'Use Supabase to store and serve files.', - sidebar_label: 'Overview', -} +--- +id: 'storage' +title: 'Storage' +description: 'Use Supabase to store and serve files.' +subtitle: 'Use Supabase to store and serve files.' +sidebar_label: 'Overview' +hideToc: true +--- Supabase Storage makes it simple to upload and serve files of any size, providing a robust framework for file access controls. @@ -44,7 +43,18 @@ export const examples = [ Find the source code and documentation in the Supabase GitHub repository.
- {integrations.map((x) => ( + {[ + { + name: 'Supabase Storage API', + description: 'View the source code.', + href: 'https://github.com/supabase/storage-api', + }, + { + name: 'OpenAPI Spec', + description: 'See the Swagger Documentation for Supabase Storage.', + href: 'https://supabase.github.io/storage/', + }, + ].map((x) => (
{x.description} @@ -52,20 +62,3 @@ Find the source code and documentation in the Supabase GitHub repository.
))}
- -export const integrations = [ - { - name: 'Supabase Storage API', - description: 'View the source code.', - href: 'https://github.com/supabase/storage-api', - }, - { - name: 'OpenAPI Spec', - description: 'See the Swagger Documentation for Supabase Storage.', - href: 'https://supabase.github.io/storage/', - }, -] - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/buckets/creating-buckets.mdx b/apps/docs/content/guides/storage/buckets/creating-buckets.mdx similarity index 86% rename from apps/docs/pages/guides/storage/buckets/creating-buckets.mdx rename to apps/docs/content/guides/storage/buckets/creating-buckets.mdx index cedf1723483..b63979bc8b1 100644 --- a/apps/docs/pages/guides/storage/buckets/creating-buckets.mdx +++ b/apps/docs/content/guides/storage/buckets/creating-buckets.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-creating-buckets', - title: 'Creating Buckets', - description: 'Learn how to create Supabase Storage buckets.', - sidebar_label: 'Buckets', -} +--- +id: 'storage-creating-buckets' +title: 'Creating Buckets' +description: 'Learn how to create Supabase Storage buckets.' +sidebar_label: 'Buckets' +--- You can create a bucket using the Supabase Dashboard. Since storage is interoperable with your Postgres database, you can also use SQL or our client libraries. Here we create a bucket called "avatars": @@ -87,7 +85,3 @@ const { data, error } = await supabase.storage.createBucket('avatars', { If an upload request doesn't meet the above restrictions it will be rejected. For more information check [File Limits](/docs/guides/storage/uploads/file-limits) Section. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/buckets/fundamentals.mdx b/apps/docs/content/guides/storage/buckets/fundamentals.mdx similarity index 84% rename from apps/docs/pages/guides/storage/buckets/fundamentals.mdx rename to apps/docs/content/guides/storage/buckets/fundamentals.mdx index be6447f45c4..ccc5785e512 100644 --- a/apps/docs/pages/guides/storage/buckets/fundamentals.mdx +++ b/apps/docs/content/guides/storage/buckets/fundamentals.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-bucket-public-and-private', - title: 'Storage Buckets', - description: 'Learn how Supabase Storage Buckets works.', - sidebar_label: 'Buckets', -} +--- +id: 'storage-bucket-public-and-private' +title: 'Storage Buckets' +description: 'Learn how Supabase Storage Buckets works.' +sidebar_label: 'Buckets' +--- Buckets allow you to keep your files organized and determines the [Access Model](#access-model) for your assets. [Upload restrictions](/docs/guides/storage/buckets/creating-buckets#restricting-uploads) like max file size and allowed content types are also defined at the bucket level. @@ -40,7 +38,3 @@ Access control is still enforced for other types of operations including uploadi - Blog post content Public buckets are more performant than private buckets since they are [cached differently](/docs/guides/storage/cdn/fundamentals#public-vs-private-buckets). - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/cdn/fundamentals.mdx b/apps/docs/content/guides/storage/cdn/fundamentals.mdx similarity index 88% rename from apps/docs/pages/guides/storage/cdn/fundamentals.mdx rename to apps/docs/content/guides/storage/cdn/fundamentals.mdx index f9aea721fa9..3f0543e1d05 100644 --- a/apps/docs/pages/guides/storage/cdn/fundamentals.mdx +++ b/apps/docs/content/guides/storage/cdn/fundamentals.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-cdn', - title: 'Storage CDN', - description: 'Learn how Supabase Storage caches objects with a CDN.', - sidebar_label: 'CDN', -} +--- +id: 'storage-cdn' +title: 'Storage CDN' +description: 'Learn how Supabase Storage caches objects with a CDN.' +sidebar_label: 'CDN' +--- All assets uploaded to Supabase Storage are cached on a Content Delivery Network (CDN) to improve the latency for users all around the world. CDNs are a geographically distributed set of servers or **nodes** which cache content from an **origin server**. For Supabase Storage, the origin is the storage server running in the [same region as your project](https://supabase.com/dashboard/project/_/settings/general). Aside from performance, CDNs also help with security and availability by mitigating Distributed Denial of Service (DDoS) and other application attacks. @@ -35,7 +33,3 @@ Objects in public buckets do not require any authorization to access objects. Th For private buckets, permissions for accessing each object is checked on a per user level. For example, if two different users access the same object in a private bucket from the same region, it results in a cache miss for both the users since they might have different security policies attached to them. On the other hand, if two different users access the same object in a public bucket from the same region, it results in a cache hit for the second user. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/cdn/metrics.mdx b/apps/docs/content/guides/storage/cdn/metrics.mdx similarity index 89% rename from apps/docs/pages/guides/storage/cdn/metrics.mdx rename to apps/docs/content/guides/storage/cdn/metrics.mdx index 9257fbb32d3..f6c6fd41072 100644 --- a/apps/docs/pages/guides/storage/cdn/metrics.mdx +++ b/apps/docs/content/guides/storage/cdn/metrics.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-cdn', - title: 'Cache Metrics', - description: 'Learn how Supabase Storage caches objects with a CDN.', - sidebar_label: 'CDN', -} +--- +id: 'storage-cdn' +title: 'Cache Metrics' +description: 'Learn how Supabase Storage caches objects with a CDN.' +sidebar_label: 'CDN' +--- Cache hits can be determined via the `metadata.response.headers.cf_cache_status` key in our [Logs Explorer](/docs/guides/platform/logs#logs-explorer). Any value that corresponds to either `HIT`, `STALE`, `REVALIDATED`, or `UPDATING` is categorized as a cache hit. The following example query will show the top cache misses from the `edge_logs`: @@ -50,7 +48,3 @@ order by timestamp desc; ``` Try out [this query](https://supabase.com/dashboard/project/_/logs/explorer?q=%0Aselect%0A++timestamp_trunc%28timestamp%2C+hour%29+as+timestamp%2C%0A++countif%28h.cf_cache_status+in+%28%27HIT%27%2C+%27STALE%27%2C+%27REVALIDATED%27%2C+%27UPDATING%27%29%29+%2F+count%28f.id%29+as+ratio%0Afrom%0A++edge_logs+as+f%0A++cross+join+unnest%28f.metadata%29+as+m%0A++cross+join+unnest%28m.request%29+as+r%0A++cross+join+unnest%28m.response%29+as+res%0A++cross+join+unnest%28res.headers%29+as+h%0Awhere+starts_with%28r.path%2C+%27%2Fstorage%2Fv1%2Fobject%27%29+and+r.method+%3D+%27GET%27%0Agroup+by+timestamp%0Aorder+by+timestamp+desc%3B) in the Logs Explorer. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/cdn/smart-cdn.mdx b/apps/docs/content/guides/storage/cdn/smart-cdn.mdx similarity index 88% rename from apps/docs/pages/guides/storage/cdn/smart-cdn.mdx rename to apps/docs/content/guides/storage/cdn/smart-cdn.mdx index 2ca47c9d7b2..e6b9f235cd9 100644 --- a/apps/docs/pages/guides/storage/cdn/smart-cdn.mdx +++ b/apps/docs/content/guides/storage/cdn/smart-cdn.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-cdn', - title: 'Smart CDN', - description: 'Learn how Supabase Storage caches objects with a CDN.', - sidebar_label: 'CDN', -} +--- +id: 'storage-cdn' +title: 'Smart CDN' +description: 'Learn how Supabase Storage caches objects with a CDN.' +sidebar_label: 'CDN' +--- With Smart CDN caching enabled, the asset metadata in your database is synchronized to the edge. This automatically revalidates the cache when the asset is changed or deleted. @@ -38,7 +36,3 @@ If you anticipate that your asset might be deleted, it's advisable to set a shor If you need to ensure assets refresh directly from the origin server and bypass the cache, you can achieve this by adding a unique query string to the URL. For instance, you can use a URL like `/storage/v1/object/sign/profile-pictures/cat.jpg?version=1` with a long browser cache (e.g., 1 year). To update the picture, increment the version query parameter in the URL, like `/storage/v1/object/sign/profile-pictures/cat.jpg?version=2`. The CDN will recognize it as a new object and fetch the updated version from the origin. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/debugging/error-codes.mdx b/apps/docs/content/guides/storage/debugging/error-codes.mdx similarity index 86% rename from apps/docs/pages/guides/storage/debugging/error-codes.mdx rename to apps/docs/content/guides/storage/debugging/error-codes.mdx index 4625ce58818..a190912b3cd 100644 --- a/apps/docs/pages/guides/storage/debugging/error-codes.mdx +++ b/apps/docs/content/guides/storage/debugging/error-codes.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-errors-codes', - title: 'Error Codes', - description: 'Supabase Error Codes', - sidebar_label: 'Debugging', -} +--- +id: 'storage-errors-codes' +title: 'Error Codes' +description: 'Supabase Error Codes' +sidebar_label: 'Debugging' +--- Here's a list of the most common error codes and their potential resolutions: @@ -57,7 +55,3 @@ This issue occurs where there is a unhandled error. **Resolution:** - File a support ticket to Storage team [here](https://supabase.com/dashboard/support/new) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/debugging/logs.mdx b/apps/docs/content/guides/storage/debugging/logs.mdx similarity index 84% rename from apps/docs/pages/guides/storage/debugging/logs.mdx rename to apps/docs/content/guides/storage/debugging/logs.mdx index 4202bd22355..c21d4b96757 100644 --- a/apps/docs/pages/guides/storage/debugging/logs.mdx +++ b/apps/docs/content/guides/storage/debugging/logs.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-logs', - title: 'Logs', - description: 'Learn how to check Storage Logs', - sidebar_label: 'Debugging', -} +--- +id: 'storage-logs' +title: 'Logs' +description: 'Learn how to check Storage Logs' +sidebar_label: 'Debugging' +--- Accessing the [Storage Logs](https://supabase.green/dashboard/project/__/logs/explorer?q=select+id%2C+storage_logs.timestamp%2C+event_message+from+storage_logs%0A++%0A++order+by+timestamp+desc%0A++limit+100%0A++) allows you to examine all incoming request logs to your Storage service. You can also filter logs and delve into specific aspects of your requests. @@ -62,7 +60,3 @@ where r.remoteAddress in ("IP_ADDRESS") order by timestamp desc limit 100; ``` - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/production/scaling.mdx b/apps/docs/content/guides/storage/production/scaling.mdx similarity index 89% rename from apps/docs/pages/guides/storage/production/scaling.mdx rename to apps/docs/content/guides/storage/production/scaling.mdx index 35e760fc98f..f1b2bfebb0c 100644 --- a/apps/docs/pages/guides/storage/production/scaling.mdx +++ b/apps/docs/content/guides/storage/production/scaling.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-schema-optimizations', - title: 'Storage Optimizations', - description: 'Scaling Storage', - subtitle: 'Scaling Storage', - sidebar_label: 'Schema', -} +--- +id: 'storage-schema-optimizations' +title: 'Storage Optimizations' +description: 'Scaling Storage' +subtitle: 'Scaling Storage' +sidebar_label: 'Schema' +--- Here are some optimizations that you can consider to improve performance and reduce costs as you start scaling Storage. @@ -86,7 +84,3 @@ const { data, error } = await supabase.rpc('list_objects', { ## Optimizing RLS When creating RLS policies against the storage tables you can add indexes to the interested columns to speed up the lookup - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/quickstart.mdx b/apps/docs/content/guides/storage/quickstart.mdx similarity index 93% rename from apps/docs/pages/guides/storage/quickstart.mdx rename to apps/docs/content/guides/storage/quickstart.mdx index 084fa5ba658..233d05e5738 100644 --- a/apps/docs/pages/guides/storage/quickstart.mdx +++ b/apps/docs/content/guides/storage/quickstart.mdx @@ -1,13 +1,11 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-quickstart', - title: 'Storage Quickstart', - description: 'Learn how to use Supabase to store and serve files.', - subtitle: 'Learn how to use Supabase to store and serve files.', - sidebar_label: 'Quickstart', - tocVideo: 'J9mTPY8rIXE', -} +--- +id: 'storage-quickstart' +title: 'Storage Quickstart' +description: 'Learn how to use Supabase to store and serve files.' +subtitle: 'Learn how to use Supabase to store and serve files.' +sidebar_label: 'Quickstart' +tocVideo: 'J9mTPY8rIXE' +--- This guide shows the basic functionality of Supabase Storage. Find a full [example application on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/nextjs-user-management). @@ -238,7 +236,3 @@ create policy "Public Access" allowFullScreen >
- -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/schema/design.mdx b/apps/docs/content/guides/storage/schema/design.mdx similarity index 80% rename from apps/docs/pages/guides/storage/schema/design.mdx rename to apps/docs/content/guides/storage/schema/design.mdx index 5e1acef5493..2a55a2a2445 100644 --- a/apps/docs/pages/guides/storage/schema/design.mdx +++ b/apps/docs/content/guides/storage/schema/design.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-schema-design', - title: 'The Storage Schema', - description: 'Learn about the storage schema', - subtitle: 'Learn about the storage schema', - sidebar_label: 'Schema', -} +--- +id: 'storage-schema-design' +title: 'The Storage Schema' +description: 'Learn about the storage schema' +subtitle: 'Learn about the storage schema' +sidebar_label: 'Schema' +--- Storage uses Postgres to store metadata regarding your buckets and objects. Users can use RLS (Row-Level Security) policies for access control. This data is stored in a dedicated schema within your project called `storage`. @@ -28,7 +26,3 @@ You have the option to query this table directly to retrieve information about y We strongly recommend refraining from making any alterations to the `storage` schema and treating it as read-only. This approach is important because any modifications to the schema on your end could potentially clash with our future updates, leading to downtime. However, we encourage you to add custom indexes as they can significantly improve the performance of the RLS policies you create for enforcing access control. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/schema/helper-functions.mdx b/apps/docs/content/guides/storage/schema/helper-functions.mdx similarity index 80% rename from apps/docs/pages/guides/storage/schema/helper-functions.mdx rename to apps/docs/content/guides/storage/schema/helper-functions.mdx index 53e1be7ad02..a29ab4bb17a 100644 --- a/apps/docs/pages/guides/storage/schema/helper-functions.mdx +++ b/apps/docs/content/guides/storage/schema/helper-functions.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-schema', - title: 'Storage Helper Functions', - description: 'Learn the storage schema', - subtitle: 'Learn the storage schema', - sidebar_label: 'Schema', -} +--- +id: 'storage-schema' +title: 'Storage Helper Functions' +description: 'Learn the storage schema' +subtitle: 'Learn the storage schema' +sidebar_label: 'Schema' +--- Supabase Storage provides SQL helper functions which you can use to write RLS policies. @@ -63,7 +61,3 @@ with check ( bucket_id = 'cats' and storage.extension(name) = 'png' ); ``` - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/security/access-control.mdx b/apps/docs/content/guides/storage/security/access-control.mdx similarity index 90% rename from apps/docs/pages/guides/storage/security/access-control.mdx rename to apps/docs/content/guides/storage/security/access-control.mdx index 6dc1219eb21..e6af3eee841 100644 --- a/apps/docs/pages/guides/storage/security/access-control.mdx +++ b/apps/docs/content/guides/storage/security/access-control.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-access-control', - title: 'Storage Access Control', - description: 'Learn how to restrict Supabase file uploads.', - sidebar_label: 'Uploads', - tocVideo: '4ERX__Y908k', -} +--- +id: 'storage-access-control' +title: 'Storage Access Control' +description: 'Learn how to restrict Supabase file uploads.' +sidebar_label: 'Uploads' +tocVideo: '4ERX__Y908k' +--- Supabase Storage is designed to work perfectly with Postgres [Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security) (RLS). @@ -103,7 +101,3 @@ using ( auth.uid() = owner_id ); If you exclusively use Storage from trusted clients, such as your own servers, and need to bypass the RLS policies, you can use the `service key` in the `Authorization` header. Service keys entirely bypass RLS policies, granting you unrestricted access to all Storage APIs. Remember you should not share the service key publicly. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/serving/downloads.mdx b/apps/docs/content/guides/storage/serving/downloads.mdx similarity index 69% rename from apps/docs/pages/guides/storage/serving/downloads.mdx rename to apps/docs/content/guides/storage/serving/downloads.mdx index a603fbd8259..31256c46cb0 100644 --- a/apps/docs/pages/guides/storage/serving/downloads.mdx +++ b/apps/docs/content/guides/storage/serving/downloads.mdx @@ -1,13 +1,11 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-image-transformations', - title: 'Serving assets from Storage', - description: 'Serving assets from Storage', - subtitle: 'Serving assets from Storage', - sidebar_label: 'Serving assets', - tocVideo: 'dLqSmxX3r7I', -} +--- +id: 'storage-image-transformations' +title: 'Serving assets from Storage' +description: 'Serving assets from Storage' +subtitle: 'Serving assets from Storage' +sidebar_label: 'Serving assets' +tocVideo: 'dLqSmxX3r7I' +--- ## Public buckets @@ -16,13 +14,15 @@ As mentioned in the [Buckets Fundamentals](/docs/guides/storage/buckets/fundamen You can access them by using this conventional URL: ``` -https://[project_id].supabase.co/storage/v1/public/[bucket]/[asset-name] +https://[project_id].supabase.co/storage/v1/object/public/[bucket]/[asset-name] ``` You can also use the Supabase SDK `getPublicUrl` to generate this url for you ```js -const publicUrl = supabase.storage.from('bucket').getPublicUrl('filePath.jpg') +const { data } = supabase.storage.from('bucket').getPublicUrl('filePath.jpg') + +console.log(data.publicUrl) ``` ### Downloading @@ -45,7 +45,7 @@ You can access them only by: You can sign a time-limited URL that you can share to your users by invoking the `createSignedUrl` method on the SDK. ```js -const { data, error } = supabase.storage +const { data, error } = await supabase.storage .from('bucket') .createSignedUrl('private-document.pdf', 3600) @@ -53,7 +53,3 @@ if (data) { console.log(data.signedUrl) } ``` - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/serving/image-transformations.mdx b/apps/docs/content/guides/storage/serving/image-transformations.mdx similarity index 95% rename from apps/docs/pages/guides/storage/serving/image-transformations.mdx rename to apps/docs/content/guides/storage/serving/image-transformations.mdx index e247e421f68..d60dc23a173 100644 --- a/apps/docs/pages/guides/storage/serving/image-transformations.mdx +++ b/apps/docs/content/guides/storage/serving/image-transformations.mdx @@ -1,13 +1,11 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-image-transformations', - title: 'Storage Image Transformations', - description: 'Transform images with Storage', - subtitle: 'Transform images with Storage', - sidebar_label: 'Image Transformations', - tocVideo: 'dLqSmxX3r7I', -} +--- +id: 'storage-image-transformations' +title: 'Storage Image Transformations' +description: 'Transform images with Storage' +subtitle: 'Transform images with Storage' +sidebar_label: 'Image Transformations' +tocVideo: 'dLqSmxX3r7I' +--- Supabase Storage offers the functionality to optimize and resize images on the fly. Any image stored in your buckets can be transformed and optimized for fast delivery. @@ -383,7 +381,3 @@ IMGPROXY_URL=yourinternalimgproxyurl.internal.com allowFullScreen > - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/uploads/file-limits.mdx b/apps/docs/content/guides/storage/uploads/file-limits.mdx similarity index 82% rename from apps/docs/pages/guides/storage/uploads/file-limits.mdx rename to apps/docs/content/guides/storage/uploads/file-limits.mdx index 223203ef65e..84c816470ea 100644 --- a/apps/docs/pages/guides/storage/uploads/file-limits.mdx +++ b/apps/docs/content/guides/storage/uploads/file-limits.mdx @@ -1,11 +1,9 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'storage-file-limits', - title: 'Limits', - description: 'Learn how to increase Supabase file limits.', - sidebar_label: 'Uploads', -} +--- +id: 'storage-file-limits' +title: 'Limits' +description: 'Learn how to increase Supabase file limits.' +sidebar_label: 'Uploads' +--- You can customize the Max file size for file uploads. @@ -35,7 +33,3 @@ You can have different restrictions on a per bucket level. You are able to restrict the file types (eg. `pdf`, `images`, `videos`) along with the max file size which should be lower than the global limit. To apply these limit on a bucket level see [Creating Bucket](/docs/guides/storage/buckets/creating-buckets#restricting-uploads) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/uploads/resumable-uploads.mdx b/apps/docs/content/guides/storage/uploads/resumable-uploads.mdx similarity index 94% rename from apps/docs/pages/guides/storage/uploads/resumable-uploads.mdx rename to apps/docs/content/guides/storage/uploads/resumable-uploads.mdx index 4a8faed56d2..73e07e7067b 100644 --- a/apps/docs/pages/guides/storage/uploads/resumable-uploads.mdx +++ b/apps/docs/content/guides/storage/uploads/resumable-uploads.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'resumable-uploads', - title: 'Resumable Uploads', - description: 'Learn how to upload files to Supabase Storage.', - subtitle: 'Learn how to upload files to Supabase Storage.', - sidebar_label: 'Uploads', -} +--- +id: 'resumable-uploads' +title: 'Resumable Uploads' +description: 'Learn how to upload files to Supabase Storage.' +subtitle: 'Learn how to upload files to Supabase Storage.' +sidebar_label: 'Uploads' +--- The resumable upload method is recommended when: @@ -149,7 +147,3 @@ We do advise against overwriting files when possible, as the CDN will take some Uploading a file to a new path is the recommended way to avoid propagation delays and stale content. To learn more, see the [CDN](/docs/guides/storage/cdn/fundamentals) guide. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/storage/uploads/standard-uploads.mdx b/apps/docs/content/guides/storage/uploads/standard-uploads.mdx similarity index 89% rename from apps/docs/pages/guides/storage/uploads/standard-uploads.mdx rename to apps/docs/content/guides/storage/uploads/standard-uploads.mdx index d3699892e13..90f219f5726 100644 --- a/apps/docs/pages/guides/storage/uploads/standard-uploads.mdx +++ b/apps/docs/content/guides/storage/uploads/standard-uploads.mdx @@ -1,12 +1,10 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'standard-uploads', - title: 'Standard Uploads', - description: 'Learn how to upload files to Supabase Storage.', - subtitle: 'Learn how to upload files to Supabase Storage.', - sidebar_label: 'Uploads', -} +--- +id: 'standard-uploads' +title: 'Standard Uploads' +description: 'Learn how to upload files to Supabase Storage.' +subtitle: 'Learn how to upload files to Supabase Storage.' +sidebar_label: 'Uploads' +--- ## Uploading @@ -125,7 +123,3 @@ await supabase.storage.from('bucket_name').upload('file_path', file, { When two or more clients upload a file to the same path, the first client to complete the upload will succeed and the other clients will receive a `400 Asset Already Exists` error. If you provide the `x-upsert` header the last client to complete the upload will succeed instead. - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/data/footer.ts b/apps/docs/data/footer.ts index cdd4e567f3e..9ea6bba69ae 100644 --- a/apps/docs/data/footer.ts +++ b/apps/docs/data/footer.ts @@ -1,4 +1,5 @@ -import { IconCheckCircle, IconLifeBuoy, PrivacySettings } from 'ui' +import { IconCheckCircle, IconLifeBuoy } from 'ui' +import { PrivacySettings } from 'ui-patterns/PrivacySettings' export const primaryLinks = [ { diff --git a/apps/docs/docs/ref/javascript/release-notes.mdx b/apps/docs/docs/ref/javascript/release-notes.mdx index edd38f4a762..e61166d7186 100644 --- a/apps/docs/docs/ref/javascript/release-notes.mdx +++ b/apps/docs/docs/ref/javascript/release-notes.mdx @@ -185,7 +185,7 @@ Supabase.js v2 release notes. We're removing the `signIn()` method in favor of more explicit function signatures: - `signInWithPassword()`, `signInWithOtp()`, and `signInWithOtp()`. + `signInWithPassword()`, `signInWithOtp()`, and `signInWithOAuth()`. diff --git a/apps/docs/generator/legacy.ts b/apps/docs/generator/legacy.ts index 9cb6dced62c..cf066ef1d0a 100644 --- a/apps/docs/generator/legacy.ts +++ b/apps/docs/generator/legacy.ts @@ -259,10 +259,10 @@ function extractParamTypeAsString(paramDefinition) { x.value ? `${x.value}` : x.name - ? `${x.name}` - : x.type - ? `${x.type}` - : '' + ? `${x.name}` + : x.type + ? `${x.type}` + : '' ) .join(' | ') } diff --git a/apps/docs/internals/generate-sitemap.mjs b/apps/docs/internals/generate-sitemap.mjs index 52c00e86bbc..c82e2709deb 100644 --- a/apps/docs/internals/generate-sitemap.mjs +++ b/apps/docs/internals/generate-sitemap.mjs @@ -53,7 +53,7 @@ async function generate() { ` - const formatted = prettier.format(sitemap, { + const formatted = await prettier.format(sitemap, { ...prettierConfig, parser: 'html', }) diff --git a/apps/docs/layouts/DefaultGuideLayout.tsx b/apps/docs/layouts/DefaultGuideLayout.tsx index d2c01704763..9bda33bb1df 100644 --- a/apps/docs/layouts/DefaultGuideLayout.tsx +++ b/apps/docs/layouts/DefaultGuideLayout.tsx @@ -12,7 +12,10 @@ interface Props { } children: any toc?: any + // [Charis] Deprecate meta.hide_table_of_contents once the content migration is over + hideToc?: boolean currentPage?: string + editLink?: string } const Layout: FC = (props) => { diff --git a/apps/docs/layouts/SiteLayout.tsx b/apps/docs/layouts/SiteLayout.tsx index 0b63909f289..ed1447aa80c 100644 --- a/apps/docs/layouts/SiteLayout.tsx +++ b/apps/docs/layouts/SiteLayout.tsx @@ -201,8 +201,8 @@ const MobileHeader = memo(function MobileHeader() { {mobileMenuOpen ? 'Close' : menuLevel - ? levelsData[menuLevel].name - : levelsData['home'].name} + ? levelsData[menuLevel].name + : levelsData['home'].name} ) @@ -267,7 +267,7 @@ const Container = memo(function Container(props: PropsWithChildren) { id="docs-content-container" className={[ // 'overflow-x-auto', - 'w-full h-screen transition-all ease-out', + 'w-full transition-all ease-out', // 'absolute lg:relative', mobileMenuOpen ? 'ml-[75%] sm:ml-[50%] md:ml-[33%] overflow-hidden' : 'overflow-auto', // desktop override any margin styles @@ -294,7 +294,6 @@ const NavContainer = memo(function NavContainer() { 'lg:left-0', 'transition-all', 'top-0', - 'h-screen', 'flex flex-col ml-0', ].join(' ')} > @@ -352,8 +351,8 @@ const SiteLayout = ({ children }: PropsWithChildren<{}>) => { Supabase Docs -
-
+
+
diff --git a/apps/docs/layouts/guides/index.tsx b/apps/docs/layouts/guides/index.tsx index fc0fc87ae40..274d0748d3f 100644 --- a/apps/docs/layouts/guides/index.tsx +++ b/apps/docs/layouts/guides/index.tsx @@ -1,9 +1,11 @@ import { MDXProvider } from '@mdx-js/react' import { NextSeo } from 'next-seo' import Head from 'next/head' +import { usePathname } from 'next/navigation' import { useRouter } from 'next/router' import { FC, useEffect, useRef, useState } from 'react' -import { ExpandableVideo, IconExternalLink } from 'ui' +import { IconExternalLink } from 'ui' +import { ExpandableVideo } from 'ui-patterns/ExpandableVideo' import components from '~/components' import { highlightSelectedTocItem } from '~/components/CustomHTMLElements/CustomHTMLElements.utils' import { FooterHelpCalloutType } from '~/components/FooterHelpCallout' @@ -23,6 +25,7 @@ interface Props { tocVideo?: string canonical?: string } + editLink?: string children: any toc?: any currentPage?: string @@ -30,6 +33,7 @@ interface Props { } const Layout: FC = (props) => { + const pathname = usePathname() const [hash] = useHash() const articleRef = useRef() @@ -60,7 +64,7 @@ const Layout: FC = (props) => { return { text, link, level } }) setTocList(newHeadings) - }, []) + }, [pathname]) // Needed to recalculate the ToC when the page changes const hasTableOfContents = tocList.length > 0 const tocVideoPreview = `http://img.youtube.com/vi/${props.meta.tocVideo}/0.jpg` @@ -136,8 +140,12 @@ const Layout: FC = (props) => {
Edit this page on GitHub diff --git a/apps/docs/lib/docs.ts b/apps/docs/lib/docs.ts index 2ae4f8dfe80..b9ac429c1aa 100644 --- a/apps/docs/lib/docs.ts +++ b/apps/docs/lib/docs.ts @@ -1,98 +1,99 @@ -import fs from 'fs' -import { join } from 'path' +import { type CodeHikeConfig, remarkCodeHike } from '@code-hike/mdx' import matter from 'gray-matter' -import nonGeneratedReferencePages from 'data/nonGeneratedReferencePages' -import { REFERENCES } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants' +import { serialize } from 'next-mdx-remote/serialize' +import type { SerializeOptions } from 'next-mdx-remote/dist/types' +import { existsSync } from 'node:fs' +import { readdir, readFile } from 'node:fs/promises' +import { dirname, join, extname, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import remarkGfm from 'remark-gfm' -const docsDirectory = process.cwd() +import codeHikeTheme from 'config/code-hike.theme.json' assert { type: 'json' } -const getPathToGeneratedDoc = (slug: string) => { - const sections = slug.split('/') - const updatedSections = sections.slice() - updatedSections.splice(updatedSections.length - 1, 0, 'generated') - return updatedSections.join('/') +const DOCS_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '..') +const GUIDES_DIRECTORY = join(DOCS_DIRECTORY, 'content/guides') + +type GuideFrontmatter = { + title: string + description?: string + hideToc?: boolean } -export function getDocsBySlug(slug: string) { - const realSlug = slug.replace(/\.mdx$/, '') +export function isValidGuideFrontmatter(obj: object): obj is GuideFrontmatter { + if (!('title' in obj) || typeof obj.title !== 'string') return false + if ('description' in obj && typeof obj.description !== 'string') return false + return true +} - const formattedSlug = - (realSlug.includes('reference/javascript/') && - !nonGeneratedReferencePages.includes(realSlug)) || - (realSlug.includes('reference/dart/') && !nonGeneratedReferencePages.includes(realSlug)) || - (realSlug.includes('reference/cli/') && !nonGeneratedReferencePages.includes(realSlug)) || - (realSlug.includes('reference/api/') && !nonGeneratedReferencePages.includes(realSlug)) || - (realSlug.includes('reference/auth/') && !nonGeneratedReferencePages.includes(realSlug)) || - (realSlug.includes('reference/realtime/') && !nonGeneratedReferencePages.includes(realSlug)) || - (realSlug.includes('reference/storage/') && !nonGeneratedReferencePages.includes(realSlug)) - ? getPathToGeneratedDoc(realSlug) - : realSlug +export async function getGuidesStaticPaths(section: string) { + const directory = join(GUIDES_DIRECTORY, section) + const files = (await readdir(directory, { recursive: true })) + .filter((file) => extname(file) === '.mdx') + .map((file) => ({ + params: { + slug: file.replace(/\.mdx$/, '').split(sep), + }, + })) - // files can either be .md or .mdx - // we need to check which one exists - let fullPath - let fullPathMD = join(docsDirectory, `${formattedSlug}.md`) - let fullPathMDX = join(docsDirectory, `${formattedSlug}.mdx`) - - if (fs.existsSync(fullPathMD)) { - fullPath = fullPathMD + // Index page isn't included in the directory + const indexFile = join(GUIDES_DIRECTORY, `${section}.mdx`) + if (existsSync(indexFile)) { + files.push({ params: { slug: [] } }) } - if (fs.existsSync(fullPathMDX)) { - fullPath = fullPathMDX + return { + paths: files, + fallback: false, + } +} + +export async function getGuidesStaticProps( + section: string, + { params }: { params?: { slug?: string | Array } } +) { + let relPath: string + switch (typeof params?.slug) { + case 'string': + relPath = section + sep + params.slug + break + case 'object': // actually an array + relPath = section + sep + params.slug.join(sep) + break + case 'undefined': + relPath = section } - // if no match, 404 - if (!fs.existsSync(fullPath)) { - console.log(`\nfile ${fullPath} not found, redirect to 404\n`) - fullPath = join(docsDirectory, 'pages/404.mdx') + const fullPath = join(GUIDES_DIRECTORY, relPath + '.mdx') + const mdx = await readFile(fullPath, 'utf-8') + + const editLink = `supabase/supabase/blob/master/apps/docs/content/guides/${relPath}.mdx` + + const { data: frontmatter, content } = matter(mdx) + if (!isValidGuideFrontmatter(frontmatter)) { + throw Error('Type of frontmatter is not valid') } - const fileContents = fs.readFileSync(fullPath, 'utf8') - - const { data, content } = matter(fileContents) - - // Append the title as the h1 tag in the content - const formattedContent = data.title !== undefined ? `# ${data.title} \n\n${content}` : content - - return { slug: realSlug, meta: data, content: formattedContent } -} - -export function getAllDocs() { - const slugs = walk('docs') - const docs = slugs.map((slug) => getDocsBySlug(slug)) - - return docs -} - -function walk(dir: string) { - let results: string[] = [] - const list = fs.readdirSync(dir) - list.forEach(function (file) { - file = dir + '/' + file - let slugs = [] - - fs.readdirSync(dir).forEach((file) => { - let absolute = join(dir, file) - if (fs.statSync(absolute).isDirectory()) { - fs.readdirSync(absolute).forEach((subFile) => slugs.push(file.concat('/' + subFile))) - } - }) - }) - return results -} - -// [Joshen] Initially tried to simplify the NavBar versioning by reading the directory -// but caused some issues on Vercel. Just gonna park this for now. -export function getLibraryVersions(slug: string) { - const paths = slug.split('/') - if (paths.includes('reference') && paths.length >= 3) { - const reference = REFERENCES[paths[2]] - if (reference?.library !== undefined) { - const path = `data/nav/${reference.library}` - const list = fs.readdirSync(path).map((file) => file.split('.')[0]) - return list.reverse() - } + const codeHikeOptions: CodeHikeConfig = { + theme: codeHikeTheme, + lineNumbers: true, + showCopyButton: true, + skipLanguages: [], + autoImport: false, + } + + const mdxOptions: SerializeOptions = { + mdxOptions: { + useDynamicImport: true, + remarkPlugins: [remarkGfm, [remarkCodeHike, codeHikeOptions]], + }, + } + const mdxSource = await serialize(content, mdxOptions) + + return { + props: { + frontmatter, + mdxSource, + editLink, + }, } - return [] } diff --git a/apps/docs/lib/mdx/generateOldRefMarkdown.tsx b/apps/docs/lib/mdx/generateOldRefMarkdown.tsx deleted file mode 100644 index 2358fd5c698..00000000000 --- a/apps/docs/lib/mdx/generateOldRefMarkdown.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { serialize } from 'next-mdx-remote/serialize' - -import toc from 'markdown-toc' - -import { getDocsBySlug } from '~/lib/docs' - -async function generateOldRefMarkdown(slug) { - let doc = getDocsBySlug(slug) - const content = await serialize(doc.content ?? '', { - // MDX's available options, see the MDX docs for more info. - // https://mdxjs.com/packages/mdx/#compilefile-options - // Indicates whether or not to parse the frontmatter from the mdx source - }) - return { - props: { - /* - * old reference docs are below - */ - ...doc, - content, - toc: toc(doc.content, { maxdepth: 1, firsth1: false }), - }, - } -} - -export default generateOldRefMarkdown diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index 30da2ccad49..6e5f75eec60 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -1,11 +1,11 @@ // @ts-check -import nextMdx from '@next/mdx' -import remarkGfm from 'remark-gfm' -import rehypeSlug from 'rehype-slug' import { remarkCodeHike } from '@code-hike/mdx' +import nextMdx from '@next/mdx' +import rehypeSlug from 'rehype-slug' +import remarkGfm from 'remark-gfm' -import withYaml from 'next-plugin-yaml' import configureBundleAnalyzer from '@next/bundle-analyzer' +import withYaml from 'next-plugin-yaml' import codeHikeTheme from 'config/code-hike.theme.json' assert { type: 'json' } @@ -34,25 +34,11 @@ const withMDX = nextMdx({ /** @type {import('next').NextConfig} nextConfig */ const nextConfig = { - outputFileTracing: true, - experimental: { - // Storybook 7.5 upgrade seems to causes dev deps to be included in build output, removing it here - outputFileTracingExcludes: { - '*': [ - './node_modules/@swc/core-linux-x64-gnu', - './node_modules/@swc/core-linux-x64-musl', - './node_modules/esbuild/**/*', - './node_modules/webpack/**/*', - './node_modules/rollup/**/*', - ], - }, - }, - // Append the default value with md extensions pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'], // reactStrictMode: true, // swcMinify: true, - basePath: '/docs', + basePath: process.env.NEXT_PUBLIC_BASE_PATH || '/docs', images: { dangerouslyAllowSVG: true, domains: [ @@ -64,7 +50,7 @@ const nextConfig = { 'weweb-changelog.ghost.io', 'img.youtube.com', 'archbee-image-uploads.s3.amazonaws.com', - 'obuldanrptloktxcffvn.supabase.co' + 'obuldanrptloktxcffvn.supabase.co', ], }, // TODO: @next/mdx ^13.0.2 only supports experimental mdxRs flag. next ^13.0.2 will stop warning about this being unsupported. @@ -74,7 +60,15 @@ const nextConfig = { transform: 'lodash/{{member}}', }, }, - transpilePackages: ['ui', 'common', 'mermaid', 'mdx-mermaid', 'dayjs', 'shared-data'], + transpilePackages: [ + 'ui', + 'ui-patterns', + 'common', + 'mermaid', + 'mdx-mermaid', + 'dayjs', + 'shared-data', + ], async headers() { return [ { diff --git a/apps/docs/package.json b/apps/docs/package.json index 0883cf3331c..8f6eadb9088 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -34,12 +34,12 @@ "@tanstack/react-query": "^5.13.4", "common": "*", "config": "*", - "framer-motion": "^6.5.1", + "framer-motion": "^11.0.3", "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "hast-util-has-property": "^2.0.1", "isbot": "^3.6.5", - "jsrsasign": "^10.5.26", + "jsrsasign": "^11.0.0", "lodash": "^4.17.21", "markdown-toc": "^1.2.0", "mdast": "^3.0.0", @@ -68,6 +68,7 @@ "remark-gfm": "^3.0.1", "shared-data": "*", "ui": "*", + "ui-patterns": "*", "unist-builder": "^3.0.1", "unist-util-filter": "^4.0.1", "unist-util-visit": "^4.1.2", @@ -89,7 +90,6 @@ "npm-run-all": "^4.1.5", "openapi-types": "^12.0.2", "openapi-typescript": "^6.7.2", - "sass": "^1.68.0", "tsconfig": "*", "tsx": "^4.6.2", "typescript": "^5.2.2" diff --git a/apps/docs/pages/_app.tsx b/apps/docs/pages/_app.tsx index 498dd08d20e..83852f73b22 100644 --- a/apps/docs/pages/_app.tsx +++ b/apps/docs/pages/_app.tsx @@ -9,18 +9,19 @@ import { createClient } from '@supabase/supabase-js' import { QueryClientProvider, useQueryClient } from '@tanstack/react-query' import { AuthProvider, ThemeProvider, useTelemetryProps, useThemeSandbox } from 'common' import { useRouter } from 'next/router' -import { type PropsWithChildren, useCallback, useEffect, useState } from 'react' -import { AppPropsWithLayout } from 'types' -import { CommandMenuProvider, PortalToast, useConsent } from 'ui' -import { TabsProvider } from 'ui/src/components/Tabs' +import { useCallback, useEffect, useState, type PropsWithChildren } from 'react' +import { CommandMenuProvider, PortalToast, TabsProvider } from 'ui' +import { useConsent } from 'ui-patterns/ConsentToast' + import Favicons from '~/components/Favicons' import { IPv4DeprecationBanner } from '~/components/IPv4DeprecationBanner' import SiteLayout from '~/layouts/SiteLayout' import { IS_PLATFORM } from '~/lib/constants' import { unauthedAllowedPost } from '~/lib/fetch/fetchWrappers' import { useRootQueryClient } from '~/lib/fetch/queryClient' -import { useOnLogout } from '~/lib/userAuth' import { LOCAL_STORAGE_KEYS, remove } from '~/lib/storage' +import { useOnLogout } from '~/lib/userAuth' +import { AppPropsWithLayout } from '~/types' /** * @@ -191,16 +192,18 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { <> - - - - - +
+ + + + + +
diff --git a/apps/docs/pages/api/ai/docs.ts b/apps/docs/pages/api/ai/docs.ts new file mode 100644 index 00000000000..2f6ab7e7883 --- /dev/null +++ b/apps/docs/pages/api/ai/docs.ts @@ -0,0 +1,123 @@ +import { SupabaseClient } from '@supabase/supabase-js' +import { ApplicationError, UserError, clippy } from 'ai-commands/edge' +import { NextRequest } from 'next/server' +import OpenAI from 'openai' + +export const runtime = 'edge' + +const openAiKey = process.env.OPENAI_KEY +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL as string +const supabaseServiceKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY as string + +export default async function handler(req: NextRequest) { + if (!openAiKey) { + return new Response( + JSON.stringify({ + error: 'No OPENAI_KEY set. Create this environment variable to use AI features.', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + if (!supabaseUrl) { + return new Response( + JSON.stringify({ + error: + 'No NEXT_PUBLIC_SUPABASE_URL set. Create this environment variable to use AI features.', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + if (!supabaseServiceKey) { + return new Response( + JSON.stringify({ + error: + 'No NEXT_PUBLIC_SUPABASE_ANON_KEY set. Create this environment variable to use AI features.', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + } + + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req) + default: + return new Response( + JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }), + { + status: 405, + headers: { 'Content-Type': 'application/json', Allow: 'POST' }, + } + ) + } +} + +async function handlePost(request: NextRequest) { + const openai = new OpenAI({ apiKey: openAiKey }) + + const body = await (request.json() as Promise<{ + messages: { content: string; role: 'user' | 'assistant' }[] + }>) + + const { messages } = body + + if (!messages) { + throw new UserError('Missing messages in request data') + } + + const supabaseClient = new SupabaseClient(supabaseUrl, supabaseServiceKey) + + try { + const response = await clippy(openai, supabaseClient, messages) + + // Proxy the streamed SSE response from OpenAI + return new Response(response.body, { + headers: { + 'Content-Type': 'text/event-stream', + }, + }) + } catch (error: unknown) { + console.error(error) + if (error instanceof UserError) { + return new Response( + JSON.stringify({ + error: error.message, + data: error.data, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ) + } else if (error instanceof ApplicationError) { + // Print out application errors with their additional data + console.error(`${error.message}: ${JSON.stringify(error.data)}`) + } else { + // Print out unexpected errors as is to help with debugging + console.error(error) + } + + // TODO: include more response info in debug environments + return new Response( + JSON.stringify({ + error: 'There was an error processing your request', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + } + ) + } +} diff --git a/apps/docs/pages/guides/ai/[[...slug]].tsx b/apps/docs/pages/guides/ai/[[...slug]].tsx new file mode 100644 index 00000000000..af8cc713906 --- /dev/null +++ b/apps/docs/pages/guides/ai/[[...slug]].tsx @@ -0,0 +1,28 @@ +import { type GetStaticPaths, type GetStaticProps, type InferGetStaticPropsType } from 'next' +import { MDXRemote } from 'next-mdx-remote' + +import components from '~/components' +import Layout from '~/layouts/DefaultGuideLayout' +import { getGuidesStaticPaths, getGuidesStaticProps } from '~/lib/docs' + +export const getStaticPaths = (async () => { + return getGuidesStaticPaths('ai') +}) satisfies GetStaticPaths + +export const getStaticProps = (async (args) => { + return getGuidesStaticProps('ai', args) +}) satisfies GetStaticProps + +export default function AiGuide({ + frontmatter, + mdxSource, + editLink, +}: InferGetStaticPropsType) { + const { hideToc, ...meta } = frontmatter + + return ( + + + + ) +} diff --git a/apps/docs/pages/guides/auth/[[...slug]].tsx b/apps/docs/pages/guides/auth/[[...slug]].tsx new file mode 100644 index 00000000000..777f65e6ddd --- /dev/null +++ b/apps/docs/pages/guides/auth/[[...slug]].tsx @@ -0,0 +1,28 @@ +import { type GetStaticPaths, type GetStaticProps, type InferGetStaticPropsType } from 'next' +import { MDXRemote } from 'next-mdx-remote' + +import components from '~/components' +import Layout from '~/layouts/DefaultGuideLayout' +import { getGuidesStaticPaths, getGuidesStaticProps } from '~/lib/docs' + +export const getStaticPaths = (async () => { + return getGuidesStaticPaths('auth') +}) satisfies GetStaticPaths + +export const getStaticProps = (async (args) => { + return getGuidesStaticProps('auth', args) +}) satisfies GetStaticProps + +export default function AuthGuide({ + frontmatter, + mdxSource, + editLink, +}: InferGetStaticPropsType) { + const { hideToc, ...meta } = frontmatter + + return ( + + + + ) +} diff --git a/apps/docs/pages/guides/auth/auth-password-reset.mdx b/apps/docs/pages/guides/auth/auth-password-reset.mdx deleted file mode 100644 index 7490f1edb1f..00000000000 --- a/apps/docs/pages/guides/auth/auth-password-reset.mdx +++ /dev/null @@ -1,250 +0,0 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import { Accordion } from 'ui' - -export const meta = { - id: 'auth-password-reset', - title: 'Password Reset', - description: 'How to reset password with Supabase Auth', -} - -- Send email for password reset using [`.resetPasswordForEmail`](/docs/reference/javascript/auth-resetpasswordforemail) while providing a `redirectTo` parameter -- Email link will work as a magic link and log the user in then take them to the url specified in the `redirectTo` parameter -- Create form to update the password and call the [`.updateUser`](/docs/reference/javascript/auth-updateuser) method with the new password - -## Single Page Application (SPA) - -### Sending password reset email - - - - -Supabase provides a convenient method [`.resetPasswordForEmail`](/docs/reference/javascript/auth-resetpasswordforemail) to reset a user password. This method takes a parameter of `redirectTo` which we will use to pass an absolute URL to the update password page. This URL must be saved in your allowed [Redirect URLs](https://supabase.com/dashboard/project/_/auth/url-configuration) list found at [Authentication > Redirect Configuration](https://supabase.com/dashboard/project/_/auth/url-configuration) or it won't redirect the user. - -```js -await supabase.auth.resetPasswordForEmail('hello@example.com', { - redirectTo: 'http://example.com/account/update-password', -}) -``` - - - - -Supabase provides a convenient method [`.resetPasswordForEmail`](/docs/reference/kotlin/auth-resetpasswordforemail) to reset a user password. This method takes a parameter of `redirectUrl` which we will use to pass an absolute URL to the update password page. This URL must be saved in your allowed [Redirect URLs](https://supabase.com/dashboard/project/_/auth/url-configuration) list found at [Authentication > Redirect Configuration](https://supabase.com/dashboard/project/_/auth/url-configuration) or it won't redirect the user. - -```kotlin -supabase.auth.resetPasswordForEmail( - email = "hello@example.com", - redirectUrl = "http://example.com/account/update-password" -) -``` - -If you are on one of the Kotlin targets that have built-in support for redirect url handling like Android, you may want to checkout [OAuth and OTP link verification](https://supabase.com/docs/reference/kotlin/initializing). - - - - -### Email link - -The email link you receive will work like a magic link. This way when you click the link you will be logged into the website. Since we passed a redirect URL to the [`.resetPasswordForEmail`](https://supabase.com/docs/reference/javascript/auth-resetpasswordforemail) method the user should be sent to the update password page. - -### Update password - - - - -To update the password we call the [`.updateUser`](/docs/reference/javascript/auth-updateuser) method and pass along the new password to this method. - -```js -await supabase.auth.updateUser({ password: new_password }) -``` - - - - -To update the password we call the [`.modifyUser`](/docs/reference/kotlin/auth-updateuser) method and pass along the new password to this method. - -```kotlin -supabase.auth.modifyUser { - password = "new_password" -} -``` - - - - -## Server-Side Rendering (SSR) - -### Sending password reset email - - - - -Supabase provides a convenient method [`.resetPasswordForEmail`](/docs/reference/javascript/auth-resetpasswordforemail) to reset a user password. This method takes a parameter of `redirectTo` which we will use to pass an absolute URL to the callback page along with a query parameter to the update password page. This URL must be saved in your allowed [Redirect URLs](https://supabase.com/dashboard/project/_/auth/url-configuration) list found at [Authentication > Redirect Configuration](https://supabase.com/dashboard/project/_/auth/url-configuration) or it won't redirect the user. - -```js -await supabase.auth.resetPasswordForEmail('hello@example.com', { - redirectTo: 'http://example.com/api/auth/callback?next=/account/update-password', -}) -``` - - - -We are using `next` as our query parameter, but this can name whatever you like. - - - - - - -Supabase provides a convenient method [`.resetPasswordForEmail`](/docs/reference/kotlin/auth-resetpasswordforemail) to reset a user password. This method takes a parameter of `redirectUrl` which we will use to pass an absolute URL to the update password page. This URL must be saved in your allowed [Redirect URLs](https://supabase.com/dashboard/project/_/auth/url-configuration) list found at [Authentication > Redirect Configuration](https://supabase.com/dashboard/project/_/auth/url-configuration) or it won't redirect the user. - -```kotlin -supabase.auth.resetPasswordForEmail( - email = "hello@example.com", - redirectUrl = "http://example.com/api/auth/callback?next=/account/update-password" -) -``` - -If you are on one of the Kotlin targets that have built-in support for redirect url handling like Android, you may want to checkout [OAuth and OTP link verification](https://supabase.com/docs/reference/kotlin/initializing). - - - - -### Email link - -The email link you receive will behave like a magic link. When the link is clicked you will be sent to the `redirectTo` URL you specified that points to the path with the exchange code. - -### Exchange authorization code - - - - -After redirecting to the server page, we need to retrieve the code from the query parameter called `code` and pass it to the `.exchangeCodeForSession` function. - -```js -// api/auth/callback.ts - -// The code is retrieved from the query parameter - use whichever mechanism is recommended -// for your app/framework. This is just an example. -const code = url.searchParams.get('code') - -// call the Supabase API to exchange the code for a session -await supabase.auth.exchangeCodeForSession(code) -``` - - - -The query parameter is always `code` for the authorization code returned from the Supabase API - - - -We will also need to check for the `next` query parameter to redirect the user to the update password page. - -```ts -// api/auth/callback.ts - -// The password page path is retrieved from the query parameter - use whichever mechanism is recommended -// for your app/framework. This is just an example. -const next = url.searchParams.get('next') - -// using Next.js API response object in this example -res.redirect(next) -``` - - - - -Here is an example on Android: - -```kotlin -//Wherever you initialize your SupabaseClient: -val client = createSupabaseClient(supabaseUrl, supabaseKey) { - install(GoTrue) { - flowType = FlowType.PKCE - scheme = "exampleApp" - host = "callback" - } -} -``` - -```xml - - - - - - - - -``` - -```kotlin -//MainActivity.kt -override fun onCreate() { - client.handleDeeplinks(intent) -} -``` - -That's it! `supabase-kt` will take care of parsing and importing the session. Other targets are similar, you can check [OAuth and OTP link verification](https://supabase.com/docs/reference/kotlin/initializing) for more information. - - - - -### Update password - - - - -To update the password we call the [`.updateUser`](/docs/reference/javascript/auth-updateuser) method and pass along the new password to this method. - -```js -await supabase.auth.updateUser({ password: new_password }) -``` - - - - -To update the password we call the [`.modifyUser`](/docs/reference/kotlin/auth-updateuser) method and pass along the new password to this method. - -```kotlin -supabase.auth.modifyUser { - password = "new_password" -} -``` - - - - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/enterprise-sso.mdx b/apps/docs/pages/guides/auth/enterprise-sso.mdx deleted file mode 100644 index 5ca0dffcd0c..00000000000 --- a/apps/docs/pages/guides/auth/enterprise-sso.mdx +++ /dev/null @@ -1,12 +0,0 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - title: 'Enterprise Single Sign-On', - description: 'Learn about Single Sign-On support in Supabase Auth for enterprise applications', -} - -Supabase Auth supports building enterprise applications that require Single Sign-On (SSO) authentication [with SAML 2.0](/docs/guides/auth/sso/auth-sso-saml). - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/phone-login.mdx b/apps/docs/pages/guides/auth/phone-login.mdx deleted file mode 100644 index 6d7169e74b1..00000000000 --- a/apps/docs/pages/guides/auth/phone-login.mdx +++ /dev/null @@ -1,52 +0,0 @@ -import Layout from '~/layouts/DefaultGuideLayout' -import { IconPanel, GlassPanel, IconMail } from 'ui' -import Link from 'next/link' -import { PhoneLoginsItems } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants' - -export const meta = { - title: 'Phone Login', - description: 'Learn about logging in to your platform using SMS one-time passwords.', -} - - - - Authenticating users via SMS can become expensive. Adjust your project's rate limits and [configure CAPTCHA](/docs/guides/auth/auth-captcha) to control the bill. Read more about this in the [Production Checklist](/docs/guides/platform/going-into-prod). - - - -Phone Login is a method of authentication that allows users to log in to a website or application without using a password. Instead of entering a password, the user provides another form of authentication through a one-time code sent via SMS. - -## Benefits - -There are several reasons why you might want to add phone login to your applications: - -- **Improved user experience**: By eliminating the need for users to remember and enter complex passwords, phone login can make it easier and more convenient for users to log in to your application. This can improve the overall user experience and make it more enjoyable for users to interact with your application. - -- **Increased security**: Phone login can improve the security of your application by reducing the risk of password-related security breaches, such as password reuse and weak passwords. By using alternative forms of authentication, such as one-time codes or biometric factors, you can make it more difficult for unauthorized users to access your application. - -- **Reduced support burden**: Phone login can also help reduce the support burden for your team by eliminating the need to handle password recovery flows or deal with other password-related issues. This can free up your team to focus on other important tasks and improve the efficiency of your operation. - -## Set up a provider with Supabase Auth - -Supabase supports Phone Login with several communications platforms. Follow the guides below to set up a provider with Supabase Auth. - -
- {PhoneLoginsItems.map((item) => ( - - - {item.linkDescription} - - -))} - -
- -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/phone-login/messagebird.mdx b/apps/docs/pages/guides/auth/phone-login/messagebird.mdx deleted file mode 100644 index d88a23d153a..00000000000 --- a/apps/docs/pages/guides/auth/phone-login/messagebird.mdx +++ /dev/null @@ -1,357 +0,0 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-messagebird', - title: 'Phone Auth with MessageBird', - description: 'How to set up and use Mobile OTP with MessageBird and Supabase.', -} - - - -Authenticating users via SMS can become expensive. Adjust your project's rate limits and [configure CAPTCHA](/docs/guides/auth/auth-captcha) to control the bill. Read more about this in the [Production Checklist](/docs/guides/platform/going-into-prod). - - - -## Overview - -In this guide we'll show you how to authenticate your users with SMS based OTP (One-Time Password) tokens. - -There are two reasons to use Supabase SMS OTP tokens: - -- You want users to log in with mobile + password, and the mobile should be verified via SMS -- You want users to log in with mobile ONLY (i.e. passwordless login) - -We'll cover: - -- [Finding your MessageBird credentials](#finding-your-messagebird-credentials) -- [Using OTP with password based logins](#using-otp-with-password-based-logins) -- [Using OTP as a passwordless sign-in mechanism](#using-otp-as-a-passwordless-sign-in-mechanism) - -What you'll need: - -- A MessageBird account (sign up here: https://dashboard.messagebird.com/en/sign-up) -- A Supabase project (create one here: https://supabase.com/dashboard) -- A mobile phone capable of receiving SMS - -## Steps - -### Finding your MessageBird credentials - -Start by logging into your MessageBird account and verify the mobile number you'll be using to test with: https://dashboard.messagebird.com/en/getting-started/sms - -This is the number that will be receiving the SMS OTPs. - -![Verify your own phone number](/docs/img/guides/auth-messagebird/1.png) - -![Get your API Keys](/docs/img/guides/auth-messagebird/2.png) - -Navigate to the [dashboard settings](https://dashboard.messagebird.com/en/settings/sms) to set the default originator. The messagebird originator is the name or number from which the message is sent. -For more information, you can refer to the messagebird article on choosing an originator [here](https://support.messagebird.com/hc/en-us/articles/115002628665-Choosing-an-originator) - -![Set the default originator](/docs/img/guides/auth-messagebird/3.png) - -You will need the following values to get started: - -- Live API Key / Test API Key -- MessageBird originator - -Now go to the [Auth > Providers](https://supabase.com/dashboard/project/_/auth/providers) page in the Supabase dashboard and select "Phone" from the Auth Providers list. - -You should see an option to enable the Phone provider. - -Toggle it on, and copy the 2 values over from the Messagebird dashboard. Click save. - -Note: If you use the Test API Key, the OTP will not be delivered to the mobile number specified but messagebird will log the response in the dashboard. -If the Live API Key is used instead, the OTP will be delivered and there will be a deduction in your free credits. - -Plugin MessageBird credentials - -Now the backend should be setup, we can proceed to add our client-side code! - -#### SMS custom template - -The SMS message sent to a phone containing an OTP code can be customized. This is useful if you need to mention a brand name or display a website address. - -Go to [Auth > Templates](https://supabase.com/dashboard/project/_/auth/templates) page in the Supabase dashboard. - -Use the variable `.Code` in the template to display the code. - -### Using OTP with password based logins - -In this use scenario we'll be using the user's mobile phone number as an alternative to an email address when signing up along with a password. You may want to think hard about the permanency of this however. It is not uncommon for mobile phone numbers to be recycled by phone networks when users cancel their phone contracts or move countries, therefore granting access to the user's account to whoever takes over the phone number in the future. Soon we'll add multi-factor auth, which will mitigate this risk, but for now you may want to give some thought to allowing your users to recover their account by some other means in an emergency. - - - - -Using supabase-js on the client you'll want to use the same `signUp` method that you'd use for email based sign ups, but with the `phone` param instead of the `email param`: - -```js -const { - data: { user, session }, - error, -} = await supabase.auth.signUp({ - phone: '+13334445555', - password: 'some-password', -}) -``` - - - - -```kotlin -supabase.auth.signUpWith(Phone) { - phone = "+13334445555" - password = "some-password" -} -``` - - - - -```bash -curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/signup' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "phone": "+13334445555", - "password": "some-password" -}' -``` - - - - -The user will now receive an SMS with a 6-digit pin that you will need to receive from them within 60-seconds before they can login to their account. - - - - -You should present a form to the user so they can input the 6 digit pin, then send it along with the phone number to `verifyOtp`: - -```js -const { - data: { session }, - error, -} = await supabase.auth.verifyOtp({ - phone: '+13334445555', - token: '123456', -}) -``` - - - - -You should present a form to the user so they can input the 6 digit pin, then send it along with the phone number to `verifyPhoneOtp`: - -```kotlin -supabase.auth.verifyPhoneOtp( - type = OtpType.Phone.SMS, - phone = "+13334445555", - token = "123456" -) -``` - - - - -```bash -curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/verify' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "type": "sms", - "phone": "+13334445555", - "token": "123456" -}' -``` - - - - -If successful the user will now be logged in and you should receive a valid session like: - -```json -{ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjI3MjkxNTc3LCJzdWIiOiJmYTA2NTQ1Zi1kYmI1LTQxY2EtYjk1NC1kOGUyOTg4YzcxOTEiLCJlbWFpbCI6IiIsInBob25lIjoiNjU4NzUyMjAyOSIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6InBob25lIn0sInVzZXJfbWV0YWRhdGEiOnt9LCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.1BqRi0NbS_yr1f6hnr4q3s1ylMR3c1vkiJ4e_N55dhM", - "token_type": "bearer", - "expires_in": 3600, - "refresh_token": "LSp8LglPPvf0DxGMSj-vaQ" -} -``` - -The access token can be sent in the Authorization header as a Bearer token for any CRUD operations on supabase-js. See our guide on [Row Level Security](/docs/guides/auth#row-level-security) for more info on restricting access on a user basis. - -Also now that the mobile has been verified, the user can use the number and password to sign in without needing to verify their number each time: - - - - -```js -const { user, error } = await supabase.auth.signInWithPassword({ - phone: '+13334445555', - password: 'some-password', -}) -``` - - - - -```kotlin -supabase.auth.signInWith(Phone) { - phone = "+13334445555" - password = "some-password" -} -``` - - - - -```bash -curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/token?grant_type=password' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "phone": "+13334445555", - "password": "some-password" -}' -``` - - - - -### Using OTP as a passwordless sign-in mechanism - -In this scenario you are granting your user's the ability to login to their account without needing to set a password on their account, all they have to do to log in is verify their mobile each time using the OTP. - - - - -In JavaScript we can use the `signIn` method with a single parameter: `phone` - -```js -const { data, error } = await supabase.auth.signInWithOtp({ - phone: '+13334445555', -}) -``` - - - - -In Kotlin, we can use the `signInWith(OTP)` method and change the property `phone` - -```kotlin -supabase.auth.signInWith(OTP) { - phone = "+13334445555" -} -``` - - - - -```bash -curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/otp' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "phone": "+13334445555" -}' -``` - - - - -The second step is the same as the previous section, you need to collect the 6-digit pin from the user and pass it along with their phone number to the verify method: - - - - -```js -const { - data: { session }, - error, -} = await supabase.auth.verifyOtp({ - phone: '+13334445555', - token: '123456', -}) -``` - - - - -```kotlin -supabase.auth.verifyPhoneOtp( - type = OtpType.Phone.SMS, - phone = "+13334445555", - token = "123456" -) -``` - - - - -```bash -curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/verify' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "type": "sms", - "phone": "+13334445555", - "token": "123456" -}' -``` - - - - -and the response should also be the same as above: - -```json -{ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjI3MjkxNTc3LCJzdWIiOiJmYTA2NTQ1Zi1kYmI1LTQxY2EtYjk1NC1kOGUyOTg4YzcxOTEiLCJlbWFpbCI6IiIsInBob25lIjoiNjU4NzUyMjAyOSIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6InBob25lIn0sInVzZXJfbWV0YWRhdGEiOnt9LCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.1BqRi0NbS_yr1f6hnr4q3s1ylMR3c1vkiJ4e_N55dhM", - "token_type": "bearer", - "expires_in": 3600, - "refresh_token": "LSp8LglPPvf0DxGMSj-vaQ" -} -``` - -The user does not have a password therefore will need to sign in via this method each time they want to access your service. - -## Resources - -- [MessageBird Signup](https://dashboard.messagebird.com/en/sign-up) -- [Supabase Dashboard](https://supabase.com/dashboard) -- [Supabase Row Level Security](/docs/guides/auth#row-level-security) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/phone-login/twilio.mdx b/apps/docs/pages/guides/auth/phone-login/twilio.mdx deleted file mode 100644 index 64639092dce..00000000000 --- a/apps/docs/pages/guides/auth/phone-login/twilio.mdx +++ /dev/null @@ -1,471 +0,0 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-twilio', - title: 'Phone Auth with Twilio', - description: 'How to set up and use Mobile OTP with Twilio and Supabase.', - video: 'https://www.youtube.com/v/akScoPO01bc', -} - - - -Authenticating users via SMS can become expensive. Adjust your project's rate limits and [configure CAPTCHA](/docs/guides/auth/auth-captcha) to control the bill. Read more about this in the [Production Checklist](/docs/guides/platform/going-into-prod). - - - -In this guide we'll show you how to authenticate your users with SMS based One-Time Password (OTP) tokens. - -There are two reasons to use Supabase SMS OTP tokens: - -- You want users to log in with a phone number and password, and verify the phone number on signup with SMS -- You want users to log in with a phone number ONLY (i.e. passwordless login) - -What you'll need: - -- Twilio account ([sign up](https://www.twilio.com/try-twilio)) -- Supabase project (create one [here](https://supabase.com/dashboard)) -- Mobile phone capable of receiving SMS - -SMS Authentication can be done with either Twilio Verify or Twilio Programmable Messaging. [Twilio Verify](https://www.twilio.com/en-us/trusted-activation/verify) is a specialized OTP solution and is recommended for most developers that need over-the-phone authentication. Alternatively you can use [Twilio Programmable Messaging](https://www.twilio.com/docs/messaging) which offers generic SMS sending support. - -## Twilio Verify - -To set up Twilio Verify, you will need to: - -1. Create a new [verification service](https://support.twilio.com/hc/en-us/articles/360033309133-Getting-Started-with-Twilio-Verify-V2) in the Twilio dashboard. -2. [Switch Phone Provider to Twilio Verify](https://supabase.com/dashboard/project/_/auth/providers) -3. Configure the Twilio Verify Service ID field using the Verification Service ID obtained in 1. - -When using Twilio Verify, OTPs are generated by Twilio. This means that: - -- Unlike other providers, the OTP expiry duration and message content fields are not configurable via the Supabase dashboard. Please head to Twilio Verify to configure these settings. -- The token remains the same during its validity period until the verification is successful. This means if your user make another request within that period, they will receive the same token. -- Twilio Verify has a separate set of rate limits that apply. Visit Twilio's [Rate Limit and Timeouts page](https://www.twilio.com/docs/verify/api/rate-limits-and-timeouts) to find out more. - - - -At this time, Twilio Verify is only supported on the `whatsapp` and `sms` channels. - - - -## Twilio (Programmable Messaging) - -In this section we'll cover: - -- [Finding your Twilio credentials](#finding-your-twilio-credentials) -- [Using OTP with password based logins](#using-otp-with-password-based-logins) -- [Using OTP as a passwordless sign-in mechanism](#using-otp-as-a-passwordless-sign-in-mechanism) - -What you'll need: - -- A Twilio account (sign up here: https://www.twilio.com/try-twilio) -- A Supabase project (create one here: https://supabase.com/dashboard) -- A mobile phone capable of receiving SMS - -## Video - -
- -
- -## Steps - -### Finding your Twilio credentials - -Start by logging into your Twilio account and starting a new project: https://www.twilio.com/console/projects/create - -Give your project a name and verify the mobile number you'll be using to test with. This is the number that will be receiving the SMS OTPs. - -![Name your twilio project](/docs/img/guides/auth-twilio/1.png) -![verify your own phone number](/docs/img/guides/auth-twilio/2.png) - -Select 'SMS', 'Identity & Verification', and 'With code' as options on the welcome form. - -![Form Fields](/docs/img/guides/auth-twilio/3.png) - -When you're back on the [Twilio console screen](https://www.twilio.com/console), you need to scroll down and click 'Get a trial phone number' - this is the number that you'll be sending SMSs from. - -![Get a trial phone number](/docs/img/guides/auth-twilio/4.png) - -![Successful phone number](/docs/img/guides/auth-twilio/5.png) - -You should now be able to see all three values you'll need to get started: - -- Account SID -- Auth Token -- Sender Phone Number - -![All the credentials you'll need](/docs/img/guides/auth-twilio/6.png) - -Now go to the [Auth > Providers](https://supabase.com/dashboard/project/_/auth/providers) page in the Supabase dashboard and select "Phone" from the Auth Providers list. - -You should see an option to enable the Phone provider. - -Toggle it on, and copy the 3 values over from the Twilio dashboard. Click save. - -Note: for "Twilio Message Service SID" you can use the Sender Phone Number generated above. - -![Plug in Twilio credentials](/docs/img/guides/auth-twilio/8.png) - -Now the backend should be setup, we can proceed to add our client-side code! - - - The `Twilio Content SID` field is specific to Twilio Programmable Messaging (WhatsApp) senders. - Disregard this field if you are only sending SMS messages. Refer to the section on WhatsApp OTP - Logins for further details. - - -#### SMS custom template - -The SMS message sent to a phone containing an OTP code can be customized. This is useful if you need to mention a brand name or display a website address. - -Go to the [Auth > Providers](https://supabase.com/dashboard/project/_/auth/providers) page in the Supabase dashboard and select "Phone" from the Auth Providers list. Scroll to the very bottom of the "Phone" section to the "SMS Message" input - you can customize the SMS message here. - -Use the variable `.Code` in the template to display the OTP code. Here's an example in the SMS template. - -![example in the SMS template](/docs/img/guides/auth-twilio/9.png) - -### Using OTP with password based logins - -In this scenario we'll be using the user's mobile phone number and a corresponding password as an alternative to signing up with an email address. Note: please thoroughly consider potential security implications when signing up with a combination of phone number and password. Phone numbers are sometimes recycled by phone networks when users cancel their phone contracts or move countries, thereby granting access to the user's account to the subsequent owner of the phone number. In the near future Supabase will support multifactor authentication, which will mitigate this risk, but for now you may want to consider allowing your users to recover their account by some other means in an emergency. - - - - -Using supabase-js on the client you'll want to use the same `signUp` method that you'd use for email based sign ups, but with the `phone` param instead of the `email param`: - -```js -const { - data: { user, session }, - error, -} = await supabase.auth.signUp({ - phone: '+13334445555', - password: 'some-password', -}) -``` - - - - -```kotlin -supabase.auth.signUpWith(Phone) { - phone = "+13334445555" - password = "some-password" -} -``` - - - - -```bash -curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/signup' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "phone": "+13334445555", - "password": "some-password" -}' -``` - - - - -The user will now receive an SMS with a 6-digit pin that you will need to receive from them within 60-seconds before they can login to their account. - - - - -You should present a form to the user so they can input the 6 digit pin, then send it along with the phone number to `verifyOtp`: - -```js -const { - data: { session }, - error, -} = await supabase.auth.verifyOtp({ - phone: '+13334445555', - token: '123456', - type: 'sms', -}) -``` - - - - -You should present a form to the user so they can input the 6 digit pin, then send it along with the phone number to `verifyPhoneOtp`: - -```kotlin -supabase.auth.verifyPhoneOtp( - type = OtpType.Phone.SMS, - phone = "+13334445555", - token = "123456" -) -``` - - - - -```bash -curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/verify' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "type": "sms", - "phone": "+13334445555", - "token": "123456" -}' -``` - - - - -If successful the user will now be logged in and you should receive a valid session like: - -```json -{ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjI3MjkxNTc3LCJzdWIiOiJmYTA2NTQ1Zi1kYmI1LTQxY2EtYjk1NC1kOGUyOTg4YzcxOTEiLCJlbWFpbCI6IiIsInBob25lIjoiNjU4NzUyMjAyOSIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6InBob25lIn0sInVzZXJfbWV0YWRhdGEiOnt9LCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.1BqRi0NbS_yr1f6hnr4q3s1ylMR3c1vkiJ4e_N55dhM", - "token_type": "bearer", - "expires_in": 3600, - "refresh_token": "LSp8LglPPvf0DxGMSj-vaQ" -} -``` - -The access token can be sent in the Authorization header as a Bearer token for any CRUD operations on supabase-js. See our guide on [Row Level Security](/docs/guides/auth#row-level-security) for more info on restricting access on a user basis. - -Also now that the mobile has been verified, the user can use the number and password to sign in without needing to verify their number each time: - - - - -```js -const { user, error } = await supabase.auth.signInWithPassword({ - phone: '+13334445555', - password: 'some-password', -}) -``` - - - - -```kotlin -supabase.auth.signInWith(Phone) { - phone = "+13334445555" - password = "some-password" -} -``` - - - - -```bash -curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/token?grant_type=password' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "phone": "+13334445555", - "password": "some-password" -}' -``` - - - - -### Using OTP as a passwordless sign-in mechanism - -In this scenario you are granting your user's the ability to login to their account without needing to set a password on their account, all they have to do to log in is verify their mobile each time using the OTP. - - - - -In JavaScript we can use the `signIn` method with a single parameter: `phone` - -```js -const { data, error } = await supabase.auth.signInWithOtp({ - phone: '+13334445555', -}) -``` - - - - -In Kotlin, we can use the `signInWith(OTP)` method and change the property `phone` - -```kotlin -supabase.auth.signInWith(OTP) { - phone = "+13334445555" -} -``` - - - - -```bash -curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/otp' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "phone": "+13334445555" -}' -``` - - - - -The second step is the same as the previous section, you need to collect the 6-digit pin from the user and pass it along with their phone number to the verify method: - - - - -```js -const { - data: { session }, - error, -} = await supabase.auth.verifyOtp({ - phone: '+13334445555', - token: '123456', - type: 'sms', -}) -``` - - - - -```kotlin -supabase.auth.verifyPhoneOtp( - type = OtpType.Phone.SMS, - phone = "+13334445555", - token = "123456" -) -``` - - - - -```bash -curl -X POST 'https://cvwawazfelidkloqmbma.supabase.co/auth/v1/verify' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "type": "sms", - "phone": "+13334445555", - "token": "123456" -}' -``` - - - - -and the response should also be the same as above: - -```json -{ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjI3MjkxNTc3LCJzdWIiOiJmYTA2NTQ1Zi1kYmI1LTQxY2EtYjk1NC1kOGUyOTg4YzcxOTEiLCJlbWFpbCI6IiIsInBob25lIjoiNjU4NzUyMjAyOSIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6InBob25lIn0sInVzZXJfbWV0YWRhdGEiOnt9LCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.1BqRi0NbS_yr1f6hnr4q3s1ylMR3c1vkiJ4e_N55dhM", - "token_type": "bearer", - "expires_in": 3600, - "refresh_token": "LSp8LglPPvf0DxGMSj-vaQ" -} -``` - -The user does not have a password therefore will need to sign in via this method each time they want to access your service. - -## WhatsApp OTP logins - -In some cases, you may wish to use WhatsApp as a delivery channel instead. Here are some examples our users have cited: - -- You want higher deliverability -- You wish for a secure channel -- Your users mostly use WhatsApp as a messaging platform - -Twilio Verify can be used with WhatsApp OTPs with no additional configuration. - -Complete the following steps to use WhatsApp OTP with Twilio Programmable Messaging: - -1. Go through the [Twilio self sign up guide for WhatsApp](https://www.twilio.com/docs/whatsapp/self-sign-up). -2. Register a Twilio Content Template via the [API](https://www.twilio.com/docs/content/whatsappauthentication) and note the corresponding Twilio Content SID. You can also use the Template Builder on the Twilio dashboard to create a Content Template - ![Twilio Content SID Image](/docs/img/guides/auth-twilio/twilio_content_sid.png) -3. Register the Twilio Content SID on the Supabase dashboard under Authentication > Providers > Phone > Twilio Content SID. Ensure you have Twilio selected as your phone provider. - -Note that you may only use one Twilio Content SID at a time and that Supabase Auth will use the Content Template over the `SMS Message` field when sending WhatsApp messages. Use Twilio Verify if you need to use more than one message template. - -The sign in process with WhatsApp is similar to the sign in process for SMS. Do note the additional `whatsapp` parameter added: - -```js -const { data, error } = await supabase.auth.signInWithOtp({ - phone: '+57336567365', - options: { - channel: 'whatsapp', - }, -}) -``` - -You can also sign up with `whatsapp` as a channel: - -```js -const { data, error } = await supabase.auth.signUp({ - phone: '+57336567365', - password: 'testsupabasenow', - options: { - channel: 'whatsapp', - }, -}) -``` - -There is no change in the verification process, you should continue to use the `sms` type for verification - -```js -// After receiving a WhatsApp OTP -const { - data: { session }, - error, -} = await supabase.auth.verifyOtp({ - phone: '+57336567365', - token: '123456', - type: 'sms', -}) -``` - -## Resources - -- [Reasons to use Twilio Verify](https://www.twilio.com/blog/9-reasons-to-use-the-verify-api) -- [Twilio Verify Documentation](https://www.twilio.com/docs/verify) -- [Twilio Signup](https://www.twilio.com/try-twilio) -- [Supabase Dashboard](https://supabase.com/dashboard) -- [Supabase Row Level Security](/docs/guides/auth#row-level-security) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/auth/phone-login/vonage.mdx b/apps/docs/pages/guides/auth/phone-login/vonage.mdx deleted file mode 100644 index ac260899565..00000000000 --- a/apps/docs/pages/guides/auth/phone-login/vonage.mdx +++ /dev/null @@ -1,352 +0,0 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'auth-vonage', - title: 'Phone Auth with Vonage', - description: 'How to set up and use Mobile OTP with Vonage and Supabase.', -} - - - -Authenticating users via SMS can become expensive. Adjust your project's rate limits and [configure CAPTCHA](/docs/guides/auth/auth-captcha) to control the bill. Read more about this in the [Production Checklist](/docs/guides/platform/going-into-prod). - - - -## Overview - -In this guide we'll show you how to authenticate your users with SMS based OTP (One-Time Password) tokens. - -There are two reasons to use Supabase SMS OTP tokens: - -- You want users to log in with mobile + password, and the mobile should be verified via SMS -- You want users to log in with mobile ONLY (i.e. passwordless login) - -We'll cover: - -- [Getting your Vonage API Key](#finding-your-vonage-api-key) -- [Using OTP with password based logins](#using-otp-with-password-based-logins) -- [Using OTP as a passwordless sign-in mechanism](#using-otp-as-a-passwordless-sign-in-mechanism) - -What you'll need: - -- A Vonage account (sign up here: https://dashboard.nexmo.com/sign-up) -- A Supabase project (create one here: https://supabase.com/dashboard) -- A mobile phone capable of receiving SMS - -## Steps - -### Getting your Vonage credentials - -Start by logging into your Vonage Dashboard at https://dashboard.nexmo.com/ - -You will see you API Key and API Secret here, which is actually all you need to get started. - -In most countries, a phone number is actually optional and you can also use any Alphanumeric Sender ID of up to 11 characters length (8 for India) as a Sender ID (from). This means you do not need a number to test with in most cases. - -To find out more about supported countries for Alphanumeric Sender ID, check this overview: https://help.nexmo.com/hc/en-us/articles/115011781468-SMS-Features-Overview-Outbound-only- - -Hint: Some countries might need a Sender ID Registration to allow sending with an Alphanumeric Sender ID. You can find this information in the help article as well. If Alpha Sender IDs are not supported, you will need to buy a phone number. - -### Getting a phone number (optional) - -If you want a phone number to send SMS from, you can buy one from the Vonage Dashboard under Numbers > Buy Numbers (https://dashboard.nexmo.com/buy-numbers). - -Select the country you want a number for. You will need a mobile phone number with SMS or SMS+Voice capability. After you have bought the number, you will be able to send SMS from it. - -### Configure Supabase - -Now go to the [Auth > Providers](https://supabase.com/dashboard/project/_/auth/providers) page in the Supabase dashboard and select "Phone" from the Auth Providers list. - -You should see an option to enable the Phone provider. - -Toggle it on, and copy the API key, API secret and phone number values over from the Vonage dashboard. Click save. - -Now the backend should be setup, we can proceed to add our client-side code! - -#### SMS custom template - -The SMS message sent to a phone containing an OTP code can be customized. This is useful if you need to mention a brand name or display a website address. - -Go to [Auth > Templates](https://supabase.com/dashboard/project/_/auth/templates) page in the Supabase dashboard. - -Use the variable `.Code` in the template to display the code. - -### Using OTP with password based logins - -In this use scenario we'll be using the user's mobile phone number as an alternative to an email address when signing up along with a password. You may want to think hard about the permanency of this however. It is not uncommon for mobile phone numbers to be recycled by phone networks when users cancel their phone contracts or move countries, therefore granting access to the user's account to whoever takes over the phone number in the future. - - - - -Using supabase-js on the client you'll want to use the same `signUp` method that you'd use for email based sign ups, but with the `phone` param instead of the `email param`: - -```js -const { - data: { user, session }, - error, -} = await supabase.auth.signUp({ - phone: '491512223334444', - password: 'some-password', -}) -``` - - - - -```kotlin -supabase.auth.signUpWith(Phone) { - phone = "+13334445555" - password = "some-password" -} -``` - - - - -```bash -curl -X POST 'https://xxx.supabase.co/auth/v1/signup' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "phone": "491512223334444", - "password": "some-password" -}' -``` - - - - -The user will now receive an SMS with a 6-digit pin that you will need to receive from them within 60-seconds before they can login to their account. - - - - -You should present a form to the user so they can input the 6 digit pin, then send it along with the phone number to `verifyOtp`: - -```js -const { - data: { session }, - error, -} = await supabase.auth.verifyOtp({ - phone: '491512223334444', - token: '123456', -}) -``` - - - - -You should present a form to the user so they can input the 6 digit pin, then send it along with the phone number to `verifyPhoneOtp`: - -```kotlin -supabase.auth.verifyPhoneOtp( - type = OtpType.Phone.SMS, - phone = "+13334445555", - token = "123456" -) -``` - - - - -```bash -curl -X POST 'https://xxx.supabase.co/auth/v1/verify' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "type": "sms", - "phone": "491512223334444", - "token": "123456" -}' -``` - - - - -If successful the user will now be logged in and you should receive a valid session like: - -```json -{ - "access_token": "eyJxxx...", - "token_type": "bearer", - "expires_in": 3600, - "refresh_token": "yyy..." -} -``` - -The access token can be sent in the Authorization header as a Bearer token for any CRUD operations on supabase-js. See our guide on [Row Level Security](/docs/guides/auth#row-level-security) for more info on restricting access on a user basis. - -Also now that the mobile has been verified, the user can use the number and password to sign in without needing to verify their number each time: - - - - -```js -const { user, error } = await supabase.auth.signInWithPassword({ - phone: '491512223334444', - password: 'some-password', -}) -``` - - - - -```kotlin -supabase.auth.signInWith(Phone) { - phone = "+13334445555" - password = "some-password" -} -``` - - - - -```bash -curl -X POST 'https://xxx.supabase.co/auth/v1/token?grant_type=password' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "phone": "491512223334444", - "password": "some-password" -}' -``` - - - - -### Using OTP as a passwordless sign-in mechanism - -In this scenario you are granting your user's the ability to login to their account without needing to set a password on their account, all they have to do to log in is verify their mobile each time using the OTP. - - - - -In JavaScript we can use the `signIn` method with a single parameter: `phone` - -```js -const { data, error } = await supabase.auth.signInWithOtp({ - phone: '491512223334444', -}) -``` - - - - -In Kotlin, we can use the `signInWith(OTP)` method and change the property `phone` - -```kotlin -supabase.auth.signInWith(OTP) { - phone = "+13334445555" -} -``` - - - - -```bash -curl -X POST 'https://xxx.supabase.co/auth/v1/otp' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "phone": "491512223334444" -}' -``` - - - - -The second step is the same as the previous section, you need to collect the 6-digit pin from the user and pass it along with their phone number to the verify method: - - - - -```js -const { - data: { session }, - error, -} = await supabase.auth.verifyOtp({ - phone: '491512223334444', - token: '123456', -}) -``` - - - - -```kotlin -supabase.auth.verifyPhoneOtp( - type = OtpType.Phone.SMS, - phone = "+13334445555", - token = "123456" -) -``` - - - - -```bash -curl -X POST 'https://xxx.supabase.co/auth/v1/verify' \ --H "apikey: SUPABASE_KEY" \ --H "Content-Type: application/json" \ --d '{ - "type": "sms", - "phone": "491512223334444", - "token": "123456" -}' -``` - - - - -and the response should also be the same as above: - -```json -{ - "access_token": "eyJxxx...", - "token_type": "bearer", - "expires_in": 3600, - "refresh_token": "yyy..." -} -``` - -The user does not have a password therefore will need to sign in via this method each time they want to access your service. - -## Resources - -- [Vonage Signup](https://dashboard.nexmo.com/sign-up) -- [Supabase Dashboard](https://supabase.com/dashboard) -- [Supabase Row Level Security](/docs/guides/auth#row-level-security) - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/cli/managing-environments.mdx b/apps/docs/pages/guides/cli/managing-environments.mdx index 17428200b1b..a373ab6f507 100644 --- a/apps/docs/pages/guides/cli/managing-environments.mdx +++ b/apps/docs/pages/guides/cli/managing-environments.mdx @@ -11,7 +11,13 @@ export const meta = { This guide shows you how to set up your local Supabase development environment that integrates with GitHub Actions to automatically test and release schema changes to staging and production Supabase projects. -![Deploy migration](/docs/img/guides/cli/cicd-github.png) + ## Set up a local environment @@ -153,7 +159,13 @@ Without the `-f` file flag, the output is written to stdout by default. In a production environment, we recommend using a CI/CD pipeline to deploy new migrations with GitHub Actions rather than deploying from your local machine. -![Deploy migration](/docs/img/guides/cli/cicd-github.png) + This example uses two Supabase projects, one for production and one for staging. diff --git a/apps/docs/pages/guides/database/connecting-to-postgres.mdx b/apps/docs/pages/guides/database/connecting-to-postgres.mdx index e5ea17fb32c..9b836b3b3f5 100644 --- a/apps/docs/pages/guides/database/connecting-to-postgres.mdx +++ b/apps/docs/pages/guides/database/connecting-to-postgres.mdx @@ -12,12 +12,12 @@ export const meta = { Supabase provides several options for programmatically connecting to your Postgres database: 1. Programmatic access using the Data APIs -1. Direct connections using the built-in Postgres connection system 1. Connection pooling for scalable connections +1. Direct connections using the built-in Postgres connection system -Starting on , direct database connections will start resolving to an IPv6 address. If you connect to your database using the REST and GraphQL endpoints, this doesn't affect you. If you use the database connection string, see the GitHub discussion on [how to prepare for IPv6](https://github.com/orgs/supabase/discussions/17817). +Direct database connections will start resolving to an IPv6 address. Starting from January 26, 2024, projects will be gradually migrated to this behavior. If you connect to your database using the REST or GraphQL endpoints, this doesn't affect you. If you use the database connection string, see the GitHub discussion on [how to prepare for IPv6](https://github.com/orgs/supabase/discussions/17817). @@ -31,37 +31,61 @@ Supabase provides auto-updating Data APIs. These are the easiest way to get star ## Connection pooler -Every Supabase project comes with a connection pooler for managing connections to your Postgres database. +A "connection pool" is a system (external to Postgres) which manages Postgres connections. -A connection pooler is useful for managing a large number of _temporary_ connections - for example, if you are using Prisma, Drizzle, Kysely, or anything deployed to a Serverless environment (AWS Lambdas or Edge Functions). Supabase's connection pooler also supports ipv4 out of the box. +When a client makes a request, the pooler "allocates" an available connection to the client. When the client transaction or session is completed the connection is returned to the pool and is free to be used by another client. + +
+ + +
Connecting to the database directly vs using a Connection Pooler
+
+ +Every Supabase project comes with a connection pooler for managing connections to your database. The pooler provides 2 important services: + +1. It manages connections for applications that connect and disconnect from the database frequently. For example, serverless functions and ORMs such as Prisma, Drizzle, and Kysely often make and drop connections to the database. If they connected directly each time, they would quickly exhaust your database server's memory. To connect to your database efficiently with such tools, you need a pooler. +2. It provides an IPv4 endpoint to connect to your database, which you can use if your network provider doesn't support IPv6. This is unlike the direct database connection, which resolves to an IPv6 address, unless you've enabled the [IPv4 add-on](https://supabase.com/docs/guides/platform/ipv4-address). + + + +The connection pooler is available in 2 modes: `Transaction` and `Session`. + + + +Transaction mode is recommended if you are connecting from serverless environments. A connection is assigned to the client for the duration of a transaction. Two consecutive transactions from the same client can be executed over two different connections. Some session-based Postgres features such as prepared statements are _not available_ with this option. + +Session mode is similar to connecting to your database directly. There is full support for prepared statements in this mode. When a new client connects, a connection is assigned to the client until it disconnects. You might run into pooler connection limits since the connection is held till the client disconnects. You can find the connection pool config in the [Database settings](/dashboard/project/_/settings/database) inside the dashboard: 1. Go to the `Settings` section. 2. Click `Database`. -3. Under `Connect to your database via connection pooling`, copy your `Connection string`. +3. Under `Connection string`, make sure `Use connection pooling` is checked. Copy the URI. ## Direct connections -You can use a direct connection to connect directly to your Postgres database. By default, this connection uses ipv6, which isn't supported by all network providers. If you need an ipv4 address, use the [connection pooler](#connection-pooler) instead. +You can also use a direct connection to connect directly to your Postgres database. By default, you can connect to it via IPv6, which isn't supported by all networks. If you need an IPv4 address, use the [connection pooler](#connection-pooler) instead or enable the [IPv4 add-on](https://supabase.com/docs/guides/platform/ipv4-address). You can find the direct connection string in the [Database settings](https://supabase.com/dashboard/project/_/settings/database) inside the dashboard: 1. Go to the `Settings` section. 2. Click `Database`. -3. Under `Connect to your database directly`, copy your `Connection string`. +3. Under `Connection string`, make sure `Use connection pooling` is unchecked. Copy the URI. ## Choosing a connection method -- The Data APIs provide programmatic access and have [built-in connection pooling](https://postgrest.org/en/stable/references/connection_pool.html). You can use these for all browser and application interactions. We recommend using these wherever possible. -- A "connection pooler" is a tool which keeps connections "alive". You should use this for serverless functions and tools which disconnect from the database frequently, like Prisma, Drizzle, Kysely, etc. You should also use this if your network doesn't support ipv6. -- A "direct connection" is Postgres' native connection system. You can use this for tools which are always alive, such as long-running server, as long as your network supports ipv6. - -Why would you use a connection pool? Primarily because the way that Postgres handles connections isn't very scalable for a large number of _temporary_ connections. You can use these simple questions to determine which connection method to use: - -- Are you on a network that doesn't support ipv6? Use the connection pooler. -- Are you connecting to your database and then _disconnecting_ immediately (e.g. a serverless environment)? Use the connection pooler. -- Are you connecting to a database and _maintaining_ a connection, and does your network support ipv6? If yes, use a direct connection. +- Are you performing operations supported by the Data APIs? We recommend using the APIs. +- Are you connecting to your database and then _disconnecting_ immediately (for example, are you running your code in a serverless environment)? Use the connection pooler in transaction mode. +- Are you on a network that doesn't support IPv6? Use the connection pooler. (If you need a long-lived connection, for example for prepared statements, use session mode.) +- Are you connecting to your database and _maintaining_ a connection, and does your network support IPv6? Use a direct connection. ## Connecting with SSL @@ -108,7 +132,7 @@ You can obtain your connection info and Server root certificate from your applic - ```ts + ```ts schema.ts import { pgTable, serial, text, varchar } from "drizzle-orm/pg-core"; export const users = pgTable('users', { @@ -126,24 +150,25 @@ You can obtain your connection info and Server root certificate from your applic - Connect to your database using the Connection Pooler for serverless environments, and the Direct Connection for long-running servers. + Connect to your database using the Connection Pooler. + + In your [`Database Settings`](https://supabase.com/dashboard/project/_/settings/database), make sure `Use connection pooler` is checked, then copy the URI and save it as the `DATABASE_URL` environment variable. Remember to replace the password placeholder with your actual database password. - ```ts + ```ts db.ts + import 'dotenv/config' + import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' - import { users } from './schema' const connectionString = process.env.DATABASE_URL // Disable prefetch as it is not supported for "Transaction" pool mode - const client = postgres(connectionString, { prepare: false }) - const db = drizzle(client); - - const allUsers = await db.select().from(users); + export const client = postgres(connectionString, { prepare: false }) + export const db = drizzle(client); ``` @@ -168,7 +193,16 @@ You can obtain your connection info and Server root certificate from your applic - ![Register a new postgres server.](/docs/img/guides/database/register-server-pgAdmin.png) + + @@ -184,7 +218,7 @@ You can obtain your connection info and Server root certificate from your applic - ![Name Postgres Server.](/docs/img/guides/database/name-pg-server.png) + ![Name Postgres Server.](/docs/img/guides/database/connecting-to-postgres/pgadmin/name-pg-server.png) @@ -194,13 +228,13 @@ You can obtain your connection info and Server root certificate from your applic - Add the connection info. You can use the "Direct connection" config, which you can find in your Supabase dashboard. + Add the connection info. Go to your [`Database Settings`](https://supabase.com/dashboard/project/_/settings/database). Make sure `Use connection pooling` is enabled. Switch the connection mode to `Session` and copy your connection parameters. - ![Add Connection Info.](/docs/img/guides/database/add-pg-server-conn-info.png) + ![Add Connection Info.](/docs/img/guides/database/connecting-to-postgres/pgadmin/add-pg-server-conn-info.png) @@ -216,7 +250,7 @@ You can obtain your connection info and Server root certificate from your applic - ![Add Connection Info.](/docs/img/guides/database/add-ssl-config-parameter.png) + ![Add Connection Info.](/docs/img/guides/database/connecting-to-postgres/pgadmin/database-settings-host.png) @@ -228,10 +262,12 @@ You can obtain your connection info and Server root certificate from your applic [`psql`](https://www.postgresql.org/docs/current/app-psql.html) is a command-line tool that comes with Postgres. -Assuming you've downloaded your SSL certificate to `$HOME/Downloads/prod-supabase.cer`, and your host address is `db.ref.supabase.co` you connect to your database via SSL: +Download your [SSL certificate](#connecting-with-ssl) to `/path/to/prod-supabase.cer`. + +Find your connection settings. Go to your [`Database Settings`](https://supabase.com/dashboard/project/_/settings/database) and make sure `Use connection pooling` is checked. Change the connection mode to `Session`, and copy the parameters into the connection string: ```shell -psql "sslmode=verify-full sslrootcert=$HOME/Downloads/prod-supabase.cer host=db.ref.supabase.co dbname=postgres user=postgres" +psql "sslmode=verify-full sslrootcert=/path/to/prod-supabase.cer host=[CLOUD_PROVIDER]-0-[REGION].pooler.supabase.com dbname=postgres user=postgres.[PROJECT_REF]" ``` ### Connecting with Prisma @@ -266,7 +302,9 @@ Refer to our [Prisma integration guide](https://supabase.com/partners/integratio - Create a `db.js` file with the connection details. Use the Connection Pooler for serverless environments, and the Direct Connection for long-running servers. + Create a `db.js` file with the connection details. + + To get your connection details, go to your [`Database Settings`](https://supabase.com/dashboard/project/_/settings/database). Make sure `Use connection pooling` is enabled. Choose `Transaction Mode` if you're on a platform with transient connections, such as a serverless function, and `Session Mode` if you have a long-lived connection. Copy the URI and save it as the environment variable `DATABASE_URL`. @@ -316,39 +354,30 @@ Refer to our [Prisma integration guide](https://supabase.com/partners/integratio -## How connection pooling works +## Troubleshooting Supavisor -A "connection pool" is a system (external to Postgres) which manages Postgres connections. +Connection pooler logs are found [here](https://supabase.com/dashboard/project/_/logs/pooler-logs). The following are common errors and their solutions: -When a client makes a request, the pooler "allocates" an available connection to the client. When the client transaction or session is completed the connection is returned to the pool and is free to be used by another client. +- Max client connections reached + This error happens when the number of connections to Supavisor is more than [the allowed limit of your compute add-on](https://supabase.com/docs/guides/platform/compute-add-ons). Upgrade the database to a higher compute add-on to increase the number of Supavisor connections. -![Connection pooling](/docs/img/guides/database/connection-pool.png) +- Connection failed \{:error, :eaddrnotavail} to 'db.xxx.supabase.co':5432 + Supavisor cannot connect to the customer database. This is usually caused if the target database is unable to respond. -There are several pool modes, each handling connections differently: +- Connection failed \{:error, :nxdomain} to 'db.xxx.supabase.co':5432 + Supavisor cannot connect to the customer database. This is usually caused if the target database is unable to respond. -### Session +- Connection closed when state was authentication + This error happens when either the database doesn’t exist or if the user doesn't have the right credentials. -When a new client connects, a connection is assigned to the client until it disconnects. Afterward, the connection is returned back to the pool. +- Subscribe error: \{:error, :worker_not_found} + This log event is emitted when the client tries to connect to the database, but Supavisor does not have the necessary information to route the connection. Try reconnecting to the database as it can take some time for the project information to propagate to Supavisor. -All Postgres features can be used with this option. +- Subscribe error: \{:error, \{:badrpc, \{:error, \{:erpc, :timeout}}}} + This is a timeout error when the communication between different Supavisor nodes takes longer than expected. Try reconnecting to the database. -### Transaction - -This is the suggested option for serverless functions. A connection is assigned to the client for the duration of a transaction. Two consecutive transactions from the same client could be executed over two different connections. Some session-based Postgres features such as prepared statements are not available with this option. - -### Statement - -This is the most granular option. Connections are returned to the pool after every statement. Transactions with multiple statements are not allowed. This is best used when `AUTOCOMMIT` is in use. - -### Supavisor vs PgBouncer - -Supabase previously used PgBouncer for connection pooling. We have now deprecated PgBouncer in favor of Supavisor. Supavisor is available on all new and existing projects. - -[Supavisor](https://github.com/supabase/supavisor) is a new connection pooler by Supabase that runs on a high-availability cluster, segregated from your database. This means more resources are available for your database. No Application changes are required to switch from PgBouncer to Supavisor, you simply need to choose the new connection string from the "Connection Pooling" section on [Database settings](https://supabase.com/dashboard/project/_/settings/database). - -PgBouncer is now deprecated and is in the process of being removed from the platform, you can [see full details and timelines of the deprecation on Github Discussions](https://github.com/orgs/supabase/discussions/17817). Additionally, your Supabase database domain (db.projectref.supabase.co) will start resolving to an IPv6 address. No changes are required if your network supports IPv6. Otherwise, update your applications to use Supavisor which will continue to support IPv4 connections. - -[Read the full pgBouncer and IPv4 deprecation details](https://github.com/orgs/supabase/discussions/17817). +- 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. export const Page = ({ children }) => diff --git a/apps/docs/pages/guides/database/extensions.mdx b/apps/docs/pages/guides/database/extensions.mdx index daaf0921efb..8a55c4f4a5c 100644 --- a/apps/docs/pages/guides/database/extensions.mdx +++ b/apps/docs/pages/guides/database/extensions.mdx @@ -57,6 +57,10 @@ In addition to the pre-configured extensions, you can also install your own SQL +### Upgrade Extensions + +If a new version of an extension becomes available on Supabase, you need to initiate a software upgrade in the [Infrastructure Settings](https://supabase.com/dashboard/project/_/settings/infrastructure) to access it. Software upgrades can also be initiated by restarting your server in the [General Settings](https://supabase.com/dashboard/project/_/settings/general). + ### Full list of extensions Supabase is pre-configured with over 50 extensions. You can also install your own SQL extensions directly in the database through our SQL editor. diff --git a/apps/docs/pages/guides/database/extensions/index_advisor.mdx b/apps/docs/pages/guides/database/extensions/index_advisor.mdx index 282e429e131..2330ea15892 100644 --- a/apps/docs/pages/guides/database/extensions/index_advisor.mdx +++ b/apps/docs/pages/guides/database/extensions/index_advisor.mdx @@ -6,7 +6,7 @@ export const meta = { description: 'Automatically optimize SQL queries', } -[Index advisor](https://github.com/supabase/index_advisor) is a Postgres extension for recommending indexes to improve query performance. +[Index advisor](https://database.dev/olirice/index_advisor) is a Postgres extension for recommending indexes to improve query performance. For example: @@ -154,7 +154,8 @@ from ## Resources -- Official [`index_advisor` GitHub Repository](https://github.com/supabase/index_advisor) +- dbdev [`index_advisor`](https://database.dev/olirice/index_advisor) docs +- dbdev [Github Repository](https://github.com/supabase/dbdev) export const Page = ({ children }) => diff --git a/apps/docs/pages/guides/database/extensions/pg_cron.mdx b/apps/docs/pages/guides/database/extensions/pg_cron.mdx index 02c15a88180..486d11ae6aa 100644 --- a/apps/docs/pages/guides/database/extensions/pg_cron.mdx +++ b/apps/docs/pages/guides/database/extensions/pg_cron.mdx @@ -147,6 +147,24 @@ Unschedules a job called `'nightly-vacuum'` select cron.unschedule('nightly-vacuum'); ``` +### Viewing previously ran jobs + +View the last ten jobs that have ran + +```sql +select + * +from cron.job_run_details +order by start_time desc +limit 10; +``` + + + +The records in cron.job_run_details are not cleaned automatically which will take up disk space in your datbabase. + + + ## Resources - [pg_cron GitHub Repository](https://github.com/citusdata/pg_cron) diff --git a/apps/docs/pages/guides/database/extensions/plv8.mdx b/apps/docs/pages/guides/database/extensions/plv8.mdx index ae1fd4b018d..3ade38a3688 100644 --- a/apps/docs/pages/guides/database/extensions/plv8.mdx +++ b/apps/docs/pages/guides/database/extensions/plv8.mdx @@ -85,6 +85,13 @@ select function_name(); const { data, error } = supabase.rpc('function_name') ``` + + + +```kotlin +val data = supabase.postgrest.rpc("function_name") +``` + diff --git a/apps/docs/pages/guides/database/extensions/postgis.mdx b/apps/docs/pages/guides/database/extensions/postgis.mdx index 158d9899680..c85e0c8f4dd 100644 --- a/apps/docs/pages/guides/database/extensions/postgis.mdx +++ b/apps/docs/pages/guides/database/extensions/postgis.mdx @@ -141,6 +141,25 @@ await supabase.from('restaurants').insert([ ]); ``` + + + +```kotlin +@Serializable +data class Restaurant( + val name: String, + val location: String //you could also use a custom type with a custom serializer for more type safety +) +``` + +```kotlin +val data = supabase.from("restaurants").insert(listOf( + Restaurant("Supa Burger", "POINT(-73.946823 40.807416)"), + Restaurant("Supa Pizza", "POINT(-73.94581 40.807475)"), + Restaurant("Supa Taco", "POINT(-73.945826 40.80629)"), +)) +``` + @@ -198,6 +217,19 @@ final data = await supabase.rpc('nearby_restaurants',params: { }); ``` + + + +```kotlin +val data = supabase.postgrest.rpc( + function = "nearby_restaurants", + parameters = buildJsonObject { //You can put here any serializable object including your own classes + put("lat", 40.807313) + put("lon", -73.946713) + } +) +``` + @@ -284,6 +316,21 @@ final data = await supabase.rpc('restaurants_in_view', params: { }); ``` + + + +```kotlin +val data = supabase.postgrest.rpc( + function = "restaurants_in_view", + parameters = buildJsonObject { //You can put here any serializable object including your own classes + put("min_lat", 40.807) + put("min_lon", -73.946) + put("max_lat", 40.808) + put("max_lon", -73.945) + } +) +``` + diff --git a/apps/docs/pages/guides/database/extensions/uuid-ossp.mdx b/apps/docs/pages/guides/database/extensions/uuid-ossp.mdx index 7cc9a02f4dd..30e18a67355 100644 --- a/apps/docs/pages/guides/database/extensions/uuid-ossp.mdx +++ b/apps/docs/pages/guides/database/extensions/uuid-ossp.mdx @@ -88,7 +88,6 @@ create table contacts ( id uuid default uuid_generate_v4(), first_name text, last_name text, - primary key (id) ); ``` diff --git a/apps/docs/pages/guides/database/extensions/wrappers/[slug].tsx b/apps/docs/pages/guides/database/extensions/wrappers/[slug].tsx index 2363a4ce7d0..64e70309bb7 100644 --- a/apps/docs/pages/guides/database/extensions/wrappers/[slug].tsx +++ b/apps/docs/pages/guides/database/extensions/wrappers/[slug].tsx @@ -80,11 +80,12 @@ interface WrappersDocsProps { title: string description?: string } + editLink?: string } -export default function WrappersDocs({ source, meta }: WrappersDocsProps) { +export default function WrappersDocs({ source, meta, editLink }: WrappersDocsProps) { return ( - + ) @@ -101,10 +102,9 @@ export const getStaticProps: GetStaticProps = async ({ params } const { remoteFile, meta } = page - - const response = await fetch( - `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}` - ) + const repoPath = `${org}/${repo}/${branch}/${docsDir}/${remoteFile}` + const repoEditPath = `${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}` + const response = await fetch(`https://raw.githubusercontent.com/${repoPath}`) const source = await response.text() @@ -119,7 +119,6 @@ export const getStaticProps: GetStaticProps = async ({ params if (hostname !== placeholderHostname || pathname === '/') { return url } - const relativePage = ( pathname.endsWith('.md') ? pathname.replace(/\.md$/, '') @@ -166,7 +165,7 @@ export const getStaticProps: GetStaticProps = async ({ params }, }) - return { props: { source: mdxSource, meta } } + return { props: { source: mdxSource, meta, editLink: repoEditPath } } } export const getStaticPaths: GetStaticPaths = async () => { diff --git a/apps/docs/pages/guides/database/full-text-search.mdx b/apps/docs/pages/guides/database/full-text-search.mdx index 3c00b29a21d..a4aebb2c62b 100644 --- a/apps/docs/pages/guides/database/full-text-search.mdx +++ b/apps/docs/pages/guides/database/full-text-search.mdx @@ -136,6 +136,17 @@ final result = await client .eq('title', 'Harry'); ``` + + + +```kotlin +val data = supabase.from("books").select { + filter { + eq("title", "Harry") + } +} +``` + @@ -174,6 +185,17 @@ final result = await client .textSearch('title', "'Harry'"); ``` + + + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("title", "'Harry'", TextSearchType.NONE) + } +} +``` + @@ -219,6 +241,17 @@ final result = await client .textSearch('description', "'big'"); ``` + + + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("description", "'big'", TextSearchType.NONE) + } +} +``` + @@ -287,6 +320,23 @@ final result = await client .textSearch('title_description', "little") ``` + + + +```sql +create function title_description(books) returns text as $$ + select $1.title || ' ' || $1.description; +$$ language sql immutable; +``` + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("title_description", "title", TextSearchType.NONE) + } +} +``` + @@ -341,6 +391,17 @@ final result = await client .textSearch('description', "'little' & 'big'"); ``` + + + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("description", "'title' & 'big'", TextSearchType.NONE) + } +} +``` + @@ -394,6 +455,17 @@ final result = await client .textSearch('description', "'little' | 'big'"); ``` + + + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("description", "'title' | 'big'", TextSearchType.NONE) + } +} +``` + @@ -494,6 +566,17 @@ final result = await client .textSearch('fts', "'little' & 'big'"); ``` + + + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("fts", "'title' & 'big'", TextSearchType.NONE) + } +} +``` + @@ -552,6 +635,17 @@ final result = await client .textSearch('description', "'big' <-> 'dreams'"); ``` + + + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("description", "'big' <-> 'dreams'", TextSearchType.NONE) + } +} +``` + @@ -595,6 +689,17 @@ final result = await client .textSearch('description', "'year' <2> 'school'"); ``` + + + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("description", "'year' <2> 'school'", TextSearchType.NONE) + } +} +``` + @@ -641,6 +746,17 @@ final result = await client .textSearch('description', "'big' & !'little'"); ``` + + + +```kotlin +val data = supabase.from("books").select { + filter { + textSearch("description", "'big' & !'little'", TextSearchType.NONE) + } +} +``` + diff --git a/apps/docs/pages/guides/database/functions.mdx b/apps/docs/pages/guides/database/functions.mdx index cd6c4b5447b..c6fcf35e80f 100644 --- a/apps/docs/pages/guides/database/functions.mdx +++ b/apps/docs/pages/guides/database/functions.mdx @@ -96,6 +96,15 @@ final data = await supabase Reference: [rpc()](../../reference/dart/rpc) + + + +```kotlin +val data = supabase.postgrest.rpc("hello_world") +``` + +Reference: [rpc()](../../reference/kotlin/rpc) + @@ -209,6 +218,17 @@ final data = await supabase .eq('id', 1); ``` + + + +```kotlin +val data = supabase.postgrest.rpc("get_planets") { + filter { + eq("id", 1) + } +} +``` + @@ -263,6 +283,18 @@ final data = await supabase .rpc('add_planet', params: { 'name': 'Jakku' }); ``` + + + +```kotlin +val data = supabase.postgrest.rpc( + function = "add_planet", + parameters = buildJsonObject { //You can put here any serializable object including your own classes + put("name", "Jakku") + } +) +``` + diff --git a/apps/docs/pages/guides/database/joins-and-nesting.mdx b/apps/docs/pages/guides/database/joins-and-nesting.mdx index c1f1ae42529..9848d66b6e9 100644 --- a/apps/docs/pages/guides/database/joins-and-nesting.mdx +++ b/apps/docs/pages/guides/database/joins-and-nesting.mdx @@ -137,6 +137,13 @@ const countriesWithCities: CountriesWithCities = data final data = await supabase.from('countries').select('id, name, cities(id, name)'); ``` + + + +```kotlin +val data = supabase.from("countries").select(Columns.raw("id, name, cities(id, name)")) +``` + @@ -217,6 +224,13 @@ const { data, error } = await supabase.from('teams').select(` final data = await supabase.from('teams').select('id, team_name, users(id, name)'); ``` + + + +```kotlin +val data = supabase.from("teams").select(Columns.raw("id, team_name, users(id, name)")); +``` + diff --git a/apps/docs/pages/guides/database/json.mdx b/apps/docs/pages/guides/database/json.mdx index ddaf6520db1..5b9aba8d385 100644 --- a/apps/docs/pages/guides/database/json.mdx +++ b/apps/docs/pages/guides/database/json.mdx @@ -179,6 +179,35 @@ const { data, error } = await supabase.from('books').insert([ ]) ``` + + + +```kotlin +@Serializable +data class BookMetadata( + val description: String, + val price: Double, + val ages: List +) + +@Serializable +data class Book( + val title: String, + val author: String, + val metadata: BookMetadata +) +``` + +```kotlin +val data = supabase.from("books").insert(listOf( + Book("The Poky Little Puppy", "Janette Sebring Lowrey", BookMetadata("Puppy is slower than other, bigger animals.", 5.95, listOf(3, 6))), + Book("Tale of Peter Rabbit", "Beatrix Potter", BookMetadata("Rabbit eats some vegetables.", 4.49, listOf(2, 5))), + Book("Tootle", "Gertrude Crampton", BookMetadata("Little toy train has big dreams.", 3.99, listOf(2, 5))), + Book("Green Eggs and Ham", "Dr. Seuss", BookMetadata("Sam has changing food preferences and eats unusually colored food.", 7.49, listOf(4, 8))), + Book("Harry Potter and the Goblet of Fire", "J.K. Rowling", BookMetadata("Fourth year of school starts, big drama ensues.", 24.95, listOf(10, 99))) +)) +``` + @@ -220,6 +249,19 @@ const { data, error } = await supabase.from('books').select(` `) ``` + + + +```kotlin +val data = supabase.from("books").select(Columns.raw(""" + title, + description: metadata->description, + price: metadata->price, + low_age: metadata->ages->0, + high_age: metadata->ages->1 +""".trimIndent())) +``` + diff --git a/apps/docs/pages/guides/database/managing-passwords.mdx b/apps/docs/pages/guides/database/managing-passwords.mdx index 4fc8c3a8b36..52bda4776ae 100644 --- a/apps/docs/pages/guides/database/managing-passwords.mdx +++ b/apps/docs/pages/guides/database/managing-passwords.mdx @@ -8,7 +8,7 @@ export const meta = { Your Postgres database is the core of your Supabase project, so it's important that it has a strong, secure password at all times. -If you use special symbols in your postgres password, you must remember to [percent-encode](https://en.wikipedia.org/wiki/Percent-encoding) your password later if using the postgres connection string e.g. `postgresql://postgres:p%3Dword@db.cvwawazfelidkloqmbma.supabase.co:5432/postgres` +If you use special symbols in your postgres password, you must remember to [percent-encode](https://en.wikipedia.org/wiki/Percent-encoding) your password later if using the postgres connection string e.g. `postgresql://postgres.projectref:p%3Dword@aws-0-us-east-1.pooler.supabase.com:6543/postgres` ## Changing your project password diff --git a/apps/docs/pages/guides/database/partitions.mdx b/apps/docs/pages/guides/database/partitions.mdx index e3c7df72de2..f109f8a6b97 100644 --- a/apps/docs/pages/guides/database/partitions.mdx +++ b/apps/docs/pages/guides/database/partitions.mdx @@ -157,7 +157,7 @@ partition by list(country); create table customers_americas partition of customers - for values in ('US', 'CANADA') + for values in ('US', 'CANADA'); create table customers_asia partition of customers diff --git a/apps/docs/pages/guides/database/postgres/roles-superuser.mdx b/apps/docs/pages/guides/database/postgres/roles-superuser.mdx new file mode 100644 index 00000000000..1ec3f5064b7 --- /dev/null +++ b/apps/docs/pages/guides/database/postgres/roles-superuser.mdx @@ -0,0 +1,25 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'roles', + title: 'Roles, superuser access and unsupported operations', + slug: 'roles', +} + +Supabase provides the default `postgres` role to all instances deployed. Superuser access is not given as it allows destructive operations to be performed on the database. + +To ensure you are not impacted by this, additional privileges are granted to the `postgres` user to allow it to run some operations that are normally restricted to superusers. + +However, this does mean that some operations, that typically require `superuser` privileges, are not available on Supabase. These are documented below: + +## Unsupported operations + +- `CREATE ROLE ... WITH REPLICATION` +- `CREATE SUBSCRIPTION` +- `CREATE EVENT TRIGGER` +- `COPY ... FROM PROGRAM` +- `ALTER USER ... WITH SUPERUSER` + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/database/postgres/roles.mdx b/apps/docs/pages/guides/database/postgres/roles.mdx index 1d3b785205a..a04ce85a9cc 100644 --- a/apps/docs/pages/guides/database/postgres/roles.mdx +++ b/apps/docs/pages/guides/database/postgres/roles.mdx @@ -40,7 +40,7 @@ Your Postgres database is the core of your Supabase project, so it's important t ### Special symbols in passwords -If you use special symbols in your Postgres password, you must remember to [percent-encode](https://en.wikipedia.org/wiki/Percent-encoding) your password later if using the Postgres connection string e.g. `postgresql://postgres:p%3Dword@db.xxx.supabase.co:5432/postgres`. +If you use special symbols in your postgres password, you must remember to [percent-encode](https://en.wikipedia.org/wiki/Percent-encoding) your password later if using the postgres connection string e.g. `postgresql://postgres.projectref:p%3Dword@aws-0-us-east-1.pooler.supabase.com:6543/postgres` ### Changing your project password diff --git a/apps/docs/pages/guides/database/postgres/row-level-security.mdx b/apps/docs/pages/guides/database/postgres/row-level-security.mdx index e287ada68dd..f0f4b265359 100644 --- a/apps/docs/pages/guides/database/postgres/row-level-security.mdx +++ b/apps/docs/pages/guides/database/postgres/row-level-security.mdx @@ -260,7 +260,7 @@ const { data } = supabase .eq('user_id', userId) ``` -Even though this duplicates the contents of the Policy, Postgres can use the to construct a better query plan. +Even though this duplicates the contents of the Policy, Postgres can use the filter to construct a better query plan. #### Benchmarks diff --git a/apps/docs/pages/guides/database/postgres/setup-replication-external.mdx b/apps/docs/pages/guides/database/postgres/setup-replication-external.mdx new file mode 100644 index 00000000000..1633f40900b --- /dev/null +++ b/apps/docs/pages/guides/database/postgres/setup-replication-external.mdx @@ -0,0 +1,68 @@ +import { createDecipheriv } from 'crypto' +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + title: 'Replicate to another Postgres database using Logical Replication', + description: + 'Example to setup logical replication using publish-subscribe to a Postgres database outside of Supabase', + footerHelpType: 'postgres', +} + +For this example, you will need: + +- A Supabase project +- A PostgreSQL database (running v10 or newer) + +You will be running commands on both of these databases to publish changes from the Supabase database to the external database. + +1. Create a `publication` on the **Supabase database**: + +```sql +CREATE PUBLICATION example_pub; +``` + +2. Also on the **Supabase database**, create a `replication slot`: + +```sql +select pg_create_logical_replication_slot('example_slot', 'pgoutput'); +``` + +3. Now we will connect to our **external database** and subscribe to our `publication` Note: ): + + + +This will need a direct connection to your database and you can find the connection info in the [Dashboard](https://supabase.com/dashboard/project/_/settings/database). + +You will also need to ensure that ipv6 is supported by your replication destination. + + + +```sql +CREATE SUBSCRIPTION example_sub +CONNECTION 'host=db.oaguxblfdassqxvvwtfe.supabase.co user=postgres password=YOUR_PASS dbname=postgres' +PUBLICATION example_pub +WITH (copy_data = true, create_slot=false, slot_name=example_slot); +``` + + + +`create_slot` is set to `false` because `slot_name` is provided and the slot was already created in Step 2. +To copy data from before the slot was created, set `copy_data` to `true`. + + + +4. Add all the tables that you want replicated to the publication. + +```sql +ALTER PUBLICATION example_pub ADD TABLE example_table; +``` + +5. Check the replication status using `pg_stat_replication` + +```sql +select * from pg_stat_replication; +``` + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/database/postgres/triggers.mdx b/apps/docs/pages/guides/database/postgres/triggers.mdx index ff102f06153..81b2e67d74c 100644 --- a/apps/docs/pages/guides/database/postgres/triggers.mdx +++ b/apps/docs/pages/guides/database/postgres/triggers.mdx @@ -13,7 +13,7 @@ In Postgres, a trigger executes a set of actions automatically on table events s Creating triggers involve 2 parts: -1. A [Function](docs/guides/database/functions) which will be executed (called the Trigger Function) +1. A [Function](/docs/guides/database/functions) which will be executed (called the Trigger Function) 2. The actual Trigger object, with parameters around when the trigger should be run. An example of a trigger is: @@ -27,7 +27,7 @@ execute function trigger_function(); ## Trigger functions -A trigger function is a user-defined [Function](docs/guides/database/functions) that Postgres executes when the trigger is fired. +A trigger function is a user-defined [Function](/docs/guides/database/functions) that Postgres executes when the trigger is fired. ### Example trigger function diff --git a/apps/docs/pages/guides/database/sql-to-api.mdx b/apps/docs/pages/guides/database/sql-to-api.mdx index fa031db611b..9e8a486d0ae 100644 --- a/apps/docs/pages/guides/database/sql-to-api.mdx +++ b/apps/docs/pages/guides/database/sql-to-api.mdx @@ -13,7 +13,7 @@ Select a set of columns from a single table with where, order by, and limit clau ```sql select first_name, last_name, team_id, age from players -where age between 20 and 24 and team_id <> 'STL' +where age between 20 and 24 and team_id != 'STL' order by last_name, first_name desc limit 20; ``` @@ -52,7 +52,7 @@ Select all columns from a single table with a complex where clause: AND OR AND ```sql select * from players -where ((team_id = 'CHN' and age > 35) or (team_id <> 'CHN' and age is not null)); +where ((team_id = 'CHN' and age > 35) or (team_id != 'CHN' and age is not null)); ``` ```js diff --git a/apps/docs/pages/guides/database/tables.mdx b/apps/docs/pages/guides/database/tables.mdx index 9590fe48157..4fa2dc22b68 100644 --- a/apps/docs/pages/guides/database/tables.mdx +++ b/apps/docs/pages/guides/database/tables.mdx @@ -202,22 +202,25 @@ values -```sql -const { data, error } = await supabase - .from('movies') - .insert([{ +```js +const { data, error } = await supabase.from('movies').insert([ + { name: 'The Empire Strikes Back', - description: 'After the Rebels are brutally overpowered by the Empire on the ice planet Hoth, Luke Skywalker begins Jedi training with Yoda.' - }, { + description: + 'After the Rebels are brutally overpowered by the Empire on the ice planet Hoth, Luke Skywalker begins Jedi training with Yoda.', + }, + { name: 'Return of the Jedi', - description: 'After a daring mission to rescue Han Solo from Jabba the Hutt, the Rebels dispatch to Endor to destroy the second Death Star.' - }]) + description: + 'After a daring mission to rescue Han Solo from Jabba the Hutt, the Rebels dispatch to Endor to destroy the second Death Star.', + }, +]) ``` -```sql +```dart await supabase .from('movies') .insert([{ @@ -229,6 +232,26 @@ await supabase }]); ``` + + + +```kotlin +@Serializable +data class Movie( + val name: String, + val description: String +) +``` + +```kotlin +supabase + .from("movies") + .insert(listOf( + Movie("The Empire Strikes Back", "After the Rebels are brutally overpowered by the Empire on the ice planet Hoth, Luke Skywalker begins Jedi training with Yoda."), + Movie("Return of the Jedi", "After a daring mission to rescue Han Solo from Jabba the Hutt, the Rebels dispatch to Endor to destroy the second Death Star."), + )) +``` + diff --git a/apps/docs/pages/guides/functions.mdx b/apps/docs/pages/guides/functions.mdx deleted file mode 100644 index 65233fe00e2..00000000000 --- a/apps/docs/pages/guides/functions.mdx +++ /dev/null @@ -1,178 +0,0 @@ -import Layout from '~/layouts/DefaultGuideLayout' - -export const meta = { - id: 'functions', - title: 'Edge Functions', - description: 'Globally distributed TypeScript functions.', - subtitle: 'Globally distributed TypeScript functions.', - sidebar_label: 'Overview', - hide_table_of_contents: true, -} - -Edge Functions are server-side TypeScript functions, distributed globally at the edge—close to your users. They can be used for listening to webhooks or integrating your Supabase project with third-parties [like Stripe](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/stripe-webhooks). Edge Functions are developed using [Deno](https://deno.com), which offers a few benefits to you as a developer: - -- It is open source. -- It is portable. Supabase Edge Functions run locally, and on any other Deno-compatible platform (including self-hosted infrastructure). -- It is TypeScript first and supports WASM. -- Edge Functions are globally distributed for low-latency. - -
- -
- -## Examples - -Check out the [Edge Function Examples](https://github.com/supabase/supabase/tree/master/examples/edge-functions) in our GitHub repository. - -
- {examples.map((x) => ( -
- - - {x.description} - - -
- ))} -
- -export const examples = [ - { - name: 'With supabase-js', - description: 'Use the Supabase client inside your Edge Function.', - href: '/guides/functions/auth', - }, - { - name: 'Type-Safe SQL with Kysely', - description: - 'Combining Kysely with Deno Postgres gives you a convenient developer experience for interacting directly with your Postgres database.', - href: '/guides/functions/kysely-postgres', - }, - { - name: 'With CORS headers', - description: 'Send CORS headers for invoking from the browser.', - href: '/guides/functions/cors', - }, - { - name: 'React Native with Stripe', - description: 'Full example for using Supabase and Stripe, with Expo.', - href: 'https://github.com/supabase-community/expo-stripe-payments-with-supabase-functions', - }, - { - name: 'Flutter with Stripe', - description: 'Full example for using Supabase and Stripe, with Flutter.', - href: 'https://github.com/supabase-community/flutter-stripe-payments-with-supabase-functions', - }, - { - name: 'Building a RESTful Service API', - description: - 'Learn how to use HTTP methods and paths to build a RESTful service for managing tasks.', - href: 'https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/restful-tasks/index.ts', - }, - { - name: 'Working with Supabase Storage', - description: 'An example on reading a file from Supabase Storage.', - href: 'https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/read-storage/index.ts', - }, - { - name: 'Open Graph Image Generation', - description: 'Generate Open Graph images with Deno and Supabase Edge Functions.', - href: '/guides/functions/examples/og-image', - }, - { - name: 'OG Image Generation & Storage CDN Caching', - description: 'Cache generated images with Supabase Storage CDN.', - href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/og-image-with-storage-cdn', - }, - { - name: 'Get User Location', - description: `Get user location data from user's IP address.`, - href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/location', - }, - { - name: 'Cloudflare Turnstile', - description: `Protecting Forms with Cloudflare Turnstile.`, - href: '/guides/functions/examples/cloudflare-turnstile', - }, - { - name: 'Connect to Postgres', - description: `Connecting to Postgres from Edge Functions.`, - href: '/guides/functions/connect-to-postgres', - }, - { - name: 'Github Actions', - description: `Deploying Edge Functions with GitHub Actions.`, - href: '/guides/functions/examples/github-actions', - }, - { - name: 'Oak Server Middleware', - description: `Request Routing with Oak server middleware.`, - href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/oak-server', - }, - { - name: 'Hugging Face', - description: `Access 100,000+ Machine Learning models.`, - href: '/guides/ai/examples/huggingface-image-captioning', - }, - { - name: 'OpenAI', - description: `Using OpenAI in Edge Functions.`, - href: '/guides/ai/examples/openai', - }, - { - name: 'Stripe Webhooks', - description: `Handling signed Stripe Webhooks with Edge Functions.`, - href: '/guides/functions/examples/stripe-webhooks', - }, - { - name: 'Send emails', - description: `Send emails in Edge Functions with Resend.`, - href: '/guides/functions/examples/send-emails', - }, - { - name: 'Web Stream', - description: `Server-Sent Events in Edge Functions.`, - href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/streams', - }, - { - name: 'Puppeteer', - description: `Generate screenshots with Puppeteer.`, - href: '/guides/functions/examples/screenshots', - }, - { - name: 'Discord Bot', - description: `Building a Slash Command Discord Bot with Edge Functions.`, - href: '/guides/functions/examples/discord-bot', - }, - { - name: 'Telegram Bot', - description: `Building a Telegram Bot with Edge Functions.`, - href: '/guides/functions/examples/telegram-bot', - }, - { - name: 'Upload File', - description: `Process multipart/form-data.`, - href: 'https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/file-upload-storage', - }, - { - name: 'Upstash Redis', - description: `Build an Edge Functions Counter with Upstash Redis.`, - href: '/guides/functions/examples/upstash-redis', - }, - { - name: 'Rate Limiting', - description: `Rate Limiting Edge Functions with Upstash Redis.`, - href: '/guides/functions/examples/rate-limiting', - }, - { - name: 'Slack Bot Mention Edge Function', - description: `Slack Bot handling Slack mentions in Edge Function`, - href: '/guides/functions/examples/slack-bot-mention', - }, -] - -export const Page = ({ children }) => - -export default Page diff --git a/apps/docs/pages/guides/functions/[[...slug]].tsx b/apps/docs/pages/guides/functions/[[...slug]].tsx new file mode 100644 index 00000000000..8e41b131d3e --- /dev/null +++ b/apps/docs/pages/guides/functions/[[...slug]].tsx @@ -0,0 +1,28 @@ +import { type GetStaticPaths, type GetStaticProps, type InferGetStaticPropsType } from 'next' +import { MDXRemote } from 'next-mdx-remote' + +import components from '~/components' +import Layout from '~/layouts/DefaultGuideLayout' +import { getGuidesStaticPaths, getGuidesStaticProps } from '~/lib/docs' + +export const getStaticPaths = (async () => { + return getGuidesStaticPaths('functions') +}) satisfies GetStaticPaths + +export const getStaticProps = (async (args) => { + return getGuidesStaticProps('functions', args) +}) satisfies GetStaticProps + +export default function FunctionsGuide({ + frontmatter, + mdxSource, + editLink, +}: InferGetStaticPropsType) { + const { hideToc, ...meta } = frontmatter + + return ( + + + + ) +} diff --git a/apps/docs/pages/guides/getting-started.mdx b/apps/docs/pages/guides/getting-started.mdx index eb92573d7a0..af87b8fe2bd 100644 --- a/apps/docs/pages/guides/getting-started.mdx +++ b/apps/docs/pages/guides/getting-started.mdx @@ -1,6 +1,8 @@ import Layout from '~/layouts/DefaultLayout' import Link from 'next/link' -import { GlassPanel, IconPanel, Button, IconChevronRight } from 'ui' +import { Button, IconChevronRight } from 'ui' +import { GlassPanel } from 'ui-patterns/GlassPanel' +import { IconPanel } from 'ui-patterns/IconPanel' export const meta = { id: 'getting-started', diff --git a/apps/docs/pages/guides/getting-started/architecture.mdx b/apps/docs/pages/guides/getting-started/architecture.mdx index c5ab7d932e3..50c2e80fde4 100644 --- a/apps/docs/pages/guides/getting-started/architecture.mdx +++ b/apps/docs/pages/guides/getting-started/architecture.mdx @@ -21,7 +21,13 @@ Our goal at Supabase is to make _all_ of Postgres easy to use. That doesn’t me Each Supabase project consists of several tools: -![Supabase Architecture](/docs/img/supabase-architecture.png) + ### PostgreSQL (database) diff --git a/apps/docs/pages/guides/getting-started/quickstarts/laravel.mdx b/apps/docs/pages/guides/getting-started/quickstarts/laravel.mdx index 258dd3071e8..e711fbd6fde 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/laravel.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/laravel.mdx @@ -52,9 +52,13 @@ export const meta = { Go to [database.new](https://database.new) and create a new Supabase project. Save your database password securely. - When your project is up and running, navigate to the [database settings](https://supabase.com/dashboard/project/_/settings/database) to find the URI connection string. + When your project is up and running, navigate to the [database settings](https://supabase.com/dashboard/project/_/settings/database) to find the URI connection string. Make sure **Use connection pooling** is checked and **Session mode** is selected. Then copy the URI. Replace the password placeholder with your saved database password. - Laravel ships with a Postgres adapter out of the box, you can simply configure it via the environment variables. You can find the database URL in your [Supabase Dashboard](/dashboard/project/_/settings/database). + + + If your network supports IPv6 connections, you can also use the direct connection string. Uncheck **Use connection pooling** and copy the new URI. + + @@ -62,7 +66,7 @@ export const meta = { ```bash .env DB_CONNECTION=pgsql - DATABASE_URL=postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:6543/postgres + DATABASE_URL=postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:5432/postgres ```
@@ -123,7 +127,7 @@ export const meta = { - Run the development server. Go to http://127.0.0.1:8000 in a browser to see your application. You can also nvaigate to http://127.0.0.1:8000/register and http://127.0.0.1:8000/login to register and log in users. + Run the development server. Go to http://127.0.0.1:8000 in a browser to see your application. You can also navigate to http://127.0.0.1:8000/register and http://127.0.0.1:8000/login to register and log in users. diff --git a/apps/docs/pages/guides/getting-started/quickstarts/nextjs.mdx b/apps/docs/pages/guides/getting-started/quickstarts/nextjs.mdx index ab22feac195..c9c9d408b22 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/nextjs.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/nextjs.mdx @@ -97,11 +97,9 @@ export const meta = { ```ts app/notes/page.tsx import { createClient } from '@/utils/supabase/server'; - import { cookies } from 'next/headers'; export default async function Notes() { - const cookieStore = cookies() - const supabase = createClient(cookieStore); + const supabase = createClient(); const { data: notes } = await supabase.from("notes").select(); return
{JSON.stringify(notes, null, 2)}
diff --git a/apps/docs/pages/guides/getting-started/quickstarts/redwoodjs.mdx b/apps/docs/pages/guides/getting-started/quickstarts/redwoodjs.mdx index 37b59f5e735..a7035770b70 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/redwoodjs.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/redwoodjs.mdx @@ -22,7 +22,7 @@ export const meta = { - ![New Project](/docs/img/redwoodjs-qs-new-project.png) + ![New project for redwoodjs](/docs/img/guides/getting-started/quickstarts/redwoodjs/new-project.png)
@@ -48,7 +48,7 @@ export const meta = { - ![New Project](/docs/img/redwoodjs-qs-connection-strings-pool.png) + ![pooled connection for redwoodjs](/docs/img/guides/getting-started/quickstarts/redwoodjs/pooled-connection-strings.png) diff --git a/apps/docs/pages/guides/getting-started/quickstarts/ruby-on-rails.mdx b/apps/docs/pages/guides/getting-started/quickstarts/ruby-on-rails.mdx index 0ae0e7fb31e..5779a84cfc0 100644 --- a/apps/docs/pages/guides/getting-started/quickstarts/ruby-on-rails.mdx +++ b/apps/docs/pages/guides/getting-started/quickstarts/ruby-on-rails.mdx @@ -34,16 +34,20 @@ export const meta = { Go to [database.new](https://database.new) and create a new Supabase project. Save your database password securely. - When your project is up and running, navigate to the [database settings](https://supabase.com/dashboard/project/_/settings/database) to find the URI connection string. + When your project is up and running, navigate to the [database settings](https://supabase.com/dashboard/project/_/settings/database) to find the URI connection string. Make sure **Use connection pooling** is checked and **Session mode** is selected. Then copy the URI. Replace the password placeholder with your saved database password. - Rails ships with a Postgres adapter included, you can simply configure it via the environment variables. You can find the database URL in your [Supabase Dashboard](/dashboard/project/_/settings/database). + + + If your network supports IPv6 connections, you can also use the direct connection string. Uncheck **Use connection pooling** and copy the new URI. + + ```bash Terminal - export DATABASE_URL=postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:6543/postgres + export DATABASE_URL=postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:5432/postgres ``` diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-angular.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-angular.mdx index a21b2410a15..cae2ef8bba7 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-angular.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-angular.mdx @@ -230,7 +230,10 @@ export class AccountComponent implements OnInit { avatar_url: '', }) - constructor(private readonly supabase: SupabaseService, private formBuilder: FormBuilder) {} + constructor( + private readonly supabase: SupabaseService, + private formBuilder: FormBuilder + ) {} async ngOnInit(): Promise { await this.getProfile() @@ -421,7 +424,10 @@ export class AvatarComponent { @Output() upload = new EventEmitter() - constructor(private readonly supabase: SupabaseService, private readonly dom: DomSanitizer) {} + constructor( + private readonly supabase: SupabaseService, + private readonly dom: DomSanitizer + ) {} async downloadImage(path: string) { try { diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-expo-react-native.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-expo-react-native.mdx index 647a20c842b..c5b9b7279a7 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-expo-react-native.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-expo-react-native.mdx @@ -451,10 +451,10 @@ photos and videos. ### Additional dependency installation -You will need a file picker that works on the environment you will build the project for, we will use react-native-document-picker in this example. +You will need an image picker that works on the environment you will build the project for, we will use `expo-image-picker` in this example. ```bash -expo install react-native-document-picker +expo install expo-image-picker ``` ### Create an upload widget @@ -466,7 +466,7 @@ We can start by creating a new component: import { useState, useEffect } from 'react' import { supabase } from '../lib/supabase' import { StyleSheet, View, Alert, Image, Button } from 'react-native' -import DocumentPicker, { isCancel, isInProgress, types } from 'react-native-document-picker' +import * as ImagePicker from 'expo-image-picker' interface Props { size: number @@ -507,39 +507,43 @@ export default function Avatar({ url, size = 150, onUpload }: Props) { try { setUploading(true) - const file = await DocumentPicker.pickSingle({ - presentationStyle: 'fullScreen', - copyTo: 'cachesDirectory', - type: types.images, - mode: 'open', + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, // Restrict to only images + allowsMultipleSelection: false, // Can only select one image + allowsEditing: true, // Allows the user to crop / rotate their photo before uploading it + quality: 1, + exif: false, // We don't want nor need that data. }) - const photo = { - uri: file.fileCopyUri, - type: file.type, - name: file.name, + if (result.canceled || !result.assets || result.assets.length === 0) { + console.log('User cancelled image picker.') + return } - const formData = new FormData() - formData.append('file', photo) + const image = result.assets[0] + console.log('Got image', image) - const fileExt = file.name.split('.').pop() - const filePath = `${Math.random()}.${fileExt}` - - const { error } = await supabase.storage.from('avatars').upload(filePath, formData) - - if (error) { - throw error + if (!image.uri) { + throw new Error('No image uri!') // Realistically, this should never happen, but just in case... } - onUpload(filePath) + const arraybuffer = await fetch(image.uri).then((res) => res.arrayBuffer()) + + const fileExt = image.uri?.split('.').pop()?.toLowerCase() ?? 'jpeg' + const path = `${Date.now()}.${fileExt}` + const { data, error: uploadError } = await supabase.storage + .from('avatars') + .upload(path, arraybuffer, { + contentType: image.mimeType ?? 'image/jpeg', + }) + + if (uploadError) { + throw uploadError + } + + onUpload(data.path) } catch (error) { - if (isCancel(error)) { - console.warn('cancelled') - // User cancelled the picker, exit any dialogs or menus and move on - } else if (isInProgress(error)) { - console.warn('multiple pickers were opened, only the last will be considered') - } else if (error instanceof Error) { + if (error instanceof Error) { Alert.alert(error.message) } else { throw error @@ -583,7 +587,9 @@ const styles = StyleSheet.create({ }, noImage: { backgroundColor: '#333', - border: '1px solid rgb(200, 200, 200)', + borderWidth: 1, + borderStyle: 'solid', + borderColor: 'rgb(200, 200, 200)', borderRadius: 5, }, }) @@ -598,23 +604,22 @@ And then we can add the widget to the Account page: import Avatar from './Avatar' // ... - return ( +return ( + + {/* Add to the body */} - {/* Add to the body */} - - { - setAvatarUrl(url) - updateProfile({ username, website, avatar_url: url }) - }} - /> - - {/* ... */} + { + setAvatarUrl(url) + updateProfile({ username, website, avatar_url: url }) + }} + /> - ) -} + {/* ... */} + +) // ... ``` diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-flutter.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-flutter.mdx index 2bf0b17035e..e0d3f53b4fd 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-flutter.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-flutter.mdx @@ -47,7 +47,7 @@ Now that we have the dependencies installed let's setup deep links. Setting up deep links is required to bring back the user to the app when they click on the magic link to sign in. We can setup deep links with just a minor tweak on our Flutter application. -We will use `io.supabase.flutterquickstart` as the scheme, and `login-callback` as the host for our deep link in this example, but you can change it to whatever you would like. +We have to use `io.supabase.flutterquickstart` as the scheme. In this example, we will use `login-callback` as the host for our deep link, but you can change it to whatever you would like. First, add `io.supabase.flutterquickstart://login-callback/` as a new [redirect URL](https://supabase.com/dashboard/project/_/auth/url-configuration) in the Dashboard. diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-ionic-angular.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-ionic-angular.mdx index 1c082b246cf..19f4343bcda 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-ionic-angular.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-ionic-angular.mdx @@ -70,7 +70,10 @@ export interface Profile { export class SupabaseService { private supabase: SupabaseClient - constructor(private loadingCtrl: LoadingController, private toastCtrl: ToastController) { + constructor( + private loadingCtrl: LoadingController, + private toastCtrl: ToastController + ) { this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey) } @@ -252,7 +255,10 @@ export class AccountPage implements OnInit { email = '' - constructor(private readonly supabase: SupabaseService, private router: Router) {} + constructor( + private readonly supabase: SupabaseService, + private router: Router + ) {} ngOnInit() { this.getEmail() this.getProfile() @@ -319,7 +325,10 @@ import { SupabaseService } from './supabase.service' styleUrls: ['app.component.scss'], }) export class AppComponent { - constructor(private supabase: SupabaseService, private router: Router) { + constructor( + private supabase: SupabaseService, + private router: Router + ) { this.supabase.authChanges((_, session) => { console.log(session) if (session?.user) { @@ -474,7 +483,10 @@ export class AvatarComponent { @Output() upload = new EventEmitter() - constructor(private readonly supabase: SupabaseService, private readonly dom: DomSanitizer) { + constructor( + private readonly supabase: SupabaseService, + private readonly dom: DomSanitizer + ) { addIcons({ person }) } diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-svelte.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-svelte.mdx index f5634a1db9b..f250a8f7631 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-svelte.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-svelte.mdx @@ -121,11 +121,11 @@ Let's create a new component for that called `Account.svelte`. ```html src/lib/Account.svelte -
+
Email: {session.user.email}
- +
- +
-
- ``` diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-swift.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-swift.mdx index 7ab17387e78..f16baa2d46a 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-swift.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-swift.mdx @@ -274,7 +274,7 @@ struct AppView: View { } } .task { - for await state in await supabase.auth.onAuthStateChange() { + for await state in await supabase.auth.authStateChanges { if [.initialSession, .signedIn, .signedOut].contains(state.event) { isAuthenticated = state.session != nil } diff --git a/apps/docs/pages/guides/platform/backups.mdx b/apps/docs/pages/guides/platform/backups.mdx index ddf4e9e16da..a0515b0d6fc 100644 --- a/apps/docs/pages/guides/platform/backups.mdx +++ b/apps/docs/pages/guides/platform/backups.mdx @@ -16,6 +16,12 @@ export const mutuallyExclusiveNote = ( Database backups are an integral part of any disaster recovery plan. Disasters come in many shapes and sizes. It could be as simple as accidentally deleting a table column, the database crashing, or even a natural calamity wiping out the underlying hardware a database is running on. The risks and impact brought by these scenarios can never be fully eliminated, but only minimized or even mitigated. Having database backups is a form of insurance policy. They are essentially snapshots of the database at various points in time. When disaster strikes, database backups allow the project to be brought back to any of these points in time, therefore averting the crisis. + + +The Supabase team regularly monitors the status of backups. In case of any issues, you can [contact support](https://supabase.com/dashboard/support/new). Also you can check out our [status page](https://status.supabase.com/) at any time. + + + ## Frequency of backups When deciding how often a database should be backed up, the key business metric Recovery Point Objective (RPO) should be considered. RPO is the threshold for how much data, measured in time, a business could lose when disaster strikes. This amount is fully dependent on a business and its underlying requirements. A low RPO would mean that database backups would have to be taken at an increased cadence throughout the day. Each Supabase project has access to two forms of backups, Daily Backups and Point-in-Time Recovery (PITR). The agreed upon RPO would be a deciding factor in choosing which solution best fits a project. @@ -62,6 +68,8 @@ When selecting a backup to restore to, select the closest available one made bef The Dashboard will then prompt for a confirmation before proceeding with the restoration. The project will be inaccessible following this. As such, do ensure to allot downtime beforehand. This is dependent on the size of the database. The larger it is, the longer the downtime will be. Once the confirmation has been given, the underlying SQL of the chosen backup is then run against the project. The PostgreSQL utility [psql](https://www.postgresql.org/docs/current/app-psql.html) is used to facilitate the restoration. The Dashboard will display a notification once the restoration completes. +If your project is using subscriptions or replication slots, you will need to drop them prior to the restoration, and re-create them afterwards. The slot used by Realtime is exempted from this, and will be handled automatically. + {/* screenshot of the Dashboard of the project completing restoration */} ## Point-in-Time recovery diff --git a/apps/docs/pages/guides/platform/billing-faq.mdx b/apps/docs/pages/guides/platform/billing-faq.mdx new file mode 100644 index 00000000000..018ab1bd227 --- /dev/null +++ b/apps/docs/pages/guides/platform/billing-faq.mdx @@ -0,0 +1,178 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'billing-faq', + title: 'Billing FAQ', + description: + 'This documentation covers frequently asked questions around subscription plans, payments, invoices and billing in general', + subtitle: + 'This documentation covers frequently asked questions around subscription plans, payments, invoices and billing in general', +} + +## Organizations and projects + +#### What are organizations and projects? + +The Supabase Platform has "organizations" and "projects". An organization may contain multiple projects. Each project is a dedicated Supabase instance with all of its sub-services including Storage, Auth, Functions and Realtime. +Each organization only has a single subscription with a single plan (Free, Pro, Team or Enterprise). Project addons such as [Compute](https://supabase.com/docs/guides/platform/compute-add-ons), [Custom Domains](https://supabase.com/docs/guides/platform/custom-domains) and [PITR](https://supabase.com/docs/guides/platform/backups#point-in-time-recovery) are configured per project and are added to your organization subscription. +Read more on [how billing is organized](https://supabase.com/docs/guides/platform/org-based-billing#how-billing-is-organized). + +#### How many free projects can I have? + +You are entitled to two active free projects. Paused projects do not count towards your quota. Note that within an organization, we count the free project limits from all members that are either Owner or Admin. If you’ve got another organization member with the Admin or Owner role that has already exhausted their free project quota, you won’t be able to launch another free project in that organization. You can create simply create another free plan organization or change the role of the affected member in your [organization’s team settings](https://supabase.com/dashboard/org/_/team). + +#### Can I mix free and paid projects in a single organization? + +The subscription plan is set on the organization level and it is not possible to mix paid and non-paid projects inside a single organization. However, you can have a paid and a free organization and make use of the [self-serve project transfers](https://supabase.com/docs/guides/platform/project-transfer) to organize your projects. All projects in an organization benefit from the subscription plan. If your organization is on the Pro plan, all projects within the organization benefit from no project pausing, automated backups and so on. + +#### Can I transfer my projects to another organization? + +Yes, you can transfer your projects to another organization. You can find instructions on how to transfer your projects [here](https://supabase.com/docs/guides/platform/project-transfer). + +#### Can I transfer my credits to another organization? + +Yes, you can transfer the credits to another organization. Please submit a [support ticket](https://supabase.help). + +## Pricing + +See the [Pricing page](https://supabase.com/pricing) for details. + +#### Are there any charges for paused projects? + +No, we do not charge for paused projects. Compute hours are only counted for active instances. Paused projects do not incur any compute usage charges. + +#### How are multiple projects billed under a paid organization? + +We provide a dedicated server for every Supabase project. Each paid organization comes with $10 in Compute Credits to cover one project on the default instance size. Additional projects start at ~$10 a month (billed hourly). See how billing for compute works [here](https://supabase.com/docs/guides/platform/org-based-billing#usage-based-billing-for-compute). + +Running 3 projects in a Pro plan organization on the default micro instance: + +- $25 Pro plan +- $30 for 3 projects on the default compute instance size +- $10 Compute credits + ⇒ $45 / month + +Refer to our docs on [how billing works](https://supabase.com/docs/guides/platform/org-based-billing) for more [examples](https://supabase.com/docs/guides/platform/org-based-billing#billing-examples) and insights. + +#### How does compute billing work? + +Each Supabase project is a dedicated VM and Postgres database. By default, your instance runs on the Micro compute instance. You have the option to upgrade your compute size in your [Project settings](https://supabase.com/dashboard/project/_/settings/addons). See [Compute Add-ons](https://supabase.com/docs/guides/platform/compute-add-ons) for available options. + +When you change your compute size, there are no immediate upfront charges. Instead, you will be billed based on the compute runtime hours during your billing cycle reset. + +If you launch additional instances on your paid plan, we will add the corresponding compute hours to your final invoice. + +If you upgrade your project to a larger instance for 10 hours and then downgrade, you’ll only pay for the larger instance for the 10 hours of usage at the end of your billing cycle. You can see your current compute usage on your [organization’s usage page](https://supabase.com/dashboard/org/_/usage). + +Read more on [how billing for compute works](https://supabase.com/docs/guides/platform/org-based-billing#usage-based-billing-for-compute). + +#### 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. + +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). + +## Plans and subscriptions + +#### How do I change my subscription plan? + +Change your subscription plan in your [organization's billing settings](https://supabase.com/dashboard/org/_/billing). To upgrade to an Enterprise plan, complete the [Enterprise request form](https://forms.supabase.com/enterprise). + +#### What happens if I cancel my subscription? + +The organization is given credits for unused time on the subscription plan or addons. The credits will not expire and can be used again in the future. + +#### I mistakenly upgraded the wrong organization and then downgraded it. Could you please issue a refund? + +We can transfer the amount as credits to another organization of your choice. You can use these credits to upgrade the organization, or if you have already upgraded, the credits will be used to pay the next month's invoice. Please create a [support ticket](https://supabase.help) for this case. + +## Quotas and spend caps + +#### What will happen when I exceed the free plan quota? + +When you exceed the Free plan quota, we will monitor and notify you about the exceeding limits. It is important to take action at this point. If you continue to exceed the limits without reducing your usage, we will pause your projects. To avoid project pausing, you have two options: reduce your usage or upgrade to a paid plan. + +#### What will happen when I exceed the Pro plan quota and have the spend cap on? + +When you exceed the Pro plan quota, we will monitor and notify you about the exceeding limits. To unblock yourself, you can simply toggle off your spend cap in your [organization’s billing settings](https://supabase.com/dashboard/org/_/billing) to pay for over-usage beyond the Pro plans limits. If you continue to exceed the limits without reducing your usage or turning off the spend cap, we will pause your projects. + +#### How do I scale beyond the limits of my Pro plan? + +The Pro plan has a spend cap enabled by default to keep costs under control. If you want to scale beyond the plans included quota, ~~simply~~ switch off the spend cap to pay for additional usage beyond the plans included limits. You can toggle the spend cap in the [organization's billing settings](https://supabase.com/dashboard/org/_/billing). Read more about the [spend cap](https://supabase.com/docs/guides/platform/spend-cap). + +## Reports and invoices + +#### Where do I find my invoices? + +You can find all invoices from your organization on your [organization’s invoices page](https://supabase.com/dashboard/org/_/invoices). + +#### Where can I see a breakdown of usage? + +You can find the breakdown of your usage on your [organization’s usage page](https://supabase.com/dashboard/org/_/usage). + +#### Where can I check my credit balance? + +You can check your Credit balance on the [organization’s billing page](https://supabase.com/dashboard/org/_/billing). Credits will be used on future invoices before charging your payment method. If you have enough credits to cover an invoice, there is no charge at all. + +#### Can I include the VAT number? + +You can update your VAT number in the Tax ID section of your [organization’s billing page](https://supabase.com/dashboard/org/_/billing). + +#### Can I change the details of an existing invoice? + +Any changes made to your billing details will only be reflected in your upcoming invoices. Our payment provider cannot regenerate previous invoices. Therefore, please make sure to update the billing details before the upcoming invoices are finalized. + +## Payments and billing cycle + +#### What payment methods are available? + +We accept credit card payments only. If you cannot pay via credit card, we do offer alternatives for larger upfront payments. Please create a [support ticket](https://supabase.help) in case you’re interested. + +#### What currency can I pay in? + +All our invoices are issued in USD, but you can pay in any currency as long as your credit card provider allows charging in USD after conversion. + +#### Can I change the payment method? + +Yes, you will have to add the new payment method before being allowed to remove the old one. +This can be done from your dashboard on the [organization’s billing page](https://supabase.com/dashboard/org/_/billing). + +#### When are payments taken? + +Payments are taken at the beginning of each billing cycle. You will be charged once a month. You can see the current billing cycle and upcoming invoice in your [organization's billing settings](https://supabase.com/dashboard/org/_/billing). The subscription plan, custom domain and PITR addons are charged upfront, whereas usage-charges, including compute, are charged in hindsight based on your usage. + +#### Where can I change my billing details? + +You can update your billing details on the [organization’s billing page](https://supabase.com/dashboard/org/_/billing). +Please note that any changes made to your billing details will only be reflected in your upcoming invoices. Our payment provider cannot regenerate previous invoices. + +#### What happens if I am unable to make the payment? + +When an invoice becomes overdue, we will pause your projects and downgrade your organization to the Free plan. You will be able to restore your projects once you have paid all outstanding invoices. + +#### Why am I overdue? + +We were unable to charge your payment method. This likely means that the payment was not successfully processed with the credit card on your account profile. +You can be overdue when + +- A card is expired +- The bank declined the payment. +- There was no card on record - No payment method was added. + To rectify, you can add a new payment method by navigating to your [organization’s billing page](https://supabase.com/dashboard/org/_/billing). + If you are facing any issues, please raise a [support ticket](https://supabase.help). + +#### Can I delay my payment? + +No, you cannot delay your payment. + +#### Can I get a refund of my unused credits? + +No, we do not provide refunds. Please refer to our [Terms of Service](https://supabase.com/terms#:~:text=5.-,Fees%20and%20Taxes.,-1.%20Fees.). + +#### What do I do if my bill looks wrong? + +Please submit a [support ticket](https://supabase.help) through the dashboard. Select the affected organization and provide the invoice number for us to look at your case. + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/platform/branching.mdx b/apps/docs/pages/guides/platform/branching.mdx index f3cbc8ff903..7421a6f9b14 100644 --- a/apps/docs/pages/guides/platform/branching.mdx +++ b/apps/docs/pages/guides/platform/branching.mdx @@ -7,11 +7,26 @@ export const meta = { subtitle: 'Use Supabase Branches to test and preview changes', } -Use branching to safely and easily experiment with changes to your Supabase projects. + - +Branching is an early access only feature, which must not be used in a production setting. -We do not recommended Branching in a production environment while it is in Early Access. + + +Use branching to safely and easily experiment with changes to your Supabase project. + +Supabase branches work like Git branches. They let you create and test changes like new configurations, database schemas, or features in a separate, temporary instance 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. + +If you understand Git, you already understand Supabase Branching. + + + +Branching will move to Public Beta in future, but only available for Pro plan and above. @@ -147,9 +162,9 @@ You can use the [Supabase CLI](/docs/guides/cli) to manage changes inside a loca - + - Sync your database changes using `supabase db pull`. You can find your database URL in your [database settings](https://supabase.com/dashboard/project/_/settings/database), under the URI tab of the Connection String settings panel. + Pull your database changes using `supabase db pull`. You can find your database URL in your [database settings](https://supabase.com/dashboard/project/_/settings/database), under the URI tab of the Connection String settings panel. Make sure **Use connection pooling** is checked so you can use the IPv4-enabled connection pooler. (Without connection pooling, your database is only accessible over IPv6, which isn't yet supported by all network providers.) diff --git a/apps/docs/pages/guides/platform/migrating-and-upgrading-projects.mdx b/apps/docs/pages/guides/platform/migrating-and-upgrading-projects.mdx index 763a84b2ea2..a80f36d0853 100644 --- a/apps/docs/pages/guides/platform/migrating-and-upgrading-projects.mdx +++ b/apps/docs/pages/guides/platform/migrating-and-upgrading-projects.mdx @@ -119,17 +119,11 @@ Migrating projects can be achieved using the Supabase CLI. This is particularly ### Before you begin - Install [Postgres](https://www.postgresql.org/download/) so you can run `psql` and `pg_dump`. -- Install [Supabase CLI](/docs/guides/cli#installation). +- Install the latest version of [Supabase CLI](/docs/guides/cli#installation). - Create a new [Supabase project](https://supabase.com/dashboard). - Install [Docker Desktop](https://www.docker.com) for your platform. - Set environment variables for the old project's database URL as `$OLD_DB_URL` and the new project's as `$NEW_DB_URL`. - To find the database URL for a project, go to the project's dashboard page [Project Settings/Database](https://supabase.com/dashboard/project/_/settings/database) and look at `Connection string / URI`. For example, to set the `$OLD_DB_URL` you would run `export OLD_DB_URL=postgresql://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF#].supabase.co:5432/postgres`. - - - -Note that this direct connection string to the database uses IPv6. If your network provider doesn't yet support IPv6, you can connect via the pooler connection string, available under the **Connection pooling** section of [**Database settings**](https://supabase.com/dashboard/project/_/settings/database). The connection string has the format `postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:6543/postgres`. - - + To find the database URL for a project, go to the project's dashboard page [Project Settings/Database](https://supabase.com/dashboard/project/_/settings/database) and look under `Connection string`. If your network provider supports IPv6, you can disable `Use connection pooling`. Otherwise, enable `Use connection pooling`. For the pooler mode, `Transaction` will work. ### Backup your old database @@ -139,7 +133,6 @@ Note that this direct connection string to the database uses IPv6. If your netwo supabase db dump --db-url "$OLD_DB_URL" -f roles.sql --role-only supabase db dump --db-url "$OLD_DB_URL" -f schema.sql supabase db dump --db-url "$OLD_DB_URL" -f data.sql --use-copy --data-only -supabase db dump --db-url "$OLD_DB_URL" -f history.sql --use-copy --data-only --schema supabase_migrations ``` ### Restore to your new project @@ -173,7 +166,6 @@ psql \ --file schema.sql \ --command 'SET session_replication_role = replica' \ --file data.sql \ - --file history.sql \ --dbname "$NEW_DB_URL" ``` @@ -184,6 +176,21 @@ Troubleshooting notes: - If you have created any [custom roles](https://supabase.com/dashboard/project/_/database/roles) with `login` attribute, you have to manually set their passwords in the new project. - If you run into any permission errors related to `supabase_admin` during restore, edit the `schema.sql` file and comment out any lines containing `ALTER ... OWNER TO "supabase_admin"`. +### Preserving migration history + +If you were using Supabase CLI for managing migrations on your old database and would like to preserve the migration history in your newly restored project, you need to insert the migration records separately using the following commands. + +```bash +supabase db dump --db-url "$OLD_DB_URL" -f history_schema.sql --schema supabase_migrations +supabase db dump --db-url "$OLD_DB_URL" -f history_data.sql --use-copy --data-only --schema supabase_migrations +psql \ + --single-transaction \ + --variable ON_ERROR_STOP=1 \ + --file history_schema.sql \ + --file history_data.sql \ + --dbname "$NEW_DB_URL" +``` + ### Schema changes to `auth` and `storage` If you have modified the `auth` and `storage` schemas in your old project, such as adding triggers or RLS policies, you have to restore them separately. The Supabase CLI can help you diff the changes to these schemas using the following commands. diff --git a/apps/docs/pages/guides/platform/network-restrictions.mdx b/apps/docs/pages/guides/platform/network-restrictions.mdx index e46e9b85225..9ffbb95e96d 100644 --- a/apps/docs/pages/guides/platform/network-restrictions.mdx +++ b/apps/docs/pages/guides/platform/network-restrictions.mdx @@ -77,8 +77,7 @@ Restrictions applied successfully: true ## Limitations - The current iteration of Network Restrictions applies to connections to Postgres and the database pooler; it doesn't currently apply to APIs offered over HTTPS (e.g., PostgREST, Storage, and Auth). This includes using Supabase client libraries like [supabase-js](https://supabase.com/docs/reference/javascript). -- Network Restrictions should not be used if you need to connect to your Postgres database using Edge Functions. -- [Supavisor](https://github.com/supabase/supavisor/) does not currently support Network Restrictions. Network restrictions support will be enabled from 24th January 2024. Any changes to the network restrictions configuration will apply to both Supavisor and direct connections to Postgres after this date. +- If network restrictions are enabled, direct access to your database from Edge Functions will always be blocked. Using the Supabase client library [supabase-js](https://supabase.com/docs/reference/javascript) is recommended to connect to a database with network restrictions from Edge Functions. export const Page = ({ children }) => diff --git a/apps/docs/pages/guides/platform/read-replicas.mdx b/apps/docs/pages/guides/platform/read-replicas.mdx index 5825b38a676..b43a62f5928 100644 --- a/apps/docs/pages/guides/platform/read-replicas.mdx +++ b/apps/docs/pages/guides/platform/read-replicas.mdx @@ -96,7 +96,7 @@ A read replica's API endpoint only supports `GET` requests to the [REST API](htt ### Dedicated connection pool -A connection pool through Supavisor is also available for each read replica. Its unique connection string can be found in your [dashboard database settings](https://supabase.com/dashboard/project/_/settings/database) under **Connection Pooling Custom Configuration**. +A connection pool through Supavisor is also available for each read replica. Its unique connection string can be found in your [dashboard database settings](https://supabase.com/dashboard/project/_/settings/database) under **Connection string**. ### Querying through the SQL editor diff --git a/apps/docs/pages/guides/realtime/[[...slug]].tsx b/apps/docs/pages/guides/realtime/[[...slug]].tsx new file mode 100644 index 00000000000..dddabae2a82 --- /dev/null +++ b/apps/docs/pages/guides/realtime/[[...slug]].tsx @@ -0,0 +1,28 @@ +import { type GetStaticPaths, type GetStaticProps, type InferGetStaticPropsType } from 'next' +import { MDXRemote } from 'next-mdx-remote' + +import components from '~/components' +import Layout from '~/layouts/DefaultGuideLayout' +import { getGuidesStaticPaths, getGuidesStaticProps } from '~/lib/docs' + +export const getStaticPaths = (async () => { + return getGuidesStaticPaths('realtime') +}) satisfies GetStaticPaths + +export const getStaticProps = (async (args) => { + return getGuidesStaticProps('realtime', args) +}) satisfies GetStaticProps + +export default function RealtimeGuide({ + frontmatter, + mdxSource, + editLink, +}: InferGetStaticPropsType) { + const { hideToc, ...meta } = frontmatter + + return ( + + + + ) +} diff --git a/apps/docs/pages/guides/resources.mdx b/apps/docs/pages/guides/resources.mdx index 6b239c60396..a125d86be09 100644 --- a/apps/docs/pages/guides/resources.mdx +++ b/apps/docs/pages/guides/resources.mdx @@ -1,6 +1,8 @@ import Layout from '~/layouts/DefaultLayout' import Link from 'next/link' -import { GlassPanel, IconPanel, Button, IconChevronRight } from 'ui' +import { Button, IconChevronRight } from 'ui' +import { GlassPanel } from 'ui-patterns/GlassPanel' +import { IconPanel } from 'ui-patterns/IconPanel' export const meta = { title: 'Resources', diff --git a/apps/docs/pages/guides/resources/migrating-to-supabase/amazon-rds.mdx b/apps/docs/pages/guides/resources/migrating-to-supabase/amazon-rds.mdx index 63ba35b2bda..32ad2fa7240 100644 --- a/apps/docs/pages/guides/resources/migrating-to-supabase/amazon-rds.mdx +++ b/apps/docs/pages/guides/resources/migrating-to-supabase/amazon-rds.mdx @@ -24,11 +24,12 @@ Supabase's core is Postgres, enabling the use of row-level security and providin ## Retrieve your Supabase host [#retrieve-supabase-host] 1. If you're new to Supabase, [create a project](https://supabase.com/dashboard). + Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database). + 1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. 1. Under **Connection Info**, note your Host (`$SUPABASE_HOST`). -1. Save your password or [reset it](https://supabase.com/dashboard/project/_/settings/database) if you forgot it. -![Finding Supabase host address](/docs/img/database/database-settings-host.png) +![Finding Supabase host address](/docs/img/guides/resources/migrating-to-supabase/amazon-rds/database-settings-host.png) ## Migrate the database @@ -57,10 +58,12 @@ Alternatively, you can use [pgloader](https://github.com/dimitri/pgloader), a fl Install pgloader. Create a configuration file (e.g., config.load). + For your destination, use your Supabase connection string with `Use connection pooling` enabled, and the mode set to `Session`. You can get the string from your [`Database Settings`](https://supabase.com/dashboard/project/_/settings/general). + ```sql load database from mysql://user:password@host/source_db - into postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:6543/postgres + into postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:5432/postgres alter schema 'public' owner to 'postgres'; set wal_buffers = '64MB', max_wal_senders = 0, statement_timeout = 0, work_mem to '2GB'; ``` diff --git a/apps/docs/pages/guides/resources/migrating-to-supabase/firebase-auth.mdx b/apps/docs/pages/guides/resources/migrating-to-supabase/firebase-auth.mdx index 9b898785f38..958a84a9c4e 100644 --- a/apps/docs/pages/guides/resources/migrating-to-supabase/firebase-auth.mdx +++ b/apps/docs/pages/guides/resources/migrating-to-supabase/firebase-auth.mdx @@ -33,7 +33,7 @@ git clone https://github.com/supabase-community/firebase-to-supabase.git ``` 1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. -1. Under **Connection Info**, copy the Host string and replace the entry in your `supabase-service.json` file. +1. Under `Connection parameters`, enable `Use connection pooling` and set the mode to `Session`. Replace the `Host` and `User` fields with the values shown. 1. Enter the password you used when you created your Supabase project in the `password` entry in the `supabase-service.json` file. ## Generate a Firebase private key [#generate-firebase-private-key] diff --git a/apps/docs/pages/guides/resources/migrating-to-supabase/firestore-data.mdx b/apps/docs/pages/guides/resources/migrating-to-supabase/firestore-data.mdx index c8d30b87b40..40e89473890 100644 --- a/apps/docs/pages/guides/resources/migrating-to-supabase/firestore-data.mdx +++ b/apps/docs/pages/guides/resources/migrating-to-supabase/firestore-data.mdx @@ -32,7 +32,7 @@ git clone https://github.com/supabase-community/firebase-to-supabase.git ``` 1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. -1. Under **Connection Info**, copy the Host string and replace the entry in your `supabase-service.json` file. +1. Under `Connection parameters`, enable `Use connection pooling` and set the mode to `Session`. Replace the `Host` and `User` fields with the values shown. 1. Enter the password you used when you created your Supabase project in the `password` entry in the `supabase-service.json` file. ## Generate a Firebase private key [#generate-firebase-private-key] diff --git a/apps/docs/pages/guides/resources/migrating-to-supabase/heroku.mdx b/apps/docs/pages/guides/resources/migrating-to-supabase/heroku.mdx index dc96741d70a..9e222e55203 100644 --- a/apps/docs/pages/guides/resources/migrating-to-supabase/heroku.mdx +++ b/apps/docs/pages/guides/resources/migrating-to-supabase/heroku.mdx @@ -34,11 +34,11 @@ Alternatively, use the [Heroku to Supabase migration tool](https://migrate.supab - User (`$HEROKU_USER`) - Password (`$HEROKU_PASSWORD`) -## Retrieve your Supabase host [#retrieve-supabase-host] +## Retrieve your Supabase connection string [#retrieve-supabase-connection-string] 1. If you're new to Supabase, [create a project](https://supabase.com/dashboard). 1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. -1. Under **Connection Info**, note your Host (`$SUPABASE_HOST`). +1. Under **Connection string**, make sure `Use connection pooling` is enabled. Copy the URI and replace the password placeholder with your database password. ## Export your Heroku database to a file [#export-heroku-database] @@ -55,7 +55,7 @@ pg_dump --clean --if-exists --quote-all-identifiers \ Use `psql` to import the Heroku database file to your Supabase project. ```bash -psql -h $SUPABASE_HOST -U postgres -f heroku_dump.sql +psql -d "$YOUR_CONNECTION_STRING" -f heroku_dump.sql ``` ## Additional options diff --git a/apps/docs/pages/guides/resources/migrating-to-supabase/mssql.mdx b/apps/docs/pages/guides/resources/migrating-to-supabase/mssql.mdx index 012529761b7..f4c70cdafd8 100644 --- a/apps/docs/pages/guides/resources/migrating-to-supabase/mssql.mdx +++ b/apps/docs/pages/guides/resources/migrating-to-supabase/mssql.mdx @@ -21,11 +21,12 @@ Before you begin the migration, you need to collect essential information about ## Retrieve your Supabase host [#retrieve-supabase-host] 1. If you're new to Supabase, [create a project](https://supabase.com/dashboard). + Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database). + 1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. 1. Under **Connection Info**, note your Host (`$SUPABASE_HOST`). -1. Save your password or [reset it](https://supabase.com/dashboard/project/_/settings/database) if you forgot it. -![Finding Supabase host address](/docs/img/database/database-settings-host.png) +![Finding Supabase host address](/docs/img/guides/resources/migrating-to-supabase/mssql/database-settings-host.png) ## Migrate the database @@ -54,10 +55,12 @@ Alternatively, you can use [pgloader](https://github.com/dimitri/pgloader), a fl Install pgloader. Create a configuration file (e.g., config.load). + For your destination, use your Supabase connection string with `Use connection pooling` enabled, and the mode set to `Session`. You can get the string from your [`Database Settings`](https://supabase.com/dashboard/project/_/settings/general). + ```sql LOAD DATABASE FROM mssql://USER:PASSWORD@HOST/SOURCE_DB - INTO postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:6543/postgres + INTO postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:5432/postgres ALTER SCHEMA 'public' OWNER TO 'postgres'; set wal_buffers = '64MB', max_wal_senders = 0, statement_timeout = 0, work_mem to '2GB'; ``` diff --git a/apps/docs/pages/guides/resources/migrating-to-supabase/mysql.mdx b/apps/docs/pages/guides/resources/migrating-to-supabase/mysql.mdx index 5ca1c587670..0699182b9c9 100644 --- a/apps/docs/pages/guides/resources/migrating-to-supabase/mysql.mdx +++ b/apps/docs/pages/guides/resources/migrating-to-supabase/mysql.mdx @@ -22,11 +22,12 @@ Before you begin the migration, you need to collect essential information about ## Retrieve your Supabase host [#retrieve-supabase-host] 1. If you're new to Supabase, [create a project](https://supabase.com/dashboard). + Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database). + 1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. 1. Under **Connection Info**, note your Host (`$SUPABASE_HOST`). -1. Save your password or [reset it](https://supabase.com/dashboard/project/_/settings/database) if you forgot it. -![Finding Supabase host address](/docs/img/database/database-settings-host.png) +![Finding Supabase host address](/docs/img/guides/resources/migrating-to-supabase/mysql/database-settings-host.png) ## Migrate the database @@ -55,10 +56,12 @@ Alternatively, you can use [pgloader](https://github.com/dimitri/pgloader), a fl Install pgloader. Create a configuration file (e.g., config.load). + For your destination, use your Supabase connection string with `Use connection pooling` enabled, and the mode set to `Session`. You can get the string from your [`Database Settings`](https://supabase.com/dashboard/project/_/settings/general). + ```sql load database from mysql://user:password@host/source_db - into postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:6543/postgres + into postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:5432/postgres alter schema 'public' owner to 'postgres'; set wal_buffers = '64MB', max_wal_senders = 0, statement_timeout = 0, work_mem to '2GB'; ``` diff --git a/apps/docs/pages/guides/resources/migrating-to-supabase/postgres.mdx b/apps/docs/pages/guides/resources/migrating-to-supabase/postgres.mdx index 4ada6ef3f15..e4df0bb4a78 100644 --- a/apps/docs/pages/guides/resources/migrating-to-supabase/postgres.mdx +++ b/apps/docs/pages/guides/resources/migrating-to-supabase/postgres.mdx @@ -21,14 +21,15 @@ Example: %env PSQL_COMMAND=PGPASSWORD=RgaMDfTS_password_FTPa7 psql -h dpg-a_server_in.oregon-postgres.provider.com -U my_db_pxl0_user my_db_pxl0 ``` -## Retrieve your Supabase host [#retrieve-supabase-host] +## Retrieve your Supabase connection string [#retrieve-supabase-connection-string] 1. If you're new to Supabase, [create a project](https://supabase.com/dashboard). -1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. -1. Under **Connection Info**, note your Host (`$SUPABASE_HOST`). -1. Save your password or [reset it](https://supabase.com/dashboard/project/_/settings/database) if you forgot it. + Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database). -![Finding Supabase host address](/docs/img/guides/resources/migrating-to-supabase/render/supabase_dashboard.png) +1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. +1. Under **Connection string**, make sure `Use connection pooling` is enabled. Copy the URI and replace the password placeholder with your database password. + +![Finding Supabase host address](/docs/img/guides/resources/migrating-to-supabase/postgres/database-settings-host.png) ## Migrate the database @@ -65,7 +66,7 @@ pg_dump --clean --if-exists --quote-all-identifiers \ Use `psql` to import the Postgres database file to your Supabase project. ```bash -psql -h $SUPA_URL -U postgres --file dump.sql -p 6543 -d postgres +psql -d "$YOUR_CONNECTION_STRING" -f dump.sql ``` ### Additional options diff --git a/apps/docs/pages/guides/resources/migrating-to-supabase/render.mdx b/apps/docs/pages/guides/resources/migrating-to-supabase/render.mdx index f8178141fc5..b64d1e250b6 100644 --- a/apps/docs/pages/guides/resources/migrating-to-supabase/render.mdx +++ b/apps/docs/pages/guides/resources/migrating-to-supabase/render.mdx @@ -25,14 +25,13 @@ Example: %env PSQL_COMMAND=PGPASSWORD=RgaMDfTS_password_FTPa7 psql -h dpg-a_server_in.oregon-postgres.render.com -U my_db_pxl0_user my_db_pxl0 ``` -## Retrieve your Supabase host [#retrieve-supabase-host] +## Retrieve your Supabase connection string [#retrieve-supabase-connection-string] 1. If you're new to Supabase, [create a project](https://supabase.com/dashboard). -1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. -1. Under **Connection Info**, note your Host (`$SUPABASE_HOST`). -1. Save your password or [reset it](https://supabase.com/dashboard/project/_/settings/database) if you forgot it. + Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database). -![Finding Supabase host address](/docs/img/guides/resources/migrating-to-supabase/render/supabase_dashboard.png) +1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. +1. Under **Connection string**, make sure `Use connection pooling` is enabled. Copy the URI and replace the password placeholder with your database password. ## Migrate the database @@ -69,7 +68,7 @@ pg_dump --clean --if-exists --quote-all-identifiers \ Use `psql` to import the Render database file to your Supabase project. ```bash -psql -h $SUPA_URL -U postgres --file render_dump.sql -p 6543 -d postgres +psql -d "$YOUR_CONNECTION_STRING" -f render_dump.sql ``` ### Additional options diff --git a/apps/docs/pages/guides/self-hosting/docker.mdx b/apps/docs/pages/guides/self-hosting/docker.mdx index dc455d227ac..adc6dd1deaa 100644 --- a/apps/docs/pages/guides/self-hosting/docker.mdx +++ b/apps/docs/pages/guides/self-hosting/docker.mdx @@ -187,7 +187,13 @@ Supabase is a combination of open source tools, each specifically chosen for Ent If the tools and communities already exist, with an MIT, Apache 2, or equivalent open license, we will use and support that tool. If the tool doesn't exist, we build and open source it ourselves. -![Supabase Architecture](/docs/img/supabase-architecture.png) + - [Kong](https://github.com/Kong/kong) is a cloud-native API gateway. - [GoTrue](https://github.com/supabase/gotrue) is an JWT based API for managing users and issuing JWT tokens. diff --git a/apps/docs/pages/guides/storage/[[...slug]].tsx b/apps/docs/pages/guides/storage/[[...slug]].tsx new file mode 100644 index 00000000000..c3ae4c4b3c3 --- /dev/null +++ b/apps/docs/pages/guides/storage/[[...slug]].tsx @@ -0,0 +1,28 @@ +import { type GetStaticPaths, type GetStaticProps, type InferGetStaticPropsType } from 'next' +import { MDXRemote } from 'next-mdx-remote' + +import components from '~/components' +import Layout from '~/layouts/DefaultGuideLayout' +import { getGuidesStaticPaths, getGuidesStaticProps } from '~/lib/docs' + +export const getStaticPaths = (async () => { + return getGuidesStaticPaths('storage') +}) satisfies GetStaticPaths + +export const getStaticProps = (async (args) => { + return getGuidesStaticProps('storage', args) +}) satisfies GetStaticProps + +export default function StorageGuide({ + frontmatter, + mdxSource, + editLink, +}: InferGetStaticPropsType) { + const { hideToc, ...meta } = frontmatter + + return ( + + + + ) +} diff --git a/apps/docs/pages/index.mdx b/apps/docs/pages/index.mdx index 6a7a8195082..4c1a02b59a2 100644 --- a/apps/docs/pages/index.mdx +++ b/apps/docs/pages/index.mdx @@ -1,14 +1,8 @@ import HomeLayout from '~/layouts/HomeLayout' import Link from 'next/link' -import { - Button, - GlassPanel, - IconBackground, - IconChevronRight, - IconPanel, - IconPlay, - TextLink, -} from 'ui' +import { Button, IconBackground, IconChevronRight, IconPlay, TextLink } from 'ui' +import { GlassPanel } from 'ui-patterns/GlassPanel' +import { IconPanel } from 'ui-patterns/IconPanel' import HomeMenuIconPicker from '~/components/Navigation/NavigationMenu/HomeMenuIconPicker' diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 7c6ee7b83e0..b2f25cf9f0d 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -68,6 +68,7 @@ Terry Sutton Thomas E Thor Schaeff Tom Ashley +Tyler Hillery Tyler Fontaine Tyler Shukert TzeYiing L diff --git a/apps/docs/public/img/guides/cli/cicd-github.png b/apps/docs/public/img/guides/cli/cicd-github.png deleted file mode 100644 index 01577b44525..00000000000 Binary files a/apps/docs/public/img/guides/cli/cicd-github.png and /dev/null differ diff --git a/apps/docs/public/img/guides/database/add-pg-server-conn-info.png b/apps/docs/public/img/guides/database/add-pg-server-conn-info.png deleted file mode 100644 index 1198e74ee66..00000000000 Binary files a/apps/docs/public/img/guides/database/add-pg-server-conn-info.png and /dev/null differ diff --git a/apps/docs/public/img/guides/database/connecting-to-postgres/how-connection-pooling-works--light.png b/apps/docs/public/img/guides/database/connecting-to-postgres/how-connection-pooling-works--light.png new file mode 100644 index 00000000000..4be79ad2ba2 Binary files /dev/null and b/apps/docs/public/img/guides/database/connecting-to-postgres/how-connection-pooling-works--light.png differ diff --git a/apps/docs/public/img/guides/database/connecting-to-postgres/how-connection-pooling-works.png b/apps/docs/public/img/guides/database/connecting-to-postgres/how-connection-pooling-works.png new file mode 100644 index 00000000000..24d330919b0 Binary files /dev/null and b/apps/docs/public/img/guides/database/connecting-to-postgres/how-connection-pooling-works.png differ diff --git a/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/add-pg-server-conn-info.png b/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/add-pg-server-conn-info.png new file mode 100644 index 00000000000..e2962480fcc Binary files /dev/null and b/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/add-pg-server-conn-info.png differ diff --git a/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/database-settings-host.png b/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/database-settings-host.png new file mode 100644 index 00000000000..f8f453c5d6f Binary files /dev/null and b/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/database-settings-host.png differ diff --git a/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/name-pg-server.png b/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/name-pg-server.png new file mode 100644 index 00000000000..05fa52fc4fb Binary files /dev/null and b/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/name-pg-server.png differ diff --git a/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/register-server-pgAdmin--light.png b/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/register-server-pgAdmin--light.png new file mode 100644 index 00000000000..4f2e088b45e Binary files /dev/null and b/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/register-server-pgAdmin--light.png differ diff --git a/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/register-server-pgAdmin.png b/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/register-server-pgAdmin.png new file mode 100644 index 00000000000..682797773ad Binary files /dev/null and b/apps/docs/public/img/guides/database/connecting-to-postgres/pgadmin/register-server-pgAdmin.png differ diff --git a/apps/docs/public/img/guides/getting-started/quickstarts/redwoodjs/new-project.png b/apps/docs/public/img/guides/getting-started/quickstarts/redwoodjs/new-project.png new file mode 100644 index 00000000000..85d58b26c0d Binary files /dev/null and b/apps/docs/public/img/guides/getting-started/quickstarts/redwoodjs/new-project.png differ diff --git a/apps/docs/public/img/guides/getting-started/quickstarts/redwoodjs/pooled-connection-strings.png b/apps/docs/public/img/guides/getting-started/quickstarts/redwoodjs/pooled-connection-strings.png new file mode 100644 index 00000000000..ef60a412a6b Binary files /dev/null and b/apps/docs/public/img/guides/getting-started/quickstarts/redwoodjs/pooled-connection-strings.png differ diff --git a/apps/docs/public/img/guides/resources/migrating-to-supabase/amazon-rds/database-settings-host.png b/apps/docs/public/img/guides/resources/migrating-to-supabase/amazon-rds/database-settings-host.png new file mode 100644 index 00000000000..1206b19d479 Binary files /dev/null and b/apps/docs/public/img/guides/resources/migrating-to-supabase/amazon-rds/database-settings-host.png differ diff --git a/apps/docs/public/img/guides/resources/migrating-to-supabase/mssql/database-settings-host.png b/apps/docs/public/img/guides/resources/migrating-to-supabase/mssql/database-settings-host.png new file mode 100644 index 00000000000..6e912bc8359 Binary files /dev/null and b/apps/docs/public/img/guides/resources/migrating-to-supabase/mssql/database-settings-host.png differ diff --git a/apps/docs/public/img/guides/resources/migrating-to-supabase/mysql/database-settings-host.png b/apps/docs/public/img/guides/resources/migrating-to-supabase/mysql/database-settings-host.png new file mode 100644 index 00000000000..67a653b47b0 Binary files /dev/null and b/apps/docs/public/img/guides/resources/migrating-to-supabase/mysql/database-settings-host.png differ diff --git a/apps/docs/public/img/guides/resources/migrating-to-supabase/postgres/database-settings-host.png b/apps/docs/public/img/guides/resources/migrating-to-supabase/postgres/database-settings-host.png new file mode 100644 index 00000000000..67a653b47b0 Binary files /dev/null and b/apps/docs/public/img/guides/resources/migrating-to-supabase/postgres/database-settings-host.png differ diff --git a/apps/docs/public/img/guides/resources/migrating-to-supabase/render/database-settings-host.png b/apps/docs/public/img/guides/resources/migrating-to-supabase/render/database-settings-host.png new file mode 100644 index 00000000000..67a653b47b0 Binary files /dev/null and b/apps/docs/public/img/guides/resources/migrating-to-supabase/render/database-settings-host.png differ diff --git a/apps/docs/public/img/local-dev-environment--light.svg b/apps/docs/public/img/local-dev-environment--light.svg new file mode 100644 index 00000000000..42c706a40e9 --- /dev/null +++ b/apps/docs/public/img/local-dev-environment--light.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/docs/public/img/local-dev-environment.svg b/apps/docs/public/img/local-dev-environment.svg new file mode 100644 index 00000000000..1003ad44a52 --- /dev/null +++ b/apps/docs/public/img/local-dev-environment.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/docs/public/img/supabase-architecture--light.svg b/apps/docs/public/img/supabase-architecture--light.svg new file mode 100644 index 00000000000..53627538876 --- /dev/null +++ b/apps/docs/public/img/supabase-architecture--light.svg @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/docs/public/img/supabase-architecture.png b/apps/docs/public/img/supabase-architecture.png deleted file mode 100644 index 4c82d648533..00000000000 Binary files a/apps/docs/public/img/supabase-architecture.png and /dev/null differ diff --git a/apps/docs/public/img/supabase-architecture.svg b/apps/docs/public/img/supabase-architecture.svg new file mode 100644 index 00000000000..2b0ad6325a9 --- /dev/null +++ b/apps/docs/public/img/supabase-architecture.svg @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/docs/scripts/codemod/mdx-meta.mjs b/apps/docs/scripts/codemod/mdx-meta.mjs index 8e00d256604..99c9b212fc1 100644 --- a/apps/docs/scripts/codemod/mdx-meta.mjs +++ b/apps/docs/scripts/codemod/mdx-meta.mjs @@ -3,8 +3,12 @@ /** * Copies MDX files from the `pages` directory to the `content` directory, * replacing frontmatter in `meta` with YAML frontmatter. + * + * Also deletes import and export statements. */ +let SUB_DIR = 'ai' + import { parse } from 'acorn' import { fromMarkdown } from 'mdast-util-from-markdown' import { mdxFromMarkdown } from 'mdast-util-mdx' @@ -15,8 +19,13 @@ import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) const ROOT_DIR = join(__dirname, '../..') -const PAGES_DIR = join(ROOT_DIR, 'pages/guides') -const CONTENT_DIR = join(ROOT_DIR, 'content/guides') +let PAGES_DIR = join(ROOT_DIR, 'pages/guides') +let CONTENT_DIR = join(ROOT_DIR, 'content/guides') + +if (SUB_DIR) { + PAGES_DIR = join(PAGES_DIR, SUB_DIR) + CONTENT_DIR = join(CONTENT_DIR, SUB_DIR) +} function convertToYaml(properties) { let result = '---\n' @@ -48,26 +57,55 @@ async function main() { const meta = mdxTree.children.find( (node) => node.type === 'mdxjsEsm' && node.value.trim().startsWith('export const meta') ) - if (!meta) return - const linesIncl = [meta.position.start.line, meta.position.end.line] + let yamlString = '' + if (meta) { + // @ts-ignore + const parsedMeta = parse(meta.value, { ecmaVersion: 2020, sourceType: 'module' }) + yamlString = convertToYaml( + // @ts-ignore + parsedMeta.body[0].declaration.declarations[0].init.properties + ) + } - const parsedMeta = parse(meta.value, { ecmaVersion: 2020, sourceType: 'module' }) - const yamlString = convertToYaml( - parsedMeta.body[0].declaration.declarations[0].init.properties + const importStatements = mdxTree.children.filter( + (node) => node.type === 'mdxjsEsm' && node.value.trim().match(/^import \w+ from/) ) + const exportStatements = mdxTree.children.filter( + (node) => + node.type === 'mdxjsEsm' && + (node.value.trim().match(/^export const (?!meta)/) || + node.value.trim().startsWith('export default')) + ) + + const positions = [meta, ...importStatements, ...exportStatements] + // @ts-ignore + .map(({ position }) => [position.start.line, position.end.line]) + // splicing them out in reverse order means we don't have to worry about line numbers shifting + .sort((a, b) => b[0] - a[0]) + + let index = 0 + while (index < positions.length - 1) { + const overlapsNext = positions[index][0] <= positions[index + 1][1] + if (overlapsNext) { + positions[index][0] = positions[index + 1][0] + positions.splice(index + 1, 1) + } else { + index++ + } + } + const lines = content.split('\n') - lines.splice( - meta.position.start.line - 1, - meta.position.end.line - meta.position.start.line + 1 - ) - const contentWithoutMeta = lines.join('\n') - const contentWithFrontmatter = yamlString + contentWithoutMeta + for (const position of positions) { + lines.splice(position[0] - 1, position[1] - position[0] + 1) + } + const splicedLines = lines.join('\n') + const splicedWithFrontmatter = yamlString + splicedLines const destinationPath = join(CONTENT_DIR, filename) await mkdir(dirname(destinationPath), { recursive: true }) - writeFile(destinationPath, contentWithFrontmatter) + writeFile(destinationPath, splicedWithFrontmatter) }) ) } catch (err) { diff --git a/apps/docs/scripts/search/generate-embeddings.ts b/apps/docs/scripts/search/generate-embeddings.ts index c9f7e004f75..a6931a1836b 100644 --- a/apps/docs/scripts/search/generate-embeddings.ts +++ b/apps/docs/scripts/search/generate-embeddings.ts @@ -23,6 +23,8 @@ async function generateEmbeddings() { 'NEXT_PUBLIC_SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_KEY', + 'NEXT_PUBLIC_MISC_USE_URL', + 'NEXT_PUBLIC_MISC_USE_ANON_KEY', 'SEARCH_GITHUB_APP_ID', 'SEARCH_GITHUB_APP_INSTALLATION_ID', 'SEARCH_GITHUB_APP_PRIVATE_KEY', @@ -71,7 +73,13 @@ async function generateEmbeddings() { checksum, sections, meta = {}, - }: { checksum: string; sections: Section[]; meta?: Json } = embeddingSource.process() + ragIgnore = false, + }: { + checksum: string + sections: Section[] + ragIgnore?: boolean + meta?: Json + } = embeddingSource.process() // Check for existing page in DB and compare checksums const { error: fetchPageError, data: existingPage } = await supabaseClient @@ -175,6 +183,7 @@ async function generateEmbeddings() { content, token_count: embeddingResponse.usage.total_tokens, embedding: responseData.embedding, + rag_ignore: ragIgnore, }) .select() .limit(1) diff --git a/apps/docs/scripts/search/sources/base.ts b/apps/docs/scripts/search/sources/base.ts index 0a0aea98a98..931a57290f7 100644 --- a/apps/docs/scripts/search/sources/base.ts +++ b/apps/docs/scripts/search/sources/base.ts @@ -12,7 +12,10 @@ export type Section = { export abstract class BaseLoader { type: string - constructor(public source: string, public path: string) {} + constructor( + public source: string, + public path: string + ) {} abstract load(): Promise } @@ -23,9 +26,12 @@ export abstract class BaseSource { meta?: Json sections?: Section[] - constructor(public source: string, public path: string) {} + constructor( + public source: string, + public path: string + ) {} - abstract process(): { checksum: string; meta?: Json; sections: Section[] } + abstract process(): { checksum: string; meta?: Json; ragIgnore?: boolean; sections: Section[] } abstract extractIndexedContent(): string } diff --git a/apps/docs/scripts/search/sources/github-discussion.ts b/apps/docs/scripts/search/sources/github-discussion.ts index 5a27de92c39..429a14af5c0 100644 --- a/apps/docs/scripts/search/sources/github-discussion.ts +++ b/apps/docs/scripts/search/sources/github-discussion.ts @@ -77,7 +77,10 @@ export async function fetchDiscussions(owner: string, repo: string, categoryId: export class GitHubDiscussionLoader extends BaseLoader { type = 'github-discussions' as const - constructor(source: string, public discussion: Discussion) { + constructor( + source: string, + public discussion: Discussion + ) { super(source, discussion.url) } @@ -89,7 +92,11 @@ export class GitHubDiscussionLoader extends BaseLoader { export class GitHubDiscussionSource extends BaseSource { type = 'github-discussions' as const - constructor(source: string, path: string, public discussion: Discussion) { + constructor( + source: string, + path: string, + public discussion: Discussion + ) { super(source, path) } diff --git a/apps/docs/scripts/search/sources/index.ts b/apps/docs/scripts/search/sources/index.ts index 605120453c8..2c93c69ba6a 100644 --- a/apps/docs/scripts/search/sources/index.ts +++ b/apps/docs/scripts/search/sources/index.ts @@ -1,16 +1,17 @@ import { GitHubDiscussionLoader, - GitHubDiscussionSource, + type GitHubDiscussionSource, fetchDiscussions, } from './github-discussion' -import { MarkdownLoader, MarkdownSource } from './markdown' +import { MarkdownLoader, type MarkdownSource } from './markdown' +import { IntegrationLoader, type IntegrationSource, fetchPartners } from './partner-integrations' import { CliReferenceLoader, - CliReferenceSource, + type CliReferenceSource, ClientLibReferenceLoader, - ClientLibReferenceSource, + type ClientLibReferenceSource, OpenApiReferenceLoader, - OpenApiReferenceSource, + type OpenApiReferenceSource, } from './reference-doc' import { walk } from './util' @@ -22,6 +23,7 @@ export type SearchSource = | ClientLibReferenceSource | CliReferenceSource | GitHubDiscussionSource + | IntegrationSource /** * Fetches all the sources we want to index for search @@ -91,11 +93,22 @@ export async function fetchSources() { 'spec/common-cli-sections.json' ).load() - const guideSources = (await walk('pages')) + const pagesSources = (await walk('pages')) .filter(({ path }) => /\.mdx?$/.test(path)) .filter(({ path }) => !ignoredFiles.includes(path)) .map((entry) => new MarkdownLoader('guide', entry.path).load()) + const contentSources = (await walk('content')) + .filter(({ path }) => /\.mdx?$/.test(path)) + .filter(({ path }) => !ignoredFiles.includes(path)) + .map((entry) => new MarkdownLoader('guide', entry.path, { yaml: true }).load()) + + const guideSources = [...pagesSources, ...contentSources] + + const partnerIntegrationSources = (await fetchPartners()).map((partner) => + new IntegrationLoader(partner.slug, partner).load() + ) + const githubDiscussionSources = ( await fetchDiscussions( 'supabase', @@ -115,6 +128,7 @@ export async function fetchSources() { ktLibReferenceSource, cliReferenceSource, ...githubDiscussionSources, + ...partnerIntegrationSources, ...guideSources, ]) ).flat() diff --git a/apps/docs/scripts/search/sources/markdown.ts b/apps/docs/scripts/search/sources/markdown.ts index 49c5f42b373..5682c094ffb 100644 --- a/apps/docs/scripts/search/sources/markdown.ts +++ b/apps/docs/scripts/search/sources/markdown.ts @@ -2,6 +2,7 @@ import { createHash } from 'crypto' import { ObjectExpression } from 'estree' import { readFile } from 'fs/promises' import GithubSlugger from 'github-slugger' +import matter from 'gray-matter' import { Content, Root } from 'mdast' import { fromMarkdown } from 'mdast-util-from-markdown' import { MdxjsEsm, mdxFromMarkdown } from 'mdast-util-mdx' @@ -118,15 +119,28 @@ export function parseHeading(heading: string): { heading: string; customAnchor?: * It extracts metadata, strips it of all JSX, * and splits it into sub-sections based on criteria. */ -export function processMdxForSearch(content: string): ProcessedMdx { - const checksum = createHash('sha256').update(content).digest('base64') +export function processMdxForSearch(_content: string, options?: { yaml?: boolean }): ProcessedMdx { + const checksum = createHash('sha256').update(_content).digest('base64') + + let frontmatter: Record = {} + let content = _content + if (options?.yaml) { + const parsed = matter(_content) + frontmatter = parsed.data + content = parsed.content + } const mdxTree = fromMarkdown(content, { extensions: [mdxjs()], mdastExtensions: [mdxFromMarkdown()], }) - const meta = extractMetaExport(mdxTree) + let meta: Record + if (options?.yaml) { + meta = frontmatter + } else { + meta = extractMetaExport(mdxTree) + } const serializableMeta: Json = meta && JSON.parse(JSON.stringify(meta)) @@ -192,26 +206,35 @@ export type ProcessedMdx = { export class MarkdownLoader extends BaseLoader { type = 'markdown' as const - constructor(source: string, public filePath: string) { - const path = filePath.replace(/^pages/, '').replace(/\.mdx?$/, '') + constructor( + source: string, + public filePath: string, + public options?: { yaml?: boolean } + ) { + const path = filePath.replace(/^(pages|content)/, '').replace(/\.mdx?$/, '') super(source, path) } async load() { const contents = await readFile(this.filePath, 'utf8') - return [new MarkdownSource(this.source, this.path, contents)] + return [new MarkdownSource(this.source, this.path, contents, this.options)] } } export class MarkdownSource extends BaseSource { type = 'markdown' as const - constructor(source: string, path: string, public contents: string) { + constructor( + source: string, + path: string, + public contents: string, + public options?: { yaml?: boolean } + ) { super(source, path) } process() { - const { checksum, meta, sections } = processMdxForSearch(this.contents) + const { checksum, meta, sections } = processMdxForSearch(this.contents, this.options) this.checksum = checksum this.meta = meta diff --git a/apps/docs/scripts/search/sources/partner-integrations.ts b/apps/docs/scripts/search/sources/partner-integrations.ts new file mode 100644 index 00000000000..e675a1149a6 --- /dev/null +++ b/apps/docs/scripts/search/sources/partner-integrations.ts @@ -0,0 +1,86 @@ +import { type SupabaseClient, createClient } from '@supabase/supabase-js' +import { upperFirst } from 'lodash' + +import { BaseLoader, BaseSource } from './base' +import { processMdxForSearch } from './markdown' + +type PartnerData = { + slug: string // The partner slug corresponding to the last part of the URL + overview: string // The Markdown content for indexing +} + +let supabaseClient: SupabaseClient +function getSupabaseClient() { + if (!supabaseClient) { + supabaseClient = createClient( + process.env.NEXT_PUBLIC_MISC_USE_URL!, + process.env.NEXT_PUBLIC_MISC_USE_ANON_KEY! + ) + return supabaseClient + } +} + +export async function fetchPartners() { + const supabase = getSupabaseClient() + const { data: partners } = await supabase + .from('partners') + .select('slug,overview') + .eq('approved', true) + // We want to show technology integrations, not agencies, in search + .neq('type', 'expert') + return partners +} + +export class IntegrationLoader extends BaseLoader { + type = 'partner-integration' as const + + constructor( + source: string, + public partnerData: PartnerData + ) { + const relPath = `/partners/integrations/${partnerData.slug}` + super(source, relPath) + } + + async load() { + return [new IntegrationSource(this.source, this.path, this.partnerData)] + } +} + +export class IntegrationSource extends BaseSource { + type = 'partner-integration' as const + + constructor( + source: string, + path: string, + public partnerData: PartnerData + ) { + super(source, path) + } + + process() { + const { checksum, sections } = processMdxForSearch(this.partnerData.overview) + const meta = { + title: upperFirst(this.partnerData.slug), + subtitle: 'Integration', + } + + this.checksum = checksum + this.meta = meta + this.sections = sections + + return { + checksum, + meta, + ragIgnore: true, + sections, + } + } + + extractIndexedContent() { + const sections = this.sections ?? [] + const result = + (this.meta?.title ?? '') + '\n\n' + sections.map(({ content }) => content).join('\n\n') + return result + } +} diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index e9bcaddbf65..89450174217 100644 --- a/apps/docs/spec/cli_v1_commands.yaml +++ b/apps/docs/spec/cli_v1_commands.yaml @@ -1,7 +1,7 @@ clispec: '001' info: id: cli - version: 1.131.5 + version: 1.142.1 title: Supabase CLI language: sh source: https://github.com/supabase/cli @@ -150,6 +150,15 @@ commands: name: --project-ref description: Project ref of the Supabase project. default_value: '' + - id: supabase-unlink + title: supabase unlink + summary: Unlink a Supabase project + tags: + - local-dev + links: [] + usage: supabase unlink + subcommands: [] + flags: [] - id: supabase-test title: supabase test summary: Run tests on local Supabase containers @@ -287,6 +296,18 @@ commands: usage: supabase storage cp [flags] subcommands: [] flags: + - id: cache-control + name: --cache-control + description: Custom Cache-Control header for HTTP upload. + default_value: max-age=3600 + - id: content-type + name: --content-type + description: Custom Content-Type header for HTTP upload. + default_value: auto-detect + - id: jobs + name: -j, --jobs + description: Maximum number of parallel jobs. + default_value: '1' - id: recursive name: -r, --recursive description: Recursively copy a directory. @@ -1386,7 +1407,7 @@ commands: Repaired migration history: 20230222032233 => applied tags: [] links: [] - usage: supabase migration repair [flags] + usage: supabase migration repair [version] ... [flags] subcommands: [] flags: - id: db-url @@ -2362,6 +2383,11 @@ commands: name: --local description: Generate types from the local dev database. default_value: 'false' + - id: postgrest-v9-compat + name: --postgrest-v9-compat + description: | + Generate types compatible with PostgREST v9 and below. Only use together with --db-url. + default_value: 'false' - id: project-id name: --project-id description: Generate types from a project ID. @@ -2960,6 +2986,10 @@ commands: name: --dry-run description: Prints the pg_dump script that would be executed. default_value: 'false' + - id: exclude + name: -x, --exclude + description: List of schema.tables to exclude from data-only dump. + default_value: '[]' - id: file name: -f, --file description: File path to save the dumped contents. diff --git a/apps/docs/spec/parser/package.json b/apps/docs/spec/parser/package.json index 2129f457569..963b826eb15 100644 --- a/apps/docs/spec/parser/package.json +++ b/apps/docs/spec/parser/package.json @@ -25,7 +25,7 @@ "@types/lodash": "^4.14.202", "json-stringify-safe": "^5.0.1", "lodash": "^4.17.21", - "prettier": "^2.8.8", + "prettier": "^4.0.0-alpha.8", "tsx": "^4.7.0" }, "prettier": { diff --git a/apps/docs/spec/supabase_dart_v1.yml b/apps/docs/spec/supabase_dart_v1.yml index ff5861f284f..56b84f652e0 100644 --- a/apps/docs/spec/supabase_dart_v1.yml +++ b/apps/docs/spec/supabase_dart_v1.yml @@ -381,7 +381,7 @@ functions: notes: | - Resends a signup confirmation, email change or phone change email to the user. - Passwordless sign-ins can be resent by calling the `signInWithOtp()` method again. - - Password recovery emails can be resent by calling the `resetPassswordForEmail()` method again. + - Password recovery emails can be resent by calling the `resetPasswordForEmail()` method again. - This method will only resend an email or phone OTP to the user if there was an initial signup, email change or phone change request being made. examples: - id: resend-email-signup-confirmation diff --git a/apps/docs/spec/supabase_dart_v2.yml b/apps/docs/spec/supabase_dart_v2.yml index 84b48a1bfdd..31004c3cc7d 100644 --- a/apps/docs/spec/supabase_dart_v2.yml +++ b/apps/docs/spec/supabase_dart_v2.yml @@ -274,7 +274,7 @@ functions: isSpotlight: true code: | ```dart - await supabase.auth.signInWithOAuth(Provider.github); + await supabase.auth.signInWithOAuth(OAuthProvider.github); ``` - id: sign-in-using-a-third-party-provider-with-redirect name: With `redirectTo` @@ -284,7 +284,7 @@ functions: code: | ```dart await supabase.auth.signInWithOAuth( - Provider.github, + OAuthProvider.github, redirectTo: kIsWeb ? null : 'io.supabase.flutter://reset-callback/', ); ``` @@ -296,7 +296,7 @@ functions: code: | ```dart await supabase.auth.signInWithOAuth( - Provider.github, + OAuthProvider.github, scopes: 'repo gist notifications' ); ... @@ -477,7 +477,7 @@ functions: notes: | - Resends a signup confirmation, email change, or phone change email to the user. - Passwordless sign-ins can be resent by calling the `signInWithOtp()` method again. - - Password recovery emails can be resent by calling the `resetPassswordForEmail()` method again. + - Password recovery emails can be resent by calling the `resetPasswordForEmail()` method again. - This method only resend an email or phone OTP to the user if an initial signup, email change, or phone change request was made. examples: - id: resend-email-signup-confirmation @@ -945,12 +945,13 @@ functions: - id: query-the-same-referenced-table-multiple-times name: Query the same referenced table multiple times description: | - Sometimes you will need to query the same referenced table twice. - In this case, you can use the name of the joined column to identify - which join you intend to use. For convenience, you can also give an - alias for each column. For example, if we had a shop of products, - and we wanted to get the supplier and the purchaser at the same time - (both in the users) table: + Sometimes you will need to query the same referenced table twice. In + this case, you can use the name of the joined column to identify which + join you intend to use. For convenience, you can also give an alias + for each column. This example queries a `messages` table that is + joined to a `users` table via `sender_id` and `receiver_id`. Since you + want the `name` for both sender and receiver, you can alias the + columns as `to` and `from`. code: | ```dart final data = await supabase @@ -1920,7 +1921,7 @@ functions: description: | Filters allow you to only return rows that match certain conditions. - Filters can be used on `select()`, `update()`, and `delete()` queries. + Filters can be used on `select()`, `update()`, `upsert()`, and `delete()` queries. If a Database function returns a table response, you can also apply filters. examples: @@ -1945,9 +1946,7 @@ functions: - id: chaining-filters name: Chaining Filters description: | - Filters must be applied after any of `select()`, `update()`, `upsert()`, - `delete()`, and `rpc()` and before - [modifiers](/docs/reference/dart/using-modifiers). + Filters can be chained together to produce advanced queries as shown in the example code. code: | ```dart final data = await supabase @@ -1970,16 +1969,14 @@ functions: .from('cities') .select('name, country_id'); - if (filterByName != null) { query = query.eq('name', filterByName); } - if (filterPopLow != null) { query = query.gte('population', filterPopLow); } - if (filterPopHigh != null) { query = query.lt('population', filterPopHigh); } + if (filterByName != null) query = query.eq('name', filterByName); + if (filterPopLow != null) query = query.gte('population', filterPopLow); + if (filterPopHigh != null) query = query.lt('population', filterPopHigh); final data = await query; ``` - id: filter-by-value-within-json-column name: Filter by values within a JSON column - description: | - Filters can be built up one step at a time and then executed. For example: data: sql: | ```sql @@ -1998,19 +1995,15 @@ functions: ``` response: | ```json - { - "data": [ - { - "id": 1, - "name": "Michael", - "address": { - "postcode": 90210 - } + [ + { + "id": 1, + "name": "Michael", + "address": { + "postcode": 90210 } - ], - "status": 200, - "statusText": "OK" - } + } + ] ``` code: | ```dart @@ -2056,23 +2049,22 @@ functions: (1, 2, 'Bali'), (2, 1, 'Munich'); ``` - response: | + response: | ```json - { - "data": [ - { - "name": "Indonesia", - "cities": [ - { - "name": "Bali" - } - ] - } - ], - "status": 200, - "statusText": "OK" - } + [ + { + "name": "Indonesia", + "cities": [ + { + "name": "Bali" + } + ] + } + ] ``` + description: | + You can filter on referenced tables in your `select()` query using dot + notation. - id: or title: or() description: | @@ -2105,6 +2097,52 @@ functions: .select('name, country_id') .or('id.gt.20,and(name.eq.New Zealand,name.eq.France)'); ``` + - id: use-or-on-referenced-tables + name: Use `or` on referenced tables + code: | + ```dart + final data = await supabase + .from('countries') + .select('name, cities!inner(name)') + .or('country_id.eq.1,name.eq.Beijing', referencedTable: 'cities'); + ``` + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + create table + cities ( + id int8 primary key, + country_id int8 not null references countries, + name text + ); + + insert into + countries (id, name) + values + (1, 'Germany'), + (2, 'Indonesia'); + insert into + cities (id, country_id, name) + values + (1, 2, 'Bali'), + (2, 1, 'Munich'); + ``` + response: | + ```json + [ + { + "name": "Germany", + "cities": [ + { + "name": "Munich" + } + ] + } + ] + ``` + hideCodeBlock: true - id: not title: not() @@ -2166,84 +2204,75 @@ functions: examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('countries') + .select() + .match({ 'id': 2, 'name': 'Albania' }); + ``` + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + + insert into + countries (id, name) + values + (1, 'Afghanistan'), + (2, 'Albania'), + (3, 'Algeria'); + ``` + response: | + ```json + [ + { + "name": "Albania" + } + ] + ``` + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('cities') - .select('name, country_id') - .match({'name': 'Beijing', 'country_id': 156}); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('cities') - .update({ 'name': 'Mordor' }) - .match({'name': 'Beijing', 'country_id': 156}); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('cities') - .delete() - .match({'name': 'Beijing', 'country_id': 156}); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_cities') - .match({'name': 'Beijing', 'country_id': 156}); - ``` - id: eq title: eq() description: | - Finds all rows whose value on the stated `column` exactly matches the specified `value`. + Match only rows where `column` is equal to `value`. examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('countries') + .select() + .eq('name', 'Albania'); + ``` + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + + insert into + countries (id, name) + values + (1, 'Afghanistan'), + (2, 'Albania'), + (3, 'Algeria'); + ``` + response: | + ```json + [ + { + "id": 2, + "name": "Albania" + } + ] + ``` + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('cities') - .select('name, country_id') - .eq('name', 'The shire'); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('cities') - .update({ 'name': 'Mordor' }) - .eq('name', 'San Francisco'); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('cities') - .delete() - .eq('name', 'Mordor'); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_cities') - .eq('name', 'San Francisco'); - ``` - id: neq title: neq() @@ -2252,41 +2281,41 @@ functions: examples: - id: with-select name: With `select()` - isSpotlight: true code: | ```dart final data = await supabase - .from('cities') + .from('countries') .select('name, country_id') - .neq('name', 'The shire'); + .neq('name', 'Albania'); ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('cities') - .update({ 'name': 'Mordor' }) - .neq('name', 'San Francisco'); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('cities') - .delete() - .neq('name', 'Mordor'); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_cities') - .neq('name', 'Lagos'); + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + + insert into + countries (id, name) + values + (1, 'Afghanistan'), + (2, 'Albania'), + (3, 'Algeria'); + ``` + response: | + ```json + [ + { + "id": 1, + "name": "Afghanistan" + }, + { + "id": 3, + "name": "Algeria" + } + ] ``` + hideCodeBlock: true + isSpotlight: true - id: gt title: gt() @@ -2295,41 +2324,37 @@ functions: examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('countries') + .select() + .gt('id', 2); + ``` + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + + insert into + countries (id, name) + values + (1, 'Afghanistan'), + (2, 'Albania'), + (3, 'Algeria'); + ``` + response: | + ```json + [ + { + "id": 3, + "name": "Algeria" + } + ] + ``` + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('cities') - .select('name, country_id') - .gt('country_id', 250); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('cities') - .update({ 'name': 'Mordor' }) - .gt('country_id', 250); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('cities') - .delete() - .gt('country_id', 250); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_cities') - .gt('country_id', 250); - ``` - id: gte title: gte() @@ -2338,41 +2363,41 @@ functions: examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('countries') + .select() + .gte('id', 2); + ``` + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + + insert into + countries (id, name) + values + (1, 'Afghanistan'), + (2, 'Albania'), + (3, 'Algeria'); + ``` + response: | + ```json + [ + { + "id": 2, + "name": "Albania" + }, + { + "id": 3, + "name": "Algeria" + } + ] + ``` + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('cities') - .select('name, country_id') - .gte('country_id', 250); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('cities') - .update({ 'name': 'Mordor' }) - .gte('country_id', 250); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('cities') - .delete() - .gte('country_id', 250); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_cities') - .gte('country_id', 250); - ``` - id: lt title: lt() @@ -2381,85 +2406,80 @@ functions: examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('countries') + .select() + .lt('id', 2); + ``` + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + + insert into + countries (id, name) + values + (1, 'Afghanistan'), + (2, 'Albania'), + (3, 'Algeria'); + ``` + response: | + ```json + [ + { + "id": 1, + "name": "Afghanistan" + } + ] + ``` + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('cities') - .select('name, country_id') - .lt('country_id', 250); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('cities') - .update({ 'name': 'Mordor' }) - .lt('country_id', 250); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('cities') - .delete() - .lt('country_id', 250); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_cities') - .lt('country_id', 250); - ``` - id: lte title: lte() description: | Finds all rows whose value on the stated `column` is less than or equal to the specified `value`. - $ref: '@supabase/postgrest-js.PostgrestFilterBuilder.lte' examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('countries') + .select() + .lte('id', 2); + ``` + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + + insert into + countries (id, name) + values + (1, 'Afghanistan'), + (2, 'Albania'), + (3, 'Algeria'); + ``` + response: | + ```json + [ + { + "id": 1, + "name": "Afghanistan" + }, + { + "id": 2, + "name": "Albania" + } + ] + ``` + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('cities') - .select('name, country_id') - .lte('country_id', 250); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('cities') - .update({ 'name': 'Mordor' }) - .lte('country_id', 250); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('cities') - .delete() - .lte('country_id', 250); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_cities') - .lte('country_id', 250); - ``` - id: like title: like() @@ -2469,41 +2489,37 @@ functions: examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('countries') + .select() + .like('name', '%Alba%'); + ``` + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + + insert into + countries (id, name) + values + (1, 'Afghanistan'), + (2, 'Albania'), + (3, 'Algeria'); + ``` + response: | + ```json + [ + { + "id": 2, + "name": "Albania" + } + ] + ``` + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('cities') - .select('name, country_id') - .like('name', '%la%'); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('cities') - .update({ 'name': 'Mordor' }) - .like('name', '%la%'); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('cities') - .delete() - .like('name', '%la%'); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_cities') - .like('name', '%la%'); - ``` - id: ilike title: ilike() @@ -2512,84 +2528,81 @@ functions: examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('countries') + .select() + .ilike('name', '%alba%'); + ``` + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + + insert into + countries (id, name) + values + (1, 'Afghanistan'), + (2, 'Albania'), + (3, 'Algeria'); + ``` + response: | + ```json + [ + { + "id": 2, + "name": "Albania" + } + ] + ``` + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('cities') - .select('name, country_id') - .ilike('name', '%la%'); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('cities') - .update({ 'name': 'Mordor' }) - .ilike('name', '%la%'); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('cities') - .delete() - .ilike('name', '%la%'); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_cities') - .ilike('name', '%la%'); - ``` - id: is title: isFilter() description: | A check for exact equality (null, true, false), finds all rows whose value on the stated `column` exactly match the specified `value`. examples: - - id: with-select - name: With `select()` + - id: checking-nullness + name: Checking for nullness, true or false + code: | + ```dart + final data = await supabase + .from('countries') + .select() + .isFilter('name', null); + ``` + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + + insert into + countries (id, name) + values + (1, 'null'), + (2, null); + ``` + response: | + ```json + [ + { + "id": 1, + "name": "null" + } + ] + ``` + description: | + Using the `eq()` filter doesn't work when filtering for `null`. + + Instead, you need to use `isFilter()`. + + `isFilter()` is equivalent to `is()` in SDKs for other languages. It's named `isFilter()` in Dart to avoid a conflict with the `is` keyword in Dart. + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('cities') - .select('name, country_id') - .isFilter('name', null); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('cities') - .update({ 'name': 'Mordor' }) - .isFilter('name', null); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('cities') - .delete() - .isFilter('name', null); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_cities') - .isFilter('name', null); - ``` - id: in title: inFilter() @@ -2598,246 +2611,411 @@ functions: examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('countries') + .select() + .inFilter('name', ['Albania', 'Algeria']); + ``` + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + + insert into + countries (id, name) + values + (1, 'Afghanistan'), + (2, 'Albania'), + (3, 'Algeria'); + ``` + response: | + ```json + [ + { + "id": 2, + "name": "Albania" + }, + { + "id": 3, + "name": "Algeria" + } + ] + ``` + description: | + `inFilter()` is equivalent to `in()` in SDKs for other languages. It's named `inFilter()` in Dart to avoid a conflict with the `in` keyword in Dart. + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('cities') - .select('name, country_id') - .inFilter('name', ['Rio de Janeiro', 'San Francisco']); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('cities') - .update({ 'name': 'Mordor' }) - .inFilter('name', ['Rio de Janeiro', 'San Francisco']); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('cities') - .delete() - .inFilter('name', ['Rio de Janeiro', 'San Francisco']); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_cities') - .inFilter('name', ['Rio de Janeiro', 'San Francisco']); - ``` - id: contains title: contains() examples: - - id: with-select - name: With `select()` + - id: on-array-columns + name: On array columns isSpotlight: true code: | ```dart final data = await supabase - .from('countries') - .select('name, id, main_exports') - .contains('main_exports', ['oil']); + .from('issues') + .select() + .contains('tags', ['is:open', 'priority:low']); ``` - - id: with-update - name: With `update()` + data: + sql: | + ```sql + create table + issues ( + id int8 primary key, + title text, + tags text[] + ); + + insert into + issues (id, title, tags) + values + (1, 'Cache invalidation is not working', array['is:open', 'severity:high', 'priority:low']), + (2, 'Use better names', array['is:open', 'severity:low', 'priority:medium']); + ``` + response: | + ```json + [ + { + "id": 1, + "title": "Cache invalidation is not working", + "tags": ["is:open", "severity:high", "priority:low"] + } + ] + ``` + hideCodeBlock: true + - id: on-range-columns + name: On range columns code: | ```dart final data = await supabase - .from('countries') - .update({ 'name': 'Mordor' }) - .contains('main_exports', ['oil']); + .from('reservations') + .select() + .contains('during', '[2000-01-01 13:00, 2000-01-01 13:30)'); ``` - - id: with-delete - name: With `delete()` + data: + sql: | + ```sql + create table + reservations ( + id int8 primary key, + room_name text, + during tsrange + ); + + insert into + reservations (id, room_name, during) + values + (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'), + (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)'); + ``` + response: | + ```json + [ + { + "id": 1, + "room_name": "Emerald", + "during": "[\"2000-01-01 13:00:00\",\"2000-01-01 15:00:00\")" + } + ] + ``` + description: | + Postgres supports a number of [range + types](https://www.postgresql.org/docs/current/rangetypes.html). You + can filter on range columns using the string representation of range + values. + hideCodeBlock: true + - id: on-jsonb-columns + name: On `jsonb` columns code: | ```dart final data = await supabase - .from('countries') - .delete() - .contains('main_exports', ['oil']); + .from('users') + .select('name') + .contains('address', { 'street': 'Melrose Place' }); ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_countries') - .contains('main_exports', ['oil']); + data: + sql: | + ```sql + create table + users ( + id int8 primary key, + name text, + address jsonb + ); + + insert into + users (id, name, address) + values + (1, 'Michael', '{ "postcode": 90210, "street": "Melrose Place" }'), + (2, 'Jane', '{"postcode": 90210}'); + ``` + response: | + ```json + [ + { + "name": "Michael" + } + ] ``` + hideCodeBlock: true - id: contained-by title: containedBy() examples: - - id: with-select - name: With `select()` + - id: on-array-columns + name: On array columns isSpotlight: true code: | ```dart final data = await supabase - .from('countries') - .select('name, id, main_exports') - .containedBy('main_exports', ['cars', 'food', 'machine']); + .from('classes') + .select('name') + .containedBy('days', ['monday', 'tuesday', 'wednesday', 'friday']); ``` - - id: with-update - name: With `update()` + data: + sql: | + ```sql + create table + classes ( + id int8 primary key, + name text, + days text[] + ); + + insert into + classes (id, name, days) + values + (1, 'Chemistry', array['monday', 'friday']), + (2, 'History', array['monday', 'wednesday', 'thursday']); + ``` + response: | + ```json + [ + { + "name": "Chemistry" + } + ] + ``` + hideCodeBlock: true + - id: on-range-columns + name: On range columns code: | ```dart final data = await supabase - .from('countries') - .update({ 'name': 'Mordor' }) - .containedBy('main_exports', ['orks', 'surveillance', 'evil']); + .from('reservations') + .select() + .containedBy('during', '[2000-01-01 00:00, 2000-01-01 23:59)'); ``` - - id: with-delete - name: With `delete()` + data: + sql: | + ```sql + create table + reservations ( + id int8 primary key, + room_name text, + during tsrange + ); + + insert into + reservations (id, room_name, during) + values + (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'), + (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)'); + ``` + response: | + ```json + [ + { + "id": 1, + "room_name": "Emerald", + "during": "[\"2000-01-01 13:00:00\",\"2000-01-01 15:00:00\")" + } + ] + ``` + description: | + Postgres supports a number of [range + types](https://www.postgresql.org/docs/current/rangetypes.html). You + can filter on range columns using the string representation of range + values. + hideCodeBlock: true + - id: on-jsonb-columns + name: On `jsonb` columns code: | ```dart final data = await supabase - .from('countries') - .delete() - .containedBy('main_exports', ['cars', 'food', 'machine']); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_countries') - .containedBy('main_exports', ['cars', 'food', 'machine']); + .from('users') + .select('name') + .containedBy('address', {'postcode': 90210}); ``` + data: + sql: | + ```sql + create table + users ( + id int8 primary key, + name text, + address jsonb + ); + + insert into + users (id, name, address) + values + (1, 'Michael', '{ "postcode": 90210, "street": "Melrose Place" }'), + (2, 'Jane', '{"postcode": 90210}'); + ``` + response: | + ```json + [ + { + "name": "Jane" + } + ] + ``` + hideCodeBlock: true - id: range-lt title: rangeLt() examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('reservations') + .select() + .rangeLt('during', '[2000-01-01 15:00, 2000-01-01 16:00)'); + ``` + data: + sql: | + ```sql + create table + reservations ( + id int8 primary key, + room_name text, + during tsrange + ); + + insert into + reservations (id, room_name, during) + values + (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'), + (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)'); + ``` + response: | + ```json + [ + { + "id": 1, + "room_name": "Emerald", + "during": "[\"2000-01-01 13:00:00\",\"2000-01-01 15:00:00\")" + } + ] + ``` + description: | + Postgres supports a number of [range + types](https://www.postgresql.org/docs/current/rangetypes.html). You + can filter on range columns using the string representation of range + values. + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('countries') - .select('name, id, population_range_millions') - .rangeLt('population_range_millions', '[150, 250]'); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('countries') - .update({ 'name': 'Mordor' }) - .rangeLt('population_range_millions', '[150, 250]'); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('countries') - .delete() - .rangeLt('population_range_millions', '[150, 250]'); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_countries') - .rangeLt('population_range_millions', '[150, 250]'); - ``` - id: range-gt title: rangeGt() examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('reservations') + .select() + .rangeGt('during', '[2000-01-02 08:00, 2000-01-02 09:00)'); + ``` + data: + sql: | + ```sql + create table + reservations ( + id int8 primary key, + room_name text, + during tsrange + ); + + insert into + reservations (id, room_name, during) + values + (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'), + (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)'); + ``` + response: | + ```json + [ + { + "id": 2, + "room_name": "Topaz", + "during": "[\"2000-01-02 09:00:00\",\"2000-01-02 10:00:00\")" + } + ] + ``` + description: | + Postgres supports a number of [range + types](https://www.postgresql.org/docs/current/rangetypes.html). You + can filter on range columns using the string representation of range + values. + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('countries') - .select('name, id, population_range_millions') - .rangeGt('population_range_millions', '[150, 250]'); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('countries') - .update({ 'name': 'Mordor' }) - .rangeGt('population_range_millions', '[150, 250]'); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('countries') - .delete() - .rangeGt('population_range_millions', '[150, 250]'); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_countries') - .rangeGt('population_range_millions', '[150, 250]'); - ``` - id: range-gte title: rangeGte() examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('reservations') + .select() + .rangeGte('during', '[2000-01-02 08:30, 2000-01-02 09:30)'); + ``` + data: + sql: | + ```sql + create table + reservations ( + id int8 primary key, + room_name text, + during tsrange + ); + + insert into + reservations (id, room_name, during) + values + (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'), + (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)'); + ``` + response: | + ```json + [ + { + "id": 2, + "room_name": "Topaz", + "during": "[\"2000-01-02 09:00:00\",\"2000-01-02 10:00:00\")" + } + ] + ``` + description: | + Postgres supports a number of [range + types](https://www.postgresql.org/docs/current/rangetypes.html). You + can filter on range columns using the string representation of range + values. + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('countries') - .select('name, id, population_range_millions') - .rangeGte('population_range_millions', '[150, 250]'); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('countries') - .update({ 'name': 'Mordor' }) - .rangeGte('population_range_millions', '[150, 250]'); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('countries') - .delete() - .rangeGte('population_range_millions', '[150, 250]'); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_countries') - .rangeGte('population_range_millions', '[150, 250]'); - ``` - id: range-lte title: rangeLte() @@ -2845,123 +3023,172 @@ functions: examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('reservations') + .select() + .rangeLte('during', '[2000-01-01 15:00, 2000-01-01 16:00)'); + ``` + data: + sql: | + ```sql + create table + reservations ( + id int8 primary key, + room_name text, + during tsrange + ); + + insert into + reservations (id, room_name, during) + values + (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'), + (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)'); + ``` + response: | + ```json + [ + { + "id": 1, + "room_name": "Emerald", + "during": "[\"2000-01-01 13:00:00\",\"2000-01-01 15:00:00\")" + } + ] + ``` + description: | + Postgres supports a number of [range + types](https://www.postgresql.org/docs/current/rangetypes.html). You + can filter on range columns using the string representation of range + values. + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('countries') - .select('name, id, population_range_millions') - .rangeLte('population_range_millions', '[150, 250]'); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('countries') - .update({ 'name': 'Mordor' }) - .rangeLte('population_range_millions', '[150, 250]'); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('countries') - .delete() - .rangeLte('population_range_millions', '[150, 250]'); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_countries') - .rangeLte('population_range_millions', [150, 250]); - ``` - id: range-adjacent title: rangeAdjacent() examples: - id: with-select name: With `select()` + code: | + ```dart + final data = await supabase + .from('reservations') + .select() + .rangeAdjacent('during', '[2000-01-01 12:00, 2000-01-01 13:00)'); + ``` + data: + sql: | + ```sql + create table + reservations ( + id int8 primary key, + room_name text, + during tsrange + ); + + insert into + reservations (id, room_name, during) + values + (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'), + (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)'); + ``` + response: | + ```json + [ + { + "id": 1, + "room_name": "Emerald", + "during": "[\"2000-01-01 13:00:00\",\"2000-01-01 15:00:00\")" + } + ] + ``` + description: | + Postgres supports a number of [range + types](https://www.postgresql.org/docs/current/rangetypes.html). You + can filter on range columns using the string representation of range + values. + hideCodeBlock: true isSpotlight: true - code: | - ```dart - final data = await supabase - .from('countries') - .select('name, id, population_range_millions') - .rangeAdjacent('population_range_millions', '[70, 185]'); - ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('countries') - .update({ 'name': 'Mordor' }) - .rangeAdjacent('population_range_millions', '[70, 185]'); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('countries') - .delete() - .rangeAdjacent('population_range_millions', '[70, 185]'); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_countries') - .rangeAdjacent('population_range_millions', '[70, 185]'); - ``` - id: overlaps title: overlaps() examples: - - id: with-select - name: With `select()` + - id: on-array-columns + name: On array columns + code: | + ```dart + final data = await supabase + .from('issues') + .select('title') + .overlaps('tags', ['is:closed', 'severity:high']); + ``` + data: + sql: | + ```sql + create table + issues ( + id int8 primary key, + title text, + tags text[] + ); + + insert into + issues (id, title, tags) + values + (1, 'Cache invalidation is not working', array['is:open', 'severity:high', 'priority:low']), + (2, 'Use better names', array['is:open', 'severity:low', 'priority:medium']); + ``` + response: | + ```json + [ + { + "title": "Cache invalidation is not working" + } + ] + ``` + hideCodeBlock: true isSpotlight: true + - id: on-range-columns + name: On range columns code: | ```dart final data = await supabase - .from('countries') - .select('name, id, main_exports') - .overlaps('main_exports', ['computers', 'minerals']); + .from('reservations') + .select() + .overlaps('during', '[2000-01-01 12:45, 2000-01-01 13:15)'); ``` - - id: with-update - name: With `update()` - code: | - ```dart - final data = await supabase - .from('countries') - .update({ 'name': 'Mordor' }) - .overlaps('main_exports', ['computers', 'minerals']); - ``` - - id: with-delete - name: With `delete()` - code: | - ```dart - final data = await supabase - .from('countries') - .delete() - .overlaps('main_exports', ['computers', 'minerals']); - ``` - - id: with-rpc - name: With `rpc()` - code: | - ```dart - // Only valid if the Stored Procedure returns a table type. - final data = await supabase - .rpc('echo_all_countries') - .overlaps('main_exports', ['computers', 'minerals']); + data: + sql: | + ```sql + create table + reservations ( + id int8 primary key, + room_name text, + during tsrange + ); + + insert into + reservations (id, room_name, during) + values + (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'), + (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)'); + ``` + response: | + ```json + [ + { + "id": 1, + "room_name": "Emerald", + "during": "[\"2000-01-01 13:00:00\",\"2000-01-01 15:00:00\")" + } + ] ``` + description: | + Postgres supports a number of [range + types](https://www.postgresql.org/docs/current/rangetypes.html). You + can filter on range columns using the string representation of range + values. + hideCodeBlock: true - id: text-search title: textSearch() @@ -3078,12 +3305,49 @@ functions: .rpc('echo_all_cities') .filter('name', 'in', '("Paris","Tokyo")'); ``` - - id: filter-embedded-resources - name: Filter embedded resources + - id: on-a-referenced-table + name: On a referenced table code: | ```dart final data = await supabase - .from('cities') - .select('name, countries ( name )') - .filter('countries.name', 'in', '("France","Japan")'); + .from('countries') + .select('name, countries!inner( name )') + .filter('cities.name', 'eq', 'Bali'); ``` + data: + sql: | + ```sql + create table + countries (id int8 primary key, name text); + create table + cities ( + id int8 primary key, + country_id int8 not null references countries, + name text + ); + + insert into + countries (id, name) + values + (1, 'Germany'), + (2, 'Indonesia'); + insert into + cities (id, country_id, name) + values + (1, 2, 'Bali'), + (2, 1, 'Munich'); + ``` + response: | + ```json + [ + { + "name": "Indonesia", + "cities": [ + { + "name": "Bali" + } + ] + } + ] + ``` + hideCodeBlock: true diff --git a/apps/docs/spec/supabase_js_v2.yml b/apps/docs/spec/supabase_js_v2.yml index ea0e1db6ba5..674c329b3d4 100644 --- a/apps/docs/spec/supabase_js_v2.yml +++ b/apps/docs/spec/supabase_js_v2.yml @@ -239,10 +239,8 @@ functions: - To fetch the currently logged-in user, refer to [`getUser()`](/docs/reference/javascript/auth-getuser). examples: - id: sign-up - name: Sign up + name: Sign up with an email and password isSpotlight: true - description: | - If the password is larger than 72 chars, it will be truncated to the first 72 chars. code: | ```js const { data, error } = await supabase.auth.signUp({ @@ -250,6 +248,34 @@ functions: password: 'example-password', }) ``` + - id: sign-up-phone + name: Sign up with a phone number and password (SMS) + isSpotlight: true + code: | + ```js + const { data, error } = await supabase.auth.signUp({ + phone: '123456789', + password: 'example-password', + options: { + channel: 'sms' + } + }) + ``` + - id: sign-up-phone-whatsapp + name: Sign up with a phone number and password (whatsapp) + isSpotlight: true + description: | + The user will be sent a WhatsApp message which contains a OTP. By default, a given user can only request a OTP once every 60 seconds. Note that a user will need to have a valid WhatsApp account that is linked to Twilio in order to use this feature. + code: | + ```js + const { data, error } = await supabase.auth.signUp({ + phone: '123456789', + password: 'example-password', + options: { + channel: 'whatsapp' + } + }) + ``` - id: sign-up-with-additional-user-metadata name: Sign up with additional user metadata isSpotlight: false @@ -338,7 +364,7 @@ functions: isSpotlight: true code: | ```js - const subscription = supabase.auth.onAuthStateChange((event, session) => { + const { data } = supabase.auth.onAuthStateChange((event, session) => { console.log(event, session) if (event === 'INITIAL_SESSION') { @@ -357,7 +383,7 @@ functions: }) // call unsubscribe to remove the callback - subscription.unsubscribe() + data.subscription.unsubscribe() ``` - id: listen-to-sign-out name: Listen to sign out @@ -505,12 +531,6 @@ functions: phone: '+13334445555', password: 'some-password', }) - - // After receiving a SMS with a OTP. - const { data, error } = await supabase.auth.verifyOtp({ - phone: '+13334445555', - token: '123456', - }) ``` - id: sign-in-with-otp title: 'signInWithOtp()' @@ -836,16 +856,28 @@ functions: isSpotlight: false code: | ```js - const { data, error } = await supabase.auth.updateUser({email: 'new@email.com'}) + const { data, error } = await supabase.auth.updateUser({ + email: 'new@email.com' + }) + ``` + - id: update-the-phone-for-an-authenticated-user + name: Update the phone number for an authenticated user + description: Sends a one-time password (OTP) to the new phone number. + isSpotlight: false + code: | + ```js + const { data, error } = await supabase.auth.updateUser({ + phone: '123456789' + }) ``` - id: update-the-password-for-an-authenticated-user name: Update the password for an authenticated user isSpotlight: false - description: | - If the password is larger than 72 chars, it will be truncated to the first 72 chars. code: | ```js - const { data, error } = await supabase.auth.updateUser({password: 'new password'}) + const { data, error } = await supabase.auth.updateUser({ + password: 'new password' + }) ``` - id: update-the-users-metadata name: Update the user's metadata @@ -944,7 +976,7 @@ functions: notes: | - Resends a signup confirmation, email change or phone change email to the user. - Passwordless sign-ins can be resent by calling the `signInWithOtp()` method again. - - Password recovery emails can be resent by calling the `resetPassswordForEmail()` method again. + - Password recovery emails can be resent by calling the `resetPasswordForEmail()` method again. - This method will only resend an email or phone OTP to the user if there was an initial signup, email change or phone change request being made. - You can specify a redirect url when you resend an email link using the `emailRedirectTo` option. examples: @@ -1007,7 +1039,7 @@ functions: isSpotlight: true code: | ```js - const { data, error } = supabase.auth.setSession({ + const { data, error } = await supabase.auth.setSession({ access_token, refresh_token }) @@ -1236,8 +1268,6 @@ functions: - id: create-a-new-user-with-custom-user-metadata name: With custom user metadata isSpotlight: true - description: | - If the password is larger than 72 chars, it will be truncated to the first 72 chars. code: | ```js const { data, error } = await supabase.auth.admin.createUser({ @@ -4055,7 +4085,7 @@ functions: name ) `) - .or('country_id.eq.1,name.eq.Beijing', { referenceTable: 'cities' }) + .or('country_id.eq.1,name.eq.Beijing', { referencedTable: 'cities' }) ``` data: sql: | diff --git a/apps/docs/spec/supabase_kt_v2.yml b/apps/docs/spec/supabase_kt_v2.yml index dfd761dc4e2..00a385f9bad 100644 --- a/apps/docs/spec/supabase_kt_v2.yml +++ b/apps/docs/spec/supabase_kt_v2.yml @@ -2528,6 +2528,7 @@ functions: - Requires either an email or phone number. - This method is used for passwordless sign-ins where a OTP is sent to the user's email or phone number. - If the user doesn't exist, `signInWith(OTP)` will signup the user instead. To restrict this behavior, you can set `createUser` to `false`. + - The method `signUpWith(OTP)` does the exact same thing as `signInWith(OTP)`, so it doesn't matter which one you use. - If you're using an email, you can configure whether you want the user to receive a magiclink or a OTP. - If you're using phone, you can configure whether you want the user to receive a OTP. - The magic link's destination URL is determined by the [`SITE_URL`](/docs/reference/auth/config#site_url). @@ -2677,22 +2678,24 @@ functions: title: 'Verify OTPs' $ref: '@supabase/gotrue-js.GoTrueClient.verifyOtp' notes: | - Log in a user given a User supplied OTP received via mobile. + - Verifying an OTP is done through either `verifyPhoneOtp` or `verifyEmailOtp`. + - The verification type used should be determined based on the corresponding auth method called before using `verifyPhoneOtp`/`verifyEmailOtp` to sign up / sign-in a user. examples: - id: verify-email-otp(otp) name: Verify an Email OTP isSpotlight: true code: | ```kotlin - supabase.auth.verifyEmailOtp(type = OtpType.Email.INVITE, email = "example@email.com", token = "token") + supabase.auth.verifyEmailOtp(type = OtpType.Email.EMAIL, email = "example@email.com", token = "token") ``` description: | Available types are: - - `OtpType.Email.MAGIC_LINK` - - `OtpType.Email.SIGNUP` + - `OtpType.Email.MAGIC_LINK` (deprecated) + - `OtpType.Email.SIGNUP` (deprecated) - `OtpType.Email.INVITE` - `OtpType.Email.RECOVERY` - `OtpType.Email.EMAIL_CHANGE` + - `OtpType.Email.EMAIL` - id: verify-phone-otp(otp) name: Verify an Phone OTP isSpotlight: false @@ -3502,6 +3505,7 @@ functions: - Realtime is disabled by default for new Projects for better database performance and security. You can turn it on by [managing replication](/docs/guides/database/api#managing-realtime). - If you want to receive the "previous" data for updates and deletes, you will need to set `REPLICA IDENTITY` to `FULL`, like this: `ALTER TABLE your_table REPLICA IDENTITY FULL;` - When using a method with a generic type like `track`, `broadcast` or `broadcastFlow`, you have to provide a [serializable class](/docs/reference/kotlin/installing#serialization) as the type parameter. + - Presence, Broadcast and Database updates are sent through a [Flow](https://kotlinlang.org/docs/flow.html) examples: - id: liste-to-broadcasts name: Listen to broadcasts @@ -3516,7 +3520,7 @@ functions: val broadcastFlow = channel.broadcastFlow(event = "message") - //in a new coroutine (or use Flow.onEach().launchIn(scope)): + //Collect the flow broadcastFlow.collect { //it: Message println(it) } @@ -3532,22 +3536,27 @@ functions: @Serializable data class PresenceState(val username: String) - val connectedUsers = mutableSetOf() val channel = supabase.channel("channelId") { //optional config } + + //if you want more control and want to receive the raw data, use the `presenceChangeFlow` method + val presenceFlow = channel.presenceDataFlow() - val presenceChangeFlow = channel.presenceChangeFlow() - - //in a new coroutine (or use Flow.onEach().launchIn(scope)): - presenceChangeFlow.collect { - connectedUsers += it.decodeJoinsAs() - connectedUsers -= it.decodeLeavesAs() + //Collect the flow + presenceFlow.collect { + for (presence in it) { + println(presence.username) + } } channel.subscribe(blockUntilSubscribed = true) - //send own state + //send your own state channel.track(PresenceState(username = "John")) + description: | + You have two methods you can use to listen for presence updates: + - `presenceDataFlow` takes care of caching presences based on their id and automatically emits the updated list. + - `presenceChangeFlow` emits the raw data received by realtime. Can be used if more control is required. - id: listen-to-all-database-changes name: Listen to all database changes code: | @@ -3557,7 +3566,7 @@ functions: } val changeFlow = channel.postgresChangeFlow(schema = "public") - //in a new coroutine (or use Flow.onEach().launchIn(scope)): + //Collect the flow changeFlow.collect { when(it) { is PostgresAction.Delete -> println("Deleted: ${it.oldRecord}") @@ -3576,11 +3585,17 @@ functions: val channel = supabase.channel("channelId") { //optional config } + + //This flow will automatically emit the initial data of all messages (which can be restricted by specifying a filter) and then listens for updates. + //The data is cached automatically by the primary key you specify + val users: Flow> = channel.postgresListDataFlow(schema = "public", table = "users", primaryKey = User::id) + + //If you want more control and want to receive the raw data use the `postgresChangeFlow` method: val changeFlow = channel.postgresChangeFlow(schema = "public") { table = "users" } - //in a new coroutine (or use Flow.onEach().launchIn(scope)): + //Collect the flow changeFlow.collect { when(it) { is PostgresAction.Delete -> println("Deleted: ${it.oldRecord}") @@ -3592,6 +3607,10 @@ functions: channel.subscribe() ``` + description: | + You have two methods you can use if you want to listen for database updates on a specific table: + - `postgresListDataFlow` fetches the initial data of all database entries matching your filter (or all entries if you didn't specify one) and then caches the data based on their primary key. Inserts, Updates and Deletes are handled automatically and you will get the updated list through the flow. + - `postgresChangeFlow` emits the raw data received by realtime. Events can be filtered by specifying a `PostgresAction` type parameter (see examples), or all by specifying only `PostgresAction`. Can be used if more control is required. - id: listen-to-inserts name: Listen to inserts code: | @@ -3603,7 +3622,7 @@ functions: table = "users" } - //in a new coroutine (or use Flow.onEach().launchIn(scope)): + //Collect the flow changeFlow.collect { println(it.record) } @@ -3624,11 +3643,12 @@ functions: val channel = supabase.channel("channelId") { //optional config } + val changeFlow = channel.postgresChangeFlow(schema = "public") { table = "users" } - //in a new coroutine (or use Flow.onEach().launchIn(scope)): + //Collect the flow changeFlow.collect { println(it.record) println(it.oldRecord) @@ -3650,11 +3670,12 @@ functions: val channel = supabase.channel("channelId") { //optional config } + val changeFlow = channel.postgresChangeFlow(schema = "public") { table = "users" } - //in a new coroutine (or use Flow.onEach().launchIn(scope)): + //Collect the flow changeFlow.collect { println(it.oldRecord) } @@ -3663,24 +3684,35 @@ functions: ``` - id: listening-to-row-level-changes name: Listen to row level changes - description: You can listen to individual rows using the format `{table}:{col}=eq.{val}` - where `{col}` is the column name, and `{val}` is the value which you want to match. code: | ```kotlin val channel = supabase.channel("channelId") { //optional config } + + //This flow will automatically emit the user's initial data and then emits new values on update. The flow ends on deletion + val user: Flow = channel.postgresSingleDataFlow(schema = "public", table = "users", primaryKey = User::id) { + //This is the same filter as used in postgrest, so you could use complex queries, but only one entry is fetched + eq("id", 1) + } + + //If you want more control, use the `postgresChangeFlow` method which works the same as the other examples val changeFlow = channel.postgresChangeFlow(schema = "public") { table = "users" - filter = "id=eq.1" + filter("id", FilterOperator.EQ, 1) } - //in a new coroutine: + //Collect the flow changeFlow.collect { println(it.oldRecord) } channel.subscribe() ``` + description: | + You have two methods you can use if you want to listen for updates on a specific database row: + - `postgresSingleDataFlow` fetches the initial data matching the filter and then emits updates based on realtime events. + - `postgresChangeFlow` emits the raw data received by realtime. To filter the realtime queries, use the `filter` method. - id: remove-channel description: | @@ -3689,6 +3721,7 @@ functions: notes: | - Removing a channel is a great way to maintain the performance of your project's Realtime service as well as your database if you're listening to Postgres changes. - Supabase will automatically handle cleanup 30 seconds after a client is disconnected, but unused channels may cause degradation as more clients are simultaneously subscribed. + - If you removed all channels, the client automatically disconnects from the Realtime websocket. This can be disabled in the Realtime config by setting `disconnectOnNoSubscriptions` to false. examples: - id: removes-a-channel name: Remove a channel @@ -3718,6 +3751,7 @@ functions: notes: | Unsubscribes and removes all Realtime channels from Realtime client. - Removing channels is a great way to maintain the performance of your project's Realtime service as well as your database if you're listening to Postgres changes. Supabase will automatically handle cleanup 30 seconds after a client is disconnected, but unused channels may cause degradation as more clients are simultaneously subscribed. + - If you removed all channels, the client automatically disconnects from the Realtime websocket. This can be disabled in the Realtime config by setting `disconnectOnNoSubscriptions` to false. examples: - id: remove-all-channels name: Remove all channels diff --git a/apps/docs/spec/supabase_py_v2.yml b/apps/docs/spec/supabase_py_v2.yml index 5bb793efb3f..1b9bb492e5f 100644 --- a/apps/docs/spec/supabase_py_v2.yml +++ b/apps/docs/spec/supabase_py_v2.yml @@ -144,7 +144,7 @@ functions: code: | ``` data = supabase.auth.sign_in_with_otp({ - phone: '+13334445555', + "phone": '+13334445555', }) ``` - id: sign-in-with-oauth @@ -761,7 +761,7 @@ functions: code: | ```python data, count = supabase.table('countries') - .update({'name': 'Austrailia'}) + .update({'name': 'Australia'}) .eq('id', 1) .execute() ``` @@ -778,7 +778,7 @@ functions: ``` response: | ``` - APIResponse(data=[{'id': 1, 'name': 'Austrailia'}], count=None) + APIResponse(data=[{'id': 1, 'name': 'Australia'}], count=None) ``` hideCodeBlock: true isSpotlight: true @@ -792,7 +792,7 @@ functions: code: | ```python data, count = supabase.table('countries') - .upsert({'id': 1, 'name': 'Austrailia'}) + .upsert({'id': 1, 'name': 'Australia'}) .execute() ``` data: @@ -808,7 +808,7 @@ functions: ``` response: | ``` - APIResponse(data=[{'id': 1, 'name': 'Austrailia'}], count=None) + APIResponse(data=[{'id': 1, 'name': 'Australia'}], count=None) ``` hideCodeBlock: true isSpotlight: true @@ -1906,7 +1906,7 @@ functions: name: Error handling description: | Returns one of the following errors: - + - `FunctionsHttpError`: if your function throws an error - `FunctionsRelayError`: if the Supabase Relay encounters an error processing your function isSpotlight: true @@ -1933,7 +1933,7 @@ functions: name: Passing custom headers description: | The library accepts custom headers via the `headers` option. - + Note: `supabase-py` automatically populates the `Authorization` header if there is a signed in user. isSpotlight: true code: | diff --git a/apps/docs/spec/supabase_swift_v1.yml b/apps/docs/spec/supabase_swift_v1.yml index 0f865246717..5e76cc23425 100644 --- a/apps/docs/spec/supabase_swift_v1.yml +++ b/apps/docs/spec/supabase_swift_v1.yml @@ -94,8 +94,6 @@ functions: - id: sign-up name: Sign up isSpotlight: true - description: | - If the password is larger than 72 chars, it will be truncated to the first 72 chars. code: | ```swift try await supabase.auth.signUp( @@ -360,8 +358,6 @@ functions: - id: update-the-password-for-an-authenticated-user name: Update the password for an authenticated user isSpotlight: false - description: | - If the password is larger than 72 chars, it will be truncated to the first 72 chars. code: | ```swift try await supabase.auth.update(user: UserAttributes(password: "newPassw0rd?")) diff --git a/apps/docs/spec/supabase_swift_v2.yml b/apps/docs/spec/supabase_swift_v2.yml index 586ceb850a0..4e1413ba8ff 100644 --- a/apps/docs/spec/supabase_swift_v2.yml +++ b/apps/docs/spec/supabase_swift_v2.yml @@ -94,8 +94,6 @@ functions: - id: sign-up name: Sign up isSpotlight: true - description: | - If the password is larger than 72 chars, it will be truncated to the first 72 chars. code: | ```swift try await supabase.auth.signUp( @@ -360,8 +358,6 @@ functions: - id: update-the-password-for-an-authenticated-user name: Update the password for an authenticated user isSpotlight: false - description: | - If the password is larger than 72 chars, it will be truncated to the first 72 chars. code: | ```swift try await supabase.auth.update(user: UserAttributes(password: "newPassw0rd?")) @@ -384,7 +380,7 @@ functions: notes: | - Resends a signup confirmation, email change, or phone change email to the user. - Passwordless sign-ins can be resent by calling the `signInWithOTP()` method again. - - Password recovery emails can be resent by calling the `resetPassswordForEmail()` method again. + - Password recovery emails can be resent by calling the `resetPasswordForEmail()` method again. - This method only resends an email or phone OTP to the user if there an initial signup, email change, or phone change request was made. - You can specify a redirect URL when you resend an email link using the `emailRedirectTo` option. examples: @@ -1208,17 +1204,17 @@ functions: ```swift struct City: Decodable { let name: String - let countries: [Country] } struct Country: Decodable { let name: String + let cities: [City] } - let cities: [City] = try await supabase.database - .from("cities") - .select("name, countries!inner(name)") - .eq("countries.name", value: "Indonesia") + let countries: [Country] = try await supabase.database + .from("countries") + .select("name, cities!inner(name)") + .eq("name", value: "united kingdom") .execute() .value ``` @@ -1251,8 +1247,21 @@ functions: { "data": [ { - "name": "Bali", - "countries": {"name": "Indonesia"} + "name": "united kingdom", + "cities": [ + { + "name": "London" + }, + { + "name": "Manchester" + }, + { + "name": "Liverpool" + }, + { + "name": "Bristol" + } + ] } ], "status": 200, diff --git a/apps/docs/styles/Home.module.css b/apps/docs/styles/Home.module.css index 6d7be21e752..d113f01f909 100644 --- a/apps/docs/styles/Home.module.css +++ b/apps/docs/styles/Home.module.css @@ -91,7 +91,9 @@ text-decoration: none; border: 1px solid #eaeaea; border-radius: 10px; - transition: color 0.15s ease, border-color 0.15s ease; + transition: + color 0.15s ease, + border-color 0.15s ease; max-width: 300px; } diff --git a/apps/docs/styles/main.scss b/apps/docs/styles/main.scss index d0b9484711d..088cade3e5a 100644 --- a/apps/docs/styles/main.scss +++ b/apps/docs/styles/main.scss @@ -4,11 +4,13 @@ @import './../../../packages/ui/build/css/source/global.css'; @import './../../../packages/ui/build/css/themes/dark.css'; +@import './../../../packages/ui/build/css/themes/deep-dark.css'; @import './../../../packages/ui/build/css/themes/light.css'; @font-face { font-family: 'custom-font'; - src: url(../../../packages/common/assets/fonts/CustomFont-Book.woff2) format('woff2'), + src: + url(../../../packages/common/assets/fonts/CustomFont-Book.woff2) format('woff2'), url(../../../packages/common/assets/fonts/CustomFont-Book.woff) format('woff'); font-weight: 400; font-style: normal; @@ -17,7 +19,8 @@ @font-face { font-family: 'custom-font'; - src: url(../../../packages/common/assets/fonts/CustomFont-Medium.woff2) format('woff2'), + src: + url(../../../packages/common/assets/fonts/CustomFont-Medium.woff2) format('woff2'), url(../../../packages/common/assets/fonts/CustomFont-Medium.woff) format('woff'); font-weight: 500; font-style: normal; @@ -29,7 +32,7 @@ } body { - @apply bg-background; + @apply bg-background text-foreground; scroll-behavior: smooth; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; diff --git a/apps/docs/tailwind.config.js b/apps/docs/tailwind.config.js index c8bf2490119..9b04f141695 100644 --- a/apps/docs/tailwind.config.js +++ b/apps/docs/tailwind.config.js @@ -2,12 +2,13 @@ const config = require('config/tailwind.config') module.exports = config({ content: [ - '../../packages/ui/src/**/*.{tsx,ts,js}', - './pages/**/*.{tsx,mdx}', './components/**/*.tsx', - './layouts/**/*.tsx', - './src/**/*.{ts,tsx,mdx}', + './content/**/*.{ts,tsx,mdx}', './docs/**/*.{tsx,mdx}', + './layouts/**/*.tsx', + './pages/**/*.{tsx,mdx}', + './../../packages/ui/src/**/*.{tsx,ts,js}', + './../../packages/ui-patterns/**/*.{tsx,ts,js}', ], plugins: [ function ({ addUtilities, addVariant }) { diff --git a/apps/docs/types/api.d.ts b/apps/docs/types/api.d.ts index 933a4402157..2c654f2efcb 100644 --- a/apps/docs/types/api.d.ts +++ b/apps/docs/types/api.d.ts @@ -9,8 +9,8 @@ type XOR = T | U extends object ? (Without & U) | (Without & T type OneOf = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] - ? OneOf<[XOR, ...Rest]> - : never + ? OneOf<[XOR, ...Rest]> + : never export interface paths { '/platform/notifications': { @@ -3846,7 +3846,7 @@ export interface components { message?: string status?: string }, - string + string, ] > result?: Record[] diff --git a/apps/studio/.storybook/preview.css b/apps/studio/.storybook/preview.css index 34e1f2acdc4..29b55284e98 100644 --- a/apps/studio/.storybook/preview.css +++ b/apps/studio/.storybook/preview.css @@ -6,7 +6,7 @@ .light, .light .docs-story, .dark .docs-story { - @apply bg-background; + @apply bg-studio; } /* // date picker */ @@ -62,11 +62,11 @@ } .react-datepicker__navigation--previous { - @apply right-12 w-10 h-10 rounded transition flex items-center justify-center hover:bg-background; + @apply right-12 w-10 h-10 rounded transition flex items-center justify-center hover:bg-studio; } .react-datepicker__navigation--next { - @apply right-4 w-10 h-10 rounded transition flex items-center justify-center hover:bg-background; + @apply right-4 w-10 h-10 rounded transition flex items-center justify-center hover:bg-studio; } .react-datepicker__day { @@ -93,11 +93,11 @@ } .react-datepicker__day--selecting-range-start { - @apply bg-background; + @apply bg-studio; } .react-datepicker__day--selecting-range-end { - @apply bg-background; + @apply bg-studio; } .react-datepicker__day--selected { diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index cb043aff661..6591f8e7e1a 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -204,7 +204,7 @@ const SupabaseGridLayout = (props: SupabaseGridProps) => { onImportData={onImportData} onEditForeignKeyColumnValue={onEditForeignKeyColumnValue} /> -
+
)} diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index 0371fe981d8..a654246490a 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -101,6 +101,7 @@ export async function initTable( defaultWidth: props.gridProps?.defaultColumnWidth, onAddColumn: props.editable ? props.onAddColumn : undefined, onExpandJSONEditor: props.onExpandJSONEditor, + onExpandTextEditor: props.onExpandTextEditor, }) dispatch({ diff --git a/apps/studio/components/grid/components/common/Hooks.tsx b/apps/studio/components/grid/components/common/Hooks.tsx index b1d6ef0f778..1d52d6524ba 100644 --- a/apps/studio/components/grid/components/common/Hooks.tsx +++ b/apps/studio/components/grid/components/common/Hooks.tsx @@ -62,12 +62,12 @@ export function useKeyboardShortcuts( return event.metaKey && event.shiftKey ? `Command+Shift+${event.key}` : event.metaKey - ? `Command+${event.key}` - : event.shiftKey && event.key === 'Enter' - ? `Shift+${event.key}` - : event.ctrlKey && event.key - ? `Control+${event.key}` - : event.key + ? `Command+${event.key}` + : event.shiftKey && event.key === 'Enter' + ? `Shift+${event.key}` + : event.ctrlKey && event.key + ? `Control+${event.key}` + : event.key } React.useEffect(() => { diff --git a/apps/studio/components/grid/components/common/Shortcuts.tsx b/apps/studio/components/grid/components/common/Shortcuts.tsx index 9323fc1b874..e9d4ba0b56a 100644 --- a/apps/studio/components/grid/components/common/Shortcuts.tsx +++ b/apps/studio/components/grid/components/common/Shortcuts.tsx @@ -18,8 +18,8 @@ export function Shortcuts({ gridRef }: ShortcutsProps) { return navigator?.appVersion.indexOf('Win') !== -1 ? 'windows' : navigator?.appVersion.indexOf('Mac') !== -1 - ? 'macos' - : 'unknown' + ? 'macos' + : 'unknown' } const metakey = getClientOS() === 'windows' ? 'Control' : 'Command' setMetaKey(metakey) diff --git a/apps/studio/components/grid/components/editor/JsonEditor.tsx b/apps/studio/components/grid/components/editor/JsonEditor.tsx index 1d6de055297..4423413f064 100644 --- a/apps/studio/components/grid/components/editor/JsonEditor.tsx +++ b/apps/studio/components/grid/components/editor/JsonEditor.tsx @@ -5,7 +5,7 @@ import { IconMaximize, Popover } from 'ui' import { BlockKeys, MonacoEditor, NullValue } from 'components/grid/components/common' import { useTrackedState } from 'components/grid/store' -import { prettifyJSON, tryParseJson } from 'lib/helpers' +import { prettifyJSON, tryParseJson, removeJSONTrailingComma } from 'lib/helpers' import { isNil } from 'lodash' interface JsonEditorProps @@ -36,7 +36,8 @@ export const JsonEditor = ({ }, []) const saveChanges = useCallback((newValue: string | null) => { - if (newValue !== value) commitChange(newValue) + const updatedValue = newValue !== null ? removeJSONTrailingComma(newValue) : newValue + if (updatedValue !== value) commitChange(newValue) }, []) const onChange = (_value: string | undefined) => { diff --git a/apps/studio/components/grid/components/editor/TextEditor.tsx b/apps/studio/components/grid/components/editor/TextEditor.tsx index ed0488616db..e900ca36038 100644 --- a/apps/studio/components/grid/components/editor/TextEditor.tsx +++ b/apps/studio/components/grid/components/editor/TextEditor.tsx @@ -1,6 +1,7 @@ -import { RenderEditCellProps } from 'react-data-grid' +import * as Tooltip from '@radix-ui/react-tooltip' import { useCallback, useState } from 'react' -import { Button, Popover } from 'ui' +import { RenderEditCellProps } from 'react-data-grid' +import { Button, IconMaximize, Popover } from 'ui' import { useTrackedState } from '../../store' import { BlockKeys, EmptyValue, MonacoEditor, NullValue } from '../common' @@ -10,7 +11,12 @@ export const TextEditor = ({ isNullable, isEditable, onRowChange, -}: RenderEditCellProps & { isNullable?: boolean; isEditable?: boolean }) => { + onExpandEditor, +}: RenderEditCellProps & { + isNullable?: boolean + isEditable?: boolean + onExpandEditor: (column: string, row: TRow) => void +}) => { const state = useTrackedState() const [isPopoverOpen, setIsPopoverOpen] = useState(true) const gridColumn = state.gridColumns.find((x) => x.name == column.key) @@ -27,6 +33,14 @@ export const TextEditor = ({ setIsPopoverOpen(false) }, []) + const onSelectExpand = () => { + cancelChanges() + onExpandEditor(column.key, { + ...row, + [column.key]: value || (row as any)[column.key], + }) + } + function onChange(_value: string | undefined) { if (!isEditable) return @@ -65,7 +79,32 @@ export const TextEditor = ({

Cancel changes

-
+
+
+ + +
{isNullable && ( */} {onAddRow !== undefined && onImportData !== undefined && (
) diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index d0e9d1c3465..b52234837c4 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -31,8 +31,7 @@ import { IconX, cn, } from 'ui' -import RLSBannerWarning from './RLSBannerWarning' -import RefreshButton from './RefreshButton' + import FilterDropdown from './filter' import SortPopover from './sort' @@ -89,7 +88,6 @@ const Header = ({ )}
{headerActions}
-
) } @@ -122,7 +120,6 @@ const DefaultHeader = ({ return (
-
@@ -370,8 +367,8 @@ const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => { {allRowsSelected ? `${totalRows} rows selected` : selectedRows.size > 1 - ? `${selectedRows.size} rows selected` - : `${selectedRows.size} row selected`} + ? `${selectedRows.size} rows selected` + : `${selectedRows.size} row selected`} {!allRowsSelected && totalRows > allRows.length && ( diff --git a/apps/studio/components/grid/components/header/RLSBannerWarning.tsx b/apps/studio/components/grid/components/header/RLSBannerWarning.tsx deleted file mode 100644 index 5f2bec64e66..00000000000 --- a/apps/studio/components/grid/components/header/RLSBannerWarning.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import Link from 'next/link' -import { useCallback, useState } from 'react' - -import { useParams } from 'common/hooks' -import { rlsAcknowledgedKey } from 'components/grid/constants' -import RLSDisableModalContent from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSDisableModal' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import ConfirmationModal from 'components/ui/ConfirmationModal' -import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' -import { useTableQuery } from 'data/tables/table-query' -import useEntityType from 'hooks/misc/useEntityType' -import { Button, IconAlertCircle, Modal } from 'ui' - -export default function RLSBannerWarning() { - const { project } = useProjectContext() - const { ref: projectRef, id: _id } = useParams() - const tableID = _id ? Number(_id) : undefined - - const rlsKey = rlsAcknowledgedKey(tableID) - const isAcknowledged = localStorage?.getItem(rlsKey) === 'true' ?? false - - const [isOpen, setIsOpen] = useState(false) - - const entityType = useEntityType(tableID) - const { data: currentTable } = useTableQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - id: tableID, - }, - { - enabled: entityType?.type === ENTITY_TYPE.TABLE, - } - ) - - const rlsEnabled = currentTable?.rls_enabled - const isPublicTable = currentTable?.schema === 'public' - - const handleDismissWarning = useCallback(() => { - new BroadcastChannel(rlsKey).postMessage({ type: 'dismiss' }) - localStorage.setItem(rlsKey, 'true') - setIsOpen(false) - }, [rlsKey]) - - return ( - <> - {!isAcknowledged && !rlsEnabled && isPublicTable ? ( -
-
- - Warning: You are allowing anonymous - access to your table.{' '} - - Enable Row Level Security - -
- -
-
- - setIsOpen(false)} - onSelectConfirm={handleDismissWarning} - > - - - - -
- ) : ( - <> - )} - - ) -} diff --git a/apps/studio/components/grid/components/header/RefreshButton.tsx b/apps/studio/components/grid/components/header/RefreshButton.tsx index 141092ca95e..1ebd04dfb72 100644 --- a/apps/studio/components/grid/components/header/RefreshButton.tsx +++ b/apps/studio/components/grid/components/header/RefreshButton.tsx @@ -5,10 +5,11 @@ import { sqlKeys } from 'data/sql/keys' import { useEffect, useState } from 'react' import { Button, IconCheck, IconRefreshCw } from 'ui' import { SupabaseGridQueue } from '../../constants' +import { TableLike } from 'hooks/misc/useTable' export type RefreshButtonProps = { - table: SupaTable - isRefetching: boolean + table: TableLike | SupaTable + isRefetching?: boolean } const RefreshButton = ({ table, isRefetching }: RefreshButtonProps) => { diff --git a/apps/studio/components/grid/components/header/filter/FilterRow.tsx b/apps/studio/components/grid/components/header/filter/FilterRow.tsx index 3be749c3164..b35d58b8b0a 100644 --- a/apps/studio/components/grid/components/header/filter/FilterRow.tsx +++ b/apps/studio/components/grid/components/header/filter/FilterRow.tsx @@ -25,8 +25,8 @@ const FilterRow = ({ table, filter, filterIdx, onChange, onDelete, onKeyDown }: column?.format === 'timestamptz' ? 'yyyy-mm-dd hh:mm:ss+zz' : column?.format === 'timestamp' - ? 'yyyy-mm-dd hh:mm:ss' - : 'Enter a value' + ? 'yyyy-mm-dd hh:mm:ss' + : 'Enter a value' return (
@@ -43,7 +43,7 @@ const FilterRow = ({ table, filter, filterIdx, onChange, onDelete, onKeyDown }:
} - className="w-32" + className="w-32 justify-start" > {column?.name ?? ''} diff --git a/apps/studio/components/grid/components/header/sort/SortPopover.tsx b/apps/studio/components/grid/components/header/sort/SortPopover.tsx index bbbed488808..ddba5c7134f 100644 --- a/apps/studio/components/grid/components/header/sort/SortPopover.tsx +++ b/apps/studio/components/grid/components/header/sort/SortPopover.tsx @@ -146,9 +146,11 @@ const SortOverlay = ({ table, sorts: sortsFromUrl, setParams }: SortOverlayProps ) : (

All columns have been added

)} - +
+ +
) diff --git a/apps/studio/components/grid/types/grid.ts b/apps/studio/components/grid/types/grid.ts index 121c7b2fd8d..07742481bba 100644 --- a/apps/studio/components/grid/types/grid.ts +++ b/apps/studio/components/grid/types/grid.ts @@ -69,6 +69,7 @@ export interface SupabaseGridProps { onEditRow?: (row: SupaRow) => void onError?: (error: any) => void onExpandJSONEditor: (column: string, row: SupaRow) => void + onExpandTextEditor: (column: string, row: SupaRow) => void updateTableRow: (previousRow: any, updatedData: any) => void onEditForeignKeyColumnValue: (args: { foreignKey: NonNullable diff --git a/apps/studio/components/grid/utils/gridColumns.tsx b/apps/studio/components/grid/utils/gridColumns.tsx index 5498af8a2d0..e2c259b2c4d 100644 --- a/apps/studio/components/grid/utils/gridColumns.tsx +++ b/apps/studio/components/grid/utils/gridColumns.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { CalculatedColumn } from 'react-data-grid' import { ColumnType, SupaColumn, SupaRow, SupaTable } from '../types' import { @@ -46,6 +45,7 @@ export function getGridColumns( defaultWidth?: string | number onAddColumn?: () => void onExpandJSONEditor: (column: string, row: SupaRow) => void + onExpandTextEditor: (column: string, row: SupaRow) => void } ): any[] { const columns = table.columns.map((x, idx) => { @@ -56,8 +56,8 @@ export function getGridColumns( const columnWidth = options?.defaultWidth ? options.defaultWidth : columnDefaultWidth < columnWidthBasedOnName - ? columnWidthBasedOnName - : columnDefaultWidth + ? columnWidthBasedOnName + : columnDefaultWidth const columnDefinition: CalculatedColumn = { key: x.name, @@ -81,7 +81,13 @@ export function getGridColumns( /> ), renderEditCell: options - ? getCellEditor(x, columnType, options?.editable ?? false, options.onExpandJSONEditor) + ? getCellEditor( + x, + columnType, + options?.editable ?? false, + options.onExpandJSONEditor, + options.onExpandTextEditor + ) : undefined, renderCell: getCellRenderer(x, columnType, { projectRef: options?.projectRef, @@ -110,7 +116,8 @@ function getCellEditor( columnDefinition: SupaColumn, columnType: ColumnType, isEditable: boolean, - onExpandJSONEditor: (column: string, row: any) => void + onExpandJSONEditor: (column: string, row: any) => void, + onExpandTextEditor: (column: string, row: any) => void ) { if (!isEditable) { if (['array', 'json'].includes(columnType)) { @@ -120,7 +127,9 @@ function getCellEditor( ) } else if (!['number', 'boolean'].includes(columnType)) { // eslint-disable-next-line react/display-name - return (p: any) => + return (p: any) => ( + + ) } else { return } @@ -164,7 +173,12 @@ function getCellEditor( case 'text': { // eslint-disable-next-line react/display-name return (p: any) => ( - + ) } default: { diff --git a/apps/studio/components/interfaces/Account/AuditLogs.tsx b/apps/studio/components/interfaces/Account/AuditLogs.tsx index b5e12226513..93ec9d85a0c 100644 --- a/apps/studio/components/interfaces/Account/AuditLogs.tsx +++ b/apps/studio/components/interfaces/Account/AuditLogs.tsx @@ -247,8 +247,8 @@ const AuditLogs = () => { {project?.name ? 'Project: ' : organization?.name - ? 'Organization: ' - : null} + ? 'Organization: ' + : null} {project?.name ?? organization?.name ?? '-'}

{ {log.target.metadata.project_ref ? 'Ref: ' : log.target.metadata.org_slug - ? 'Slug: ' - : null} + ? 'Slug: ' + : null} {log.target.metadata.project_ref ?? log.target.metadata.org_slug}

diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx index 488d786eb4d..13b4de36a59 100644 --- a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx @@ -1,29 +1,55 @@ import { useTheme } from 'next-themes' -import { IconMoon, IconSun, Listbox, Theme, themes } from 'ui' - import Panel from 'components/ui/Panel' +import { BASE_PATH } from 'lib/constants' +import SVG from 'react-inlinesvg' +import { + Label_Shadcn_, + RadioGroup_Shadcn_, + RadioGroupLargeItem_Shadcn_, + singleThemes, + Theme, +} from 'ui' const ThemeSettings = () => { - const { theme, resolvedTheme, setTheme } = useTheme() + const { theme, setTheme } = useTheme() + + function SingleThemeSelection() { + return ( +
+ + {singleThemes.map((theme: Theme) => ( + + + + ))} + + + ) + } return ( - Theme}> - - : } - onChange={(value: string) => setTheme(value)} - > - {themes.map((theme: Theme) => ( - - {theme.name} - - ))} - + Appearance}> + +
+ + Theme mode + +

+ Choose how Supabase looks to you. Select a single theme, or sync with your system. +

+
+ +
+

Supabase will use your selected theme

+ +
) diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeSettingsOld.tsx b/apps/studio/components/interfaces/Account/Preferences/ThemeSettingsOld.tsx new file mode 100644 index 00000000000..383883c1cbb --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeSettingsOld.tsx @@ -0,0 +1,40 @@ +import { useTheme } from 'next-themes' +import { IconMoon, IconSun, Listbox, Theme, themes } from 'ui' + +import Panel from 'components/ui/Panel' + +const ThemeSettings = () => { + const { theme, resolvedTheme, setTheme } = useTheme() + + return ( + Theme}> + + : } + onChange={(value: string) => setTheme(value)} + > + {themes + .filter( + (theme: Theme) => + // temporary fix to exclude new themes + theme.name.includes('light') || + theme.name.includes('dark') || + theme.name.includes('system') + ) + .map((theme: Theme) => ( + + {theme.name} + + ))} + + + + ) +} + +export default ThemeSettings diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeSettingsWithPreferredTheme.tsx b/apps/studio/components/interfaces/Account/Preferences/ThemeSettingsWithPreferredTheme.tsx new file mode 100644 index 00000000000..6ced2a02cac --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeSettingsWithPreferredTheme.tsx @@ -0,0 +1,177 @@ +import { useTheme } from 'next-themes' +import Panel from 'components/ui/Panel' +import { BASE_PATH } from 'lib/constants' +import { useState } from 'react' +import SVG from 'react-inlinesvg' +import { + Label_Shadcn_, + prefixedThemes, + RadioGroup_Shadcn_, + RadioGroupLargeItem_Shadcn_, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + singleThemes, + Theme, +} from 'ui' + +const ThemeSettings = () => { + // @ts-expect-error // remove this ts error if you use this file + const { theme, setTheme, setSystemPrefixResolve, themePrefix } = useTheme() + const [mode, setMode] = useState<'system' | 'single-theme'>( + theme === 'system' ? 'system' : 'single-theme' + ) + const [prefixedTheme, setPrefixedThemeState] = useState(themePrefix === '' ? 'dark' : themePrefix) + + function SingleThemeSelection() { + return ( +
+ + {singleThemes + .filter((x) => x.value !== 'system') + .map((theme: Theme) => ( + + + + ))} + + + ) + } + + function PrefixThemeSelection() { + return ( +
+ { + setSystemPrefixResolve(value) + setPrefixedThemeState(value === '' ? 'dark' : value) + }} + aria-label="Choose a preferred dark mode" + defaultValue={prefixedTheme} + value={prefixedTheme} + className="flex flex-wrap gap-3" + > + + + + {prefixedThemes.map((prefix: Theme) => ( + + + + ))} + + + ) + } + + { + mode === 'system' && ( + { + setSystemPrefixResolve(value) + setPrefixedThemeState(value === '' ? 'dark' : value) + }} + name="theme-mode" + value={prefixedTheme} + > + + {prefixedTheme} + + + + dark + + {prefixedThemes.map((prefix: Theme) => ( + + {prefix.name} + + ))} + + + ) + } + + const themeModes = { + 'single-theme': { + value: 'single-theme', + label: 'Single theme', + }, + system: { + value: 'system', + label: 'Sync with system', + }, + } + + return ( + Appearance}> + +
+ + Theme mode + +

+ Choose how Supabase looks to you. Select a single theme, or sync with your system. +

+
+ +
+ { + setMode(value) + if (value === 'system') { + setTheme('system') + } else { + setTheme('dark') + // reset prefix + setSystemPrefixResolve('') + } + }} + value={mode} + name="theme-mode" + > + + + {themeModes[mode].label} + + + + + Single theme + + + Sync with system + + + + {mode === 'single-theme' && ( + <> +

Supabase will use your selected theme

+ + + )} + {mode === 'system' && ( + <> +

Choose your preferred dark mode

+ + + )} +
+
+
+ ) +} + +export default ThemeSettings diff --git a/apps/studio/components/interfaces/Account/Preferences/index.ts b/apps/studio/components/interfaces/Account/Preferences/index.ts index c31bb56fd8e..3da36a706fe 100644 --- a/apps/studio/components/interfaces/Account/Preferences/index.ts +++ b/apps/studio/components/interfaces/Account/Preferences/index.ts @@ -1,5 +1,13 @@ import AccountInformation from './AccountInformation' import AnalyticsSettings from './AnalyticsSettings' +import ThemeSettingsOld from './ThemeSettingsOld' import ThemeSettings from './ThemeSettings' +import ThemeSettingsWithPreferredTheme from './ThemeSettingsWithPreferredTheme' -export { AccountInformation, AnalyticsSettings, ThemeSettings } +export { + AccountInformation, + AnalyticsSettings, + ThemeSettingsOld, + ThemeSettings, + ThemeSettingsWithPreferredTheme, +} diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 757d2ebcfab..8c4aa827cd7 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -1,10 +1,11 @@ import { useMonaco } from '@monaco-editor/react' import { useTheme } from 'next-themes' +import { PropsWithChildren, useEffect } from 'react' + import IncidentBanner from 'components/layouts/AppLayout/IncidentBanner' +import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner' import { getTheme } from 'components/ui/CodeEditor' import { useFlag } from 'hooks' -import { PropsWithChildren, useEffect } from 'react' -import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner' const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { const monaco = useMonaco() @@ -22,8 +23,10 @@ const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { return (
- {ongoingIncident && } - {showNoticeBanner && } +
+ {ongoingIncident && } + {showNoticeBanner && } +
{children}
) diff --git a/apps/studio/components/interfaces/App/AppBannerWrapperContext.tsx b/apps/studio/components/interfaces/App/AppBannerWrapperContext.tsx index 221a8f9c376..c49235f6eea 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapperContext.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapperContext.tsx @@ -3,38 +3,75 @@ import { PropsWithChildren, createContext, useContext, useEffect, useState } fro import { LOCAL_STORAGE_KEYS } from 'lib/constants' -const NOTICE_BANNER_KEY = LOCAL_STORAGE_KEYS.PGBOUNCER_DEPRECATION_WARNING +const PGBOUNCER_BANNER_KEY = LOCAL_STORAGE_KEYS.PGBOUNCER_IPV6_DEPRECATION_WARNING +const VERCEL_BANNER_KEY = LOCAL_STORAGE_KEYS.VERCEL_IPV6_DEPRECATION_WARNING +const DEFAULT_NOTICE_BANNER_KEY = LOCAL_STORAGE_KEYS.PGBOUNCER_DEPRECATION_WARNING // [Joshen] Update this as and when we need to use the NoticeBanner type AppBannerContextType = { - acknowledged: boolean - onUpdateAcknowledged: (value: boolean) => void + ipv6BannerAcknowledged: boolean + pgbouncerBannerAcknowledged: string[] + vercelBannerAcknowledged: string[] + onUpdateAcknowledged: (key: 'ipv6' | 'pgbouncer' | 'vercel', value: boolean | string) => void } const AppBannerContext = createContext({ - acknowledged: false, + ipv6BannerAcknowledged: false, + pgbouncerBannerAcknowledged: [], + vercelBannerAcknowledged: [], onUpdateAcknowledged: noop, }) export const useAppBannerContext = () => useContext(AppBannerContext) export const AppBannerContextProvider = ({ children }: PropsWithChildren<{}>) => { - const [acknowledged, setAcknowledged] = useState(false) + const [ipv6BannerAcknowledged, setIpv6BannerAcknowledged] = useState(false) + + // [Joshen] These will take a list of refs instead since they are project specific + // Comma separated strings, no spaces + const [pgbouncerBannerAcknowledged, setPgbouncerBannerAcknowledged] = useState([]) + const [vercelBannerAcknowledged, setVercelBannerAcknowledged] = useState([]) useEffect(() => { if (typeof window !== 'undefined') { - setAcknowledged(localStorage.getItem(NOTICE_BANNER_KEY) === 'true') + setIpv6BannerAcknowledged(localStorage.getItem(DEFAULT_NOTICE_BANNER_KEY) === 'true') + + const acknowledgedPbBouncerRefs = localStorage.getItem(PGBOUNCER_BANNER_KEY)?.split(',') ?? [] + setPgbouncerBannerAcknowledged(acknowledgedPbBouncerRefs) + + const acknowledgedVercelRefs = localStorage.getItem(VERCEL_BANNER_KEY)?.split(',') ?? [] + setVercelBannerAcknowledged(acknowledgedVercelRefs) } }, []) const value = { - acknowledged, - onUpdateAcknowledged: (value: boolean) => { - if (typeof window !== 'undefined') { - window.localStorage.setItem(NOTICE_BANNER_KEY, value.toString()) + ipv6BannerAcknowledged, + pgbouncerBannerAcknowledged, + vercelBannerAcknowledged, + onUpdateAcknowledged: (key: 'ipv6' | 'pgbouncer' | 'vercel', value: boolean | string) => { + if (key === 'ipv6' && typeof value === 'boolean') { + if (typeof window !== 'undefined') { + window.localStorage.setItem(DEFAULT_NOTICE_BANNER_KEY, value.toString()) + } + setIpv6BannerAcknowledged(value) + } + + if (key === 'pgbouncer' && typeof value === 'string' && value.length > 0) { + const updatedRefs = pgbouncerBannerAcknowledged.concat([value]) + if (typeof window !== 'undefined') { + window.localStorage.setItem(PGBOUNCER_BANNER_KEY, updatedRefs.join(',')) + } + setPgbouncerBannerAcknowledged(updatedRefs) + } + + if (key === 'vercel' && typeof value === 'string' && value.length > 0) { + const updatedRefs = pgbouncerBannerAcknowledged.concat([value]) + if (typeof window !== 'undefined') { + window.localStorage.setItem(VERCEL_BANNER_KEY, updatedRefs.join(',')) + } + setVercelBannerAcknowledged(updatedRefs) } - setAcknowledged(value) }, } @@ -42,6 +79,7 @@ export const AppBannerContextProvider = ({ children }: PropsWithChildren<{}>) => } export const useIsNoticeBannerShown = () => { - const { acknowledged } = useAppBannerContext() - return acknowledged + const { ipv6BannerAcknowledged, pgbouncerBannerAcknowledged, vercelBannerAcknowledged } = + useAppBannerContext() + return ipv6BannerAcknowledged && pgbouncerBannerAcknowledged && vercelBannerAcknowledged } diff --git a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx index 778f87089c6..bfff28e84f1 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx @@ -18,7 +18,7 @@ const CLSPreview = () => { className="text-foreground-light max-w-full" content={`This is an advanced feature and should be used with caution. Unless you have a very specific use case, we recommend just using [Row-Level Security](https://supabase.com/docs/guides/auth/row-level-security).`} /> - + Changes to column privileges will not be reflected in migrations when running{' '} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index a8a05937cda..5636661f4b2 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -2,10 +2,9 @@ import { useTelemetryProps } from 'common' import { FlaskConical } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Button, IconExternalLink, IconEye, IconEyeOff, Modal, ScrollArea, cn } from 'ui' -import { useFlag } from 'hooks' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import Telemetry from 'lib/telemetry' import { useAppStateSnapshot } from 'state/app-state' @@ -15,8 +14,6 @@ import { useFeaturePreviewContext } from './FeaturePreviewContext' import RLSAIAssistantPreview from './RLSAIAssistantPreview' const FeaturePreviewModal = () => { - const isAiAssistantEnabled = useFlag('policyEditorWithAi') - // [Ivan] We should probably move this to a separate file, together with LOCAL_STORAGE_KEYS. We should make adding new feature previews as simple as possible. const FEATURE_PREVIEWS: { key: string; name: string; content: any; discussionsUrl?: string }[] = [ { @@ -25,16 +22,12 @@ const FeaturePreviewModal = () => { content: , discussionsUrl: 'https://github.com/orgs/supabase/discussions/18038', }, - ...(isAiAssistantEnabled - ? [ - { - key: LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_AI_ASSISTANT, - name: 'Supabase Assistant for RLS policies', - content: , - discussionsUrl: 'https://github.com/orgs/supabase/discussions/19594', - }, - ] - : []), + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_AI_ASSISTANT, + name: 'Supabase Assistant for RLS policies', + content: , + discussionsUrl: 'https://github.com/orgs/supabase/discussions/19594', + }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS, name: 'Column-level privileges', @@ -47,7 +40,19 @@ const FeaturePreviewModal = () => { const snap = useAppStateSnapshot() const telemetryProps = useTelemetryProps() const featurePreviewContext = useFeaturePreviewContext() - const [selectedFeatureKey, setSelectedFeatureKey] = useState(FEATURE_PREVIEWS?.[0]?.key) + + const selectedFeaturePreview = + snap.selectedFeaturePreview === '' ? FEATURE_PREVIEWS[0].key : snap.selectedFeaturePreview + + const [selectedFeatureKey, setSelectedFeatureKey] = useState(selectedFeaturePreview) + + // this modal can be triggered on other pages + // Update local state when valtio state changes + useEffect(() => { + if (snap.selectedFeaturePreview !== '') { + setSelectedFeatureKey(snap.selectedFeaturePreview) + } + }, [snap.selectedFeaturePreview]) const { flags, onUpdateFlag } = featurePreviewContext const selectedFeature = FEATURE_PREVIEWS.find((preview) => preview.key === selectedFeatureKey) @@ -66,6 +71,11 @@ const FeaturePreviewModal = () => { ) } + function handleCloseFeaturePreviewModal() { + snap.setShowFeaturePreviewModal(false) + snap.setSelectedFeaturePreview(FEATURE_PREVIEWS[0].key) + } + return ( { className="max-w-4xl" header="Dashboard feature previews" visible={snap.showFeaturePreviewModal} - onCancel={() => snap.setShowFeaturePreviewModal(false)} + onCancel={handleCloseFeaturePreviewModal} > {FEATURE_PREVIEWS.length > 0 ? (
- + {FEATURE_PREVIEWS.map((feature) => { const isEnabled = flags[feature.key] ?? false @@ -97,7 +107,9 @@ const FeaturePreviewModal = () => { ) : ( )} -

{feature.name}

+

+ {feature.name} +

) })} diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index 79b15b87693..0dddeec961b 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -2,19 +2,7 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useState } from 'react' import ReactMarkdown from 'react-markdown' -import { - Alert, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, - Button, - Collapsible, - Form, - IconAlertTriangle, - IconCheck, - IconChevronUp, - Input, -} from 'ui' +import { Alert, Button, Collapsible, Form, IconCheck, IconChevronUp, Input } from 'ui' import { useParams } from 'common' import { components } from 'data/api' @@ -27,7 +15,7 @@ import { Provider } from './AuthProvidersForm.types' import FormField from './FormField' export interface ProviderFormProps { - config: components['schemas']['GetGoTrueConfigResponse'] + config: components['schemas']['GoTrueConfigResponse'] provider: Provider } @@ -59,8 +47,8 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => { initialValues[key] = configValue ? configValue : provider.properties[key].type === 'boolean' - ? false - : '' + ? false + : '' } } }) diff --git a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx index edec80576b2..8d4065310f3 100644 --- a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx @@ -1,7 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { useParams } from 'common' -import { observer } from 'mobx-react-lite' import { useEffect, useState } from 'react' import { AlertDescription_Shadcn_, @@ -18,6 +16,7 @@ import { } from 'ui' import { boolean, number, object, string } from 'yup' +import { Markdown } from 'components/interfaces/Markdown' import { FormActions, FormHeader, @@ -30,8 +29,7 @@ import UpgradeToPro from 'components/ui/UpgradeToPro' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useCheckPermissions, useFlag, useSelectedOrganization, useStore } from 'hooks' -import { Markdown } from 'components/interfaces/Markdown' +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' import FormField from '../AuthProvidersForm/FormField' // Use a const string to represent no chars option. Represented as empty string on the backend side. @@ -75,7 +73,7 @@ function HoursOrNeverText({ value }: { value: number }) { const formId = 'auth-config-basic-settings' -const BasicAuthSettingsForm = observer(() => { +const BasicAuthSettingsForm = () => { const { ui } = useStore() const { ref: projectRef } = useParams() const { @@ -96,9 +94,6 @@ const BasicAuthSettingsForm = observer(() => { }) const isProPlanAndUp = isSuccessSubscription && subscription?.plan?.id !== 'free' - const singlePerUserReleased = useFlag('authSingleSessionPerUserReleased') - const passwordStrengthReleased = useFlag('authPasswordStrengthReleased') - const hibpReleased = useFlag('authHIBPReleased') const INITIAL_VALUES = { DISABLE_SIGNUP: !authConfig?.DISABLE_SIGNUP, @@ -109,25 +104,11 @@ const BasicAuthSettingsForm = observer(() => { SECURITY_CAPTCHA_PROVIDER: authConfig?.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha', SESSIONS_TIMEBOX: authConfig?.SESSIONS_TIMEBOX || 0, SESSIONS_INACTIVITY_TIMEOUT: authConfig?.SESSIONS_INACTIVITY_TIMEOUT || 0, - ...(singlePerUserReleased - ? { - SESSIONS_SINGLE_PER_USER: authConfig?.SESSIONS_SINGLE_PER_USER || false, - } - : null), - - ...(passwordStrengthReleased - ? { - PASSWORD_MIN_LENGTH: authConfig?.PASSWORD_MIN_LENGTH || 6, - PASSWORD_REQUIRED_CHARACTERS: - authConfig?.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS, - - ...(hibpReleased - ? { - PASSWORD_HIBP_ENABLED: authConfig?.PASSWORD_HIBP_ENABLED || false, - } - : null), - } - : null), + SESSIONS_SINGLE_PER_USER: authConfig?.SESSIONS_SINGLE_PER_USER || false, + PASSWORD_MIN_LENGTH: authConfig?.PASSWORD_MIN_LENGTH || 6, + PASSWORD_REQUIRED_CHARACTERS: + authConfig?.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS, + PASSWORD_HIBP_ENABLED: authConfig?.PASSWORD_HIBP_ENABLED || false, } const onSubmit = (values: any, { resetForm }: any) => { @@ -173,16 +154,13 @@ const BasicAuthSettingsForm = observer(() => { {({ handleReset, resetForm, values, initialValues }: any) => { const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) // Form is reset once remote data is loaded in store + // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { if (isSuccess) resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES }) }, [isSuccess]) return ( <> - { /> - {passwordStrengthReleased && ( - <> - Passwords}> - - characters} - disabled={!canUpdateConfig} - /> - - {isProPlanAndUp ? ( - <> - ) : ( - - )} - - - - - )} + Passwords}> + + characters} + disabled={!canUpdateConfig} + /> + + {isProPlanAndUp ? ( + <> + ) : ( + + )} + + + User Sessions}> {isProPlanAndUp ? ( @@ -304,16 +277,14 @@ const BasicAuthSettingsForm = observer(() => { organizationSlug={organization!.slug} /> )} - {singlePerUserReleased && ( - - )} + { }} ) -}) +} export default BasicAuthSettingsForm diff --git a/apps/studio/components/interfaces/Auth/Hooks/BasicHooksConfig.tsx b/apps/studio/components/interfaces/Auth/Hooks/BasicHooksConfig.tsx index ea90bb8f6d3..476f85236b7 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/BasicHooksConfig.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/BasicHooksConfig.tsx @@ -21,8 +21,7 @@ import { } from 'components/ui/Forms' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useCheckPermissions, useFlag, useStore } from 'hooks' - +import { useCheckPermissions, useStore } from 'hooks' import SchemaFunctionSelector from './SchemaFunctionSelector' const schema = object({ @@ -43,19 +42,12 @@ const BasicHooksConfig = () => { isSuccess, } = useAuthConfigQuery({ projectRef }) const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() - - const customizeAccessTokenReleased = useFlag('authHooksCustomizeAccessToken') - const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') // TODO: Remove as any once these properties are defined in Auth Config types const INITIAL_VALUES = { - ...(customizeAccessTokenReleased - ? { - HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: authConfig?.HOOK_CUSTOM_ACCESS_TOKEN_ENABLED || false, - HOOK_CUSTOM_ACCESS_TOKEN_URI: authConfig?.HOOK_CUSTOM_ACCESS_TOKEN_URI || '', - } - : null), + HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: authConfig?.HOOK_CUSTOM_ACCESS_TOKEN_ENABLED || false, + HOOK_CUSTOM_ACCESS_TOKEN_URI: authConfig?.HOOK_CUSTOM_ACCESS_TOKEN_URI || '', } const onSubmit = (values: any, { resetForm }: any) => { @@ -142,23 +134,15 @@ const BasicHooksConfig = () => { descriptionText="Select the function to be called by Supabase Auth each time a new JWT is created. It should return the claims you wish to be present in the JWT." values={values} setFieldValue={setFieldValue} - disabled={!canUpdateConfig || !customizeAccessTokenReleased} + disabled={!canUpdateConfig} /> - {!customizeAccessTokenReleased && ( - - Coming soon! - - This hook is not available yet on your project. - - - )} {values.HOOK_CUSTOM_ACCESS_TOKEN_URI && ( )} diff --git a/apps/studio/components/interfaces/Auth/Hooks/EnterpriseHooksConfig.tsx b/apps/studio/components/interfaces/Auth/Hooks/EnterpriseHooksConfig.tsx index e027518dbeb..167f26f0c12 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/EnterpriseHooksConfig.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/EnterpriseHooksConfig.tsx @@ -23,8 +23,7 @@ import UpgradeToPro from 'components/ui/UpgradeToPro' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useCheckPermissions, useFlag, useSelectedOrganization, useStore } from 'hooks' - +import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' import SchemaFunctionSelector from './SchemaFunctionSelector' const schema = object({ @@ -47,10 +46,7 @@ const EnterpriseHooksConfig = () => { isSuccess, } = useAuthConfigQuery({ projectRef }) const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() - const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') - const mfaVerificationAttemptReleased = useFlag('authHookMFAVerification') - const passwordVerificationAttemptReleased = useFlag('authHookPasswordVerification') const organization = useSelectedOrganization() const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({ @@ -61,22 +57,14 @@ const EnterpriseHooksConfig = () => { isSuccessSubscription && subscription?.plan?.id !== 'free' && subscription?.plan?.id !== 'pro' const INITIAL_VALUES = { - ...(mfaVerificationAttemptReleased - ? { - HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: - authConfig?.HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED || false, - HOOK_MFA_VERIFICATION_ATTEMPT_URI: authConfig?.HOOK_MFA_VERIFICATION_ATTEMPT_URI || '', - } - : null), - ...(passwordVerificationAttemptReleased - ? { - // remove as any when the types are merged in - HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: - (authConfig as any)?.HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED || false, - HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: - (authConfig as any)?.HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI || '', - } - : null), + HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: + authConfig?.HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED || false, + HOOK_MFA_VERIFICATION_ATTEMPT_URI: authConfig?.HOOK_MFA_VERIFICATION_ATTEMPT_URI || '', + // remove as any when the types are merged in + HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: + (authConfig as any)?.HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED || false, + HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: + (authConfig as any)?.HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI || '', } const onSubmit = (values: any, { resetForm }: any) => { @@ -172,31 +160,15 @@ const EnterpriseHooksConfig = () => { descriptionText="Select the function to be called by Supabase Auth each time a user tries to verify an MFA factor. Return a decision on whether to reject the attempt and future ones, or to allow the user to keep trying." values={values} setFieldValue={setFieldValue} - disabled={ - !canUpdateConfig || - !isTeamsEnterprisePlan || - !mfaVerificationAttemptReleased - } + disabled={!canUpdateConfig || !isTeamsEnterprisePlan} /> - {!mfaVerificationAttemptReleased && ( - - Coming soon! - - This hook is not available yet on your project. - - - )} {values.HOOK_MFA_VERIFICATION_ATTEMPT_URI && ( )} @@ -212,31 +184,15 @@ const EnterpriseHooksConfig = () => { descriptionText="Select the function to be called by Supabase Auth each time a user tries to sign in with a password. Return a decision whether to allow the user to reject the attempt, or to allow the user to keep trying." values={values} setFieldValue={setFieldValue} - disabled={ - !canUpdateConfig || - !isTeamsEnterprisePlan || - !passwordVerificationAttemptReleased - } + disabled={!canUpdateConfig || !isTeamsEnterprisePlan} /> - {!passwordVerificationAttemptReleased && ( - - Coming soon! - - This hook is not available yet on your project. - - - )} {values.HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI && ( )} diff --git a/apps/studio/components/interfaces/Auth/Hooks/FunctionSelector.tsx b/apps/studio/components/interfaces/Auth/Hooks/FunctionSelector.tsx index 83f406fc0e5..ebc2af7a0a5 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/FunctionSelector.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/FunctionSelector.tsx @@ -131,6 +131,7 @@ const FunctionSelector = ({ {functions?.map((func) => ( { onSelectFunction(func.name) diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyPre.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyPre.tsx index 94a37f75139..0d0700bd74d 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyPre.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyPre.tsx @@ -52,7 +52,7 @@ export const AIPolicyPre = ({ onDiff, children, className }: AAIPolicyPreProps) hideCopy hideLineNumbers /> -
+
- -
+ +

Name:

diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx index a2273b2662e..e525696f33c 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx @@ -1,10 +1,11 @@ import { PostgresPolicy } from '@supabase/postgres-meta' import { useQueryClient } from '@tanstack/react-query' import { useChat } from 'ai/react' -import { useParams } from 'common' +import { useParams, useTelemetryProps } from 'common' import { uniqBy } from 'lodash' import { FileDiff } from 'lucide-react' import dynamic from 'next/dynamic' +import { useRouter } from 'next/router' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import toast from 'react-hot-toast' import { Button, Modal, SheetContent_Shadcn_, SheetFooter_Shadcn_, Sheet_Shadcn_, cn } from 'ui' @@ -19,8 +20,10 @@ import { databasePoliciesKeys } from 'data/database-policies/keys' import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { useSelectedOrganization, useSelectedProject } from 'hooks' -import { BASE_PATH, OPT_IN_TAGS } from 'lib/constants' +import { BASE_PATH, LOCAL_STORAGE_KEYS, OPT_IN_TAGS } from 'lib/constants' import { uuidv4 } from 'lib/helpers' +import Telemetry from 'lib/telemetry' +import { useAppStateSnapshot } from 'state/app-state' import { AIPolicyChat } from './AIPolicyChat' import { MessageWithDebug, @@ -32,9 +35,6 @@ import { AIPolicyHeader } from './AIPolicyHeader' import PolicyDetails from './PolicyDetails' import QueryError from './QueryError' import RLSCodeEditor from './RLSCodeEditor' -import Telemetry from 'lib/telemetry' -import { useTelemetryProps } from 'common' -import { useRouter } from 'next/router' const DiffEditor = dynamic( () => import('@monaco-editor/react').then(({ DiffEditor }) => DiffEditor), @@ -60,12 +60,14 @@ export const AIPolicyEditorPanel = memo(function ({ const selectedProject = useSelectedProject() const selectedOrganization = useSelectedOrganization() const router = useRouter() + const snap = useAppStateSnapshot() const telemetryProps = useTelemetryProps() // use chat id because useChat doesn't have a reset function to clear all messages const [chatId, setChatId] = useState(uuidv4()) const editorRef = useRef(null) const diffEditorRef = useRef(null) + const isTogglingPreviewRef = useRef(false) const placeholder = generatePlaceholder(selectedPolicy) const isOptedInToAI = selectedOrganization?.opt_in_tags?.includes(OPT_IN_TAGS.AI_SQL) ?? false @@ -175,14 +177,23 @@ export const AIPolicyEditorPanel = memo(function ({ setIncomingChange(undefined) }, [incomingChange]) - const onClosingPanel = useCallback(() => { + const toggleFeaturePreviewModal = () => { + isTogglingPreviewRef.current = true + onClosingPanel() + } + + const onClosingPanel = () => { const policy = editorRef.current?.getValue() if (policy || messages.length > 0 || !isAssistantChatInputEmpty) { setIsClosingPolicyEditorPanel(true) } else { + if (isTogglingPreviewRef.current) { + snap.setSelectedFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_AI_ASSISTANT) + snap.setShowFeaturePreviewModal(!snap.showFeaturePreviewModal) + } onSelectCancel() } - }, [onSelectCancel, messages, isAssistantChatInputEmpty]) + } const onSelectDebug = async () => { const policy = editorRef.current?.getValue().replaceAll('\n', ' ').replaceAll(' ', ' ') @@ -347,8 +358,11 @@ export const AIPolicyEditorPanel = memo(function ({ setOpen={setErrorPanelOpen} /> )} - -
+ + +
@@ -387,9 +401,17 @@ export const AIPolicyEditorPanel = memo(function ({ visible={isClosingPolicyEditorPanel} header="Discard changes" buttonLabel="Discard" - onSelectCancel={() => setIsClosingPolicyEditorPanel(false)} + onSelectCancel={() => { + isTogglingPreviewRef.current = false + setIsClosingPolicyEditorPanel(false) + }} onSelectConfirm={() => { + if (isTogglingPreviewRef.current) { + snap.setSelectedFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_AI_ASSISTANT) + snap.setShowFeaturePreviewModal(!snap.showFeaturePreviewModal) + } onSelectCancel() + isTogglingPreviewRef.current = false setIsClosingPolicyEditorPanel(false) }} > diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx index fafdd59bf6a..05f090c3fa1 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx @@ -1,8 +1,6 @@ import type { PostgresPolicy, PostgresTable } from '@supabase/postgres-meta' -import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common/hooks' import { PolicyEditorModal, PolicyTableRow } from 'components/interfaces/Auth/Policies' -import { useStore } from 'hooks' import { isEmpty } from 'lodash' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' @@ -19,7 +17,9 @@ import InformationBox from 'components/ui/InformationBox' import { useDatabasePolicyCreateMutation } from 'data/database-policies/database-policy-create-mutation' import { useDatabasePolicyDeleteMutation } from 'data/database-policies/database-policy-delete-mutation' import { useDatabasePolicyUpdateMutation } from 'data/database-policies/database-policy-update-mutation' -import { tableKeys } from 'data/tables/keys' +import { useTableUpdateMutation } from 'data/tables/table-update-mutation' +import ProtectedSchemaWarning from 'components/interfaces/Database/ProtectedSchemaWarning' +import { useTableEditorStateSnapshot } from 'state/table-editor' interface PoliciesProps { tables: PostgresTable[] @@ -37,9 +37,8 @@ const Policies = ({ const router = useRouter() const { ref } = useParams() const { project } = useProjectContext() + const snap = useTableEditorStateSnapshot() - const { ui, meta } = useStore() - const queryClient = useQueryClient() const isAiAssistantEnabled = useIsRLSAIAssistantEnabled() const [selectedSchemaAndTable, setSelectedSchemaAndTable] = useState({}) @@ -50,9 +49,19 @@ const Policies = ({ const { mutateAsync: createDatabasePolicy } = useDatabasePolicyCreateMutation() const { mutateAsync: updateDatabasePolicy } = useDatabasePolicyUpdateMutation() + const { mutate: updateTable } = useTableUpdateMutation({ + onError: (error) => { + toast.error(`Failed to toggle RLS: ${error.message}`) + }, + onSettled: () => { + closeConfirmModal() + }, + }) const { mutate: deleteDatabasePolicy } = useDatabasePolicyDeleteMutation({ onSuccess: () => { toast.success('Successfully deleted policy!') + }, + onSettled: () => { closeConfirmModal() }, }) @@ -70,7 +79,7 @@ const Policies = ({ setSelectedTableToToggleRLS({}) } - const onSelectToggleRLS = (table: any) => { + const onSelectToggleRLS = (table: PostgresTable) => { setSelectedTableToToggleRLS(table) } @@ -92,7 +101,7 @@ const Policies = ({ } const onSavePolicySuccess = useCallback(async () => { - ui.setNotification({ category: 'success', message: 'Policy successfully saved!' }) + toast.success('Policy successfully saved!') closePolicyEditorModal() }, [closePolicyEditorModal]) @@ -103,16 +112,13 @@ const Policies = ({ rls_enabled: !selectedTableToToggleRLS.rls_enabled, } - const res: any = await meta.tables.update(payload.id, payload) - if (res.error) { - return ui.setNotification({ - category: 'error', - message: `Failed to toggle RLS: ${res.error.message}`, - }) - } - - await queryClient.invalidateQueries(tableKeys.list(ref, selectedTableToToggleRLS.schema)) - closeConfirmModal() + updateTable({ + projectRef: project?.ref!, + connectionString: project?.connectionString, + id: payload.id, + schema: (selectedTableToToggleRLS as PostgresTable).schema, + payload: payload, + }) } const onCreatePolicy = useCallback( @@ -166,57 +172,61 @@ const Policies = ({ return ( <> - {tables.length > 0 ? ( - tables.map((table: any) => ( -
- -
- )) - ) : hasTables ? ( - - ) : ( -
- router.push(`/project/${ref}/editor`)} - > -
- } - description={ -
-

- Policies restrict, on a per-user basis, which rows can be returned by normal - queries, or inserted, updated, or deleted by data modification commands. -

-

- This is also known as Row-Level Security (RLS). Each policy is attached to a - table, and the policy is executed each time its accessed. -

-
- } +
+ {isLocked && } + {tables.length > 0 ? ( + tables.map((table: any) => ( +
+ -

- Create a table in this schema first before creating a policy. -

-
- -
- )} + + )) + ) : hasTables ? ( + + ) : ( +
+ router.push(`/project/${ref}/editor`)} + > +
+ } + description={ +
+

+ Policies restrict, on a per-user basis, which rows can be returned by normal + queries, or inserted, updated, or deleted by data modification commands. +

+

+ This is also known as Row-Level Security (RLS). Each policy is attached to a + table, and the policy is executed each time its accessed. +

+
+ } + /> +

+ Create a table in this schema first before creating a policy. +

+
+
+
+ )} +
(
- - -
{showTemplates && (
) } return ( -
-

{getTitle()}

+
+

+ {getTitle()} +

+
+ {showAssistantPreview && view === POLICY_MODAL_VIEWS.EDITOR && ( + + )} + +
) } diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx index 4639b2d763e..36121b21ecb 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx @@ -4,6 +4,8 @@ import { Modal } from 'ui' import ConfirmationModal from 'components/ui/ConfirmationModal' import { useStore } from 'hooks' +import { LOCAL_STORAGE_KEYS } from 'lib/constants' +import { useAppStateSnapshot } from 'state/app-state' import { POLICY_MODAL_VIEWS } from '../Policies.constants' import { PolicyFormField, @@ -28,6 +30,7 @@ interface PolicyEditorModalProps { schema: string table: string selectedPolicyToEdit: any + showAssistantPreview?: boolean onSelectCancel: () => void onCreatePolicy: (payload: PostgresPolicyCreatePayload) => Promise onUpdatePolicy: (payload: PostgresPolicyUpdatePayload) => Promise @@ -39,12 +42,14 @@ const PolicyEditorModal = ({ schema = '', table = '', selectedPolicyToEdit = {}, + showAssistantPreview = false, onSelectCancel = noop, onCreatePolicy, onUpdatePolicy, onSaveSuccess = noop, }: PolicyEditorModalProps) => { const { ui } = useStore() + const snap = useAppStateSnapshot() const newPolicyTemplate: PolicyFormField = { schema, @@ -91,6 +96,12 @@ const PolicyEditorModal = ({ const onReviewPolicy = () => setView(POLICY_MODAL_VIEWS.REVIEW) const onSelectBackFromTemplates = () => setView(previousView) + const onToggleFeaturePreviewModal = () => { + snap.setSelectedFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_AI_ASSISTANT) + snap.setShowFeaturePreviewModal(!snap.showFeaturePreviewModal) + onSelectCancel() + } + const onUseTemplate = (template: PolicyTemplate) => { setPolicyFormFields({ ...policyFormFields, @@ -171,9 +182,8 @@ const PolicyEditorModal = ({ return ( , ]} onCancel={isClosingPolicyEditor} @@ -212,6 +224,8 @@ const PolicyEditorModal = ({ description="Write rules with PostgreSQL's policies to fit your unique business needs." onViewTemplates={onViewTemplates} onViewEditor={onViewEditor} + showAssistantPreview={showAssistantPreview} + onToggleFeaturePreviewModal={onToggleFeaturePreviewModal} /> ) : view === POLICY_MODAL_VIEWS.EDITOR ? ( void onViewEditor: () => void + onToggleFeaturePreviewModal?: () => void } const PolicySelection = ({ description = '', + showAssistantPreview, onViewTemplates = noop, onViewEditor = noop, + onToggleFeaturePreviewModal, }: PolicySelectionProps) => { + const snap = useAppStateSnapshot() + return ( - -
-
-

{description}

-
+ +
+

{description}

@@ -48,7 +63,7 @@ const PolicySelection = ({ className=" flex h-8 w-8 items-center justify-center - rounded bg-foreground text-background + rounded bg-foreground text-background " > @@ -58,19 +73,31 @@ const PolicySelection = ({ onClick={onViewEditor} />
-

- Not sure what policies are? Check out our resources{' '} - - here - - . -

+ + {showAssistantPreview && onToggleFeaturePreviewModal !== undefined && ( + + + Try the new Supabase Assistant for RLS policies + + Create RLS policies for your tables with the help of AI + +
+ + +
+
+ )}
) } diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index cdde01cf511..46df0828dc0 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -30,21 +30,22 @@ const PolicyTableRowHeader = ({ return (
-
+

{table.name}

- {isLocked ? ( - - - Locked - - - ) : ( +
+ {isLocked && ( + + + Locked + + + )} - {table.rls_enabled ? 'RLS enabled' : 'RLS disabled'} + {table.rls_enabled ? 'Row Level Security enabled' : 'Row Level Security disabled'} - )} +
{!isLocked && (
diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx index cd154e38c69..121a5a46a7a 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx @@ -1,8 +1,9 @@ import type { PostgresPolicy, PostgresTable } from '@supabase/postgres-meta' import { noop } from 'lodash' -import { Alert } from 'ui' +import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_ } from 'ui' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { WarningIcon } from 'components/ui/Icons' import Panel from 'components/ui/Panel' import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import PolicyRow from './PolicyRow' @@ -36,6 +37,7 @@ const PolicyTableRow = ({ return ( } > - {(policies ?? []).length === 0 && ( -
-

No policies created yet

- {!isLocked && - (table.rls_enabled ? ( -

- RLS is enabled - create a policy to allow access to this table. -

- ) : ( - - Anyone with the anon. key can modify or delete your data. You should turn on RLS and - create access policies to keep your data secure. - - ))} + {(!table.rls_enabled || policies.length === 0) && ( +
+ {table.rls_enabled && policies.length === 0 && ( + + + + Row Level Security is enabled for this table, but no policies are set + + + Select queries will return an{' '} + empty array of results. + + + )} + {!table.rls_enabled && ( + + + + Warning: Row Level Security is disabled. Your table is publicly readable and + writable. + + + Anyone with the project's anonymous key can modify or delete your data. Enable RLS + and create access policies to keep your data secure. + + + )} + {policies.length === 0 && ( +

No policies created yet

+ )}
)} diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx index 1d5657d2850..620ede52b27 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx @@ -42,7 +42,7 @@ const RedirectUrlList = ({ ) : (
diff --git a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx index 7730235d0f1..5993368581e 100644 --- a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx +++ b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx @@ -216,7 +216,7 @@ const SmtpForm = () => { descriptionText={ } /> diff --git a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.utils.ts b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.utils.ts index ad2539209d4..8d33cbaf2a9 100644 --- a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.utils.ts +++ b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.utils.ts @@ -1,6 +1,6 @@ import { components } from 'data/api' -type AuthConfig = components['schemas']['GoTrueConfig'] +type AuthConfig = components['schemas']['GoTrueConfigResponse'] interface AuthConfigForm extends AuthConfig { ENABLE_SMTP: boolean diff --git a/apps/studio/components/interfaces/Auth/Users/UserDropdown.tsx b/apps/studio/components/interfaces/Auth/Users/UserDropdown.tsx index 9bdcb78c6f4..38c2edb0213 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserDropdown.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserDropdown.tsx @@ -23,6 +23,7 @@ import { IconMoreVertical, IconShieldOff, IconTrash, + IconUser, Modal, } from 'ui' @@ -30,9 +31,17 @@ interface UserDropdownProps { user: User canRemoveUser: boolean canRemoveMFAFactors: boolean + setSelectedUser: (user: User) => void + setUserSidePanelOpen: (open: boolean) => void } -const UserDropdown = ({ user, canRemoveUser, canRemoveMFAFactors }: UserDropdownProps) => { +const UserDropdown = ({ + user, + canRemoveUser, + canRemoveMFAFactors, + setSelectedUser, + setUserSidePanelOpen, +}: UserDropdownProps) => { const { ui } = useStore() const { ref } = useParams() @@ -122,6 +131,11 @@ const UserDropdown = ({ user, canRemoveUser, canRemoveMFAFactors }: UserDropdown } } + const handleViewUserInfo = () => { + setSelectedUser(user) + setUserSidePanelOpen(true) + } + return ( <> @@ -132,6 +146,11 @@ const UserDropdown = ({ user, canRemoveUser, canRemoveMFAFactors }: UserDropdown <> + + +

View user info

+
+ {user.email !== null ? ( <> @@ -151,6 +170,7 @@ const UserDropdown = ({ user, canRemoveUser, canRemoveMFAFactors }: UserDropdown ) : null} + Remove MFA factors

- {/* + {/* [Joshen] Deleting MFA factors should be different ABAC perms i think - need to double check with KM / anyone familiar with ABAC + need to double check with KM / anyone familiar with ABAC */} {!canRemoveMFAFactors && ( diff --git a/apps/studio/components/interfaces/Auth/Users/UserSidePanel.tsx b/apps/studio/components/interfaces/Auth/Users/UserSidePanel.tsx new file mode 100644 index 00000000000..5ade2553bb0 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/UserSidePanel.tsx @@ -0,0 +1,32 @@ +import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' +import { User } from 'data/auth/users-query' +import { SidePanel } from 'ui' + +interface UsersSidePanelProps { + userSidePanelOpen: boolean + selectedUser: User | undefined + setUserSidePanelOpen: (open: boolean) => void +} +const UsersSidePanel = ({ + selectedUser, + userSidePanelOpen, + setUserSidePanelOpen, +}: UsersSidePanelProps) => { + return ( + setUserSidePanelOpen(false)} + cancelText="Close" + > + + + {JSON.stringify(selectedUser, null, 2)} + + + + ) +} + +export default UsersSidePanel diff --git a/apps/studio/components/interfaces/Auth/Users/UsersList.tsx b/apps/studio/components/interfaces/Auth/Users/UsersList.tsx index 4286a0ea9ea..bc625873e3c 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersList.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersList.tsx @@ -9,6 +9,8 @@ import { IconAlertCircle } from 'ui' import UserListItem from './UsersListItem' import UsersListItemSkeleton from './UsersListItemSkeleton' import UsersPagination from './UsersPagination' +import UsersSidePanel from './UserSidePanel' +import { useState } from 'react' interface UsersListProps { page: number @@ -35,6 +37,9 @@ const UsersList = ({ isFetchingNextPage, error, }: UsersListProps) => { + const [selectedUser, setSelectedUser] = useState(undefined) + const [userSidePanelOpen, setUserSidePanelOpen] = useState(false) + // Check once on the top level, rather than checking for every row const canRemoveUser = useCheckPermissions(PermissionAction.TENANT_SQL_DELETE, 'auth.users') const canRemoveMFAFactors = useCheckPermissions( @@ -92,6 +97,8 @@ const UsersList = ({ user={x} canRemoveUser={canRemoveUser} canRemoveMFAFactors={canRemoveMFAFactors} + setSelectedUser={setSelectedUser} + setUserSidePanelOpen={setUserSidePanelOpen} /> ))} @@ -104,6 +111,12 @@ const UsersList = ({ /> + + } /> diff --git a/apps/studio/components/interfaces/Auth/Users/UsersListItem.tsx b/apps/studio/components/interfaces/Auth/Users/UsersListItem.tsx index ee0d3080021..3841efc6be1 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersListItem.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersListItem.tsx @@ -11,9 +11,17 @@ interface UserListItemProps { user: User canRemoveUser: boolean canRemoveMFAFactors: boolean + setSelectedUser: (user: User) => void + setUserSidePanelOpen: (open: boolean) => void } -const UserListItem = ({ user, canRemoveUser, canRemoveMFAFactors }: UserListItemProps) => { +const UserListItem = ({ + user, + canRemoveUser, + canRemoveMFAFactors, + setSelectedUser, + setUserSidePanelOpen, +}: UserListItemProps) => { const isUserConfirmed = user.email_confirmed_at || user.phone_confirmed_at const createdAt = dayjs(user.created_at) const lastSignedIn = dayjs(user.last_sign_in_at) @@ -59,6 +67,8 @@ const UserListItem = ({ user, canRemoveUser, canRemoveMFAFactors }: UserListItem user={user} canRemoveUser={canRemoveUser} canRemoveMFAFactors={canRemoveMFAFactors} + setSelectedUser={setSelectedUser} + setUserSidePanelOpen={setUserSidePanelOpen} /> diff --git a/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts b/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts index eeddd42654f..f39ae7270f8 100644 --- a/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts +++ b/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts @@ -10,11 +10,10 @@ export const getAddons = (selectedAddons: ProjectSelectedAddon[]) => { return { computeInstance, pitr, customDomain, ipv4 } } -export const subscriptionHasHipaaAddon = (subscription: OrgSubscription | undefined): boolean => { +export const subscriptionHasHipaaAddon = (subscription?: OrgSubscription): boolean => { if (!IS_PLATFORM) return false - return ( - subscription !== undefined && - subscription.addons.some((addon) => addon.supabase_prod_id === 'addon_security_hipaa') + return (subscription?.addons ?? []).some( + (addon) => addon.supabase_prod_id === 'addon_security_hipaa' ) } diff --git a/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx b/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx index 16319750099..52428746463 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx @@ -34,6 +34,7 @@ import { PullRequestsEmptyState, } from './EmptyStates' import Overview from './Overview' +import { MessageCircle } from 'lucide-react' const BranchManagement = () => { const { ui } = useStore() @@ -164,44 +165,45 @@ const BranchManagement = () => {
-
-
- - - -
- } - /> +
+ + +
+ + + + + Connect to your project + + Get the connection strings and environment variables for your app + + + + handleConnectionType(value)} + > + + + Connection String + + {CONNECTION_TYPES.map((type) => ( + + {type.label} + + ))} + + + {CONNECTION_TYPES.map((type) => { + const hasChildOptions = + (connectionObject.find((parent) => parent.key === selectedParent)?.children + .length || 0) > 0 + const hasGrandChildOptions = + (connectionObject + .find((parent) => parent.key === selectedParent) + ?.children.find((child) => child.key === selectedChild)?.children.length || 0) > 0 + + return ( + +
+
+ + {selectedParent && hasChildOptions && ( + + )} + {selectedChild && hasGrandChildOptions && ( + + )} +
+ {connectionObject.find((item) => item.key === selectedParent)?.guideLink && ( + + )} +
+

+ Add the following files below to your application +

+ +
+ ) + })} + + + +
+
+ + + + ) +} + +export default Connect diff --git a/apps/studio/components/interfaces/Home/Connect/Connect.types.ts b/apps/studio/components/interfaces/Home/Connect/Connect.types.ts new file mode 100644 index 00000000000..8a5ba229dda --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/Connect.types.ts @@ -0,0 +1,11 @@ +export type projectKeys = { + apiUrl: string | null + anonKey: string | null +} + +export interface ContentFileProps { + projectKeys: { + apiUrl: string + anonKey: string + } +} diff --git a/apps/studio/components/interfaces/Home/Connect/Connect.utils.ts b/apps/studio/components/interfaces/Home/Connect/Connect.utils.ts new file mode 100644 index 00000000000..9c497fb095d --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/Connect.utils.ts @@ -0,0 +1,45 @@ +import { ConnectionType } from './Connect.constants' + +export function getProjectRef(url: string): string | null { + const regex: RegExp = /https:\/\/([^\.]+)\./ + const match: RegExpMatchArray | null = url.match(regex) + + if (match) { + return match[1] + } else { + return null + } +} + +export const getContentFilePath = ({ + connectionObject, + selectedParent, + selectedChild, + selectedGrandchild, +}: { + selectedParent: string + selectedChild: string + selectedGrandchild: string + connectionObject: ConnectionType[] +}) => { + const parent = connectionObject.find((item) => item.key === selectedParent) + + if (parent) { + const child = parent.children.find((child) => child.key === selectedChild) + + // check grandchild first, then child, then parent as the fallback + if (child) { + const grandchild = child.children.find((grandchild) => grandchild.key === selectedGrandchild) + + if (grandchild) { + return `${selectedParent}/${selectedChild}/${selectedGrandchild}` + } else { + return `${selectedParent}/${selectedChild}` + } + } else { + return selectedParent + } + } + + return '' +} diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectDropdown.tsx b/apps/studio/components/interfaces/Home/Connect/ConnectDropdown.tsx new file mode 100644 index 00000000000..93b2e64fcec --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/ConnectDropdown.tsx @@ -0,0 +1,93 @@ +import { Box, Check, ChevronDown } from 'lucide-react' +import { useState } from 'react' +import { + Button, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + Command_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, + cn, +} from 'ui' +import ConnectionIcon from './ConnectionIcon' + +interface ConnectDropdownProps { + state: string + updateState: (state: string) => void + label: string + items: any[] +} + +const ConnectDropdown = ({ + state, + updateState, + label, + + items, +}: ConnectDropdownProps) => { + const [open, setOpen] = useState(false) + + function onSelectLib(key: string) { + updateState(key) + setOpen(false) + } + + const selectedItem = items.find((item) => item.key === state) + + return ( + <> + +
+ + {label} + + + + +
+ + + + + {items.map((item) => ( + { + onSelectLib(item.key) + setOpen(false) + }} + > +
+ {item.icon ? : } + + {item.label} + + +
+
+ ))} +
+
+
+
+ + ) +} + +export default ConnectDropdown diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx b/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx new file mode 100644 index 00000000000..c4b4c35628f --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx @@ -0,0 +1,29 @@ +import dynamic from 'next/dynamic' +import { projectKeys } from './Connect.types' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' + +interface ConnectContentTabProps { + projectKeys: projectKeys + filePath: string +} + +const ConnectTabContentNew = ({ projectKeys, filePath }: ConnectContentTabProps) => { + const ContentFile = dynamic( + () => import(`./content/${filePath}/content`), + { + loading: () => ( +
+ +
+ ), + } + ) + + return ( +
+ +
+ ) +} + +export default ConnectTabContentNew diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectTabs.tsx b/apps/studio/components/interfaces/Home/Connect/ConnectTabs.tsx new file mode 100644 index 00000000000..f5871de3f91 --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/ConnectTabs.tsx @@ -0,0 +1,60 @@ +import { Tabs_Shadcn_ } from 'ui' +import { FileJson2 } from 'lucide-react' +import { TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' +import { TabsContent_Shadcn_ } from 'ui' +import React, { ReactNode } from 'react' + +interface ConnectTabTriggerProps { + value: string +} +interface ConnectTabTriggersProps { + children: ReactNode[] +} + +interface ConnectFileTabProps { + children: ReactNode[] +} + +interface ConnectTabContentProps { + children: ReactNode + value: string +} +const ConnectTabs = ({ children }: ConnectFileTabProps) => { + const firstChild = children[0] + + const defaultValue = React.isValidElement(firstChild) + ? (firstChild.props as any)?.children[0]?.props?.value || '' + : null + + return {children} +} + +const ConnectTabTrigger = ({ value }: ConnectTabTriggerProps) => { + return ( + + + {value} + + ) +} + +const ConnectTabTriggers = ({ children }: ConnectTabTriggersProps) => { + return ( + + {children} + + ) +} + +export const ConnectTabContent = ({ value, children }: ConnectTabContentProps) => { + return ( + + {children} + + ) +} + +export { ConnectTabTrigger, ConnectTabTriggers, ConnectTabs } diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectionIcon.tsx b/apps/studio/components/interfaces/Home/Connect/ConnectionIcon.tsx new file mode 100644 index 00000000000..de4541decae --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/ConnectionIcon.tsx @@ -0,0 +1,29 @@ +import { BASE_PATH } from 'lib/constants' + +import { useTheme } from 'next-themes' +import Image from 'next/image' +interface ConnectionIconProps { + connection: any +} + +const ConnectionIcon = ({ connection }: ConnectionIconProps) => { + const { resolvedTheme } = useTheme() + + return ( + + ) +} + +export default ConnectionIcon diff --git a/apps/studio/components/interfaces/Home/Connect/content/drizzle/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/drizzle/content.tsx new file mode 100644 index 00000000000..3c4c6b210dd --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/content/drizzle/content.tsx @@ -0,0 +1,63 @@ +import { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' + +import { + ConnectTabs, + ConnectTabTrigger, + ConnectTabTriggers, + ConnectTabContent, +} from 'components/interfaces/Home/Connect/ConnectTabs' +import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' + +const ContentFile = ({ projectKeys }: ContentFileProps) => { + return ( + + + + + + + + + + {` +DATABASE_URL='your-database-url' + `} + + + + + + {` +import { pgTable, serial, text, varchar } from "drizzle-orm/pg-core"; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + fullName: text('full_name'), + phone: varchar('phone', { length: 256 }), +}); + `} + + + + + + {` +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { users } from './schema' + +const connectionString = process.env.DATABASE_URL + +// Disable prefetch as it is not supported for "Transaction" pool mode +const client = postgres(connectionString, { prepare: false }) +const db = drizzle(client); + +const allUsers = await db.select().from(users); + `} + + + + ) +} + +export default ContentFile diff --git a/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx new file mode 100644 index 00000000000..cb9e66fe3c6 --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx @@ -0,0 +1,181 @@ +import { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' + +import { + ConnectTabs, + ConnectTabTrigger, + ConnectTabTriggers, + ConnectTabContent, +} from 'components/interfaces/Home/Connect/ConnectTabs' +import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' + +const ContentFile = ({ projectKeys }: ContentFileProps) => { + return ( + + + + + + + + + + + + {` +NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} +NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} + `} + + + + + + {` +import { createClient } from '@/utils/supabase/server' +import { cookies } from 'next/headers' + +export default async function Page() { + const cookieStore = cookies() + const supabase = createClient(cookieStore) + + const { data: todos } = await supabase.from('todos').select() + + return ( +
    + {todos?.map((todo) => ( +
  • {todo}
  • + ))} +
+ ) +} +`} +
+
+ + + + {` +import { createServerClient, type CookieOptions } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +export const createClient = (cookieStore: ReturnType) => { + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return cookieStore.get(name)?.value; + }, + set(name: string, value: string, options: CookieOptions) { + try { + cookieStore.set({ name, value, ...options }); + } catch (error) { + // The \`set\` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + remove(name: string, options: CookieOptions) { + try { + cookieStore.set({ name, value: "", ...options }); + } catch (error) { + // The \`delete\` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + }, + }, + ); +}; +`} + + + + + {` +import { createBrowserClient } from "@supabase/ssr"; + +export const createClient = () => + createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ); +`} + + + + + + {` +import { createServerClient, type CookieOptions } from "@supabase/ssr"; +import { type NextRequest, NextResponse } from "next/server"; + +export const createClient = (request: NextRequest) => { + // Create an unmodified response + let response = NextResponse.next({ + request: { + headers: request.headers, + }, + }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return request.cookies.get(name)?.value; + }, + set(name: string, value: string, options: CookieOptions) { + // If the cookie is updated, update the cookies for the request and response + request.cookies.set({ + name, + value, + ...options, + }); + response = NextResponse.next({ + request: { + headers: request.headers, + }, + }); + response.cookies.set({ + name, + value, + ...options, + }); + }, + remove(name: string, options: CookieOptions) { + // If the cookie is removed, update the cookies for the request and response + request.cookies.set({ + name, + value: "", + ...options, + }); + response = NextResponse.next({ + request: { + headers: request.headers, + }, + }); + response.cookies.set({ + name, + value: "", + ...options, + }); + }, + }, + }, + ); + + return { supabase, response }; +}; + +`} + + +
+ ) +} + +export default ContentFile diff --git a/apps/studio/components/interfaces/Home/Connect/content/nextjs/pages/supabasejs/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/nextjs/pages/supabasejs/content.tsx new file mode 100644 index 00000000000..cf0d13d153a --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/content/nextjs/pages/supabasejs/content.tsx @@ -0,0 +1,80 @@ +import { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' + +import { + ConnectTabs, + ConnectTabTrigger, + ConnectTabTriggers, + ConnectTabContent, +} from 'components/interfaces/Home/Connect/ConnectTabs' +import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' + +const ContentFile = ({ projectKeys }: ContentFileProps) => { + return ( + + + + + + + + + + {` +NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} +NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} + `} + + + + + + {` +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + +export const supabase = createClient(supabaseUrl, supabaseKey); + `} + + + + + + {` +import { useState, useEffect } from 'react' +import { supabase } from '../utils/supabase' + +function Page() { + const [todos, setTodos] = useState([]) + + useEffect(() => { + function getTodos() { + const { data: todos } = await supabase.from('todos').select() + + if (todos.length > 1) { + setTodos(todos) + } + } + + getTodos() + }, []) + + return ( +
+ {todos.map((todo) => ( +
  • {todo}
  • + ))} +
    + ) +} +export default Page + +`} +
    +
    +
    + ) +} + +export default ContentFile diff --git a/apps/studio/components/interfaces/Home/Connect/content/nuxt/supabasejs/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/nuxt/supabasejs/content.tsx new file mode 100644 index 00000000000..c42375e073e --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/content/nuxt/supabasejs/content.tsx @@ -0,0 +1,72 @@ +import { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' + +import { + ConnectTabs, + ConnectTabTrigger, + ConnectTabTriggers, + ConnectTabContent, +} from 'components/interfaces/Home/Connect/ConnectTabs' +import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' + +const ContentFile = ({ projectKeys }: ContentFileProps) => { + return ( + + + + + + + + + + {` +SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} +SUPABASE_KEY=${projectKeys.anonKey ?? 'your-anon-key'} + `} + + + + + + {` +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = process.env.SUPABASE_URL; +const supabaseKey = process.env.SUPABASE_KEY; + +export const supabase = createClient(supabaseUrl, supabaseKey); + `} + + + + + + {` + + + +`} + + + + ) +} + +export default ContentFile diff --git a/apps/studio/components/interfaces/Home/Connect/content/prisma/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/prisma/content.tsx new file mode 100644 index 00000000000..17bdbb9d23b --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/content/prisma/content.tsx @@ -0,0 +1,46 @@ +import { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' + +import { + ConnectTabs, + ConnectTabTrigger, + ConnectTabTriggers, + ConnectTabContent, +} from 'components/interfaces/Home/Connect/ConnectTabs' +import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' + +const ContentFile = ({ projectKeys }: ContentFileProps) => { + return ( + + + + + + + + + {` +# Connect to Supabase via connection pooling with Supavisor. +DATABASE_URL="postgres://postgres.[your-supabase-project]:[password]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres?pgbouncer=true" + +# Direct connection to the database. Used for migrations. +DIRECT_URL="postgres://postgres:[password]@db.[your-supabase-project].supabase.co:5432/postgres" + `} + + + + + + {` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") +} + `} + + + + ) +} + +export default ContentFile diff --git a/apps/studio/components/interfaces/Home/Connect/content/react/supabasejs/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/react/supabasejs/content.tsx new file mode 100644 index 00000000000..dbd7d2fdae9 --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/content/react/supabasejs/content.tsx @@ -0,0 +1,79 @@ +import { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' + +import { + ConnectTabs, + ConnectTabTrigger, + ConnectTabTriggers, + ConnectTabContent, +} from 'components/interfaces/Home/Connect/ConnectTabs' +import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' + +const ContentFile = ({ projectKeys }: ContentFileProps) => { + return ( + + + + + + + + + + {` +SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} +SUPABASE_KEY=${projectKeys.anonKey ?? 'your-anon-key'} + `} + + + + + + {` +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + +export const supabase = createClient(supabaseUrl, supabaseKey); + `} + + + + + + {` +import { useState, useEffect } from 'react' +import { supabase } from '../utils/supabase' + +function Page() { + const [todos, setTodos] = useState([]) + + useEffect(() => { + function getTodos() { + const { data: todos } = await supabase.from('todos').select() + + if (todos.length > 1) { + setTodos(todos) + } + } + + getTodos() + }, []) + + return ( +
    + {todos.map((todo) => ( +
  • {todo}
  • + ))} +
    + ) +} +export default Page +`} +
    +
    +
    + ) +} + +export default ContentFile diff --git a/apps/studio/components/interfaces/Home/Connect/content/refine/supabasejs/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/refine/supabasejs/content.tsx new file mode 100644 index 00000000000..1454a0c4692 --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/content/refine/supabasejs/content.tsx @@ -0,0 +1,115 @@ +import { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' + +import { + ConnectTabs, + ConnectTabTrigger, + ConnectTabTriggers, + ConnectTabContent, +} from 'components/interfaces/Home/Connect/ConnectTabs' +import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' + +const ContentFile = ({ projectKeys }: ContentFileProps) => { + return ( + + + + + + + + + + {` +SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} +SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} + `} + + + + + + {` +import { createClient } from "@refinedev/supabase"; + +const SUPABASE_URL = process.env.SUPABASE_URL; +const SUPABASE_KEY = process.env.SUPABASE_KEY + +export const supabaseClient = createClient(SUPABASE_URL, SUPABASE_KEY, { + db: { + schema: "public", + }, + auth: { + persistSession: true, + }, +}); + `} + + + + + + {` +import { Refine, WelcomePage } from "@refinedev/core"; +import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; +import routerBindings, { + DocumentTitleHandler, + NavigateToResource, + UnsavedChangesNotifier, +} from "@refinedev/react-router-v6"; +import { dataProvider, liveProvider } from "@refinedev/supabase"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; + +import "./App.css"; +import authProvider from "./authProvider"; +import { supabaseClient } from "./utility"; +import { CountriesCreate, CountriesEdit, CountriesList, CountriesShow } from "./pages/countries"; + +function App() { + return ( + + + + + } + /> + + } /> + } /> + } /> + } /> + + + + + + + + + ); +} + +export default App; +`} + + + + ) +} + +export default ContentFile diff --git a/apps/studio/components/interfaces/Home/Connect/content/solidjs/supabasejs/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/solidjs/supabasejs/content.tsx new file mode 100644 index 00000000000..a0e2c647066 --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/content/solidjs/supabasejs/content.tsx @@ -0,0 +1,71 @@ +import { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' + +import { + ConnectTabs, + ConnectTabTrigger, + ConnectTabTriggers, + ConnectTabContent, +} from 'components/interfaces/Home/Connect/ConnectTabs' +import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' + +const ContentFile = ({ projectKeys }: ContentFileProps) => { + return ( + + + + + + + + + + {` +SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} +SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} + `} + + + + + + {` +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = process.env.SUPABASE_URL; +const supabaseKey = process.env.SUPABASE_ANON_KEY; + +export const supabase = createClient(supabaseUrl, supabaseKey); + `} + + + + + + {` +import { supabase } from '../utils/supabase' +import { createResource, For } from "solid-js"; + +async function getTodos() { + const { data: todos } = await supabase.from("todos").select(); + return data; +} + +function App() { + const [todos] = createResource(getTodos); + + return ( +
      + {(country) =>
    • {todo.name}
    • }
      +
    + ); +} + +export default App; +`} +
    +
    +
    + ) +} + +export default ContentFile diff --git a/apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx new file mode 100644 index 00000000000..6307039c60e --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx @@ -0,0 +1,77 @@ +import { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' + +import { + ConnectTabs, + ConnectTabTriggers, + ConnectTabTrigger, + ConnectTabContent, +} from 'components/interfaces/Home/Connect/ConnectTabs' +import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' + +const ContentFile = ({ projectKeys }: ContentFileProps) => { + return ( + + + + + + + + + + + {` +SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} +SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} + `} + + + + + + {` +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = process.env.SUPABASE_URL; +const supabaseKey = process.env.SUPABASE_KEY; + +export const supabase = createClient(supabaseUrl, supabaseKey); + `} + + + + + + {` +import { supabase } from "$lib/supabaseClient"; + +export async function load() { + const { data } = await supabase.from("countries").select(); + return { + countries: data ?? [], + }; +} +`} + + + + + + {` + + +
      + {#each data.countries as country} +
    • {country.name}
    • + {/each} +
    +`} +
    +
    +
    + ) +} + +export default ContentFile diff --git a/apps/studio/components/interfaces/Home/Connect/content/vuejs/supabasejs/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/vuejs/supabasejs/content.tsx new file mode 100644 index 00000000000..0bf5c3b5f4b --- /dev/null +++ b/apps/studio/components/interfaces/Home/Connect/content/vuejs/supabasejs/content.tsx @@ -0,0 +1,72 @@ +import { ContentFileProps } from 'components/interfaces/Home/Connect/Connect.types' + +import { + ConnectTabs, + ConnectTabTrigger, + ConnectTabTriggers, + ConnectTabContent, +} from 'components/interfaces/Home/Connect/ConnectTabs' +import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' + +const ContentFile = ({ projectKeys }: ContentFileProps) => { + return ( + + + + + + + + + + {` +SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} +SUPABASE_KEY=${projectKeys.anonKey ?? 'your-anon-key'} + `} + + + + + + {` +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = process.env.SUPABASE_URL; +const supabaseKey = process.env.SUPABASE_KEY; + +export const supabase = createClient(supabaseUrl, supabaseKey); + `} + + + + + + {` + + + +`} + + + + ) +} + +export default ContentFile diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx index 657108ab6f4..97ff6209aaf 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx @@ -142,10 +142,10 @@ const APIKeys = () => { !canReadAPIKeys ? 'You need additional permissions to view API keys' : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Failed - ? 'JWT secret update failed, new API key may have issues' - : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating - ? 'Updating JWT secret...' - : apiService?.defaultApiKey + ? 'JWT secret update failed, new API key may have issues' + : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating + ? 'Updating JWT secret...' + : apiService?.defaultApiKey } onChange={() => {}} descriptionText={ @@ -165,7 +165,7 @@ const APIKeys = () => { />
    -
    +
    {availableLanguages.map((language) => { const isSelected = selectedLanguage.key === language.key return ( @@ -173,7 +173,7 @@ const APIKeys = () => { key={language.key} className={[ 'px-3 py-1 text-sm cursor-pointer transition', - `${!isSelected ? 'bg-background text-foreground-light' : 'bg-surface-100'}`, + `${!isSelected ? 'bg-studio text-foreground-light' : 'bg-surface-100'}`, ].join(' ')} onClick={() => setSelectedLanguage(language)} > diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/GetStartedHero.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/GetStartedHero.tsx index 900261c4bc5..c9469de051f 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/GetStartedHero.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/GetStartedHero.tsx @@ -74,7 +74,7 @@ const GetStartedHero = () => {

    iso2

    -
    +
    @@ -88,7 +88,7 @@ const GetStartedHero = () => {

    AO

    -
    +
    @@ -102,7 +102,7 @@ const GetStartedHero = () => {

    TL

    -
    +
    @@ -116,7 +116,7 @@ const GetStartedHero = () => {

    RS

    -
    +
    @@ -130,7 +130,7 @@ const GetStartedHero = () => {

    BS

    -
    +
    @@ -144,7 +144,7 @@ const GetStartedHero = () => {

    CL

    -
    +
    @@ -158,7 +158,7 @@ const GetStartedHero = () => {

    DK

    -
    +
    diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx index eacdfffe40c..d937c8d1071 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx @@ -73,8 +73,8 @@ export const ProjectCardStatus = ({ const alertType = isCritical ? 'destructive' : projectStatus === 'isPaused' - ? 'default' - : 'warning' + ? 'default' + : 'warning' if ( (activeWarnings.length === 0 || warningContent === undefined) && @@ -112,7 +112,7 @@ export const ProjectCardStatus = ({
    {alertDescription} diff --git a/apps/studio/components/interfaces/Home/ServiceStatus.tsx b/apps/studio/components/interfaces/Home/ServiceStatus.tsx index 8999e80dcc8..5dfa42c51bc 100644 --- a/apps/studio/components/interfaces/Home/ServiceStatus.tsx +++ b/apps/studio/components/interfaces/Home/ServiceStatus.tsx @@ -119,54 +119,52 @@ const ServiceStatus = () => { const allServicesOperational = services.every((service) => service.isSuccess) return ( -
    - - - + + + {services.map((service) => ( +
    - {isBranch ? 'Preview Branch' : 'Project'} Status - - - - {services.map((service) => ( -
    -
    -

    {service.name}

    -

    - {service.isLoading - ? 'Checking status' - : service.isSuccess +

    +

    {service.name}

    +

    + {service.isLoading + ? 'Checking status' + : service.isSuccess ? 'No issues' : 'Unable to connect'} -

    -
    - {service.isLoading ? ( - - ) : service.isSuccess ? ( - - ) : ( - - )} +

    - ))} - - -
    + {service.isLoading ? ( + + ) : service.isSuccess ? ( + + ) : ( + + )} +
    + ))} +
    +
    ) } diff --git a/apps/studio/components/interfaces/Integrations/ProjectLinker.tsx b/apps/studio/components/interfaces/Integrations/ProjectLinker.tsx index 62a2780904b..0311a881d67 100644 --- a/apps/studio/components/interfaces/Integrations/ProjectLinker.tsx +++ b/apps/studio/components/interfaces/Integrations/ProjectLinker.tsx @@ -2,7 +2,6 @@ import { ReactNode, useEffect, useRef, useState } from 'react' import { toast } from 'react-hot-toast' import { Button, - cn, CommandEmpty_Shadcn_, CommandGroup_Shadcn_, CommandInput_Shadcn_, @@ -13,6 +12,7 @@ import { PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, + cn, } from 'ui' import ShimmerLine from 'components/ui/ShimmerLine' @@ -122,6 +122,7 @@ const ProjectLinker = ({ connection: { foreign_project_id: selectedForeignProject?.id, supabase_project_ref: selectedSupabaseProject?.ref, + integration_id: '0', metadata: { ...projectDetails, }, @@ -228,7 +229,7 @@ const ProjectLinker = ({ {supabaseProjects.map((project, i) => { return ( { @@ -301,7 +302,7 @@ const ProjectLinker = ({ return ( { if (project.id) setForeignProjectId(project.id) diff --git a/apps/studio/components/interfaces/Markdown.tsx b/apps/studio/components/interfaces/Markdown.tsx index a5326154fb2..9552649fc04 100644 --- a/apps/studio/components/interfaces/Markdown.tsx +++ b/apps/studio/components/interfaces/Markdown.tsx @@ -1,8 +1,7 @@ -import { ReactMarkdownProps } from 'react-markdown/lib/complex-types' -import { ReactMarkdown } from 'react-markdown/lib/react-markdown' +import { ReactMarkdown, ReactMarkdownOptions } from 'react-markdown/lib/react-markdown' import { cn } from 'ui' -interface Props extends Omit { +interface Props extends Omit { className?: string content: string extLinks?: boolean diff --git a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx index 8866b280720..f5e5e328ad5 100644 --- a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx +++ b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx @@ -379,8 +379,8 @@ const AuditLogs = () => { {project?.name ? 'Project: ' : organization?.name - ? 'Organization: ' - : null} + ? 'Organization: ' + : null} {project?.name ?? organization?.name ?? 'Unknown'}

    { {log.target.metadata.project_ref ? 'Ref: ' : log.target.metadata.org_slug - ? 'Slug: ' - : null} + ? 'Slug: ' + : null} {log.target.metadata.project_ref ?? log.target.metadata.org_slug}

    diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx index 01706ebedb0..ee1684e5c4e 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx @@ -8,6 +8,7 @@ import { Metric, USAGE_APPROACHING_THRESHOLD } from './BillingBreakdown.constant import { billingMetricUnit, formatUsage } from '../helpers' import { PricingMetric } from 'data/analytics/org-daily-stats-query' import { useMemo } from 'react' +import { formatCurrency } from 'lib/helpers' export interface BillingMetricProps { idx: number @@ -75,7 +76,7 @@ const BillingMetric = ({ {usageLabel}  {usageMeta.cost && usageMeta.cost > 0 ? ( - (${usageMeta.cost}) + ({formatCurrency(usageMeta.cost)}) ) : usageMeta.available_in_plan && !usageMeta.unlimited && relativeToSubscription ? ( ({percentageLabel}) ) : null} @@ -111,10 +112,10 @@ const BillingMetric = ({ isUsageBillingEnabled ? 'text-gray-dark-800' : isExceededLimit - ? 'text-red-900' - : isApproachingLimit - ? 'text-yellow-1000' - : 'text-gray-dark-800' + ? 'text-red-900' + : isApproachingLimit + ? 'text-yellow-1000' + : 'text-gray-dark-800' } /> diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/ComputeMetric.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/ComputeMetric.tsx index 101fbba19d9..2746c44e686 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/ComputeMetric.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/ComputeMetric.tsx @@ -7,6 +7,7 @@ import { Metric } from './BillingBreakdown.constants' import { formatUsage } from '../helpers' import { PricingMetric } from 'data/analytics/org-daily-stats-query' import React, { useMemo } from 'react' +import { formatCurrency } from 'lib/helpers' export interface ComputeMetricProps { slug?: string @@ -41,7 +42,7 @@ const ComputeMetric = ({ slug, metric, usage, relativeToSubscription }: ComputeM {usageLabel}  {relativeToSubscription && usageMeta?.cost && usageMeta.cost > 0 ? ( - (${usageMeta?.cost}) + ({formatCurrency(usageMeta?.cost)}) ) : null}
    diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx index d4b7ff6545f..7ba12b84031 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx @@ -134,8 +134,8 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { {item.unit_price === 0 ? 'FREE' : item.unit_price - ? formatCurrency(item.unit_price) - : null} + ? formatCurrency(item.unit_price) + : null} )}
    @@ -195,15 +195,15 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { {fee.usage_original ? `${formatUsage(fee.usage_metric!, fee.usage_original)}` : fee.quantity - ? fee.quantity - : null} + ? fee.quantity + : null} + {expanded && ( + + )} + + + ) +} + +export default ReportQueryPerformanceTableRow diff --git a/apps/studio/components/interfaces/Reports/Reports.CreateReportModal.tsx b/apps/studio/components/interfaces/Reports/Reports.CreateReportModal.tsx new file mode 100644 index 00000000000..8040f37309f --- /dev/null +++ b/apps/studio/components/interfaces/Reports/Reports.CreateReportModal.tsx @@ -0,0 +1,112 @@ +import { useContentInsertMutation } from 'data/content/content-insert-mutation' +import { useSelectedProject } from 'hooks' +import { uuidv4 } from 'lib/helpers' +import { useRouter } from 'next/router' +import toast from 'react-hot-toast' +import { Button, Form, Input, Modal } from 'ui' + +type CustomReport = { name: string; description?: string } +export interface CreateReportModal { + visible: boolean + onCancel: () => void + afterSubmit: () => void +} + +export const CreateReportModal = ({ visible, onCancel, afterSubmit }: CreateReportModal) => { + const project = useSelectedProject() + const insertReport = useContentInsertMutation() + const ref = project?.ref ?? 'default' + const router = useRouter() + + async function createCustomReport({ name, description }: { name: string; description?: string }) { + try { + if (!ref) return + + const res = await insertReport.mutateAsync({ + projectRef: ref, + payload: { + id: uuidv4(), + type: 'report', + name, + description: description || '', + visibility: 'project', + content: { + schema_version: 1, + period_start: { + time_period: '7d', + date: '', + }, + period_end: { + time_period: 'today', + date: '', + }, + interval: '1d', + layout: [], + }, + }, + }) + toast.success('New report created') + + const newReportId = res[0].id + router.push(`/project/${ref}/reports/${newReportId}`) + afterSubmit() + } catch (error) { + toast.error(`Failed to create report. Check console for more details.`) + console.error(error) + } + } + + return ( + +
    { + const errors: Partial = {} + + if (!vals.name) { + errors.name = 'Required' + } + + return errors + }} + onSubmit={(newVals) => createCustomReport(newVals)} + > + {({ isSubmitting }: { isSubmitting: boolean }) => ( +
    + + + + + + + + +
    + + +
    +
    +
    + )} + +
    + ) +} diff --git a/apps/studio/components/interfaces/Reports/Reports.UpdateModal.tsx b/apps/studio/components/interfaces/Reports/Reports.UpdateModal.tsx new file mode 100644 index 00000000000..d305f58e114 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/Reports.UpdateModal.tsx @@ -0,0 +1,73 @@ +import { Button, Form, Input, Modal } from 'ui' + +type CustomReport = { name: string; description?: string } + +export interface UpdateCustomReportProps { + visible: boolean + onCancel: () => void + onSubmit: (newValues: CustomReport) => void + initialValues: CustomReport +} + +export const UpdateCustomReportModal = ({ + visible, + onCancel, + onSubmit, + initialValues, +}: UpdateCustomReportProps) => { + function validate(values: CustomReport) { + const errors: Partial = {} + + if (!values.name) { + errors.name = 'Required' + } + + return errors + } + + return ( + +
    + {({ isSubmitting }: { isSubmitting: boolean }) => ( +
    + + + + + + + + +
    + + +
    +
    +
    + )} + +
    + ) +} diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.ts b/apps/studio/components/interfaces/Reports/Reports.constants.ts index 8785c5a05ff..9de4827504a 100644 --- a/apps/studio/components/interfaces/Reports/Reports.constants.ts +++ b/apps/studio/components/interfaces/Reports/Reports.constants.ts @@ -33,7 +33,7 @@ export const DEFAULT_QUERY_PARAMS = { iso_timestamp_end: REPORTS_DATEPICKER_HELPERS[0].calcTo(), } -const generateRegexpWhere = (filters: ReportFilterItem[], prepend = true) => { +export const generateRegexpWhere = (filters: ReportFilterItem[], prepend = true) => { if (filters.length === 0) return '' const conditions = filters .map((filter) => { @@ -276,9 +276,8 @@ limit 12 queries: { mostFrequentlyInvoked: { queryType: 'db', - sql: (_params) => ` + sql: (_params, where, orderBy) => ` -- Most frequently called queries --- A limit of 100 has been added below select auth.rolname, statements.query, @@ -296,13 +295,14 @@ select statements.rows / statements.calls as avg_rows from pg_stat_statements as statements inner join pg_authid as auth on statements.userid = auth.oid - order by - statements.calls desc + ${where || ''} + ${orderBy || 'order by statements.calls desc'} limit 10;`, }, mostTimeConsuming: { queryType: 'db', - sql: (_params) => `-- A limit of 100 has been added below + sql: (_, where, orderBy) => ` +-- Most time consuming queries select auth.rolname, statements.query, @@ -311,14 +311,14 @@ select to_char(((statements.total_exec_time + statements.total_plan_time)/sum(statements.total_exec_time + statements.total_plan_time) OVER()) * 100, 'FM90D0') || '%' AS prop_total_time from pg_stat_statements as statements inner join pg_authid as auth on statements.userid = auth.oid - order by - total_time desc + ${where || ''} + ${orderBy || 'order by total_time desc'} limit 10;`, }, slowestExecutionTime: { queryType: 'db', - sql: (_params) => `-- Slowest queries by max execution time --- A limit of 100 has been added below + sql: (_params, where, orderBy) => ` +-- Slowest queries by max execution time select auth.rolname, statements.query, @@ -336,8 +336,8 @@ select statements.rows / statements.calls as avg_rows from pg_stat_statements as statements inner join pg_authid as auth on statements.userid = auth.oid - order by - max_time desc + ${where || ''} + ${orderBy || 'order by max_time desc'} limit 10`, }, queryHitRate: { diff --git a/apps/studio/components/interfaces/Reports/Reports.queries.ts b/apps/studio/components/interfaces/Reports/Reports.queries.ts new file mode 100644 index 00000000000..11e6cdded25 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/Reports.queries.ts @@ -0,0 +1,33 @@ +import { useProjectContentStore } from 'stores/projectContentStore' +import { PRESET_CONFIG } from './Reports.constants' +import { Presets } from './Reports.types' +import useDbQuery from 'hooks/analytics/useDbQuery' +import { useMutation, useQuery } from '@tanstack/react-query' + +type QueryPerformanceQueryOpts = { + searchQuery: string + preset: 'mostFrequentlyInvoked' | 'mostTimeConsuming' | 'slowestExecutionTime' | 'queryHitRate' + orderBy: string | 'lat_asc' | 'lat_desc' +} +export const useQueryPerformanceQuery = ({ + preset, + orderBy, + searchQuery, +}: QueryPerformanceQueryOpts) => { + const queryPerfQueries = PRESET_CONFIG[Presets.QUERY_PERFORMANCE] + const baseSQL = queryPerfQueries.queries[preset] + + if (orderBy !== 'lat_asc' && orderBy !== 'lat_desc') { + // Default to lat_desc if not specified or invalid + orderBy = 'lat_desc' + } + + const whereSql = searchQuery + ? `WHERE auth.rolname ~ '${searchQuery}' OR statements.query ~ '${searchQuery}'` + : '' + const orderBySql = orderBy === 'lat_asc' ? 'ORDER BY total_time asc' : 'ORDER BY total_time desc' + + const sql = baseSQL.sql([], whereSql, orderBySql) + + return useDbQuery(sql, undefined, whereSql, orderBySql) +} diff --git a/apps/studio/components/interfaces/Reports/Reports.tsx b/apps/studio/components/interfaces/Reports/Reports.tsx index b699757d6c7..e9b04020e8a 100644 --- a/apps/studio/components/interfaces/Reports/Reports.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.tsx @@ -33,6 +33,7 @@ import { useProfile } from 'lib/profile' import { useProjectContentStore } from 'stores/projectContentStore' import GridResize from './GridResize' import { LAYOUT_COLUMN_COUNT } from './Reports.constants' +import { useContentQuery } from 'data/content/content-query' const DEFAULT_CHART_COLUMN_COUNT = 12 const DEFAULT_CHART_ROW_COUNT = 4 @@ -46,6 +47,10 @@ const Reports = () => { 'project_storage:all', ]) + const reportQuery = useContentQuery(ref) + const currentReport = + reportQuery.data && reportQuery.data?.content.find((report) => report.id === id) + const [report, setReport] = useState() const [loading, setLoading] = useState(false) @@ -314,9 +319,11 @@ const Reports = () => { } return ( -
    -

    Reports

    - +
    +
    +

    {currentReport?.name || 'Reports'}

    +

    {currentReport?.description}

    +
    { ) : (
    {config && startDate && endDate && ( - // @ts-ignore = Record export interface ReportQuery { queryType: ReportQueryType - sql: (filters: ReportFilterItem[]) => string + sql: (filters: ReportFilterItem[], where?: string, orderBy?: string) => string } export type ReportQueryType = 'db' | 'logs' @@ -45,4 +45,5 @@ export interface ReportFilterItem { key: string value: string | number compare: 'matches' | 'is' + query?: string } diff --git a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx index 89ced5c0ad2..e9f47b7d94f 100644 --- a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx +++ b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx @@ -289,8 +289,8 @@ const RouteTdContent = (datum: RouteTdContentProps) => ( datum.status_code >= 400 ? 'bg-orange-500' : datum.status_code >= 300 - ? 'bg-yellow-500' - : 'bg-green-500' + ? 'bg-yellow-500' + : 'bg-green-500' }`} value={String(datum.status_code)} /> diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts index b76db0b0475..4d7055b9ebd 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts @@ -1317,7 +1317,7 @@ as $$ select last_failed_at into last_failed_at from public.mfa_failed_verification_attempts where - user_id = event->'user_id' + user_id = (event->'user_id')::uuid and factor_id = event->'factor_id'; @@ -1367,7 +1367,9 @@ revoke execute revoke all on table public.mfa_failed_verification_attempts - from authenticated, anon;`.trim(), + from authenticated, anon; + +grant usage on schema public to supabase_auth_admin;`.trim(), }, { id: 27, @@ -1391,7 +1393,7 @@ as $$ select last_failed_at into last_failed_at from public.password_failed_verification_attempts where - user_id = event->'user_id'; + user_id = (event->'user_id')::uuid; if last_failed_at is not null and now() - last_failed_at < interval '10 seconds' then -- last attempt was done too quickly @@ -1437,7 +1439,9 @@ revoke execute revoke all on table public.password_failed_verification_attempts - from authenticated, anon;`.trim(), + from authenticated, anon; + +grant usage on schema public to supabase_auth_admin;`.trim(), }, { id: 28, @@ -1455,7 +1459,7 @@ as $$ is_admin boolean; begin -- Check if the user is marked as admin in the profiles table - select is_admin into is_admin from profiles where user_id = event->>'user_id'; + select is_admin into is_admin from profiles where user_id = (event->>'user_id')::uuid; -- Proceed only if the user is an admin if is_admin then @@ -1486,7 +1490,8 @@ grant execute revoke execute on function public.custom_access_token_hook from authenticated, anon; - `.trim(), + +grant usage on schema public to supabase_auth_admin;`.trim(), }, { diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index dd40acf22c3..53b9bfca647 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -607,7 +607,7 @@ const SQLEditor = () => { - + {debugSolution && (
    {debugSolution} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx index d30ed5a332f..44def70aaf4 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx @@ -68,7 +68,7 @@ const UtilityTabResults = ({ id, isExecuting }: UtilityTabResultsProps) => { return (
    -
    +
    {isTimeout ? (

    SQL query ran into an upstream timeout

    @@ -95,7 +95,7 @@ const UtilityTabResults = ({ id, isExecuting }: UtilityTabResultsProps) => {

    ) : ( -
    +
    {formattedError.length > 0 ? ( formattedError.map((x: string, i: number) => (
    diff --git a/apps/studio/components/interfaces/Settings/API/JWTSettings.tsx b/apps/studio/components/interfaces/Settings/API/JWTSettings.tsx
    index eb100875322..7eafbaf02c0 100644
    --- a/apps/studio/components/interfaces/Settings/API/JWTSettings.tsx
    +++ b/apps/studio/components/interfaces/Settings/API/JWTSettings.tsx
    @@ -112,10 +112,10 @@ const JWTSettings = () => {
                       !canReadJWTSecret
                         ? 'You need additional permissions to view the JWT secret'
                         : isJwtSecretUpdateFailed
    -                    ? 'JWT secret update failed'
    -                    : isUpdatingJwtSecret
    -                    ? 'Updating JWT secret...'
    -                    : config?.jwt_secret || ''
    +                      ? 'JWT secret update failed'
    +                      : isUpdatingJwtSecret
    +                        ? 'Updating JWT secret...'
    +                        : config?.jwt_secret || ''
                     }
                     className="input-mono"
                     descriptionText={
    @@ -124,7 +124,7 @@ const JWTSettings = () => {
                     layout="horizontal"
                   />
                   
    -
    +
    {isUpdatingJwtSecret ? (
    @@ -232,7 +232,8 @@ const JWTSettings = () => { Generating a new JWT secret will invalidate all of your API keys, including your service_role and{' '} anon keys. Your project will also be restarted during - this process, which will terminate any existing connections. + this process, which will terminate any existing connections. You may receive API + errors for up to 2 minutes while the new secret is deployed.

    diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx index 68d9e952f54..602bb1ef820 100644 --- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -1,8 +1,10 @@ import { JwtSecretUpdateError, JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'common' import { useEffect, useRef } from 'react' +import { Badge, IconAlertCircle, Input } from 'ui' -import { useParams } from 'common/hooks' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import DatabaseSelector from 'components/ui/DatabaseSelector' import Panel from 'components/ui/Panel' import { DisplayApiSettings } from 'components/ui/ProjectSettings' @@ -11,17 +13,18 @@ import { configKeys } from 'data/config/keys' import { useProjectApiQuery } from 'data/config/project-api-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' -import { useFlag, useSelectedProject, useStore } from 'hooks' +import { useFlag, useStore } from 'hooks' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' -import { Badge, IconAlertCircle, Input } from 'ui' import { JWT_SECRET_UPDATE_ERROR_MESSAGES } from './API.constants' import JWTSettings from './JWTSettings' import PostgrestConfig from './PostgrestConfig' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { PROJECT_STATUS } from 'lib/constants' const ServiceList = () => { const { ui } = useStore() const client = useQueryClient() - const project = useSelectedProject() + const { project, isLoading } = useProjectContext() const { ref: projectRef } = useParams() const state = useDatabaseSelectorStateSnapshot() @@ -78,63 +81,79 @@ const ServiceList = () => { const endpoint = !showReadReplicasUI ? primaryEndpoint : isCustomDomainActive && state.selectedDatabaseId === projectRef - ? `https://${customDomainData.customDomain.hostname}` - : selectedDatabase?.restUrl + ? `https://${customDomainData.customDomain.hostname}` + : selectedDatabase?.restUrl return (

    API Settings

    -
    - -
    Project URL
    - {showReadReplicasUI && } -
    - } - > - - {isError ? ( -
    - -

    Failed to retrieve project URL

    -
    - ) : ( - -

    URL

    - Custom domain active -
    - ) : ( - 'URL' - ) - } - readOnly - disabled - className="input-mono" - value={endpoint} - descriptionText="A RESTful endpoint for querying and managing your database." - layout="horizontal" - /> - )} - - - + {isLoading ? ( + + ) : project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY ? ( +
    +

    API Settings

    +
    + +

    + API settings are unavailable as the project is not active +

    +
    +
    + ) : ( + <> +
    + +
    Project URL
    + {showReadReplicasUI && } +
    + } + > + + {isError ? ( +
    + +

    Failed to retrieve project URL

    +
    + ) : ( + +

    URL

    + Custom domain active +
    + ) : ( + 'URL' + ) + } + readOnly + disabled + className="input-mono" + value={endpoint} + descriptionText="A RESTful endpoint for querying and managing your database." + layout="horizontal" + /> + )} + + + -
    - -
    +
    + +
    -
    - -
    +
    + +
    -
    - -
    +
    + +
    + + )}
    ) } diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx index c6f95228202..e8eae68ac94 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -1,3 +1,4 @@ +import * as Tooltip from '@radix-ui/react-tooltip' import { useParams } from 'common' import dayjs from 'dayjs' import { useTheme } from 'next-themes' @@ -14,6 +15,7 @@ import { IconAlertTriangle, IconChevronRight, IconExternalLink, + IconInfo, } from 'ui' import { @@ -33,27 +35,30 @@ import { ScaffoldSectionDetail, } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' -import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useInfraMonitoringQuery } from 'data/analytics/infra-monitoring-query' +import { useProjectSettingsQuery } from 'data/config/project-settings-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { ProjectAddonVariantMeta } from 'data/subscriptions/types' import { useFlag, useProjectByRef, useSelectedOrganization } from 'hooks' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' -import { BASE_PATH } from 'lib/constants' +import { BASE_PATH, INSTANCE_MICRO_SPECS, INSTANCE_NANO_SPECS } from 'lib/constants' import { getDatabaseMajorVersion, getSemanticVersion } from 'lib/helpers' import { SUBSCRIPTION_PANEL_KEYS, useSubscriptionPageStateSnapshot } from 'state/subscription-page' import ComputeInstanceSidePanel from './ComputeInstanceSidePanel' import CustomDomainSidePanel from './CustomDomainSidePanel' import PITRSidePanel from './PITRSidePanel' import IPv4SidePanel from './IPv4SidePanel' +import { capitalize } from 'lodash' const Addons = () => { const { resolvedTheme } = useTheme() const { ref: projectRef, panel } = useParams() const snap = useSubscriptionPageStateSnapshot() const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') - const { project: selectedProject } = useProjectContext() + const { project: selectedProject, isLoading: isLoadingProject } = useProjectContext() + const { data: projectSettings } = useProjectSettingsQuery({ projectRef }) const selectedOrg = useSelectedOrganization() const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrg?.slug }) @@ -98,7 +103,19 @@ const Addons = () => { const selectedAddons = addons?.selected_addons ?? [] const { computeInstance, pitr, customDomain, ipv4 } = getAddons(selectedAddons) - const meta = computeInstance?.variant?.meta as ProjectAddonVariantMeta | undefined + const meta = useMemo(() => { + const computeMeta = computeInstance?.variant?.meta as ProjectAddonVariantMeta | undefined + + if (!computeMeta && selectedProject?.infra_compute_size === 'nano') { + return INSTANCE_NANO_SPECS + } else if (selectedProject?.infra_compute_size === 'micro') { + return INSTANCE_MICRO_SPECS + } + + return computeMeta + }, [selectedProject, computeInstance]) + + const canUpdateIPv4 = projectSettings?.project.db_ip_addr_config === 'ipv6' return ( <> @@ -154,11 +171,23 @@ const Addons = () => { {isSuccess && ( <> + {selectedProject?.infra_compute_size === 'nano' && subscription?.plan.id !== 'free' && ( + + + + Free compute upgrade to Micro + + Paid Plans include a free upgrade to Micro compute. Your project is ready to + upgrade for no additional charges. + + + + )}
    -

    Optimized compute

    +

    Compute Size

    More information

    @@ -193,15 +222,15 @@ const Addons = () => {
    {

    Current option:

    -

    {computeInstance?.variant.name ?? 'Micro'}

    + {isLoading || (computeInstance === undefined && isLoadingProject) ? ( + + ) : ( +

    + {computeInstance?.variant.name ?? + capitalize(selectedProject?.infra_compute_size) ?? + 'Micro'} +

    + )} { >

    Your workload is currently running at the baseline disk IO bandwidth at{' '} - {meta?.baseline_disk_io_mbs?.toLocaleString() ?? 87} Mbps and may suffer + {meta?.baseline_disk_io_mbs?.toLocaleString() ?? '-'} Mbps and may suffer degradation in performance.

    @@ -252,7 +289,7 @@ const Addons = () => {

    If the disk IO budget drops to zero, your workload will run at the baseline disk IO bandwidth at{' '} - {meta?.baseline_disk_io_mbs?.toLocaleString() ?? 87} Mbps and may suffer + {meta?.baseline_disk_io_mbs?.toLocaleString() ?? '-'} Mbps and may suffer degradation in performance.

    @@ -275,7 +312,7 @@ const Addons = () => { />

    -

    {meta?.memory_gb ?? 1} GB

    +

    {meta?.memory_gb ?? '-'} GB

    @@ -291,17 +328,17 @@ const Addons = () => {

    - {meta?.cpu_cores ?? 2}-core {cpuArchitecture}{' '} + {meta?.cpu_cores ?? '?'}-core {cpuArchitecture}{' '} {meta?.cpu_dedicated ? '(Dedicated)' : '(Shared)'}

    No. of direct connections

    -

    {meta?.connections_direct ?? 60}

    +

    {meta?.connections_direct ?? '-'}

    No. of pooler connections

    -

    {meta?.connections_pooler ?? 200}

    +

    {meta?.connections_pooler ?? '-'}

    @@ -317,7 +354,7 @@ const Addons = () => {

    - {meta?.max_disk_io_mbs?.toLocaleString() ?? '2,085'} Mbps + {meta?.max_disk_io_mbs?.toLocaleString() ?? '-'} Mbps

    @@ -334,7 +371,7 @@ const Addons = () => {

    - {meta?.baseline_disk_io_mbs?.toLocaleString() ?? 87} Mbps + {meta?.baseline_disk_io_mbs?.toLocaleString() ?? '-'} Mbps

    @@ -419,7 +456,45 @@ const Addons = () => { ? 'IPv4 address is enabled' : 'IPv4 address is not enabled'}

    - + +
    + +
    +
    + + {/* Only show the tooltip if the user can't add the addon and ipv4 is not currently applied */} + {!(canUpdateIPv4 || ipv4) && ( + + +
    + + Temporarily disabled while we are migrating to IPv6, please check + back later. + +
    +
    + )} +
    + + {/* @@ -431,7 +506,7 @@ const Addons = () => { > Change IPv4 address - +
    */}
    diff --git a/apps/studio/components/interfaces/Settings/Addons/ComputeInstanceSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/ComputeInstanceSidePanel.tsx index cd3819cb051..5771f56abc6 100644 --- a/apps/studio/components/interfaces/Settings/Addons/ComputeInstanceSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/ComputeInstanceSidePanel.tsx @@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/react-query' import clsx from 'clsx' import Link from 'next/link' import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useParams } from 'common' import { useTheme } from 'next-themes' @@ -14,13 +14,15 @@ import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon- import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' -import { BASE_PATH, PROJECT_STATUS } from 'lib/constants' +import { INSTANCE_MICRO_SPECS, PROJECT_STATUS } from 'lib/constants' import Telemetry from 'lib/telemetry' import { useSubscriptionPageStateSnapshot } from 'state/subscription-page' import { Alert, AlertDescription_Shadcn_, + AlertTitle_Shadcn_, Alert_Shadcn_, + Badge, Button, IconAlertTriangle, IconExternalLink, @@ -32,40 +34,17 @@ import { import * as Tooltip from '@radix-ui/react-tooltip' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { AddonVariantId } from 'data/subscriptions/types' - -const COMPUTE_CATEGORY_OPTIONS: { - id: 'micro' | 'optimized' - name: string - imageUrl: string - imageUrlLight: string -}[] = [ - { - id: 'micro', - name: 'Micro Compute', - imageUrl: `${BASE_PATH}/img/optimized-compute-off.svg`, - imageUrlLight: `${BASE_PATH}/img/optimized-compute-off--light.svg`, - }, - { - id: 'optimized', - name: 'Optimized Compute', - imageUrl: `${BASE_PATH}/img/optimized-compute-on.svg`, - imageUrlLight: `${BASE_PATH}/img/optimized-compute-on--light.svg`, - }, -] +import { AddonVariantId, ProjectAddonVariantMeta } from 'data/subscriptions/types' const ComputeInstanceSidePanel = () => { const queryClient = useQueryClient() const { ui } = useStore() const router = useRouter() const { ref: projectRef } = useParams() - const { resolvedTheme } = useTheme() const { project: selectedProject } = useProjectContext() const organization = useSelectedOrganization() const [showConfirmationModal, setShowConfirmationModal] = useState(false) - const [selectedCategory, setSelectedCategory] = useState<'micro' | 'optimized'>('micro') - const [selectedOption, setSelectedOption] = useState('ci_micro') const canUpdateCompute = useCheckPermissions( PermissionAction.BILLING_WRITE, @@ -108,7 +87,7 @@ const ComputeInstanceSidePanel = () => { ui.setNotification({ duration: 8000, category: 'success', - message: `Successfully updated compute instance to Micro. Your project is currently being restarted to update its instance`, + message: `Successfully updated compute instance. Your project is currently being restarted to update its instance`, }) setProjectStatus(queryClient, projectRef!, PROJECT_STATUS.RESTORING) onClose() @@ -122,32 +101,77 @@ const ComputeInstanceSidePanel = () => { }) }, }) + + const [selectedOption, setSelectedOption] = useState('') + const isSubmitting = isUpdating || isRemoving const projectId = selectedProject?.id const cpuArchitecture = getCloudProviderArchitecture(selectedProject?.cloud_provider) const selectedAddons = addons?.selected_addons ?? [] - const availableAddons = addons?.available_addons ?? [] + const availableAddons = useMemo(() => { + return addons?.available_addons ?? [] + }, [addons]) const isFreePlan = subscription?.plan?.id === 'free' const subscriptionCompute = selectedAddons.find((addon) => addon.type === 'compute_instance') const pitrAddon = selectedAddons.find((addon) => addon.type === 'pitr') - const availableOptions = - availableAddons.find((addon) => addon.type === 'compute_instance')?.variants ?? [] - const selectedCompute = availableOptions.find((option) => option.identifier === selectedOption) - const hasChanges = selectedOption !== (subscriptionCompute?.variant.identifier ?? 'ci_micro') - const blockMicroDowngradeDueToPitr = - pitrAddon !== undefined && selectedOption === 'ci_micro' && hasChanges + const hasMicroOptionFromApi = useMemo(() => { + return ( + availableAddons.find((addon) => addon.type === 'compute_instance')?.variants ?? [] + ).some((variant) => variant.identifier === 'ci_micro') + }, [availableAddons]) + + const availableOptions = useMemo(() => { + const computeOptions = + availableAddons.find((addon) => addon.type === 'compute_instance')?.variants ?? [] + + // Backwards comp until API is deployed + if (!hasMicroOptionFromApi) { + // Unshift to push to start of array + computeOptions.unshift({ + identifier: 'ci_micro', + name: 'Micro', + price_description: '$0.01344/hour (~$10/month)', + price: 0.01344, + price_interval: 'hourly', + price_type: 'usage', + // @ts-ignore API types it as Record + meta: { + cpu_cores: INSTANCE_MICRO_SPECS.cpu_cores, + cpu_dedicated: INSTANCE_MICRO_SPECS.cpu_dedicated, + memory_gb: INSTANCE_MICRO_SPECS.memory_gb, + baseline_disk_io_mbs: INSTANCE_MICRO_SPECS.baseline_disk_io_mbs, + max_disk_io_mbs: INSTANCE_MICRO_SPECS.max_disk_io_mbs, + connections_direct: INSTANCE_MICRO_SPECS.connections_direct, + connections_pooler: INSTANCE_MICRO_SPECS.connections_pooler, + } as ProjectAddonVariantMeta, + }) + } + + return computeOptions + }, [availableAddons, hasMicroOptionFromApi]) + + const defaultInstanceSize = useMemo(() => { + if (!selectedProject) return '' + + return selectedProject.infra_compute_size === 'nano' ? 'ci_nano' : 'ci_micro' + }, [selectedProject]) + + const selectedCompute = availableOptions.find((option) => option.identifier === selectedOption) + const hasChanges = + selectedOption !== (subscriptionCompute?.variant.identifier ?? defaultInstanceSize) + + const blockDowngradeDueToPitr = + pitrAddon !== undefined && ['ci_micro', 'ci_nano'].includes(selectedOption) && hasChanges useEffect(() => { if (visible) { if (subscriptionCompute !== undefined) { - setSelectedCategory('optimized') setSelectedOption(subscriptionCompute.variant.identifier) } else { - setSelectedCategory('micro') - setSelectedOption('ci_micro') + setSelectedOption(defaultInstanceSize) } Telemetry.sendActivity( { @@ -168,7 +192,12 @@ const ComputeInstanceSidePanel = () => { if (!projectRef) return console.error('Project ref is required') if (!projectId) return console.error('Project ID is required') - if (selectedOption === 'ci_micro' && subscriptionCompute !== undefined) { + // Temporary backwards compatibility fix + if ( + !hasMicroOptionFromApi && + selectedOption === 'ci_micro' && + subscriptionCompute !== undefined + ) { removeAddon({ projectRef, variant: subscriptionCompute.variant.identifier }) } else { updateAddon({ @@ -188,18 +217,14 @@ const ComputeInstanceSidePanel = () => { onConfirm={() => setShowConfirmationModal(true)} loading={isLoading} disabled={ - isFreePlan || - isLoading || - !hasChanges || - blockMicroDowngradeDueToPitr || - !canUpdateCompute + isFreePlan || isLoading || !hasChanges || blockDowngradeDueToPitr || !canUpdateCompute } tooltip={ isFreePlan ? 'Unable to update compute instance on a free plan' : !canUpdateCompute - ? 'You do not have permission to update compute instance' - : undefined + ? 'You do not have permission to update compute instance' + : undefined } header={
    @@ -218,172 +243,129 @@ const ComputeInstanceSidePanel = () => { >
    + {selectedProject?.infra_compute_size === 'nano' && subscription?.plan.id !== 'free' && ( + + + Free compute upgrade to Micro + + Paid Plans include a free upgrade to Micro compute. Your project is ready to + upgrade for no additional charges. + + + )} +

    For the database, compute size refers to the amount of CPU and memory allocated to the database instance.

    -
    -
    - {COMPUTE_CATEGORY_OPTIONS.map((option) => { - const isSelected = selectedCategory === option.id - return ( -
    { - setSelectedCategory(option.id) - if (option.id === 'micro') setSelectedOption('ci_micro') - Telemetry.sendActivity( - { - activity: 'Option Selected', - source: 'Dashboard', - data: { - title: 'Change project compute size', - section: 'Add ons', - option: option.name, - }, - projectRef, - }, - router - ) - }} - > - Compute Instance - -

    - {option.name} -

    -
    - ) - })} -
    -
    - - {selectedCategory === 'optimized' && ( -
    - {isFreePlan && ( - - - View available plans - - - } - > - Upgrade your plan to change the compute size of your project - - )} - Choose the compute size you want to use

    } - onChange={(event: any) => setSelectedOption(event.target.value)} +
    + {isFreePlan && ( + + + View available plans + + + } > - {availableOptions.map((option) => ( - -
    -
    -

    {option.name}

    -
    -
    -

    - {option.meta?.memory_gb ?? 0} GB memory -

    -

    - {option.meta?.cpu_cores ?? 0}-core {cpuArchitecture} CPU ( - {option.meta?.cpu_dedicated ? 'Dedicated' : 'Shared'}) -

    -
    -
    - - ${option.price.toLocaleString()} - - - {' '} - / {option.price_interval === 'monthly' ? 'month' : 'hour'} - -
    - {option.price_interval === 'hourly' && ( - - -
    - -
    -
    - - - -
    -
    -

    - ${Number(option.price * 672).toFixed(0)} - $ - {Number(option.price * 744).toFixed(0)} per month -

    -
    -
    -
    -
    -
    - )} + Upgrade your plan to change the compute size of your project + + )} + Choose the compute size you want to use

    } + onChange={(event: any) => setSelectedOption(event.target.value)} + > + {availableOptions.map((option) => ( + +
    +
    +

    + {option.name}{' '} + {(subscriptionCompute?.variant.identifier === option.identifier || + (!subscriptionCompute && + option.identifier === + `ci_${selectedProject?.infra_compute_size}`)) && ( + Current + )} + {selectedProject?.infra_compute_size === 'nano' && + option.identifier === 'ci_micro' && Free Upgrade} +

    +
    +
    +

    + {option.meta?.memory_gb ?? 0} GB memory +

    +

    + {option.meta?.cpu_cores ?? 0}-core {cpuArchitecture} CPU ( + {option.meta?.cpu_dedicated ? 'Dedicated' : 'Shared'}) +

    +
    +
    + + {/* Price needs to be exact here */}${option.price} + + + {' '} + / {option.price_interval === 'monthly' ? 'month' : 'hour'} +
    + {option.price_interval === 'hourly' && ( + + +
    + +
    +
    + + + +
    +
    +

    + ${Number(option.price * 672).toFixed(0)} - $ + {Number(option.price * 744).toFixed(0)} per month +

    +
    +
    +
    +
    +
    + )}
    - - ))} - -
    - )} +
    + + ))} + +
    - {selectedCategory === 'micro' && ( -

    - Your database will use the standard Micro size instance of 2-core {cpuArchitecture}{' '} - CPU (Shared) with 1GB of memory. -

    - )} - - {hasChanges && selectedCategory !== 'micro' && ( + {hasChanges && (

    There are no immediate charges when changing compute. Compute Hours are a usage-based item and you're billed at the end of your billing cycle based on your @@ -400,7 +382,7 @@ const ComputeInstanceSidePanel = () => {

    )} - {hasChanges && !blockMicroDowngradeDueToPitr && ( + {hasChanges && !blockDowngradeDueToPitr && ( { )} - {blockMicroDowngradeDueToPitr && ( + {blockDowngradeDueToPitr && ( snap.setPanelKey('pitr')}> Change PITR @@ -426,7 +408,10 @@ const ComputeInstanceSidePanel = () => { You currently have PITR enabled. The minimum compute instance size for using PITR is the Small Compute.

    -

    You need to disable PITR before downgrading to Micro Compute.

    +

    + You need to disable PITR before downgrading Compute as it requires at least a + Small compute instance. +

    )} @@ -437,7 +422,7 @@ const ComputeInstanceSidePanel = () => { You have a scheduled subscription change that will be canceled if you change - your Optimized Compute add on. + your compute size. )} diff --git a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx index 8d1e8f63e01..c70c8ffe13f 100644 --- a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx @@ -14,8 +14,10 @@ import { useSubscriptionPageStateSnapshot } from 'state/subscription-page' import { Alert, AlertDescription_Shadcn_, + AlertTitle_Shadcn_, Alert_Shadcn_, Button, + IconAlertCircle, IconAlertTriangle, IconExternalLink, Radio, @@ -23,12 +25,15 @@ import { } from 'ui' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { AddonVariantId } from 'data/subscriptions/types' +import { formatCurrency } from 'lib/helpers' +import { useFlag } from 'hooks' const CustomDomainSidePanel = () => { const { ui } = useStore() const router = useRouter() const { ref: projectRef } = useParams() const organization = useSelectedOrganization() + const customDomainsDisabledDueToQuota = useFlag('customDomainsDisabledDueToQuota') const [selectedOption, setSelectedOption] = useState('cd_none') @@ -133,13 +138,21 @@ const CustomDomainSidePanel = () => { onCancel={onClose} onConfirm={onConfirm} loading={isLoading || isSubmitting} - disabled={isFreePlan || isLoading || !hasChanges || isSubmitting || !canUpdateCustomDomain} + disabled={ + isFreePlan || + isLoading || + !hasChanges || + isSubmitting || + !canUpdateCustomDomain || + // Allow disabling, but do not allow opting in + (subscriptionCDOption === undefined && customDomainsDisabledDueToQuota) + } tooltip={ isFreePlan ? 'Unable to enable custom domain on a free plan' : !canUpdateCustomDomain - ? 'You do not have permission to update custom domain' - : undefined + ? 'You do not have permission to update custom domain' + : undefined } header={
    @@ -158,6 +171,20 @@ const CustomDomainSidePanel = () => { >
    + {subscriptionCDOption === undefined && + selectedCustomDomain !== undefined && + customDomainsDisabledDueToQuota && ( + + + + Adding new custom domains temporarily disabled + + + We are working with our upstream DNS provider before we are able to sign up new + custom domains. Please check back in a few hours. + + + )}

    Custom domains allow you to present a branded experience to your users. You may set up your custom domain in the{' '} @@ -230,7 +257,7 @@ const CustomDomainSidePanel = () => { Present a branded experience to your users

    -

    ${option.price}

    +

    {formatCurrency(option.price)}

    / month

    @@ -255,7 +282,7 @@ const CustomDomainSidePanel = () => {

    Upon clicking confirm, the amount of{' '} - ${selectedCustomDomain?.price.toLocaleString()} + {formatCurrency(selectedCustomDomain?.price)} {' '} will be added to your monthly invoice.{' '} {subscription?.billing_via_partner ? ( diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index 5b9e19513da..27f518c6d13 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -5,15 +5,16 @@ import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { useParams } from 'common' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon-remove-mutation' import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' +import { AddonVariantId } from 'data/subscriptions/types' import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { formatCurrency } from 'lib/helpers' import Telemetry from 'lib/telemetry' import { useSubscriptionPageStateSnapshot } from 'state/subscription-page' import { Alert, Button, IconExternalLink, Radio, SidePanel } from 'ui' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { AddonVariantId } from 'data/subscriptions/types' const IPv4SidePanel = () => { const { ui } = useStore() @@ -124,8 +125,8 @@ const IPv4SidePanel = () => { isFreePlan ? 'Unable to enable IPv4 on a free plan' : !canUpdateIPv4 - ? 'You do not have permission to update IPv4' - : undefined + ? 'You do not have permission to update IPv4' + : undefined } header={

    @@ -219,7 +220,7 @@ const IPv4SidePanel = () => { Allow direct database connections via IPv4 address

    -

    ${option.price}

    +

    {formatCurrency(option.price)}

    / month

    @@ -246,7 +247,7 @@ const IPv4SidePanel = () => {

    Upon clicking confirm, the amount of{' '} - ${selectedIPv4?.price.toLocaleString()}{' '} + {formatCurrency(selectedIPv4?.price)}{' '} will be added to your monthly invoice.

    diff --git a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx index 11397b220d5..e539aa53a37 100644 --- a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx @@ -29,6 +29,7 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que import { AlertTriangleIcon } from 'lucide-react' import { AddonVariantId } from 'data/subscriptions/types' import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' +import { formatCurrency } from 'lib/helpers' const PITR_CATEGORY_OPTIONS: { id: 'off' | 'on' @@ -172,10 +173,10 @@ const PITRSidePanel = () => { hasHipaaAddon ? 'Unable to change PITR with HIPAA add-on' : isFreePlan - ? 'Unable to enable point in time recovery on a free plan' - : !canUpdatePitr - ? 'You do not have permission to update PITR' - : undefined + ? 'Unable to enable point in time recovery on a free plan' + : !canUpdatePitr + ? 'You do not have permission to update PITR' + : undefined } header={
    @@ -346,9 +347,7 @@ const PITRSidePanel = () => { {option.identifier.split('_')[1]} days ago

    -

    - ${option.price.toLocaleString()} -

    +

    {formatCurrency(option.price)}

    / month

    @@ -373,7 +372,7 @@ const PITRSidePanel = () => { ) : (

    Upon clicking confirm, the amount of{' '} - ${selectedPitr?.price.toLocaleString()}{' '} + {formatCurrency(selectedPitr?.price)}{' '} will be added to your monthly invoice.{' '} {subscription?.billing_via_partner ? ( <> diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx index 5635c7e4fac..e3a5b11a6b6 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx @@ -29,6 +29,7 @@ import Divider from 'components/ui/Divider' import { FormActions } from 'components/ui/Forms' import Panel from 'components/ui/Panel' import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useMaxConnectionsQuery } from 'data/database/max-connections-query' import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' import { usePoolingConfigurationUpdateMutation } from 'data/database/pooling-configuration-update-mutation' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' @@ -36,7 +37,6 @@ import { useCheckPermissions, useStore } from 'hooks' import { useDatabaseSettingsStateSnapshot } from 'state/database-settings' import { SESSION_MODE_DESCRIPTION, TRANSACTION_MODE_DESCRIPTION } from '../Database.constants' import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants' -import { useMaxConnectionsQuery } from 'data/database/max-connections-query' const formId = 'connection-pooling-form' @@ -56,7 +56,6 @@ const StringToPositiveNumber = z.union([ const FormSchema = z.object({ default_pool_size: StringToPositiveNumber, - ignore_startup_parameters: z.string(), pool_mode: z.union([z.literal('transaction'), z.literal('session'), z.literal('statement')]), max_client_conn: StringToPositiveNumber, }) @@ -91,8 +90,7 @@ export const ConnectionPooling = () => { connectionString: project?.connectionString, }) - const connectionPoolingUnavailable = - !poolingInfo?.pgbouncer_enabled && poolingInfo?.pool_mode === null + const connectionPoolingUnavailable = poolingInfo?.pool_mode === null const canUpdateConnectionPoolingConfiguration = useCheckPermissions( PermissionAction.UPDATE, @@ -107,7 +105,6 @@ export const ConnectionPooling = () => { const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: { - ignore_startup_parameters: poolingInfo?.ignore_startup_parameters, pool_mode: poolingInfo?.pool_mode as 'transaction' | 'session' | 'statement', default_pool_size: poolingInfo?.default_pool_size as number | undefined, max_client_conn: poolingInfo?.max_client_conn as number | undefined, @@ -119,10 +116,9 @@ export const ConnectionPooling = () => { onSuccess: (data) => { if (data) { form.reset({ - ignore_startup_parameters: data.ignore_startup_parameters, pool_mode: data.pool_mode, default_pool_size: data.default_pool_size, - max_client_conn: data.max_client_conn, + max_client_conn: poolingInfo?.max_client_conn, }) } @@ -137,12 +133,8 @@ export const ConnectionPooling = () => { try { await updateConfiguration({ ref: projectRef, - // pgbouncer can't be disabled in the UI, so just pass it along - pgbouncer_enabled: poolingInfo.pgbouncer_enabled, default_pool_size: data.default_pool_size as number | undefined, - ignore_startup_parameters: data.ignore_startup_parameters, pool_mode: data.pool_mode, - max_client_conn: data.max_client_conn, }) } finally { } @@ -151,7 +143,6 @@ export const ConnectionPooling = () => { useEffect(() => { if (isSuccess) { form.reset({ - ignore_startup_parameters: poolingInfo?.ignore_startup_parameters, pool_mode: poolingInfo?.pool_mode as 'transaction' | 'session' | 'statement', default_pool_size: poolingInfo?.default_pool_size as number | undefined, max_client_conn: poolingInfo?.max_client_conn as number | undefined, @@ -171,13 +162,7 @@ export const ConnectionPooling = () => { ? 'Connection Pooling is not available for this project' : 'Connection pooling configuration'}

    - {isSuccess && ( -
    - - {poolingInfo?.supavisor_enabled ? 'Supavisor' : 'PGBouncer'} - -
    - )} + Supavisor
    } footer={ - (poolingInfo?.pgbouncer_enabled ?? false) && ( - form.reset()} - helper={ - !canUpdateConnectionPoolingConfiguration - ? 'You need additional permissions to update connection pooling settings' - : undefined - } - /> - ) + form.reset()} + helper={ + !canUpdateConnectionPoolingConfiguration + ? 'You need additional permissions to update connection pooling settings' + : undefined + } + /> } > {isLoading && ( @@ -233,194 +216,165 @@ export const ConnectionPooling = () => { {connectionPoolingUnavailable && (

    Please start a new project to enable this feature.

    )} - {poolingInfo.pgbouncer_enabled && ( - <> - -
    - ( - - - Pool Mode - - - field.onChange(value)} - > - -

    Transaction mode

    -

    - {TRANSACTION_MODE_DESCRIPTION} -

    -
    - -

    Session mode

    -

    - {SESSION_MODE_DESCRIPTION} -

    -
    -
    -
    - -

    - Specify when a connection can be returned to the pool.{' '} - snap.setShowPoolingModeHelper(true)} - className="cursor-pointer underline underline-offset-2" - > - Unsure which pooling mode to use? - + + + ( + + + Pool Mode + + + field.onChange(value)} + > + +

    Transaction mode

    +

    + {TRANSACTION_MODE_DESCRIPTION}

    -
    - -
    - )} - /> - ( - - - Pool Size - - - - - {maxConnData !== undefined && - Number(form.getValues('default_pool_size') ?? 15) > - maxConnData.maxConnections * 0.8 && ( -
    - - - - Pool size is greater than 80% of the max connections ( - {maxConnData.maxConnections}) on your database - - - This may result in instability and unreliability with your - database connections. - - -
    - )} - - The maximum number of connections made to the underlying Postgres - cluster, per user+db combination. Pool size has a default of{' '} - {defaultPoolSize} based on your compute size of{' '} - {computeInstance?.variant.name ?? 'Micro'}. - - - Please refer to our{' '} -
    - documentation - {' '} - to find out more. - - - - )} - /> - {!poolingInfo?.supavisor_enabled && ( - ( - - - Ignore Startup Parameters - - - - - - Defaults are either blank or "extra_float_digits" - - - + + +

    Session mode

    +

    + {SESSION_MODE_DESCRIPTION} +

    +
    + + + +

    + Specify when a connection can be returned to the pool.{' '} + snap.setShowPoolingModeHelper(true)} + className="cursor-pointer underline underline-offset-2" + > + Unsure which pooling mode to use? + +

    + {field.value === 'session' && ( + + + Set to transaction mode to use both pooling modes concurrently + + + Session mode can be used concurrently with transaction mode by using + 5432 for session and 6543 for session. However, by configuring the + pooler mode to session here, you will not be able to use transaction + mode at the same time. + + )} - /> - )} - ( - - - Max Client Connections - - - - - - The maximum number of concurrent client connections allowed.{' '} - {poolingInfo.supavisor_enabled - ? `This value is fixed at ${defaultMaxClientConn} based on your compute size of ${ - computeInstance?.variant.name ?? 'Micro' - } and cannot be changed.` - : 'Overrides default optimizations. '} - - - Please refer to our{' '} - - documentation - {' '} - to find out more. - - - - )} - /> - - -
    - - )} +
    + + + )} + /> + ( + + + Pool Size + + + + + {maxConnData !== undefined && + Number(form.getValues('default_pool_size') ?? 15) > + maxConnData.maxConnections * 0.8 && ( +
    + + + + Pool size is greater than 80% of the max connections ( + {maxConnData.maxConnections}) on your database + + + This may result in instability and unreliability with your database + connections. + + +
    + )} + + The maximum number of connections made to the underlying Postgres cluster, + per user+db combination. Pool size has a default of {defaultPoolSize} based + on your compute size of {computeInstance?.variant.name ?? 'Micro'}. + + + Please refer to our{' '} + + documentation + {' '} + to find out more. + + +
    + )} + /> + ( + + + Max Client Connections + + + + + + The maximum number of concurrent client connections allowed. This value is + fixed at {defaultMaxClientConn} based on your compute size of{' '} + {computeInstance?.variant.name ?? 'Micro'} and cannot be changed. + + + Please refer to our{' '} + + documentation + {' '} + to find out more. + + + + )} + /> + + +
    )} diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString.tsx index e4880f6187c..4da00202e86 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString.tsx @@ -2,7 +2,18 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { useParams, useTelemetryProps } from 'common' import { useRouter } from 'next/router' import { useEffect, useRef, useState } from 'react' -import { Button, IconChevronDown, IconExternalLink, Input, Separator, Tabs } from 'ui' +import { + Button, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + Collapsible_Shadcn_, + IconChevronDown, + IconExternalLink, + Input, + Separator, + Tabs, + cn, +} from 'ui' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' @@ -35,7 +46,11 @@ const CONNECTION_TYPES = [ { id: 'python', label: 'Python' }, ] -export const DatabaseConnectionString = () => { +interface DatabaseConnectionStringProps { + appearance: 'default' | 'minimal' +} + +export const DatabaseConnectionString = ({ appearance }: DatabaseConnectionStringProps) => { const router = useRouter() const { project: projectDetails, isLoading: isProjectLoading } = useProjectContext() const { ref: projectRef, connectionString } = useParams() @@ -46,10 +61,7 @@ export const DatabaseConnectionString = () => { const connectionStringsRef = useRef(null) const [usePoolerConnection, setUsePoolerConnection] = useState(true) - const [poolingMode, setPoolingMode] = useState<'transaction' | 'session' | 'statement'>( - 'transaction' - ) - const [showUriSyntax, setShowUriSyntax] = useState(false) + const [poolingMode, setPoolingMode] = useState<'transaction' | 'session' | 'statement'>('session') const [selectedTab, setSelectedTab] = useState< 'uri' | 'psql' | 'golang' | 'jdbc' | 'dotnet' | 'nodejs' | 'php' | 'python' >('uri') @@ -142,22 +154,42 @@ export const DatabaseConnectionString = () => { }, [connectionString]) useEffect(() => { - if (poolingInfo?.pool_mode !== undefined) { + if (poolingInfo?.pool_mode === 'session') { setPoolingMode(poolingInfo.pool_mode) } }, [poolingInfo?.pool_mode]) return ( -
    +
    div:nth-child(1)]:!border-0 [&>div:nth-child(1)>div]:!p-0', + appearance === 'minimal' && 'border-0 shadow-none' + )} + bodyClassName={cn(appearance === 'minimal' && 'bg-transparent')} + headerClasses={cn(appearance === 'minimal' && 'bg-transparent')} title={ -
    -
    -
    - Connection string -
    -
    +
    + )} - {poolerConnStringSyntax.length > 0 && poolingInfo?.supavisor_enabled && ( + {poolerConnStringSyntax.length > 0 && ( <> - -
    setShowUriSyntax(!showUriSyntax)} - > -

    - How to connect to a different database or switch to another user -

    - -
    - {showUriSyntax && ( -
    -

    - You can use the following URI format to switch to a different database or user - {usePoolerConnection ? ' when using connection pooling' : ''}. -

    -

    - {poolerConnStringSyntax.map((x, idx) => { - if (x.tooltip) { - return ( - - - {x.value} - - + + + +

    +

    + How to connect to a different database or switch to another user +

    + +
    + + +
    +

    + You can use the following URI format to switch to a different database or user + {usePoolerConnection ? ' when using connection pooling' : ''}. +

    +

    + {poolerConnStringSyntax.map((x, idx) => { + if (x.tooltip) { + return ( + + + {x.value} + - - -

    - {x.tooltip} -
    - + + + +
    + {x.tooltip} +
    +
    +
    - - - ) - } else { - return ( - - {x.value} - - ) - } - })} -

    -
    + + ) + } else { + return ( + + {x.value} + + ) + } + })} +

    +
    + + + + {selectedTab === 'python' && ( + + +
    +

    + Connecting to SQL Alchemy +

    + +
    +
    + +
    +

    + Please use postgresql:// instead of postgres:// as + your dialect when connecting via SQLAlchemy. +

    +

    + Example: + create_engine("postgresql+psycopg2://...") +

    +

    +
    +
    +
    )}
    diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseSettings.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseSettings.tsx index b067539b2ef..b45be5b8f4c 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseSettings.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/DatabaseSettings.tsx @@ -7,6 +7,7 @@ import DatabaseSelector from 'components/ui/DatabaseSelector' import Panel from 'components/ui/Panel' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' import { useFlag, useSelectedProject } from 'hooks' import { pluckObjectFields } from 'lib/helpers' @@ -22,8 +23,6 @@ import { import { IPv4DeprecationNotice } from '../IPv4DeprecationNotice' import { UsePoolerCheckbox } from '../UsePoolerCheckbox' import ResetDbPassword from './ResetDbPassword' -import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' -import { getHostFromConnectionString } from './DatabaseSettings.utils' const DatabaseSettings = () => { const router = useRouter() @@ -36,9 +35,7 @@ const DatabaseSettings = () => { const showReadReplicasUI = readReplicasEnabled && selectedProject?.is_read_replicas_enabled const connectionStringsRef = useRef(null) const [usePoolerConnection, setUsePoolerConnection] = useState(true) - const [poolingMode, setPoolingMode] = useState<'transaction' | 'session' | 'statement'>( - 'transaction' - ) + const [poolingMode, setPoolingMode] = useState<'transaction' | 'session' | 'statement'>('session') const { data: poolingInfo, @@ -72,7 +69,7 @@ const DatabaseSettings = () => { : isErrorProjectSettings || isErrorPoolingInfo const isSuccess = showReadReplicasUI ? isSuccessReadReplicas - : isSuccessProjectSettings || isSuccessPoolingInfo + : isSuccessProjectSettings && isSuccessPoolingInfo const selectedDatabase = (databases ?? []).find( (db) => db.identifier === state.selectedDatabaseId @@ -88,12 +85,10 @@ const DatabaseSettings = () => { const connectionInfo = usePoolerConnection ? { - db_host: isSuccessPoolingInfo - ? getHostFromConnectionString(poolingInfo?.connectionString) - : '', + db_host: poolingInfo?.db_host, db_name: poolingInfo?.db_name, db_port: poolingInfo?.db_port, - db_user: `postgres.${projectRef}`, + db_user: poolingInfo?.db_user, } : dbConnectionInfo @@ -116,7 +111,7 @@ const DatabaseSettings = () => { }, [connectionString]) useEffect(() => { - if (poolingInfo?.pool_mode !== undefined) { + if (poolingInfo?.pool_mode === 'session') { setPoolingMode(poolingInfo.pool_mode) } }, [poolingInfo?.pool_mode]) @@ -202,7 +197,7 @@ const DatabaseSettings = () => { value={poolingMode === 'transaction' ? connectionInfo.db_port : '5432'} label="Port" /> - {isMd5 && ( + {isMd5 && usePoolerConnection && ( { - const segment = str.split('[YOUR-PASSWORD]@') - const [output] = segment[1].split(':') - return output -} - export const getConnectionStrings = ( connectionInfo: { db_user: string @@ -20,31 +14,25 @@ export const getConnectionStrings = ( pgVersion?: string } ) => { + const isMd5 = poolingInfo.connectionString.includes('options=reference') const { usePoolerConnection, projectRef } = metadata - // Pooler: user, host port - const user = usePoolerConnection ? `postgres.${projectRef}` : connectionInfo.db_user + const user = usePoolerConnection ? poolingInfo.db_user : connectionInfo.db_user const port = usePoolerConnection ? poolingInfo?.db_port : connectionInfo.db_port - // [Joshen] Temp FE: extract host from pooler connection string - const host = usePoolerConnection - ? getHostFromConnectionString(poolingInfo.connectionString) - : connectionInfo.db_host + const host = usePoolerConnection ? poolingInfo.db_host : connectionInfo.db_host const name = usePoolerConnection ? poolingInfo?.db_name : connectionInfo.db_name + const password = '[YOUR-PASSWORD]' + const showOptions = usePoolerConnection && isMd5 const uriConnString = usePoolerConnection ? poolingInfo?.connectionString - : `postgresql://${user}:[YOUR-PASSWORD]@` + `${host}:${port}` + `/${name}` - const golangConnString = - `user=${user} password=[YOUR-PASSWORD] ` + `host=${host} port=${port}` + ` dbname=${name}` - const psqlConnString = `psql -h ${host} -p ` + `${port} -d ${name} ` + `-U ${user}` - const jdbcConnString = - `jdbc:postgresql://${host}:${port}` + `/${name}?user=${user}&password=[YOUR-PASSWORD]` - const dotNetConnString = - `User Id=${user};Password=[YOUR-PASSWORD];` + - `Server=${host};Port=${port};` + - `Database=${name}` - const pythonConnString = - `user=${user} password=[YOUR-PASSWORD]` + ` host=${host} port=${port}` + ` database=${name}` + : `postgresql://${user}:${password}@` + `${host}:${port}` + `/${name}` + const golangConnString = `user=${user} password=${password} host=${host} port=${port} dbname=${name}${showOptions ? ` options=reference=${projectRef}` : ''}` + const psqlConnString = isMd5 + ? `psql "postgresql://${user}:${password}@${host}:${port}/${name}${usePoolerConnection ? `?options=reference%3D${projectRef}` : ''}` + : `psql -h ${host} -p ` + `${port} -d ${name} ` + `-U ${user}` + const jdbcConnString = `jdbc:postgresql://${host}:${port}/${name}?user=${user}&password=${password}${showOptions ? `&options=reference%3D${projectRef}` : ''}` + const dotNetConnString = `User Id=${user};Password=${password};Server=${host};Port=${port};Database=${name};${showOptions ? `Options='reference=${projectRef}'` : ''}` return { psql: psqlConnString, @@ -54,7 +42,7 @@ export const getConnectionStrings = ( dotnet: dotNetConnString, nodejs: uriConnString, php: golangConnString, - python: pythonConnString, + python: golangConnString, } } @@ -86,7 +74,7 @@ export const constructConnStringSyntax = ( portNumber: string } ) => { - const isMd5 = connString.includes('?options=reference') + const isMd5 = connString.includes('options=reference') const poolerHostDetails = [ { value: cloudProvider.toLocaleLowerCase(), tooltip: 'Cloud provider' }, { value: '-0-', tooltip: undefined }, diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx index 62997d38072..aa74d940fba 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx @@ -135,8 +135,8 @@ const ResetDbPassword = ({ disabled = false }) => { {!canResetDbPassword ? 'You need additional permissions to reset the database password' : !isProjectActive - ? 'Unable to reset database password as project is not active' - : ''} + ? 'Unable to reset database password as project is not active' + : ''}
    diff --git a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx index 04968f387fc..e9ca3edfdf1 100644 --- a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx +++ b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx @@ -109,7 +109,7 @@ const NetworkRestrictions = () => { const restrictedIps = ipv4Restrictions.concat(ipv6Restrictions) const restrictionStatus = data?.status ?? '' - const hasApplyError = restrictedIps.length === 0 && restrictionStatus === 'stored' + const hasApplyError = restrictionStatus === 'stored' const isUninitialized = restrictedIps.length === 0 && restrictionStatus.length === 0 const isAllowedAll = restrictedIps.includes('0.0.0.0/0') && restrictedIps.includes('::/0') const isDisallowedAll = diff --git a/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx b/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx index 1d0485dcfa6..c234c737219 100644 --- a/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx +++ b/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx @@ -8,8 +8,14 @@ import { AlertTitle_Shadcn_, Alert_Shadcn_, Button, + DialogClose_Shadcn_, + DialogContent_Shadcn_, + DialogDescription_Shadcn_, + DialogFooter_Shadcn_, + DialogHeader_Shadcn_, + DialogTitle_Shadcn_, + Dialog_Shadcn_, IconExternalLink, - Modal, } from 'ui' export const PoolingModesModal = () => { @@ -24,33 +30,34 @@ export const PoolingModesModal = () => { } return ( - -

    Which pooling mode should I use?

    -
    -
    - } - onCancel={() => snap.setShowPoolingModeHelper(false)} - > - + + + + +
    +

    Which pooling mode should I use?

    + +
    +
    + +

    + A connection pooler is a system (external to Postgres) which manages Postgres + connections by allocating connections whenever clients make requests. +

    +
    +
    )} -
    - - - - - + + snap.setShowPoolingModeHelper(false)}> + + + + + ) } diff --git a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx index 81050b91d0b..d08ee2e9460 100644 --- a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx @@ -159,8 +159,8 @@ const SSLConfiguration = () => { {!canUpdateSSLEnforcement ? 'You need additional permissions to update SSL enforcement for your project' : !hasAccessToSSLEnforcement - ? 'Your project does not have access to SSL enforcement' - : ''} + ? 'Your project does not have access to SSL enforcement' + : ''}
    diff --git a/apps/studio/components/interfaces/Settings/Database/UsePoolerCheckbox.tsx b/apps/studio/components/interfaces/Settings/Database/UsePoolerCheckbox.tsx index 39dc5c8e03f..30d472aeffc 100644 --- a/apps/studio/components/interfaces/Settings/Database/UsePoolerCheckbox.tsx +++ b/apps/studio/components/interfaces/Settings/Database/UsePoolerCheckbox.tsx @@ -1,24 +1,22 @@ -import * as Tooltip from '@radix-ui/react-tooltip' import { useParams } from 'common' -import { useState } from 'react' import { Badge, Button, Checkbox_Shadcn_, - CommandGroup_Shadcn_, - CommandItem_Shadcn_, - CommandList_Shadcn_, - Command_Shadcn_, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + IconCheck, IconChevronDown, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, Separator, } from 'ui' import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { usePgBouncerStatus } from 'data/config/pgbouncer-enabled-query' import { useProjectSettingsQuery } from 'data/config/project-settings-query' import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' +import { useEffect, useState } from 'react' import { useDatabaseSettingsStateSnapshot } from 'state/database-settings' interface UsePoolerCheckboxInterface { @@ -36,14 +34,20 @@ export const UsePoolerCheckbox = ({ onCheckedChange, onSelectPoolingMode, }: UsePoolerCheckboxInterface) => { - const { ref: projectRef } = useParams() const [open, setOpen] = useState(false) + const { ref: projectRef } = useParams() const snap = useDatabaseSettingsStateSnapshot() const { data, isLoading, isSuccess } = usePoolingConfigurationQuery({ projectRef }) const { data: settings, isSuccess: isSuccessSettings } = useProjectSettingsQuery({ projectRef }) + const { data: pgBouncerStatus } = usePgBouncerStatus({ projectRef: projectRef }) - const resolvesToIpV6 = !data?.supavisor_enabled && settings?.project.db_ip_addr_config === 'ipv6' + const resolvesToIpV6 = settings?.project.db_ip_addr_config === 'ipv6' + + const onSelectOption = (value: 'session' | 'transaction') => { + onSelectPoolingMode(value) + setOpen(false) + } return ( <> @@ -66,98 +70,82 @@ export const UsePoolerCheckbox = ({ {isLoading && } {isSuccess && (
    - - -
    - + + + onSelectOption('session')} + > + Session mode + {poolingMode === 'session' && } + + {data.pool_mode === 'transaction' && ( + onSelectOption('transaction')} > - Mode: {poolingMode} - -
    -
    - - - - - { - onSelectPoolingMode('transaction') - setOpen(false) - }} - onClick={() => { - onSelectPoolingMode('transaction') - setOpen(false) - }} - > - - - Transaction mode - - {data.pool_mode === 'session' && ( - - - -
    - - Pooling mode is currently configured to use - session mode. If you want to use both session mode - and transaction mode at the same time, change the - pooling mode to transaction first in the pooler - configuration settings. You can then connect to - session mode on port 5432 and transaction mode on - port 6543 - -
    -
    -
    - )} -
    -
    - { - onSelectPoolingMode('session') - setOpen(false) - }} - onClick={() => { - onSelectPoolingMode('session') - setOpen(false) - }} - > - Session mode - -
    -
    -
    - -

    snap.setShowPoolingModeHelper(true)} + Transaction mode + {poolingMode === 'transaction' && ( + + )} + + )} + + {data.pool_mode === 'session' && ( + <> + +

    +

    + To use transaction mode, change the pool mode in the{' '} + { + const el = document.getElementById('connection-pooler') + if (el) { + setOpen(false) + el.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + } + }} + > + pooling configuration settings + {' '} + to use transaction mode first. +

    +

    + You can then connect to session mode on port 5432 and + transaction mode on port 6543. +

    +
    + + )} + + + { + setOpen(false) + snap.setShowPoolingModeHelper(true) + }} > How to choose pooling modes? -

    -
    -
    + + +
    )}
    @@ -165,18 +153,17 @@ export const UsePoolerCheckbox = ({ )}
    - {isSuccess && checked && data.supavisor_enabled && ( - Supavisor - )} + {isSuccess && checked && Supavisor} {isSuccessSettings && ( {checked ? 'Resolves to IPv4' : resolvesToIpV6 - ? 'Resolves to IPv6' - : 'Will resolve to IPv6'} + ? 'Resolves to IPv6' + : 'Will resolve to IPv6'} )} + {pgBouncerStatus?.active && PgBouncer pending removal}
    diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index 69f7cfad7f4..e1cb7292c7c 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -13,12 +13,14 @@ import CustomDomainsConfigureHostname from './CustomDomainsConfigureHostname' import CustomDomainsShimmerLoader from './CustomDomainsShimmerLoader' import CustomDomainVerify from './CustomDomainVerify' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useSelectedOrganization } from 'hooks' +import { useFlag, useSelectedOrganization } from 'hooks' const CustomDomainConfig = () => { const { ref } = useParams() const organization = useSelectedOrganization() + const customDomainsDisabledDueToQuota = useFlag('customDomainsDisabledDueToQuota') + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) const plan = subscription?.plan?.id @@ -64,15 +66,22 @@ const CustomDomainConfig = () => { ) : data?.status === '0_not_allowed' ? ( } - primaryText="Custom domains are a Pro plan add-on" + primaryText={ + customDomainsDisabledDueToQuota + ? 'New custom domains are temporarily disabled' + : 'Custom domains are a Pro plan add-on' + } projectRef={ref as string} organizationSlug={organization!.slug} secondaryText={ - plan === 'free' - ? 'To configure a custom domain for your project, please upgrade to the Pro plan with the custom domains add-on selected' - : 'To configure a custom domain for your project, please enable the add-on' + customDomainsDisabledDueToQuota + ? 'We are working with our upstream DNS provider before we are able to sign up new custom domains. Please check back in a few hours.' + : plan === 'free' + ? 'To configure a custom domain for your project, please upgrade to the Pro plan with the custom domains add-on selected' + : 'To configure a custom domain for your project, please enable the add-on' } addon="customDomain" + disabled={customDomainsDisabledDueToQuota} /> ) : ( diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/Infrastructure.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/Infrastructure.tsx index 44063957396..df601a1124c 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/Infrastructure.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/Infrastructure.tsx @@ -1,9 +1,9 @@ import * as Tooltip from '@radix-ui/react-tooltip' +import { useParams } from 'common' import { observer } from 'mobx-react-lite' import Link from 'next/link' import { Alert, Badge, Button, IconPackage, Input } from 'ui' -import { useParams } from 'common/hooks' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormHeader, @@ -13,11 +13,11 @@ import { FormSectionLabel, } from 'components/ui/Forms' import { useProjectUpgradeEligibilityQuery } from 'data/config/project-upgrade-eligibility-query' -import { useFlag, useSelectedOrganization } from 'hooks' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useSelectedOrganization } from 'hooks' import PauseProjectButton from './PauseProjectButton' import ProjectUpgradeAlert from './ProjectUpgradeAlert' import RestartServerButton from './RestartServerButton' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' interface InfrastructureProps {} @@ -41,7 +41,6 @@ const Infrastructure = ({}: InfrastructureProps) => { const currentPgVersion = (current_app_version ?? '').split('supabase-postgres-')[1] const latestPgVersion = (latest_app_version ?? '').split('supabase-postgres-')[1] - const showDbUpgrades = useFlag('databaseUpgrades') const subject = 'Request%20for%20Postgres%20upgrade%20for%20project' const message = `Upgrade information:%0A• Manual intervention reason: ${requires_manual_intervention}` @@ -125,8 +124,8 @@ const Infrastructure = ({}: InfrastructureProps) => { ), ]} /> - {showDbUpgrades && data?.eligible && } - {showDbUpgrades && !data?.eligible && data?.requires_manual_intervention && ( + {data?.eligible && } + {!data?.eligible && data?.requires_manual_intervention && ( } variant="neutral" diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx index 9ae8336041d..38e0212c385 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx @@ -84,12 +84,12 @@ const PauseProjectButton = () => { {isPaused ? 'Your project is already paused' : !canPauseProject - ? 'You need additional permissions to pause this project' - : !isProjectActive - ? 'Unable to pause project as project is not active' - : !isFreePlan - ? 'Projects on a paid plan will always be running' - : ''} + ? 'You need additional permissions to pause this project' + : !isProjectActive + ? 'Unable to pause project as project is not active' + : !isFreePlan + ? 'Projects on a paid plan will always be running' + : ''}
    diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx index ec58289b469..b496a0604ee 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx @@ -150,8 +150,8 @@ const RestartServerButton = () => { {!canRestartProject ? 'You need additional permissions to restart this project' : !isProjectActive - ? 'Unable to restart project as project is not active' - : ''} + ? 'Unable to restart project as project is not active' + : ''}
    diff --git a/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx index 24e5d09b302..b809a96e156 100644 --- a/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx @@ -19,6 +19,7 @@ import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectTransferMutation } from 'data/projects/project-transfer-mutation' import { useProjectTransferPreviewQuery } from 'data/projects/project-transfer-preview-query' import { useCheckPermissions, useFlag, useSelectedProject, useStore } from 'hooks' +import { formatCurrency } from 'lib/helpers' const TransferProjectButton = () => { const { ui } = useStore() @@ -297,7 +298,7 @@ const TransferProjectButton = () => { {' '} The target organization will be billed{' '} - ${transferPreviewData.costs_on_target_organization} + {formatCurrency(transferPreviewData.costs_on_target_organization)} {' '} immediately to prorate for the remainder of the billing period. diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx index 035992db678..122cd52b089 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx @@ -26,14 +26,17 @@ import { useInfraMonitoringQuery } from 'data/analytics/infra-monitoring-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query' -import { useFlag, useSelectedOrganization } from 'hooks' +import { useSelectedOrganization, useSelectedProject } from 'hooks' import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants/metrics' import { INFRA_ACTIVITY_METRICS } from './Infrastructure.constants' +import { capitalize } from 'lodash' +import { INSTANCE_MICRO_SPECS, INSTANCE_NANO_SPECS } from 'lib/constants' const InfrastructureActivity = () => { const { ref: projectRef } = useParams() const organization = useSelectedOrganization() const [dateRange, setDateRange] = useState() + const project = useSelectedProject() const { data: subscription, isLoading: isLoadingSubscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug, @@ -47,13 +50,10 @@ const InfrastructureActivity = () => { const selectedAddons = addons?.selected_addons ?? [] const { computeInstance } = getAddons(selectedAddons) - const currentComputeInstanceSpecs = computeInstance?.variant?.meta ?? { - baseline_disk_io_mbs: 87, - max_disk_io_mbs: 2085, - cpu_cores: 2, - cpu_dedicated: true, - memory_gb: 1, - } + const currentComputeInstanceSpecs = + computeInstance?.variant?.meta ?? project?.infra_compute_size === 'nano' + ? INSTANCE_NANO_SPECS + : INSTANCE_MICRO_SPECS const currentBillingCycleSelected = useMemo(() => { // Selected by default @@ -71,8 +71,8 @@ const InfrastructureActivity = () => { subscription === undefined ? `/` : subscription.plan.id === 'free' - ? `/org/${organization?.slug ?? '[slug]'}/billing#subscription` - : `/project/${projectRef}/settings/addons` + ? `/org/${organization?.slug ?? '[slug]'}/billing#subscription` + : `/project/${projectRef}/settings/addons` const categoryMeta = INFRA_ACTIVITY_METRICS.find((category) => category.key === 'infra') @@ -181,7 +181,7 @@ const InfrastructureActivity = () => {
    - +
    {/* [Joshen] Metrics for replicas not available yet */} {/* {readReplicasEnabled && } */} @@ -273,7 +273,11 @@ const InfrastructureActivity = () => {

    Overview

    Current compute instance

    -

    {computeInstance?.variant?.name ?? 'Micro'}

    +

    + {computeInstance?.variant?.name ?? + capitalize(project?.infra_compute_size) ?? + 'Micro'} +

    diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants.ts b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants.ts index f4f48491cb6..38b928c56af 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants.ts @@ -54,7 +54,7 @@ export const AVAILABLE_REPLICA_REGIONS: Region[] = Object.keys(AWS_REGIONS) .map((key) => { return { key: key as AWS_REGIONS_KEYS, - name: AWS_REGIONS[key as AWS_REGIONS_KEYS], + name: AWS_REGIONS?.[key as AWS_REGIONS_KEYS], region: AWS_REGIONS_VALUES[key], coordinates: AWS_REGIONS_COORDINATES[key], } diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx index 4b1cca429ba..572d0a6cb98 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx @@ -155,6 +155,7 @@ const InstanceConfigurationUI = () => { fitViewOptions={{ minZoom: 0.9, maxZoom: 1 }} className="instance-configuration" zoomOnPinch={false} + zoomOnScroll={false} nodesDraggable={false} nodesConnectable={false} zoomOnDoubleClick={false} diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx index 1cbda74d9cd..3c77d1bea67 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx @@ -84,7 +84,7 @@ const MapView = ({ }, []) return ( -

    +
    0 - ? ` + ${replicas.length} replica${replicas.length > 1 ? 's' : ''} ` - : '' - }` - : `${replicas.length} Read Replica${ - replicas.length > 1 ? 's' : '' - } deployed`, + ? `Primary Database${ + replicas.length > 0 + ? ` + ${replicas.length} replica${replicas.length > 1 ? 's' : ''} ` + : '' + }` + : `${replicas.length} Read Replica${ + replicas.length > 1 ? 's' : '' + } deployed`, }, }) }} @@ -192,8 +192,8 @@ const MapView = ({ hasNoDatabases ? 'fill-background-surface-300 stroke-border-stronger' : hasPrimary - ? 'fill-brand stroke-brand-500' - : 'fill-brand-500 stroke-brand-400' + ? 'fill-brand stroke-brand-500' + : 'fill-brand-500 stroke-brand-400' }`} /> @@ -203,7 +203,7 @@ const MapView = ({ {tooltip !== undefined && zoom === 1.5 && ( -
    +
    {showRegionDetails && selectedRegion && ( -
    +

    diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx index 205318470e5..8175dd819cc 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx @@ -1,7 +1,15 @@ import * as Tooltip from '@radix-ui/react-tooltip' -import Link from 'next/link' - import { useParams } from 'common' +import Link from 'next/link' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Badge, + Button, + Input, +} from 'ui' + import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ScaffoldContainer, @@ -15,14 +23,6 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useProjectUpgradeEligibilityQuery } from 'data/config/project-upgrade-eligibility-query' import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' import { useFlag, useIsFeatureEnabled } from 'hooks' -import { - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, - Badge, - Button, - Input, -} from 'ui' import ProjectUpgradeAlert from '../General/Infrastructure/ProjectUpgradeAlert' import InstanceConfiguration from './InfrastructureConfiguration/InstanceConfiguration' @@ -47,7 +47,6 @@ const InfrastructureInfo = () => { const currentPgVersion = (current_app_version ?? '').split('supabase-postgres-')[1] const latestPgVersion = (latest_app_version ?? '').split('supabase-postgres-')[1] - const showDbUpgrades = useFlag('databaseUpgrades') const readReplicasEnabled = useFlag('readReplicas') const showReadReplicasUI = readReplicasEnabled && project?.is_read_replicas_enabled const hasReadReplicas = (databases ?? []).length > 1 @@ -155,8 +154,8 @@ const InfrastructureInfo = () => { ), ]} /> - {showDbUpgrades && data?.eligible && !hasReadReplicas && } - {showDbUpgrades && data.eligible && hasReadReplicas && ( + {data?.eligible && !hasReadReplicas && } + {data.eligible && hasReadReplicas && ( A new version of Postgres is available for your project @@ -167,7 +166,7 @@ const InfrastructureInfo = () => { )} - {showDbUpgrades && !data?.eligible && data?.requires_manual_intervention && ( + {!data?.eligible && data?.requires_manual_intervention && ( A new version of Postgres is available for your project @@ -189,49 +188,47 @@ const InfrastructureInfo = () => { )} - {showDbUpgrades && - !data?.eligible && - (data?.extension_dependent_objects || []).length > 0 && ( - - New version of Postgres available - -

    -

    - This project cannot be upgraded due to the following extension dependent - objects: -

    - -
      - {(data?.extension_dependent_objects || []).map((obj) => ( -
    • - {obj} -
    • - ))} -
    -
    -

    - Once the above objects are exported and removed, you can proceed to - upgrade your project, and re-import the objects after the upgrade - operation is complete. Please refer to the docs on additional extensions - that might also need to be dropped. + {!data?.eligible && (data?.extension_dependent_objects || []).length > 0 && ( + + New version of Postgres available + +

    +

    + This project cannot be upgraded due to the following extension dependent + objects:

    -
    - -
    - - - )} + +
      + {(data?.extension_dependent_objects || []).map((obj) => ( +
    • + {obj} +
    • + ))} +
    +
    +

    + Once the above objects are exported and removed, you can proceed to upgrade + your project, and re-import the objects after the upgrade operation is + complete. Please refer to the docs on additional extensions that might also + need to be dropped. +

    +
    + +
    + + + )} )} diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx index 6307c617114..80a0858d954 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx @@ -186,7 +186,7 @@ const GitHubIntegrationConnectionForm = ({ return ( { setOpen(false) diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx index 30cc6f8cebc..0055238a699 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx @@ -123,8 +123,8 @@ You can change the scope of the access for Supabase by configuring process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' ? 'https://vercel.com/integrations/supabase' : process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' - ? `https://vercel.com/integrations/supabase-v2-staging` - : 'https://vercel.com/integrations/supabase-v2-local' + ? `https://vercel.com/integrations/supabase-v2-staging` + : 'https://vercel.com/integrations/supabase-v2-local' let connections = (isProjectScoped diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx index 0f317f36980..8d4147ba3be 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx @@ -100,13 +100,13 @@ ${JSON.stringify(fullLog.metadata, null, 2)} className={[ 'relative flex h-full flex-grow flex-col border border-l', 'border-overlay', - 'overflow-y-scroll bg-background', + 'overflow-y-scroll bg-studio', ].join(' ')} >
    diff --git a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx index 7b748cc5b60..d88fa9de66b 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -1,10 +1,24 @@ import { isEqual } from 'lodash' -import { Key, ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import * as Tooltip from '@radix-ui/react-tooltip' import DataGrid, { Column, RenderRowProps, Row } from 'react-data-grid' -import { Alert, Button, IconClipboard, IconEye, IconEyeOff } from 'ui' +import { + Alert, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + IconChevronDown, + IconClipboard, + IconDownload, + IconEye, + IconEyeOff, + IconPlay, +} from 'ui' import CSVButton from 'components/ui/CSVButton' -import { useStore } from 'hooks' +import { useCheckPermissions, useStore } from 'hooks' import { copyToClipboard } from 'lib/helpers' import { LogQueryError, isDefaultLogPreviewFormat } from '.' import AuthColumnRenderer from './LogColumnRenderers/AuthColumnRenderer' @@ -17,6 +31,10 @@ import LogSelection, { LogSelectionProps } from './LogSelection' import { LogData, QueryType } from './Logs.types' import DefaultErrorRenderer from './LogsErrorRenderers/DefaultErrorRenderer' import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer' +import { CSVLink } from 'react-csv' +import { IS_PLATFORM } from 'common' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useProfile } from 'lib/profile' interface Props { data?: Array @@ -29,6 +47,9 @@ interface Props { queryType?: QueryType projectRef: string params: LogSelectionProps['params'] + onRun?: () => void + onSave?: () => void + hasEditorValue?: boolean } type LogMap = { [id: string]: LogData } @@ -46,13 +67,31 @@ const LogTable = ({ error, projectRef, params, + onRun, + onSave, + hasEditorValue, }: Props) => { const { ui } = useStore() const [focusedLog, setFocusedLog] = useState(null) const firstRow: LogData | undefined = data?.[0] as LogData - const columnNames = Object.keys(data[0] || {}) + const { profile } = useProfile() + const canCreateLogQuery = useCheckPermissions(PermissionAction.CREATE, 'user_content', { + resource: { type: 'log_sql', owner_id: profile?.id }, + subject: { id: profile?.id }, + }) + + // move timestamp to the first column + function getFirstRow() { + if (!firstRow) return {} + + const { timestamp, ...rest } = firstRow || {} + return { timestamp, ...rest } + } + + const columnNames = Object.keys(getFirstRow() || {}) const hasId = columnNames.includes('id') const hasTimestamp = columnNames.includes('timestamp') + const csvRef = useRef(null) const DEFAULT_COLUMNS = columnNames.map((v: keyof LogData, idx) => { const result: Column = { @@ -70,10 +109,12 @@ const LogTable = ({ } }, renderHeaderCell: (props) => { - return v + return
    {v}
    }, minWidth: 128, + maxWidth: v === 'timestamp' ? 240 : 600, // Without this, the column flickers on first render } + return result }) @@ -160,18 +201,40 @@ const LogTable = ({ }) } + const downloadCsvRef = useRef(null) + function downloadCSV() { + downloadCsvRef.current?.click() + } + const LogsExplorerTableHeader = () => ( -
    +
    - {data && data.length ? ( - <> - Query results - {data && data.length} - - ) : ( - Results will be shown below - )} + + + + + + + +
    Download CSV
    +
    + + +
    Copy to clipboard
    +
    +
    +
    + + {/* Using .hidden with a ref so I don't have to duplicate the code to download the CSV - Jordi */} +
    + +
    Download CSV
    +
    +
    +
    {onHistogramToggle && (
    + +
    + {IS_PLATFORM && ( + + + + + {!canCreateLogQuery && ( + + + +
    + + You need additional permissions to save your query + +
    +
    +
    + )} +
    + )} + - Download
    ) @@ -214,7 +315,7 @@ const LogTable = ({ } const renderNoResultAlert = () => ( -
    +
    @@ -249,16 +350,13 @@ const LogTable = ({ return ( <> -
    +
    {!queryType && }
    { console.error('Invalid project reference') return } - await deleteContent({ projectRef: ref, id: item.id }) + await deleteContent({ projectRef: ref, ids: [item.id] }) setShowConfirmModal(false) toast.success('Query deleted') } catch (error) { diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts index ebc12f6e57a..29ff5770509 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts @@ -296,10 +296,10 @@ export const SQL_FILTER_TEMPLATES: any = { 'severity.error': `metadata.level = 'error' or metadata.level = 'fatal'`, 'severity.warning': `metadata.level = 'warning'`, 'severity.info': `metadata.level = 'info'`, - 'status_code.server_error': `metadata.status between 500 and 599`, - 'status_code.client_error': `metadata.status between 400 and 499`, - 'status_code.redirection': `metadata.status between 300 and 399`, - 'status_code.success': `metadata.status between 200 and 299`, + 'status_code.server_error': `cast(metadata.status as int64) between 500 and 599`, + 'status_code.client_error': `cast(metadata.status as int64) between 400 and 499`, + 'status_code.redirection': `cast(metadata.status as int64) between 300 and 399`, + 'status_code.success': `cast(metadata.status as int64) between 200 and 299`, 'endpoints.admin': `REGEXP_CONTAINS(metadata.path, "/admin")`, 'endpoints.signup': `REGEXP_CONTAINS(metadata.path, "/signup|/invite|/verify")`, 'endpoints.authentication': `REGEXP_CONTAINS(metadata.path, "/token|/authorize|/callback|/otp|/magiclink")`, @@ -332,7 +332,6 @@ export enum LogsTableName { AUTH = 'auth_logs', REALTIME = 'realtime_logs', STORAGE = 'storage_logs', - PGBOUNCER = 'pgbouncer_logs', POSTGREST = 'postgrest_logs', SUPAVISOR = 'supavisor_logs', } @@ -346,7 +345,6 @@ export const LOGS_TABLES = { realtime: LogsTableName.REALTIME, storage: LogsTableName.STORAGE, postgrest: LogsTableName.POSTGREST, - pgbouncer: LogsTableName.PGBOUNCER, supavisor: LogsTableName.SUPAVISOR, } @@ -358,7 +356,6 @@ export const LOGS_SOURCE_DESCRIPTION = { [LogsTableName.AUTH]: 'Authentication logs from GoTrue', [LogsTableName.REALTIME]: 'Realtime server for Postgres logical replication broadcasting', [LogsTableName.STORAGE]: 'Object storage logs', - [LogsTableName.PGBOUNCER]: 'Postgres connection pooler logs', [LogsTableName.POSTGREST]: 'RESTful API web server logs', [LogsTableName.SUPAVISOR]: 'Cloud-native Postgres connection pooler logs', } @@ -657,11 +654,16 @@ export const PREVIEWER_DATEPICKER_HELPERS: DatetimeHelper[] = [ }, ] export const EXPLORER_DATEPICKER_HELPERS: DatetimeHelper[] = [ + { + text: 'Last hour', + calcFrom: () => dayjs().subtract(1, 'hour').startOf('hour').toISOString(), + calcTo: () => '', + default: true, + }, { text: 'Last 24 hours', calcFrom: () => dayjs().subtract(1, 'day').startOf('day').toISOString(), calcTo: () => '', - default: true, }, { text: 'Last 3 days', diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts index 8c5e5faf7ba..0346c3f4797 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts @@ -86,7 +86,6 @@ export type QueryType = | 'realtime' | 'storage' | 'supavisor' - | 'pgbouncer' | 'postgrest' export type Mode = 'simple' | 'custom' diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsFilterPopover.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsFilterPopover.tsx index 9ea68f0d0e2..b3e689738f8 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsFilterPopover.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsFilterPopover.tsx @@ -65,7 +65,7 @@ const LogsFilterPopover: React.FC = ({ ))}
    -
    +
    diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index 6f56f4799f8..65bfeccae7d 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -8,16 +8,21 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + IconBookOpen, + IconCheck, IconChevronDown, - IconPlay, + IconClipboard, + IconExternalLink, + IconX, Popover, + SidePanel, + Tabs, } from 'ui' import { useCheckPermissions, useIsFeatureEnabled } from 'hooks' -import { IS_PLATFORM } from 'lib/constants' import { useProfile } from 'lib/profile' import Link from 'next/link' -import React from 'react' +import React, { useState } from 'react' import { EXPLORER_DATEPICKER_HELPERS, LogsTableName, @@ -26,12 +31,14 @@ import { LogTemplate, } from '.' import DatePickers from './Logs.DatePickers' +import Table from 'components/to-be-cleaned/Table' +import { logConstants } from 'shared-data' +import { copyToClipboard } from 'lib/helpers' export interface LogsQueryPanelProps { templates?: LogTemplate[] onSelectTemplate: (template: LogTemplate) => void onSelectSource: (source: LogsTableName) => void - onRun: (e: React.MouseEvent) => void onClear: () => void onSave?: () => void hasEditorValue: boolean @@ -45,22 +52,13 @@ export interface LogsQueryPanelProps { const LogsQueryPanel = ({ templates = [], onSelectTemplate, - hasEditorValue, - onRun, - onClear, - onSave, onSelectSource, - isLoading, defaultFrom, defaultTo, onDateChange, warnings, }: LogsQueryPanelProps) => { - const { profile } = useProfile() - const canCreateLogQuery = useCheckPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'log_sql', owner_id: profile?.id }, - subject: { id: profile?.id }, - }) + const [showReference, setShowReference] = React.useState(false) const { projectAuthAll: authEnabled, @@ -161,50 +159,90 @@ const LogsQueryPanel = ({
    - - {IS_PLATFORM && onSave && ( - - + +

    Field Reference

    + } + visible={showReference} + cancelText="Close" + onCancel={() => setShowReference(false)} + hideFooter + triggerElement={ + + } + > + +
    +

    + The following table shows all the available paths that can be queried from + each respective source. Do note that to access nested keys, you would need + to perform the necessary{' '} + + unnesting joins + + +

    +
    +
    + + + {logConstants.schemas.map((schema) => ( + - Save query - - - {!canCreateLogQuery && ( - - - -
    - - You need additional permissions to save your query - -
    -
    -
    - )} - - )} +
    Factor IDFriendly NameFactor Status
    {factor.id}{factor.friendly_name}{factor.factor_type}{factor.status}Factor IDFriendly NameFactor Status
    {factor.id}{factor.friendly_name}{factor.factor_type}{factor.status}
    {formatCurrency(item.amount)} {fee.unit_price_desc ? `${fee.unit_price_desc}` : fee.unit_price - ? formatCurrency(fee.unit_price) - : null} + ? formatCurrency(fee.unit_price) + : null} {formatCurrency(fee.amount) ?? formatCurrency(0)} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx index e50dd0dfd24..77b4e6ccb73 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx @@ -30,8 +30,8 @@ const ProjectUpdateDisabledTooltip = ({ {projectUpdateDisabled ? 'Subscription changes are currently disabled. Our engineers are working on a fix.' : projectNotActive - ? 'Unable to update subscription as project is currently not active' - : ''} + ? 'Unable to update subscription as project is currently not active' + : ''} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx index 1b8e7a9f729..32b384ba0bf 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx @@ -1,3 +1,4 @@ +import { ProjectInfo } from 'data/projects/projects-query' import { OrgSubscription, ProjectAddon } from 'data/subscriptions/types' import { PricingInformation } from 'shared-data' import { Alert, IconAlertOctagon, IconMinusCircle, IconPauseCircle, Modal } from 'ui' @@ -8,6 +9,7 @@ export interface DowngradeModalProps { subscription?: OrgSubscription onClose: () => void onConfirm: () => void + projects: ProjectInfo[] } const ProjectDowngradeListItem = ({ projectAddon }: { projectAddon: ProjectAddon }) => { @@ -39,7 +41,26 @@ const DowngradeModal = ({ subscription, onClose, onConfirm, + projects, }: DowngradeModalProps) => { + // Filter out the micro addon as we're dealing with that separately + const previousProjectAddons = + subscription?.project_addons.flatMap((projectAddons) => { + const addons = projectAddons.addons.filter((it) => it.variant.identifier !== 'ci_micro') + if (!addons.length) { + return [] + } else { + return { + ...projectAddons, + // Overwrite addons, filtered out the micro addon + addons, + } + } + }) || [] + + const hasInstancesOnMicro = projects.some((project) => project.infra_compute_size === 'micro') + const downgradingToNano = subscription?.nano_enabled === true + return ( - {(subscription?.project_addons.length ?? 0) > 0 && ( - -
      - {subscription?.project_addons.map((project) => ( - - ))} -
    -
    - )} + {(previousProjectAddons.length ?? 0) > 0 || + (hasInstancesOnMicro && downgradingToNano && ( + +
      + {previousProjectAddons.map((project) => ( + + ))} + + {projects + .filter((it) => it.infra_compute_size === 'micro') + .map((project) => ( +
    • + {project.name}: Compute will be downgraded. Project will also{' '} + need to be restarted. +
    • + ))} +
    +
    + ))}
      diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/EnterpriseCard.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/EnterpriseCard.tsx index c84e942e78f..2bf411ae287 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/EnterpriseCard.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/EnterpriseCard.tsx @@ -15,7 +15,7 @@ const EnterpriseCard = ({ plan, isCurrentPlan, billingViaPartner }: EnterpriseCa
      @@ -48,9 +48,14 @@ const EnterpriseCard = ({ plan, isCurrentPlan, billingViaPartner }: EnterpriseCa className="text-xs text-foreground-light md:grid md:grid-cols-2 md:gap-x-10" > {features.map((feature) => ( -
    • +
    • ))}
    diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index e31a4e060a8..a8910fead88 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -34,6 +34,9 @@ import EnterpriseCard from './EnterpriseCard' import ExitSurveyModal from './ExitSurveyModal' import MembersExceedLimitModal from './MembersExceedLimitModal' import PaymentMethodSelection from './PaymentMethodSelection' +import { formatCurrency } from 'lib/helpers' +import { useProjectsQuery } from 'data/projects/projects-query' +import { isArray } from 'lodash' const PlanUpdateSidePanel = () => { const { ui } = useStore() @@ -51,6 +54,10 @@ const PlanUpdateSidePanel = () => { PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) + const { data: allProjects } = useProjectsQuery() + const orgProjects = (allProjects || []).filter( + (it) => it.organization_id === selectedOrganization?.id + ) const snap = useOrgSettingsPageStateSnapshot() const visible = snap.panelKey === 'subscriptionPlan' @@ -182,10 +189,6 @@ const PlanUpdateSidePanel = () => { const isDowngradeOption = planMeta?.change_type === 'downgrade' const isCurrentPlan = planMeta?.id === subscription?.plan?.id const features = billingViaPartner ? plan.featuresPartner : plan.features - const warning = - billingViaPartner && plan.warningPartner - ? plan.warningPartner - : plan.warning ?? null if (plan.id === 'tier_enterprise') { return ( @@ -220,22 +223,17 @@ const PlanUpdateSidePanel = () => { <> )} -
    +
    {(price ?? 0) > 0 &&

    From

    } {isLoadingPlans ? (
    ) : ( -

    ${price}

    +

    {formatCurrency(price)}

    )}

    {plan.costUnit}

    -
    -
    - {warning} -
    -
    {isCurrentPlan ? ( + + + + ) +} + +export default TIA diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx index bd9eea80a27..792b45ccddd 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx @@ -55,8 +55,8 @@ const GeneralSettings = () => { values.isOptedIntoAi && !existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) ? existingOptInTags.concat([OPT_IN_TAGS.AI_SQL]) : !values.isOptedIntoAi && existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) - ? existingOptInTags.filter((x) => x !== OPT_IN_TAGS.AI_SQL) - : existingOptInTags + ? existingOptInTags.filter((x) => x !== OPT_IN_TAGS.AI_SQL) + : existingOptInTags updateOrganization( { slug, name: values.name, opt_in_tags: updatedOptInTags }, diff --git a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx index 9e57024f779..c8622ed2b01 100644 --- a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx +++ b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx @@ -116,7 +116,7 @@ const SidePanelGitHubRepoLinker = ({ projectRef }: SidePanelGitHubRepoLinkerProp hideFooter onCancel={() => sidePanelStateSnapshot.setGithubConnectionsOpen(false)} > -
    +
    { hideFooter onCancel={() => sidePanelStateSnapshot.setVercelConnectionsOpen(false)} > -
    +
    {

    {new Date(x.period_end * 1000).toLocaleString()}

    -

    ${x.subtotal / 100}

    +

    {formatCurrency(x.subtotal / 100)}

    {x.number}

    diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index a495ce28d99..804ea1b0d62 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -382,35 +382,6 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { )} )} - - - } - defaultVisibility={true} - hideCollapse - title="Billed via organization" - description={ -
    -

    - This organization will use the new organization-based billing, which gives you a - single subscription for your entire organization, instead of having individual - subscriptions per project.{' '} -

    -
    - -
    -
    - } - /> -
    diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx index dad463eaa62..708de836d82 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx @@ -270,7 +270,7 @@ export const USAGE_CATEGORIES: CategoryMeta[] = [ name: 'Realtime Messages', unit: 'absolute', description: - "Count of messages going through Realtime.\nUsage example: If you do a database change and 5 clients listen to that change via Realtime, that's 5 messages. If you broadcast a message and 4 clients listen to that, that's 5 messages (1 message sent, 4 received).\nBilling is based on the total amount of messages throughout your billing period.", + "Count of messages going through Realtime. Includes database changes, broadcast and presence. \nUsage example: If you do a database change and 5 clients listen to that change via Realtime, that's 5 messages. If you broadcast a message and 4 clients listen to that, that's 5 messages (1 message sent, 4 received).\nBilling is based on the total amount of messages throughout your billing period.", chartDescription: 'The data refreshes every 24 hours.', links: [ { diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx index 84b5d788383..f9dae822e37 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -103,7 +103,7 @@ const Usage = () => { return ( <> - +
    {isLoadingSubscription && } diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx index 278d563d808..987f544f56f 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx @@ -159,9 +159,9 @@ const AttributeUsage = ({ ? 'bg-foreground-light' : 'bg-red-900' : usageBasedBilling === false && - usageRatio >= USAGE_APPROACHING_THRESHOLD - ? 'bg-amber-900' - : 'bg-foreground-light' + usageRatio >= USAGE_APPROACHING_THRESHOLD + ? 'bg-amber-900' + : 'bg-foreground-light' )} bgClass="bg-surface-300" value={usageMeta?.usage ?? 0} @@ -205,8 +205,8 @@ const AttributeUsage = ({ {(usageMeta?.pricing_free_units ?? 0) === -1 || usageExcess < 0 ? `0${attribute.unit === 'bytes' ? ' GB' : ''}` : attribute.unit === 'bytes' - ? `${usageExcess.toFixed(2)} GB` - : usageExcess.toLocaleString()} + ? `${usageExcess.toFixed(2)} GB` + : usageExcess.toLocaleString()}

    )} diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Bucket.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Bucket.tsx index b156deec8db..02361fe320e 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Bucket.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Bucket.tsx @@ -35,10 +35,10 @@ const Bucket = ({ language, apikey, endpoint }: ContentProps) => { {allowedMimeTypes === null ? 'All types are allowed' : (allowedMimeTypes ?? []).length === 0 - ? 'No types are allowed' - : (allowedMimeTypes ?? []).length > 1 - ? (allowedMimeTypes ?? []).join(', ') - : 'Unknown'} + ? 'No types are allowed' + : (allowedMimeTypes ?? []).length > 1 + ? (allowedMimeTypes ?? []).join(', ') + : 'Unknown'}

    Max file size limit:{' '} diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.constants.ts b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.constants.ts index 246776f8c60..67b9adefa81 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.constants.ts +++ b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.constants.ts @@ -603,11 +603,11 @@ export const DOCS_RESOURCE_CONTENT: { curl -X POST '${endpoint}/rest/v1/rpc/${rpcName}' \\${bashParams} -H "Content-Type: application/json" \\ -H "apikey: ${apiKey}" ${ - showBearer - ? `\\ + showBearer + ? `\\ -H "Authorization: Bearer ${apiKey}"` - : '' - } + : '' + } `, js: ` let { data, error } = await supabase @@ -745,8 +745,8 @@ let { data: ${resourceId}, error } = await supabase .neq('column', 'Not equal to') // Arrays - .cs('array_column', ['array', 'contains']) - .cd('array_column', ['contained', 'by']) + .contains('array_column', ['array', 'contains']) + .containedBy('array_column', ['contained', 'by']) `, }, ] diff --git a/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx b/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx index d8a6846f41e..32eef004323 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx @@ -77,7 +77,7 @@ const NoResultAlert = ({

    -
    +

    Not sure what to do?

    Browse our documentation

    diff --git a/apps/studio/components/interfaces/Realtime/Inspector/NoChannelEmptyState.tsx b/apps/studio/components/interfaces/Realtime/Inspector/NoChannelEmptyState.tsx index c3025a105ff..8224136f599 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/NoChannelEmptyState.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/NoChannelEmptyState.tsx @@ -2,7 +2,7 @@ import { Button, IconExternalLink } from 'ui' const NoChannelEmptyState = () => { return ( -
    +

    Join a channel to start listening to messages

    diff --git a/apps/studio/components/interfaces/Reports/GridResize.tsx b/apps/studio/components/interfaces/Reports/GridResize.tsx index 1d98e938f90..6e17cd4e33a 100644 --- a/apps/studio/components/interfaces/Reports/GridResize.tsx +++ b/apps/studio/components/interfaces/Reports/GridResize.tsx @@ -1,5 +1,4 @@ import RGL, { WidthProvider } from 'react-grid-layout' - import { LAYOUT_COLUMN_COUNT } from './Reports.constants' import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler' diff --git a/apps/studio/components/interfaces/Reports/ReportFilterBar.tsx b/apps/studio/components/interfaces/Reports/ReportFilterBar.tsx index 24c2e95b290..bed81a02361 100644 --- a/apps/studio/components/interfaces/Reports/ReportFilterBar.tsx +++ b/apps/studio/components/interfaces/Reports/ReportFilterBar.tsx @@ -121,7 +121,7 @@ const ReportFilterBar = ({ key: productFilter.filterKey, compare: 'matches', value: productFilter.filterValue, - } as ReportFilterItem) + }) as ReportFilterItem ) onRemoveFilters(toRemove) if (nextProductFilter) { diff --git a/apps/studio/components/interfaces/Reports/ReportHeader.tsx b/apps/studio/components/interfaces/Reports/ReportHeader.tsx index bfe2a5fbd3d..39a68e44057 100644 --- a/apps/studio/components/interfaces/Reports/ReportHeader.tsx +++ b/apps/studio/components/interfaces/Reports/ReportHeader.tsx @@ -9,20 +9,6 @@ interface Props { const ReportHeader: React.FC = ({ title, onRefresh, isLoading }) => (

    {title}

    -
    ) export default ReportHeader diff --git a/apps/studio/components/interfaces/Reports/ReportQueryPerformanceTableRow.tsx b/apps/studio/components/interfaces/Reports/ReportQueryPerformanceTableRow.tsx new file mode 100644 index 00000000000..83cec5b027a --- /dev/null +++ b/apps/studio/components/interfaces/Reports/ReportQueryPerformanceTableRow.tsx @@ -0,0 +1,51 @@ +import Table from 'components/to-be-cleaned/Table' +import React from 'react' +import { cn } from 'ui' +import { Editor } from '@monaco-editor/react' + +type Props = { + sql: string + colSpan: number + children: React.ReactNode +} + +const ReportQueryPerformanceTableRow = ({ sql, colSpan, children }: Props) => { + const [expanded, setExpanded] = React.useState(false) + + return ( + <> + setExpanded(!expanded)}>{children} +
    +
    + +
    +
    + Path + , + + Type + , + ]} + body={schema.fields + .sort((a: any, b: any) => a.path - b.path) + .map((field) => ( + + ))} + /> + + ))} + + - - @@ -213,4 +251,71 @@ const LogsQueryPanel = ({ ) } +const Field = ({ + field, +}: { + field: { + path: string + type: string + } +}) => { + const [isCopied, setIsCopied] = useState(false) + + return ( + + + copyToClipboard(field.path, () => { + setIsCopied(true) + setTimeout(() => setIsCopied(false), 3000) + }) + } + > + {field.path} + {isCopied ? ( + + + + + + + +
    + Copied +
    +
    +
    +
    + ) : ( + + + + + + + +
    + Copy value +
    +
    +
    +
    + )} +
    + {field.type} +
    + ) +} + export default LogsQueryPanel diff --git a/apps/studio/components/interfaces/Settings/Vault/VaultToggle.tsx b/apps/studio/components/interfaces/Settings/Vault/VaultToggle.tsx index 39fca0bb7c0..909cec0a957 100644 --- a/apps/studio/components/interfaces/Settings/Vault/VaultToggle.tsx +++ b/apps/studio/components/interfaces/Settings/Vault/VaultToggle.tsx @@ -70,7 +70,7 @@ const VaultToggle = () => { return (
    !bucket?.public && values.public ? 'Warning: Making bucket public' : bucket?.public && !values.public - ? 'Warning: Making bucket private' - : '' + ? 'Warning: Making bucket private' + : '' } variant="warning" withIcon @@ -151,8 +151,8 @@ const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => {!bucket?.public && values.public ? `This will make all objects in the bucket "${bucket?.name}" public` : bucket?.public && !values.public - ? `All objects in "${bucket?.name}" will be made private and will only be accessible via signed URLs or downloaded with the right authorisation headers` - : ''} + ? `All objects in "${bucket?.name}" will be made private and will only be accessible via signed URLs or downloaded with the right authorisation headers` + : ''}

    )} diff --git a/apps/studio/components/interfaces/Support/SupportForm.tsx b/apps/studio/components/interfaces/Support/SupportForm.tsx index 5a851f1d66a..81258d829c8 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.tsx +++ b/apps/studio/components/interfaces/Support/SupportForm.tsx @@ -5,6 +5,9 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { ChangeEvent, useEffect, useRef, useState } from 'react' import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, Button, Checkbox, Form, @@ -35,6 +38,7 @@ import { CATEGORY_OPTIONS, SERVICE_OPTIONS, SEVERITY_OPTIONS } from './Support.c import { formatMessage, uploadAttachments } from './SupportForm.utils' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { Project } from 'data/projects/project-detail-query' +import { HelpCircle } from 'lucide-react' const MAX_ATTACHMENTS = 5 const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive'] @@ -54,6 +58,7 @@ const SupportForm = ({ setSentCategory }: SupportFormProps) => { const [uploadedFiles, setUploadedFiles] = useState([]) const [uploadedDataUrls, setUploadedDataUrls] = useState([]) const [selectedServices, setSelectedServices] = useState([]) + const [textAreaValue, setTextAreaValue] = useState('') const { data: organizations, @@ -99,19 +104,19 @@ const SupportForm = ({ setSentCategory }: SupportFormProps) => { selectedProjectFromUrl !== undefined ? selectedProjectFromUrl.ref : projects.length > 0 - ? projects[0].ref - : 'no-project' + ? projects[0].ref + : 'no-project' ) const selectedOrganizationSlug = selectedOrganizationFromUrl !== undefined ? selectedOrganizationFromUrl.slug : selectedProjectRef !== 'no-project' - ? organizations?.find((org) => { - const project = projects.find((project) => project.ref === selectedProjectRef) - return org.id === project?.organization_id - })?.slug - : organizations?.[0]?.slug + ? organizations?.find((org) => { + const project = projects.find((project) => project.ref === selectedProjectRef) + return org.id === project?.organization_id + })?.slug + : organizations?.[0]?.slug const { data: subscription, isLoading: isLoadingSubscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganizationSlug, @@ -213,6 +218,27 @@ const SupportForm = ({ setSentCategory }: SupportFormProps) => { submitSupportTicket(payload) } + const handleTextMessageChange = (event: React.ChangeEvent) => { + setTextAreaValue(event.target.value) + } + + const ipv4MigrationStrings = [ + 'ipv4', + 'ipv6', + 'supavisor', + 'pgbouncer', + '5432', + 'ENETUNREACH', + 'ECONNREFUSED', + 'P1001', + 'connect: no route to', + 'network is unreac', + 'could not translate host name', + 'address family not supported by protocol', + ] + + const ipv4MigrationStringMatched = ipv4MigrationStrings.some((str) => textAreaValue.includes(str)) + return (
    {({ resetForm, values }: any) => { @@ -288,7 +314,7 @@ const SupportForm = ({ setSentCategory }: SupportFormProps) => { }, [isReady]) return ( -
    +

    How can we help?

    @@ -316,7 +342,7 @@ const SupportForm = ({ setSentCategory }: SupportFormProps) => { {values.category !== 'Login_issues' && (
    -
    +
    {isLoadingProjects && (

    Which project is affected?

    @@ -478,7 +504,7 @@ const SupportForm = ({ setSentCategory }: SupportFormProps) => {

    )} -
    +
    )} -
    +
    handleTextMessageChange(e)} /> + {ipv4MigrationStringMatched && ( + + + Connection issues? + +

    + Having trouble connecting to your project? It could be related to our + migration from PGBouncer and IPv4. +

    +

    + Please review this GitHub discussion. It's up to date and covers many + frequently asked questions. +

    +

    + +

    +
    +
    + )}
    {['Problem', 'Database_unresponsive', 'Performance'].includes( values.category diff --git a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx index 2e0469f3c06..62b57b56565 100644 --- a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx @@ -1,4 +1,3 @@ -import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' import toast from 'react-hot-toast' import { Alert, Button, Checkbox, IconExternalLink, Modal } from 'ui' @@ -8,13 +7,12 @@ import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ConfirmationModal from 'components/ui/ConfirmationModal' import { useDatabaseColumnDeleteMutation } from 'data/database-columns/database-column-delete-mutation' -import { entityTypeKeys } from 'data/entity-types/keys' import { useTableRowDeleteAllMutation } from 'data/table-rows/table-row-delete-all-mutation' import { useTableRowDeleteMutation } from 'data/table-rows/table-row-delete-mutation' import { useTableRowTruncateMutation } from 'data/table-rows/table-row-truncate-mutation' +import { useTableDeleteMutation } from 'data/tables/table-delete-mutation' import { useGetTables } from 'data/tables/tables-query' -import { viewKeys } from 'data/views/keys' -import { useStore, useUrlState } from 'hooks' +import { useUrlState } from 'hooks' import { TableLike } from 'hooks/misc/useTable' import { noop } from 'lib/void' import { useGetImpersonatedRole } from 'state/role-impersonation-state' @@ -34,8 +32,6 @@ const DeleteConfirmationDialogs = ({ includeColumns = false, onAfterDeleteTable = noop, }: DeleteConfirmationDialogsProps) => { - const { meta } = useStore() - const queryClient = useQueryClient() const { project } = useProjectContext() const snap = useTableEditorStateSnapshot() @@ -72,12 +68,26 @@ const DeleteConfirmationDialogs = ({ const selectedColumnToDelete = snap.confirmationDialog.column removeDeletedColumnFromFiltersAndSorts(selectedColumnToDelete.name) toast.success(`Successfully deleted column "${selectedColumnToDelete.name}"`) - snap.closeConfirmationDialog() }, onError: (error) => { if (!(snap.confirmationDialog?.type === 'column')) return const selectedColumnToDelete = snap.confirmationDialog.column toast.error(`Failed to delete ${selectedColumnToDelete!.name}: ${error.message}`) + }, + onSettled: () => { + snap.closeConfirmationDialog() + }, + }) + const { mutateAsync: deleteTable } = useTableDeleteMutation({ + onSuccess: async () => { + const tables = await getTables(snap.selectedSchemaName) + onAfterDeleteTable(tables) + toast.success(`Successfully deleted table "${selectedTable?.name}"`) + }, + onError: (error) => { + toast.error(`Failed to delete ${selectedTable?.name}: ${error.message}`) + }, + onSettled: () => { snap.closeConfirmationDialog() }, }) @@ -88,10 +98,11 @@ const DeleteConfirmationDialogs = ({ snap.confirmationDialog.callback?.() } toast.success(`Successfully deleted selected row(s)`) - snap.closeConfirmationDialog() }, onError: (error) => { toast.error(`Failed to delete row: ${error.message}`) + }, + onSettled: () => { snap.closeConfirmationDialog() }, }) @@ -102,10 +113,11 @@ const DeleteConfirmationDialogs = ({ snap.confirmationDialog.callback?.() } toast.success(`Successfully deleted selected rows`) - snap.closeConfirmationDialog() }, onError: (error) => { toast.error(`Failed to delete rows: ${error.message}`) + }, + onSettled: () => { snap.closeConfirmationDialog() }, }) @@ -116,10 +128,11 @@ const DeleteConfirmationDialogs = ({ snap.confirmationDialog.callback?.() } toast.success(`Successfully deleted all rows from table`) - snap.closeConfirmationDialog() }, onError: (error) => { toast.error(`Failed to delete rows: ${error.message}`) + }, + onSettled: () => { snap.closeConfirmationDialog() }, }) @@ -157,36 +170,15 @@ const DeleteConfirmationDialogs = ({ if (!(snap.confirmationDialog?.type === 'table')) return const selectedTableToDelete = selectedTable - try { - if (selectedTableToDelete === undefined) return + if (selectedTableToDelete === undefined) return - const response: any = await meta.tables.del(selectedTableToDelete.id, isDeleteWithCascade) - if (response.error) throw response.error - - const tables = await getTables(snap.selectedSchemaName) - - await Promise.all([ - queryClient.invalidateQueries( - tableKeys.list(project?.ref, selectedTableToDelete.schema, includeColumns) - ), - queryClient.invalidateQueries(entityTypeKeys.list(projectRef)), - // invalidate all views from this schema - snap.selectedSchemaName - ? queryClient.invalidateQueries( - viewKeys.listBySchema(projectRef, snap.selectedSchemaName) - ) - : null, - // invalidate the view if there's a view with this id - queryClient.invalidateQueries(viewKeys.view(projectRef, selectedTableToDelete?.id)), - ]) - - onAfterDeleteTable(tables) - toast.success(`Successfully deleted table "${selectedTableToDelete.name}"`) - } catch (error: any) { - toast.error(`Failed to delete ${selectedTableToDelete?.name}: ${error.message}`) - } finally { - snap.closeConfirmationDialog() - } + deleteTable({ + projectRef: project?.ref!, + connectionString: project?.connectionString, + schema: selectedTableToDelete.schema, + id: selectedTableToDelete.id, + cascade: isDeleteWithCascade, + }) } const getImpersonatedRole = useGetImpersonatedRole() diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 0bc637dcf49..f5339c43d4c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -4,11 +4,18 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { MousePointer2 } from 'lucide-react' import Link from 'next/link' -import { useEffect, useRef, useState } from 'react' +import { useState } from 'react' import toast from 'react-hot-toast' -import { Button, IconAlertCircle, IconLock, Modal } from 'ui' +import { + Button, + IconLock, + IconPlusCircle, + Modal, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, +} from 'ui' -import { rlsAcknowledgedKey } from 'components/grid/constants' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import APIDocsButton from 'components/ui/APIDocsButton' import ConfirmationModal from 'components/ui/ConfirmationModal' @@ -17,19 +24,32 @@ import { useDatabasePublicationsQuery } from 'data/database-publications/databas import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation' import { useCheckPermissions, useIsFeatureEnabled } from 'hooks' import { RoleImpersonationPopover } from '../RoleImpersonationSelector' -import { useTableEditorStateSnapshot } from 'state/table-editor' + +import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' + +import { useTableUpdateMutation } from 'data/tables/table-update-mutation' export interface GridHeaderActionsProps { table: PostgresTable } const GridHeaderActions = ({ table }: GridHeaderActionsProps) => { - const snap = useTableEditorStateSnapshot() const { ref } = useParams() const { project } = useProjectContext() const realtimeEnabled = useIsFeatureEnabled('realtime:all') + const { mutate: updateTable } = useTableUpdateMutation({ + onError: (error) => { + toast.error(`Failed to toggle RLS: ${error.message}`) + }, + onSettled: () => { + closeConfirmModal() + }, + }) + const [showEnableRealtime, setShowEnableRealtime] = useState(false) + const [open, setOpen] = useState(false) + const [rlsConfirmModalOpen, setRlsConfirmModalOpen] = useState(false) const projectRef = project?.ref const { data } = useDatabasePoliciesQuery({ @@ -66,27 +86,6 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => { // This will change when we allow autogenerated API docs for schemas other than `public` const doesHaveAutoGeneratedAPIDocs = table.schema === 'public' - const rlsKey = rlsAcknowledgedKey(table.id.toString()) - const rlsAcknowledgedChannel = useRef(new BroadcastChannel(rlsKey)) - const [showRLSWarning, setShowRLSWarning] = useState( - !table.rls_enabled && localStorage.getItem(rlsKey) === 'true' - ) - - useEffect(() => { - if (rlsAcknowledgedChannel.current) { - rlsAcknowledgedChannel.current.close() - } - - rlsAcknowledgedChannel.current = new BroadcastChannel(rlsKey) - rlsAcknowledgedChannel.current.onmessage = (event) => { - if (event.data.type === 'dismiss') { - setShowRLSWarning(true) - } - } - - return () => rlsAcknowledgedChannel.current.close() - }, [rlsKey]) - const toggleRealtime = async () => { if (!project) return console.error('Project is required') if (!realtimePublication) return console.error('Unable to find realtime publication') @@ -108,6 +107,24 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => { }) } + const closeConfirmModal = () => { + setRlsConfirmModalOpen(false) + } + const onToggleRLS = async () => { + const payload = { + id: table.id, + rls_enabled: !table.rls_enabled, + } + + updateTable({ + projectRef: project?.ref!, + connectionString: project?.connectionString, + id: payload.id, + schema: table.schema, + payload: payload, + }) + } + return ( <>
    @@ -135,29 +152,91 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => { )} - - {(table.rls_enabled || showRLSWarning) && ( - + {table.rls_enabled ? ( +
    + {policies.length < 1 ? ( + + + + + + + + + +
    +
    +

    RLS is enabled for this table, but no policies are set.

    +

    + Select queries will return an empty array of results. +

    +
    +
    +
    +
    +
    + ) : ( + + + + )} +
    + ) : ( + setOpen(!open)} modal={false}> + + + + +

    + Row Level Security (RLS) +

    +
    +

    + You can restrict and control who can read, write and update data in this table + using Row Level Security. +

    +

    + With RLS enabled, anonymous users will not be able to read/write data in the + table. +

    +
    + +
    +
    +
    +
    )} @@ -209,6 +288,17 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => { )} + + ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx index c2cc35bd3c6..bf1786702e2 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ActionBar.tsx @@ -36,13 +36,7 @@ const ActionBar = ({ return (
    - @@ -51,7 +45,6 @@ const ActionBar = ({ {applyFunction !== undefined ? ( // Old solution, necessary when loading is handled by this component itself - ) +const ColumnForeignKey = ({ column, relations, closePanel }: ColumnForeignKeyProps) => { + const { project } = useProjectContext() + const snap = useTableEditorStateSnapshot() + const { data } = useForeignKeyConstraintsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: column.schema, + }) + + const getRelationStatus = (fk: ForeignKey) => { + const existingRelation = (data ?? []).find((x) => x.id === fk.id) + const stateRelation = relations.find((x) => x.id === fk.id) + + if (stateRelation?.toRemove) return 'REMOVE' + if (existingRelation === undefined && stateRelation !== undefined) return 'ADD' + if (existingRelation !== undefined && stateRelation !== undefined) { + const hasUpdated = checkIfRelationChanged(existingRelation, stateRelation) + if (hasUpdated) return 'UPDATE' + else return undefined + } } - const foreignKeyUIState = getForeignKeyUIState(originalForeignKey, column?.foreignKey) - - switch (foreignKeyUIState) { - case 'Add': - return ( - - ) - case 'Remove': - return ( - - ) - case 'Update': - return ( - - ) - case 'Info': - return ( - - ) - default: - return
    - } + return ( +
    +
    + {relations.map((relation) => { + const status = getRelationStatus(relation) + return ( + + ) + })} +
    + + + {relations.length > 0 ? ( + <> + These are the foreign keys which the column{' '} + {column.name} is included + + ) : column.isNewColumn ? ( + <>Foreign keys can be added after creating the column + ) : ( + <> + Foreign keys which include the column {column.name}{' '} + will be shown here + + )} + + + All foreign keys on the table can be managed by editing the{' '} + + {column.schema}.{column.table} + {' '} + table. + + + +
    + ) } export default ColumnForeignKey - -// Just to break the components into smaller ones, we can create separate files for these - -interface ColumnForeignKeyInformationProps { - columnName: string - foreignKey?: ExtendedPostgresRelationship - onSelectEditRelation: () => void - onSelectRemoveRelation: () => void -} - -const ColumnForeignKeyInformation = ({ - columnName, - foreignKey, - onSelectEditRelation, - onSelectRemoveRelation, -}: ColumnForeignKeyInformationProps) => { - const updateAction = getForeignKeyCascadeAction(foreignKey?.update_action) - const deletionAction = getForeignKeyCascadeAction(foreignKey?.deletion_action) - - return ( - -
    -

    - This column has the following foreign key relation: -

    -
    -

    {columnName}

    - -

    - {foreignKey?.target_table_schema}.{foreignKey?.target_table_name}. - {foreignKey?.target_column_name} -

    -
    -
    - {updateAction !== undefined && On update: {updateAction}} - {deletionAction !== undefined && ( - On delete: {deletionAction} - )} -
    -
    -
    - - -
    -
    - } - /> - ) -} - -interface ColumnForeignKeyAddedProps { - columnName: string - foreignKey?: ExtendedPostgresRelationship - onSelectEditRelation: () => void - onSelectRemoveRelation: () => void -} - -const ColumnForeignKeyAdded = ({ - columnName, - foreignKey, - onSelectEditRelation, - onSelectRemoveRelation, -}: ColumnForeignKeyAddedProps) => { - const updateAction = getForeignKeyCascadeAction(foreignKey?.update_action) - const deletionAction = getForeignKeyCascadeAction(foreignKey?.deletion_action) - - return ( - -
    - - The following foreign key relation will be added: - -
    -

    0 ? 'text-code font-mono text-xs' : '' - } max-w-xs truncate`} - > - {columnName || 'This column'} -

    - -

    - {foreignKey?.target_table_schema}.{foreignKey?.target_table_name}. - {foreignKey?.target_column_name} -

    -
    -
    - {updateAction !== undefined && On update: {updateAction}} - {deletionAction !== undefined && ( - On delete: {deletionAction} - )} -
    -
    -
    - - -
    -
    - } - /> - ) -} - -interface ColumnForeignKeyRemovedProps { - columnName: string - originalForeignKey?: ExtendedPostgresRelationship - onSelectEditRelation: () => void - onSelectCancelRemoveRelation: () => void -} - -const ColumnForeignKeyRemoved = ({ - columnName, - originalForeignKey, - onSelectEditRelation, - onSelectCancelRemoveRelation, -}: ColumnForeignKeyRemovedProps) => { - return ( - -
    -

    - The following foreign key relation will be{' '} - removed: -

    -
    - {columnName} - - - {originalForeignKey?.target_table_schema}.{originalForeignKey?.target_table_name}. - {originalForeignKey?.target_column_name} - -
    -
    -
    - - -
    -
    - } - /> - ) -} - -interface ColumnForeignKeyUpdatedProps { - columnName: string - originalForeignKey?: ExtendedPostgresRelationship - updatedForeignKey?: ExtendedPostgresRelationship - onSelectEditRelation: () => void - onSelectRemoveRelation: () => void -} - -const ColumnForeignKeyUpdated = ({ - columnName, - originalForeignKey, - updatedForeignKey, - onSelectEditRelation, - onSelectRemoveRelation, -}: ColumnForeignKeyUpdatedProps) => { - const originalKey = `${originalForeignKey?.target_table_schema}.${originalForeignKey?.target_table_name}.${originalForeignKey?.target_column_name}` - const updatedKey = `${updatedForeignKey?.target_table_schema}.${updatedForeignKey?.target_table_name}.${updatedForeignKey?.target_column_name}` - - const originalUpdateAction = getForeignKeyCascadeAction(originalForeignKey?.update_action) - const updatedUpdateAction = getForeignKeyCascadeAction(updatedForeignKey?.update_action) - - const originalDeletionAction = getForeignKeyCascadeAction(originalForeignKey?.deletion_action) - const updatedDeletionAction = getForeignKeyCascadeAction(updatedForeignKey?.deletion_action) - - return ( - -
    -

    - The foreign key relation will be updated as such: -

    -
    - {columnName} - -
    - {originalKey !== updatedKey && ( -

    - {originalKey} -

    - )} - {updatedKey} -
    -
    -
    - {originalDeletionAction !== updatedDeletionAction && ( - <> - - On delete: {originalDeletionAction ?? 'No action'} - - On delete: {updatedDeletionAction ?? 'No action'} - - )} -
    -
    - {originalUpdateAction !== updatedUpdateAction && ( - <> - - On update: {originalUpdateAction ?? 'No action'} - - On update: {updatedUpdateAction ?? 'No action'} - - )} -
    -
    -
    - - -
    -
    - } - /> - ) -} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx index ada75e45011..c3e4607a0bc 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx @@ -1,3 +1,4 @@ +import * as Tooltip from '@radix-ui/react-tooltip' import type { PostgresType } from '@supabase/postgres-meta' import { noop } from 'lodash' import Link from 'next/link' @@ -6,6 +7,7 @@ import { Alert, Button, IconCalendar, + IconCircle, IconExternalLink, IconHash, IconToggleRight, @@ -54,25 +56,6 @@ const ColumnType = ({ const isAvailableType = value ? availableTypes.includes(value) : true const recommendation = RECOMMENDED_ALTERNATIVE_DATA_TYPE[value] - if (!isAvailableType) { - return ( - - ) - } - const inferIcon = (type: string) => { switch (type) { case 'number': @@ -90,10 +73,86 @@ const ColumnType = ({ case 'bool': return default: - return
    + return } } + if (!isAvailableType) { + return ( + + + x.name === value)?.type ?? '')} + value={value} + descriptionText={ + showLabel + ? 'Custom non-native psql data types currently cannot be changed to a different data type via Supabase Studio' + : '' + } + /> + + {!showLabel && ( + + + +
    + + Custom non-native psql data types currently cannot be changed to a different data + type via Supabase Studio + +
    +
    +
    + )} +
    + ) + } + + if (disabled && !showLabel) { + return ( + + + x.name === value)?.type ?? '')} + value={value} + /> + + {!showLabel && description && ( + + + +
    + {description} +
    +
    +
    + )} +
    + ) + } + return (
    - diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx index ea075246638..4b56050abd6 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx @@ -1,212 +1,216 @@ -import type { PostgresColumn, PostgresSchema, PostgresTable } from '@supabase/postgres-meta' -import { find, get, isEmpty, sortBy } from 'lodash' -import { useEffect, useState } from 'react' +import { PostgresTable } from '@supabase/postgres-meta' +import { sortBy } from 'lodash' +import { Table } from 'lucide-react' +import { Fragment, useEffect, useState } from 'react' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + IconArrowRight, + IconDatabase, + IconExternalLink, + IconHelpCircle, + IconX, + Listbox, + SidePanel, +} from 'ui' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import InformationBox from 'components/ui/InformationBox' import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' import { useSchemasQuery } from 'data/database/schemas-query' import { useTablesQuery } from 'data/tables/tables-query' -import { Dictionary } from 'types' -import { - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, - IconAlertCircle, - IconAlertTriangle, - IconDatabase, - IconHelpCircle, - Input, - Listbox, - SidePanel, -} from 'ui' +import { uuidv4 } from 'lib/helpers' +import { useTableEditorStateSnapshot } from 'state/table-editor' import ActionBar from '../ActionBar' -import { ColumnField } from '../SidePanelEditor.types' +import { NUMERICAL_TYPES, TEXT_TYPES } from '../SidePanelEditor.constants' import { FOREIGN_KEY_CASCADE_OPTIONS } from './ForeignKeySelector.constants' import { ForeignKey } from './ForeignKeySelector.types' import { generateCascadeActionDescription } from './ForeignKeySelector.utils' -interface ForeignKeySelectorProps { - column: ColumnField - metadata?: any - visible: boolean - closePanel: () => void - saveChanges: ( - value: - | { - table: PostgresTable - column: PostgresColumn - deletionAction: string - updateAction: string - } - | undefined - ) => void +const EMPTY_STATE: ForeignKey = { + id: undefined, + schema: 'public', + table: '', + columns: [] as { source: string; target: string }[], + deletionAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, + updateAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, } -const ForeignKeySelector = ({ - column, - visible = false, - closePanel, - saveChanges, +interface ForeignKeySelectorProps { + visible: boolean + table: { id: number; name: string; columns: any[] } + foreignKey?: ForeignKey + onClose: () => void + onSaveRelation: (fk: ForeignKey) => void +} + +export const ForeignKeySelector = ({ + visible, + table, + foreignKey, + onClose, + onSaveRelation, }: ForeignKeySelectorProps) => { const { project } = useProjectContext() - const [errors, setErrors] = useState({}) - const [selectedForeignKey, setSelectedForeignKey] = useState({ - schema: 'public', - table: '', - column: '', - deletionAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - updateAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - }) + const snap = useTableEditorStateSnapshot() + + const [fk, setFk] = useState(EMPTY_STATE) + const [errors, setErrors] = useState<{ columns?: string; types?: any[]; typeNotice?: any[] }>({}) + const hasTypeErrors = (errors?.types ?? []).filter((x: any) => x !== undefined).length > 0 + const hasTypeNotices = (errors?.typeNotice ?? []).filter((x: any) => x !== undefined).length > 0 const { data: schemas } = useSchemasQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) - // change the return type because of the includeColumns param const { data: tables } = useTablesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, - schema: selectedForeignKey.schema, + schema: fk.schema, includeColumns: true, }) - const foreignKey = column?.foreignKey - const selectedTable: PostgresTable | undefined = find(tables, { - name: selectedForeignKey?.table, - schema: selectedForeignKey?.schema, - }) - const selectedColumn: PostgresColumn | undefined = find(selectedTable?.columns ?? [], { - name: selectedForeignKey?.column, - }) + const selectedTable = (tables ?? []).find((x) => x.name === fk.table && x.schema === fk.schema) - useEffect(() => { - // Reset the state of the side panel - if (visible) { - setErrors({}) - - if (foreignKey) { - setSelectedForeignKey({ - schema: foreignKey.target_table_schema, - table: foreignKey.target_table_name, - column: foreignKey.target_column_name, - deletionAction: foreignKey.deletion_action, - updateAction: foreignKey.update_action, - }) - } else { - setSelectedForeignKey({ - schema: 'public', - table: '', - column: '', - deletionAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - updateAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - }) - } - } - }, [visible]) + const disableApply = selectedTable === undefined || hasTypeErrors const updateSelectedSchema = (schema: string) => { - const updatedForeignKey = { - schema, - table: '', - column: '', - deletionAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - updateAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - } - setSelectedForeignKey(updatedForeignKey) + const updatedFk = { ...EMPTY_STATE, schema } + setFk(updatedFk) } const updateSelectedTable = (tableId: number) => { setErrors({}) if (!tableId) { - return setSelectedForeignKey({ - schema: '', - table: '', - column: '', - deletionAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - updateAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - }) + return setFk({ ...EMPTY_STATE, schema: fk.schema, columns: [{ source: '', target: '' }] }) } - - const table = find(tables, { id: tableId }) - if (table) { - const primaryColumn = table.primary_keys[0]?.name - const firstColumn = table.columns?.length ? table.columns[0].name : undefined - - setSelectedForeignKey({ + const table = (tables ?? []).find((x) => x.id === tableId) + if (table) + setFk({ + ...EMPTY_STATE, + tableId: table.id, schema: table.schema, table: table.name, - column: primaryColumn ?? firstColumn, - deletionAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - updateAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, + columns: [{ source: '', target: '' }], }) - } } - const updateSelectedColumn = (columnId: string) => { - setErrors({}) - const column = find(selectedTable?.columns, { id: columnId }) - if (column) { - const updatedForeignKey = { ...selectedForeignKey, column: column.name } as ForeignKey - setSelectedForeignKey(updatedForeignKey) - } + const addColumn = () => { + setFk({ ...fk, columns: fk.columns.concat([{ source: '', target: '' }]) }) + } + + const onRemoveColumn = (idx: number) => { + setFk({ ...fk, columns: fk.columns.filter((_, i) => i !== idx) }) + } + + const updateSelectedColumn = (idx: number, key: 'target' | 'source', value: string) => { + const updatedRelations = fk.columns.map((x, i) => { + if (i === idx) { + if (key === 'target') { + const targetType = selectedTable?.columns?.find((col) => col.name === value)?.format + return { ...x, [key]: value, targetType } + } else { + const sourceType = table.columns.find((col) => col.name === value)?.format as string + return { ...x, [key]: value, sourceType } + } + } else { + return x + } + }) + setFk({ ...fk, columns: updatedRelations }) } const updateCascadeAction = (action: 'updateAction' | 'deletionAction', value: string) => { setErrors({}) - setSelectedForeignKey({ ...selectedForeignKey, [action]: value }) + setFk({ ...fk, [action]: value }) } - const onSaveChanges = (resolve: () => void) => { - const errors = {} as Dictionary - if (!selectedForeignKey?.table) { - errors['table'] = 'Please select a table' + const validateSelection = (resolve: any) => { + const errors: any = {} + const incompleteColumns = fk.columns.filter( + (column) => column.source === '' || column.target === '' + ) + if (incompleteColumns.length > 0) errors['columns'] = 'Please ensure that columns are selected' + + if (Object.keys(errors).length > 0) { + setErrors(errors) + resolve() + return + } else { + if (fk.table !== '') onSaveRelation(fk) + onClose() + resolve() } - if (selectedForeignKey?.table && !selectedForeignKey.column) { - errors['column'] = `The table ${selectedForeignKey.table} has no columns` - } - setErrors(errors) - if (isEmpty(errors)) { - if (!selectedTable || !selectedColumn) { - // Remove foreign key since no table selected - saveChanges(undefined) - } else { - saveChanges({ - table: selectedTable, - column: selectedColumn, - deletionAction: selectedForeignKey.deletionAction, - updateAction: selectedForeignKey.updateAction, - }) + } + + const validateType = () => { + const typeNotice: any = [] + const typeErrors: any = [] + + fk.columns.forEach((column) => { + const { source, target, sourceType: sType, targetType: tType } = column + const sourceColumn = table.columns.find((col) => col.name === source) + const sourceType = sType ?? sourceColumn?.format + const targetType = + tType ?? selectedTable?.columns?.find((col) => col.name === target)?.format ?? '' + + // [Joshen] Doing this way so that its more readable + // If either source or target not selected yet, thats okay + if (source === '' || target === '') { + return typeErrors.push(undefined) } - } - resolve() + + if (sourceColumn?.isNewColumn && targetType !== '') { + return typeNotice.push({ sourceType, targetType }) + } + + // If source and target are in the same type of data types, thats okay + if ( + (NUMERICAL_TYPES.includes(sourceType) && NUMERICAL_TYPES.includes(targetType)) || + (TEXT_TYPES.includes(sourceType) && TEXT_TYPES.includes(targetType)) || + (TEXT_TYPES.includes(sourceType) && TEXT_TYPES.includes(targetType)) || + (sourceType === 'uuid' && targetType === 'uuid') + ) { + return typeErrors.push(undefined) + } + + // Otherwise just check if the format is equal to each other + if (sourceType === targetType) { + return typeErrors.push(undefined) + } + + typeErrors.push({ sourceType, targetType }) + }) + + setErrors({ types: typeErrors, typeNotice }) } - const matchingColumnTypes = selectedColumn?.format === column?.format + useEffect(() => { + if (visible) { + if (foreignKey !== undefined) setFk(foreignKey) + else setFk({ ...EMPTY_STATE, id: uuidv4() }) + } + }, [visible]) + + useEffect(() => { + if (visible) validateType() + }, [fk]) return ( - Edit foreign key relation{' '} - {column?.name && ( - <> - for {get(column, ['name'], '')} - - )} - - } + onCancel={onClose} + className="max-w-[480px]" + header={`${foreignKey === undefined ? 'Add' : 'Manage'} foreign key relationship${foreignKey === undefined ? ' to' : 's for'} ${table.name.length > 0 ? table.name : 'new table'}`} customFooter={ validateSelection(resolve)} /> } > @@ -225,11 +229,10 @@ const ForeignKeySelector = ({ updateSelectedSchema(value)} > - {schemas?.map((schema: PostgresSchema) => { + {schemas?.map((schema) => { return ( updateSelectedTable(Number(value))} > @@ -259,7 +261,12 @@ const ForeignKeySelector = ({ {sortBy(tables, ['schema']).map((table) => { return ( - +
    } + >
    {/* For aria searching to target the table name instead of schema */} {table.name} @@ -271,86 +278,150 @@ const ForeignKeySelector = ({ })} - {selectedForeignKey?.table && ( + {fk.schema && fk.table && ( <> - {(selectedTable?.columns ?? []).length === 0 ? ( - - Select a column from{' '} - - {selectedForeignKey?.schema}.{selectedForeignKey?.table} - {' '} - to reference to -
    - } - error={errors.column} - placeholder="This table has no columns available" - /> - ) : ( - - Select a column from{' '} - - {selectedForeignKey?.schema}.{selectedForeignKey?.table} - {' '} - to reference to - - } - error={errors.column} - onChange={(value: string) => updateSelectedColumn(value)} - > - {(selectedTable?.columns ?? []).map((column: PostgresColumn) => ( - -
    - {column.name} - {column.format} +
    + +
    +
    + {snap.selectedSchemaName}. + {table.name.length > 0 ? table.name : '[unnamed table]'} +
    +
    + {fk.schema}.{fk.table} +
    + {fk.columns.length === 0 && ( + + + There are no foreign key relations between the tables + + + )} + {fk.columns.map((_, idx) => ( + +
    + updateSelectedColumn(idx, 'source', value)} + > + + --- + + {(table?.columns ?? []) + .filter((x) => x.name.length !== 0) + .map((column) => ( + +
    + {column.name} + + {column.format === '' ? '-' : column.format} + +
    +
    + ))} +
    - +
    + +
    +
    + updateSelectedColumn(idx, 'target', value)} + > + + --- + + {(selectedTable?.columns ?? []).map((column) => ( + +
    + {column.name} + {column.format} +
    +
    + ))} +
    +
    +
    +
    +
    ))} - - )} - {!matchingColumnTypes && !column?.format && ( - - - - The referenced column's type will be updated to {selectedColumn?.data_type} - - - The referenced column - {column?.name && {column.name}} - - {' '} - must match the type of the selected foreign column when creating a foreign key - relationship. - - - - )} - {!matchingColumnTypes && column?.format && ( - - - Column types do not match - - The referenced column - {column?.name && {column.name}} - is of type - {column.format} - while the selected foreign column - - {selectedTable?.name}.{selectedColumn?.name} - - has - {selectedColumn?.data_type}type. These two - columns can't be referenced until they are of the same type. - - - )} +
    +
    + + {errors.columns &&

    {errors.columns}

    } + {hasTypeErrors && ( + + Column types do not match + + The following columns cannot be referenced as they are not of the same type: + +
      + {(errors?.types ?? []).map((x, idx: number) => { + if (x === undefined) return null + return ( +
    • + {fk.columns[idx]?.source} ( + {x.sourceType}) and{' '} + {fk.columns[idx]?.target}( + {x.targetType}) +
    • + ) + })} +
    +
    + )} + {hasTypeNotices && ( + + Column types will be updated + + The following columns will have their types updated to match their + referenced column + +
      + {(errors?.typeNotice ?? []).map((x, idx: number) => { + if (x === undefined) return null + return ( +
    • +
      + {fk.columns[idx]?.source}{' '} + {x.targetType} +
      +
    • + ) + })} +
    +
    + )} +
    +
    @@ -392,18 +463,17 @@ const ForeignKeySelector = ({ {generateCascadeActionDescription( 'update', - selectedForeignKey.updateAction, - `${selectedForeignKey.schema}.${selectedForeignKey.table}` + fk.updateAction, + `${fk.schema}.${fk.table}` )}

    } - error={errors.column} onChange={(value: string) => updateCascadeAction('updateAction', value)} > {FOREIGN_KEY_CASCADE_OPTIONS.filter((option) => @@ -417,30 +487,32 @@ const ForeignKeySelector = ({ }> + + Documentation + + + } descriptionText={ <>

    {generateCascadeActionDescription( 'delete', - selectedForeignKey.deletionAction, - `${selectedForeignKey.schema}.${selectedForeignKey.table}` + fk.deletionAction, + `${fk.schema}.${fk.table}` )}

    -

    - - Learn more about cascade deletes - -

    } - error={errors.column} onChange={(value: string) => updateCascadeAction('deletionAction', value)} > {FOREIGN_KEY_CASCADE_OPTIONS.map((option) => ( @@ -456,5 +528,3 @@ const ForeignKeySelector = ({ ) } - -export default ForeignKeySelector diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types.ts index 7dcc9d8d1fe..ac3705a3784 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types.ts @@ -1,7 +1,12 @@ export interface ForeignKey { + id?: number | string + name?: string + tableId?: number + schema: string table: string - column?: string + columns: { source: string; sourceType?: string; target: string; targetType?: string }[] deletionAction: string updateAction: string + toRemove?: boolean } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils.tsx index 16226850d80..5b5352ac721 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils.tsx @@ -1,7 +1,25 @@ import * as Tooltip from '@radix-ui/react-tooltip' -import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' import { IconHelpCircle } from 'ui' + +import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' +import { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query' import { getForeignKeyCascadeAction } from '../ColumnEditor/ColumnEditor.utils' +import { ForeignKey } from './ForeignKeySelector.types' + +export const formatForeignKeys = (fks: ForeignKeyConstraint[]): ForeignKey[] => { + return fks.map((x) => { + return { + id: x.id, + name: x.constraint_name, + tableId: x.target_id, + schema: x.target_schema, + table: x.target_table, + columns: x.source_columns.map((y, i) => ({ source: y, target: x.target_columns[i] })), + deletionAction: x.deletion_action, + updateAction: x.update_action, + } + }) +} export const generateCascadeActionDescription = ( action: 'update' | 'delete', diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx index 1def91748b0..7e925616466 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx @@ -164,7 +164,7 @@ const ForeignRowSelector = ({ onSelect(row[columnName ?? ''])} + onRowSelect={(row) => onSelect(row[columnName?.[0] ?? ''])} /> ) : (
    diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx index b38a841a4fa..02889333aa6 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx @@ -41,8 +41,8 @@ const InputField = ({ field.defaultValue === null ? '' : typeof field.defaultValue === 'string' && field.defaultValue.length === 0 - ? 'EMPTY' - : `Default: ${field.defaultValue}` + ? 'EMPTY' + : `Default: ${field.defaultValue}` } onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })} /> @@ -129,10 +129,10 @@ const InputField = ({ field.value === null && field.defaultValue === null ? 'NULL' : field.value === '' - ? 'EMPTY' - : typeof field.defaultValue === 'string' && field.defaultValue.length === 0 - ? 'EMPTY' - : `NULL (Default: ${field.defaultValue})` + ? 'EMPTY' + : typeof field.defaultValue === 'string' && field.defaultValue.length === 0 + ? 'EMPTY' + : `NULL (Default: ${field.defaultValue})` } actions={
    @@ -253,8 +253,8 @@ const InputField = ({ field.isIdentity ? 'Automatically generated as identity' : field.defaultValue !== null - ? `Default: ${field.defaultValue}` - : 'NULL' + ? `Default: ${field.defaultValue}` + : 'NULL' } disabled={!isEditable} onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx index da215279651..38bd01beb42 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx @@ -72,8 +72,8 @@ const DrilldownPane = ({ pane, jsonData, activeKey, onSelectKey = noop }: Drilld {isNull(jsonData[key]) ? 'null' : typeof jsonData[key] === 'string' - ? `"${jsonData[key]}"` - : jsonData[key].toString()} + ? `"${jsonData[key]}"` + : jsonData[key].toString()}

    ))} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonCodeEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonCodeEditor.tsx index f34d103d112..063d87d0900 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonCodeEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonCodeEditor.tsx @@ -1,4 +1,4 @@ -import Editor, { OnChange, OnMount } from '@monaco-editor/react' +import Editor, { OnChange } from '@monaco-editor/react' import { noop } from 'lodash' // [Joshen] Should just use CodeEditor instead of declaring Editor here so that all the mount logic is consistent @@ -10,25 +10,13 @@ interface JsonEditorProps { } const JsonEditor = ({ value = '', readOnly = false, onInputChange = noop }: JsonEditorProps) => { - const onMount: OnMount = (editor) => { - editor.changeViewZones((accessor) => { - accessor.addZone({ - afterLineNumber: 0, - heightInPx: 4, - domNode: document.createElement('div'), - }) - }) - } - - const Loading = () =>

    Loading

    - return ( } + loading={

    Loading

    } options={{ readOnly, tabSize: 2, @@ -40,7 +28,16 @@ const JsonEditor = ({ value = '', readOnly = false, onInputChange = noop }: Json fixedOverflowWidgets: true, lineNumbersMinChars: 4, }} - onMount={onMount} + onMount={(editor) => { + editor.changeViewZones((accessor) => { + accessor.addZone({ + afterLineNumber: 0, + heightInPx: 4, + domNode: document.createElement('div'), + }) + }) + editor.focus() + }} onChange={onInputChange} /> ) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonEditor.tsx index 8daaa6d1b27..28cf419760d 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonEditor.tsx @@ -1,10 +1,10 @@ +import * as Tooltip from '@radix-ui/react-tooltip' import { useEffect, useState } from 'react' import { Button, IconAlignLeft, SidePanel } from 'ui' -import * as Tooltip from '@radix-ui/react-tooltip' import TwoOptionToggle from 'components/ui/TwoOptionToggle' -import { useStore } from 'hooks' -import { minifyJSON, prettifyJSON, tryParseJson } from 'lib/helpers' +import { minifyJSON, prettifyJSON, tryParseJson, removeJSONTrailingComma } from 'lib/helpers' +import toast from 'react-hot-toast' import ActionBar from '../../ActionBar' import DrilldownViewer from './DrilldownViewer' import JsonCodeEditor from './JsonCodeEditor' @@ -17,7 +17,7 @@ interface JsonEditProps { applyButtonLabel?: string readOnly?: boolean closePanel: () => void - onSaveJSON: (value: string | number | null) => void + onSaveJSON: (value: string | number | null, resolve: () => void) => void } const JsonEdit = ({ @@ -30,7 +30,6 @@ const JsonEdit = ({ closePanel, onSaveJSON, }: JsonEditProps) => { - const { ui } = useStore() const [view, setView] = useState<'edit' | 'view'>('edit') const [jsonStr, setJsonStr] = useState('') @@ -43,26 +42,15 @@ const JsonEdit = ({ const validateJSON = async (resolve: () => void) => { try { - const minifiedJSON = minifyJSON(jsonStr) - if (onSaveJSON) onSaveJSON(minifiedJSON) + const newJsonStr = removeJSONTrailingComma(jsonStr) + const minifiedJSON = minifyJSON(newJsonStr) + if (onSaveJSON) onSaveJSON(minifiedJSON, resolve) } catch (error: any) { - const message = error.message - ? `Error: ${error.message}` - : 'JSON seems to have an invalid structure.' - ui.setNotification({ category: 'error', message, duration: 4000 }) - } finally { resolve() + toast.error('JSON seems to have an invalid structure.') } } - function onInputChange(value: string | undefined) { - setJsonStr(value ?? '') - } - - function onToggleClick(option: 'edit' | 'view') { - setView(option) - } - function prettify() { const res = prettifyJSON(jsonStr) setJsonStr(res) @@ -112,7 +100,7 @@ const JsonEdit = ({ options={['view', 'edit']} activeOption={view} borderOverride="border-gray-500" - onClickOption={onToggleClick} + onClickOption={setView} />
    @@ -135,7 +123,7 @@ const JsonEdit = ({ setJsonStr(val ?? '')} value={jsonStr.toString()} /> diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts index 1ce268496bd..43e863e68f2 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts @@ -25,14 +25,14 @@ export const generateRowFields = ( isUndefined(row) && TEXT_TYPES.includes(column.format) ? null : isUndefined(row) && column.format === 'bool' && !column.is_nullable - ? column.default_value - : isUndefined(row) && column.format === 'bool' && column.is_nullable - ? 'null' - : isUndefined(row) - ? '' - : DATETIME_TYPES.includes(column.format) - ? convertPostgresDatetimeToInputDatetime(column.format, row[column.name]) - : parseValue(row[column.name], column.format) + ? column.default_value + : isUndefined(row) && column.format === 'bool' && column.is_nullable + ? 'null' + : isUndefined(row) + ? '' + : DATETIME_TYPES.includes(column.format) + ? convertPostgresDatetimeToInputDatetime(column.format, row[column.name]) + : parseValue(row[column.name], column.format) const foreignKey = find(relationships, (relationship) => { return ( @@ -164,8 +164,8 @@ export const generateRowObjectFromFields = ( const value = TEXT_TYPES.includes(field.format) ? field.value : (field?.value ?? '').length === 0 - ? null - : field.value + ? null + : field.value if (isArray && value !== null) { rowObject[field.name] = tryParseJson(value) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx new file mode 100644 index 00000000000..0fc87addb3e --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx @@ -0,0 +1,117 @@ +import remarkGfm from 'remark-gfm' +import { Editor } from '@monaco-editor/react' +import { useEffect, useState } from 'react' +import { IconLoader, SidePanel } from 'ui' + +import { useTableEditorStateSnapshot } from 'state/table-editor' +import ActionBar from '../ActionBar' +import TwoOptionToggle from 'components/ui/TwoOptionToggle' +import { Markdown } from 'components/interfaces/Markdown' + +interface TextEditorProps { + visible: boolean + readOnly?: boolean + closePanel: () => void + onSaveField: (value: string, resolve: () => void) => void +} + +export const TextEditor = ({ + visible, + readOnly = false, + closePanel, + onSaveField, +}: TextEditorProps) => { + const [strValue, setStrValue] = useState('') + const [view, setView] = useState<'edit' | 'view'>('edit') + + const snap = useTableEditorStateSnapshot() + const column = (snap.sidePanel?.type === 'cell' && snap.sidePanel.value?.column) ?? '' + const row = (snap.sidePanel?.type === 'cell' && snap.sidePanel.value?.row) ?? {} + const value = row[column as keyof typeof row] as unknown as string + + useEffect(() => { + if (visible) { + setView('edit') + setStrValue(value) + } + }, [visible]) + + const saveValue = (resolve: () => void) => { + if (onSaveField) onSaveField(strValue, resolve) + } + + return ( + closePanel()} + header={ +
    +

    + {readOnly ? 'Viewing' : 'Editing'} value of: {column} +

    + +
    + } + customFooter={ + + } + > +
    + {view === 'edit' ? ( +
    + } + options={{ + readOnly, + tabSize: 2, + fontSize: 13, + minimap: { + enabled: false, + }, + wordWrap: 'on', + fixedOverflowWidgets: true, + lineNumbersMinChars: 4, + }} + onMount={(editor) => { + editor.changeViewZones((accessor) => { + accessor.addZone({ + afterLineNumber: 0, + heightInPx: 4, + domNode: document.createElement('div'), + }) + }) + editor.focus() + }} + onChange={(val) => setStrValue(val ?? '')} + /> +
    + ) : ( + + + + )} +
    +
    + ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor.tsx index 9eeb00728bc..d072fe906c4 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SchemaEditor.tsx @@ -9,10 +9,9 @@ import ActionBar from './ActionBar' interface SchemaEditorProps { visible: boolean closePanel: () => void - saveChanges: (resolve: any) => void } -const SchemaEditor = ({ visible, closePanel, saveChanges }: SchemaEditorProps) => { +const SchemaEditor = ({ visible, closePanel }: SchemaEditorProps) => { const { project } = useProjectContext() const [errors, setErrors] = useState<{ name?: string }>({ name: undefined }) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants.ts index e9d7dd63492..47140892b7f 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants.ts @@ -1,7 +1,15 @@ import { sortBy, concat } from 'lodash' import { PostgresDataTypeOption } from './SidePanelEditor.types' -const NUMERICAL_TYPES = ['int2', 'int4', 'int8', 'float4', 'float8', 'numeric', 'double precision'] +export const NUMERICAL_TYPES = [ + 'int2', + 'int4', + 'int8', + 'float4', + 'float8', + 'numeric', + 'double precision', +] export const JSON_TYPES = ['json', 'jsonb'] export const TEXT_TYPES = ['text', 'varchar'] diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 1748379fdbd..bfacdc7e05e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -10,6 +10,7 @@ import ConfirmationModal from 'components/ui/ConfirmationModal' import { useDatabasePublicationCreateMutation } from 'data/database-publications/database-publications-create-mutation' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation' +import { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query' import { entityTypeKeys } from 'data/entity-types/keys' import { sqlKeys } from 'data/sql/keys' import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation' @@ -21,16 +22,21 @@ import { useGetImpersonatedRole } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' import { Dictionary } from 'types' import { ColumnEditor, RowEditor, SpreadsheetImport, TableEditor } from '.' +import { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types' import ForeignRowSelector from './RowEditor/ForeignRowSelector/ForeignRowSelector' import JsonEdit from './RowEditor/JsonEditor/JsonEditor' +import { TextEditor } from './RowEditor/TextEditor' import SchemaEditor from './SchemaEditor' +import { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './SidePanelEditor.types' import { - ColumnField, - CreateColumnPayload, - ExtendedPostgresRelationship, - UpdateColumnPayload, -} from './SidePanelEditor.types' -import { createColumn, updateColumn } from './SidePanelEditor.utils' + createColumn, + createTable, + duplicateTable, + insertRowsViaSpreadsheet, + insertTableRows, + updateColumn, + updateTable, +} from './SidePanelEditor.utils' import { ImportContent } from './TableEditor/TableEditor.types' export interface SidePanelEditorProps { @@ -52,7 +58,7 @@ const SidePanelEditor = ({ const snap = useTableEditorStateSnapshot() const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] }) - const { meta, ui } = useStore() + const { ui } = useStore() const queryClient = useQueryClient() const { project } = useProjectContext() @@ -129,11 +135,9 @@ const SidePanelEditor = ({ } } else { saveRowError = new Error('No primary key') - ui.setNotification({ - category: 'error', - message: - "We can't make changes to this table because there is no primary key. Please create a primary key and try again.", - }) + toast.error( + "We can't make changes to this table because there is no primary key. Please create a primary key and try again." + ) } } } @@ -145,25 +149,36 @@ const SidePanelEditor = ({ } } - const onSaveJSON = async (value: string | number | null) => { - if (selectedTable === undefined || !(snap.sidePanel?.type === 'json')) return - const selectedValueForJsonEdit = snap.sidePanel.jsonValue + const onSaveColumnValue = async (value: string | number | null, resolve: () => void) => { + if (selectedTable === undefined) return - try { + let payload + let configuration + const isNewRecord = false + const identifiers = {} as Dictionary + if (snap.sidePanel?.type === 'json') { + const selectedValueForJsonEdit = snap.sidePanel.jsonValue const { row, column } = selectedValueForJsonEdit - const payload = { [column]: value === null ? null : JSON.parse(value as any) } - const identifiers = {} as Dictionary + payload = { [column]: value === null ? null : JSON.parse(value as any) } selectedTable.primary_keys.forEach((column) => (identifiers[column.name] = row![column.name])) + configuration = { identifiers, rowIdx: row.idx } + } else if (snap.sidePanel?.type === 'cell') { + const column = snap.sidePanel.value?.column + const row = snap.sidePanel.value?.row - const isNewRecord = false - const configuration = { identifiers, rowIdx: row.idx } + if (!column || !row) return + payload = { [column]: value === null ? null : value } + selectedTable.primary_keys.forEach((column) => (identifiers[column.name] = row![column.name])) + configuration = { identifiers, rowIdx: row.idx } + } - saveRow(payload, isNewRecord, configuration, (error) => { - if (error) { - toast.error(error?.message ?? 'Something went wrong while trying to save the JSON value') - } - }) - } catch (error: any) {} + if (payload !== undefined && configuration !== undefined) { + try { + await saveRow(payload, isNewRecord, configuration, () => {}) + } finally { + resolve() + } + } } const onSaveForeignRow = async (value: any) => { @@ -185,21 +200,19 @@ const SidePanelEditor = ({ const saveColumn = async ( payload: CreateColumnPayload | UpdateColumnPayload, - foreignKey: ExtendedPostgresRelationship | undefined, isNewRecord: boolean, configuration: { columnId?: string }, resolve: any ) => { const selectedColumnToEdit = snap.sidePanel?.type === 'column' && snap.sidePanel.column - const { columnId } = configuration + const response = isNewRecord ? await createColumn({ projectRef: project?.ref!, connectionString: project?.connectionString, payload: payload as CreateColumnPayload, selectedTable: selectedTable as PostgresTable, - foreignKey, }) : await updateColumn({ projectRef: project?.ref!, @@ -207,7 +220,6 @@ const SidePanelEditor = ({ id: columnId as string, payload: payload as UpdateColumnPayload, selectedTable: selectedTable as PostgresTable, - foreignKey, }) if (response?.error) { @@ -314,11 +326,11 @@ const SidePanelEditor = ({ .filter((t: any) => t.id !== table.id) .map((t: any) => `${t.schema}.${t.name}`) : !isAlreadyEnabled && enabled - ? // Toggle realtime on - [`${table.schema}.${table.name}`].concat( - publicationTables.map((t: any) => `${t.schema}.${t.name}`) - ) - : null + ? // Toggle realtime on + [`${table.schema}.${table.name}`].concat( + publicationTables.map((t: any) => `${t.schema}.${t.name}`) + ) + : null if (realtimeTables === null) return await updatePublication({ id, @@ -334,8 +346,13 @@ const SidePanelEditor = ({ } const saveTable = async ( - payload: any, + payload: { + name: string + schema: string + comment?: string | undefined + }, columns: ColumnField[], + foreignKeyRelations: ForeignKey[], isNewRecord: boolean, configuration: { tableId?: number @@ -343,12 +360,19 @@ const SidePanelEditor = ({ isRLSEnabled: boolean isRealtimeEnabled: boolean isDuplicateRows: boolean + existingForeignKeyRelations: ForeignKeyConstraint[] }, resolve: any ) => { let toastId let saveTableError = false - const { importContent, isRLSEnabled, isRealtimeEnabled, isDuplicateRows } = configuration + const { + importContent, + isRLSEnabled, + isRealtimeEnabled, + isDuplicateRows, + existingForeignKeyRelations, + } = configuration try { if ( @@ -356,17 +380,15 @@ const SidePanelEditor = ({ snap.sidePanel.mode === 'duplicate' && selectedTable ) { - const duplicateTable = selectedTable + const tableToDuplicate = selectedTable - toastId = ui.setNotification({ - category: 'loading', - message: `Duplicating table: ${duplicateTable.name}...`, - }) + toastId = toast.loading(`Duplicating table: ${tableToDuplicate.name}...`) - const table: any = await meta.duplicateTable(payload, { + const table = await duplicateTable(project?.ref!, project?.connectionString, payload, { isRLSEnabled, isDuplicateRows, - duplicateTable, + duplicateTable: tableToDuplicate, + foreignKeyRelations, }) if (isRealtimeEnabled) await updateTableRealtime(table, isRealtimeEnabled) @@ -375,50 +397,51 @@ const SidePanelEditor = ({ queryClient.invalidateQueries(entityTypeKeys.list(project?.ref)), ]) - ui.setNotification({ - id: toastId, - category: 'success', - message: `Table ${duplicateTable.name} has been successfully duplicated into ${table.name}!`, - }) - + toast.success( + `Table ${tableToDuplicate.name} has been successfully duplicated into ${table.name}!`, + { id: toastId } + ) onTableCreated(table) } else if (isNewRecord) { - toastId = ui.setNotification({ - category: 'loading', - message: `Creating new table: ${payload.name}...`, - }) + toastId = toast.loading(`Creating new table: ${payload.name}...`) - const table = await meta.createTable(toastId, payload, columns, isRLSEnabled, importContent) - if (isRealtimeEnabled) await updateTableRealtime(table, true) + const table = await createTable({ + projectRef: project?.ref!, + connectionString: project?.connectionString, + toastId, + payload, + columns, + foreignKeyRelations, + isRLSEnabled, + importContent, + }) + if (isRealtimeEnabled) await updateTableRealtime(table as PostgresTable, true) await Promise.all([ queryClient.invalidateQueries(tableKeys.list(project?.ref, table.schema, includeColumns)), queryClient.invalidateQueries(entityTypeKeys.list(project?.ref)), ]) - ui.setNotification({ - id: toastId, - category: 'success', - message: `Table ${table.name} is good to go!`, - }) - - onTableCreated(table) + toast.success(`Table ${table.name} is good to go!`, { id: toastId }) + onTableCreated(table as PostgresTable) } else if (selectedTable) { - toastId = ui.setNotification({ - category: 'loading', - message: `Updating table: ${selectedTable?.name}...`, - }) + toastId = toast.loading(`Updating table: ${selectedTable?.name}...`) - const { table, hasError } = await meta.updateTable(toastId, selectedTable, payload, columns) + const { table, hasError } = await updateTable({ + projectRef: project?.ref!, + connectionString: project?.connectionString, + toastId, + table: selectedTable, + payload, + columns, + foreignKeyRelations, + existingForeignKeyRelations, + }) await updateTableRealtime(table, isRealtimeEnabled) if (hasError) { - ui.setNotification({ - id: toastId, - category: 'info', - message: `Table ${table.name} has been updated, but there were some errors`, - }) + toast(`Table ${table.name} has been updated, but there were some errors`, { id: toastId }) } else { queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints'])) await Promise.all([ @@ -433,19 +456,15 @@ const SidePanelEditor = ({ ]) ), queryClient.invalidateQueries(entityTypeKeys.list(project?.ref)), - queryClient.invalidateQueries(tableKeys.table(project?.ref, table.schema)), + queryClient.invalidateQueries(tableKeys.table(project?.ref, table.id)), ]) - ui.setNotification({ - id: toastId, - category: 'success', - message: `Successfully updated ${table.name}!`, - }) + toast.success(`Successfully updated ${table.name}!`, { id: toastId }) } } } catch (error: any) { saveTableError = true - ui.setNotification({ error, id: toastId, category: 'error', message: error.message }) + toast.error(error.message, { id: toastId }) } if (!saveTableError) { @@ -469,7 +488,9 @@ const SidePanelEditor = ({ if (file && rowCount > 0) { // CSV file upload - const { error }: any = await meta.insertRowsViaSpreadsheet( + const res: any = await insertRowsViaSpreadsheet( + project.ref!, + project.connectionString, file, selectedTable, selectedHeaders, @@ -482,18 +503,15 @@ const SidePanelEditor = ({ }) } ) - if (error) { - ui.setNotification({ - error, - id: toastId, - category: 'error', - message: `Failed to import data: ${error.message}`, - }) + if (res.error) { + toast.error(`Failed to import data: ${res.error.message}`, { id: toastId }) return resolve() } } else { // Text paste - const { error } = await meta.insertTableRows( + const res: any = await insertTableRows( + project.ref!, + project.connectionString, selectedTable, importContent.rows, selectedHeaders, @@ -508,13 +526,8 @@ const SidePanelEditor = ({ }) } ) - if (error) { - ui.setNotification({ - error, - id: toastId, - category: 'error', - message: `Failed to import data: ${error.message}`, - }) + if (res.error) { + toast.error(`Failed to import data: ${res.error.message}`, { id: toastId }) return resolve() } } @@ -580,11 +593,7 @@ const SidePanelEditor = ({ saveChanges={saveTable} updateEditorDirty={() => setIsEdited(true)} /> - {}} - /> + + { - if (!table) return [] - - const views = await getViews({ - projectRef: meta.projectRef, - connectionString: meta.connectionString, - schema, - }) - const decryptedView = views.find((view) => view.name === `decrypted_${table}`) - if (!decryptedView) return [] - - const encryptedColumns = await meta.query( - `SELECT column_name as name FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'decrypted_${table}' and column_name like 'decrypted_%'` - ) - if (!encryptedColumns.error) { - return encryptedColumns.map((column: any) => column.name.split('decrypted_')[1]) - } else { - console.error('Error fetching encrypted columns', encryptedColumns.error) - return [] - } -} - -export function useEncryptedColumns({ schemaName, tableName }: UseEncryptedColumnsArgs) { - const { meta } = useStore() - const [encryptedColumns, setEncryptedColumns] = useState([]) - - useEffect(() => { - let isMounted = true - - const getEncryptedColumns = async () => { - if (schemaName !== undefined && tableName !== undefined) { - const columns = await listEncryptedColumns(meta, schemaName, tableName) - - if (isMounted) { - setEncryptedColumns(columns) - } - } - } - - getEncryptedColumns() - - return () => { - isMounted = false - } - }, [schemaName, tableName]) - - return encryptedColumns -} - -/** - * The functions below are basically just queries but may be supported directly - * from the pg-meta library in the future - */ -export const addPrimaryKey = async ( - projectRef: string, - connectionString: string | undefined, - schema: string, - table: string, - columns: string[] -) => { - const primaryKeyColumns = columns.join('","') - const query = `ALTER TABLE "${schema}"."${table}" ADD PRIMARY KEY ("${primaryKeyColumns}")` - return await executeSql({ - projectRef: projectRef, - connectionString: connectionString, - sql: query, - queryKey: ['primary-keys'], - }) -} - -export const removePrimaryKey = async ( - projectRef: string, - connectionString: string | undefined, - schema: string, - table: string -) => { - const query = `ALTER TABLE "${schema}"."${table}" DROP CONSTRAINT "${table}_pkey"` - return await executeSql({ - projectRef: projectRef, - connectionString: connectionString, - sql: query, - queryKey: ['primary-keys'], - }) -} - -// [Joshen TODO] Eventually need to extend this to composite foreign keys -export const addForeignKey = async ( - projectRef: string, - connectionString: string | undefined, - relationship: ExtendedPostgresRelationship -) => { - const { deletion_action, update_action } = relationship - const deletionAction = - deletion_action === FOREIGN_KEY_CASCADE_ACTION.CASCADE - ? 'ON DELETE CASCADE' - : deletion_action === FOREIGN_KEY_CASCADE_ACTION.RESTRICT - ? 'ON DELETE RESTRICT' - : deletion_action === FOREIGN_KEY_CASCADE_ACTION.SET_DEFAULT - ? 'ON DELETE SET DEFAULT' - : deletion_action === FOREIGN_KEY_CASCADE_ACTION.SET_NULL - ? 'ON DELETE SET NULL' - : '' - const updateAction = - update_action === FOREIGN_KEY_CASCADE_ACTION.CASCADE - ? 'ON UPDATE CASCADE' - : update_action === FOREIGN_KEY_CASCADE_ACTION.RESTRICT - ? 'ON UPDATE RESTRICT' - : '' - - const query = ` - ALTER TABLE "${relationship.source_schema}"."${relationship.source_table_name}" - ADD CONSTRAINT "${relationship.source_table_name}_${relationship.source_column_name}_fkey" - FOREIGN KEY ("${relationship.source_column_name}") - REFERENCES "${relationship.target_table_schema}"."${relationship.target_table_name}" ("${relationship.target_column_name}") - ${updateAction} - ${deletionAction}; - ` - .replace(/\s+/g, ' ') - .trim() - return await executeSql({ - projectRef: projectRef, - connectionString: connectionString, - sql: query, - queryKey: ['foreign-keys'], - }) -} - -export const removeForeignKey = async ( - projectRef: string, - connectionString: string | undefined, - relationship: Partial -) => { - const constraintName = - relationship.constraint_name || - `${relationship.source_table_name}_${relationship.source_column_name}_fkey` - const query = ` - ALTER TABLE "${relationship.source_schema}"."${relationship.source_table_name}" - DROP CONSTRAINT IF EXISTS "${constraintName}" - ` - .replace(/\s+/g, ' ') - .trim() - return await executeSql({ - projectRef: projectRef, - connectionString: connectionString, - sql: query, - queryKey: ['foreign-keys'], - }) -} - -/** - * The methods below involve several contexts due to the UI flow of the - * dashboard and hence do not sit within their own stores - */ -export const createColumn = async ({ - projectRef, - connectionString, - payload, - selectedTable, - foreignKey, -}: { - projectRef: string - connectionString: string | undefined - payload: CreateColumnPayload - selectedTable: PostgresTable - foreignKey?: ExtendedPostgresRelationship -}) => { - const toastId = toast.loading(`Creating column "${payload.name}"...`) - try { - // Once pg-meta supports composite keys, we can remove this logic - const { isPrimaryKey, ...formattedPayload } = payload - const column = await createDatabaseColumn({ - projectRef: projectRef, - connectionString: connectionString, - payload: formattedPayload, - }) - - // Firing createColumn in createTable will bypass this block - if (isPrimaryKey) { - toast.loading('Assigning primary key to column...', { id: toastId }) - // Same logic in createTable: Remove any primary key constraints first (we'll add it back later) - const existingPrimaryKeys = selectedTable.primary_keys.map((x) => x.name) - - if (existingPrimaryKeys.length > 0) { - await removePrimaryKey(projectRef, connectionString, column.schema, column.table) - } - - const primaryKeyColumns = existingPrimaryKeys.concat([column.name]) - await addPrimaryKey( - projectRef, - connectionString, - column.schema, - column.table, - primaryKeyColumns - ) - } - - if (foreignKey !== undefined) { - toast.loading('Adding foreign key to column...', { id: toastId }) - await addForeignKey(projectRef, connectionString, foreignKey) - } - - toast.success(`Successfully created column "${column.name}"`, { id: toastId }) - } catch (error: any) { - toast.error(`An error occurred while creating the column "${payload.name}"`, { id: toastId }) - return { error } - } -} - -export const updateColumn = async ({ - projectRef, - connectionString, - id, - payload, - selectedTable, - foreignKey, - skipPKCreation, - skipSuccessMessage = false, -}: { - projectRef: string - connectionString: string | undefined - id: string - payload: UpdateColumnPayload - selectedTable: PostgresTable - foreignKey?: ExtendedPostgresRelationship - skipPKCreation?: boolean - skipSuccessMessage?: boolean -}) => { - try { - const { isPrimaryKey, ...formattedPayload } = payload - const column = await updateDatabaseColumn({ - projectRef: projectRef, - connectionString: connectionString, - id, - payload: formattedPayload, - }) - - const originalColumn = find(selectedTable.columns, { id }) - const existingForeignKey = find(selectedTable.relationships, { - source_column_name: originalColumn!.name, - }) - - if (!skipPKCreation && isPrimaryKey !== undefined) { - const existingPrimaryKeys = selectedTable.primary_keys.map((x) => x.name) - - // Primary key is getting updated for the column - if (existingPrimaryKeys.length > 0) { - await removePrimaryKey(projectRef, connectionString, column.schema, column.table) - } - - const primaryKeyColumns = isPrimaryKey - ? existingPrimaryKeys.concat([column.name]) - : existingPrimaryKeys.filter((x) => x !== column.name) - - if (primaryKeyColumns.length) { - await addPrimaryKey( - projectRef, - connectionString, - column.schema, - column.table, - primaryKeyColumns - ) - } - } - - // For updating of foreign key relationship, we remove the original one by default - // Then just add whatever was in foreignKey - simplicity over trying to derive whether to update or not - if (existingForeignKey !== undefined) { - await removeForeignKey(projectRef, connectionString, existingForeignKey) - } - - if (foreignKey !== undefined) { - await addForeignKey(projectRef, connectionString, foreignKey) - } - - if (!skipSuccessMessage) { - toast.success(`Successfully updated column "${column.name}"`) - } - } catch (error: any) { - return { error } - } -} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx new file mode 100644 index 00000000000..feaed7b0154 --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -0,0 +1,919 @@ +import { PostgresPrimaryKey, PostgresTable } from '@supabase/postgres-meta' +import { chunk, find, isEmpty, isEqual } from 'lodash' +import Papa from 'papaparse' +import { useEffect, useState } from 'react' +import toast from 'react-hot-toast' + +import { Query } from 'components/grid/query/Query' +import SparkBar from 'components/ui/SparkBar' +import { createDatabaseColumn } from 'data/database-columns/database-column-create-mutation' +import { deleteDatabaseColumn } from 'data/database-columns/database-column-delete-mutation' +import { updateDatabaseColumn } from 'data/database-columns/database-column-update-mutation' +import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' +import { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query' +import { entityTypeKeys } from 'data/entity-types/keys' +import { getQueryClient } from 'data/query-client' +import { executeSql } from 'data/sql/execute-sql-query' +import { sqlKeys } from 'data/sql/keys' +import { tableKeys } from 'data/tables/keys' +import { createTable as createTableMutation } from 'data/tables/table-create-mutation' +import { deleteTable as deleteTableMutation } from 'data/tables/table-delete-mutation' +import { getTable } from 'data/tables/table-query' +import { updateTable as updateTableMutation } from 'data/tables/table-update-mutation' +import { getTables } from 'data/tables/tables-query' +import { getViews } from 'data/views/views-query' +import { useStore } from 'hooks' +import { timeout, tryParseJson } from 'lib/helpers' +import { IMetaStore } from 'stores/pgmeta/MetaStore' +import { + generateCreateColumnPayload, + generateUpdateColumnPayload, +} from './ColumnEditor/ColumnEditor.utils' +import { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types' +import { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './SidePanelEditor.types' +import { checkIfRelationChanged } from './TableEditor/ForeignKeysManagement/ForeignKeysManagement.utils' +import { ImportContent } from './TableEditor/TableEditor.types' + +const BATCH_SIZE = 1000 +const CHUNK_SIZE = 1024 * 1024 * 0.1 // 0.1MB + +export interface UseEncryptedColumnsArgs { + schemaName?: string + tableName?: string +} + +const listEncryptedColumns = async (meta: IMetaStore, schema: string, table: string) => { + if (!table) return [] + + const views = await getViews({ + projectRef: meta.projectRef, + connectionString: meta.connectionString, + schema, + }) + const decryptedView = views.find((view) => view.name === `decrypted_${table}`) + if (!decryptedView) return [] + + const encryptedColumns = await meta.query( + `SELECT column_name as name FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'decrypted_${table}' and column_name like 'decrypted_%'` + ) + if (!encryptedColumns.error) { + return encryptedColumns.map((column: any) => column.name.split('decrypted_')[1]) + } else { + console.error('Error fetching encrypted columns', encryptedColumns.error) + return [] + } +} + +export function useEncryptedColumns({ schemaName, tableName }: UseEncryptedColumnsArgs) { + const { meta } = useStore() + const [encryptedColumns, setEncryptedColumns] = useState([]) + + useEffect(() => { + let isMounted = true + + const getEncryptedColumns = async () => { + if (schemaName !== undefined && tableName !== undefined) { + const columns = await listEncryptedColumns(meta, schemaName, tableName) + + if (isMounted) { + setEncryptedColumns(columns) + } + } + } + + getEncryptedColumns() + + return () => { + isMounted = false + } + }, [schemaName, tableName]) + + return encryptedColumns +} + +/** + * The functions below are basically just queries but may be supported directly + * from the pg-meta library in the future + */ +export const addPrimaryKey = async ( + projectRef: string, + connectionString: string | undefined, + schema: string, + table: string, + columns: string[] +) => { + const primaryKeyColumns = columns.join('","') + const query = `ALTER TABLE "${schema}"."${table}" ADD PRIMARY KEY ("${primaryKeyColumns}")` + return await executeSql({ + projectRef: projectRef, + connectionString: connectionString, + sql: query, + queryKey: ['primary-keys'], + }) +} + +export const removePrimaryKey = async ( + projectRef: string, + connectionString: string | undefined, + schema: string, + table: string +) => { + const query = `ALTER TABLE "${schema}"."${table}" DROP CONSTRAINT "${table}_pkey"` + return await executeSql({ + projectRef: projectRef, + connectionString: connectionString, + sql: query, + queryKey: ['primary-keys'], + }) +} + +export const getAddForeignKeySQL = ({ + table, + foreignKeys, +}: { + table: { schema: string; name: string } + foreignKeys: ForeignKey[] +}) => { + const getOnDeleteSql = (action: string) => + action === FOREIGN_KEY_CASCADE_ACTION.CASCADE + ? 'ON DELETE CASCADE' + : action === FOREIGN_KEY_CASCADE_ACTION.RESTRICT + ? 'ON DELETE RESTRICT' + : action === FOREIGN_KEY_CASCADE_ACTION.SET_DEFAULT + ? 'ON DELETE SET DEFAULT' + : action === FOREIGN_KEY_CASCADE_ACTION.SET_NULL + ? 'ON DELETE SET NULL' + : '' + const getOnUpdateSql = (action: string) => + action === FOREIGN_KEY_CASCADE_ACTION.CASCADE + ? 'ON UPDATE CASCADE' + : action === FOREIGN_KEY_CASCADE_ACTION.RESTRICT + ? 'ON UPDATE RESTRICT' + : '' + return ( + foreignKeys + .map((relation) => { + const { deletionAction, updateAction } = relation + const onDeleteSql = getOnDeleteSql(deletionAction) + const onUpdateSql = getOnUpdateSql(updateAction) + return ` + ALTER TABLE "${table.schema}"."${table.name}" + ADD CONSTRAINT "${table.schema}_${table.name}_${relation.columns.map((x) => x.source).join('_')}_fkey" + FOREIGN KEY (${relation.columns.map((column) => `"${column.source}"`).join(',')}) + REFERENCES "${relation.schema}"."${relation.table}" (${relation.columns.map((column) => `"${column.target}"`).join(',')}) + ${onUpdateSql} + ${onDeleteSql} + ` + .replace(/\s+/g, ' ') + .trim() + }) + .join(';') + ';' + ) +} + +export const addForeignKey = async ({ + projectRef, + connectionString, + table, + foreignKeys, +}: { + projectRef: string + connectionString: string | undefined + table: { schema: string; name: string } + foreignKeys: ForeignKey[] +}) => { + const query = getAddForeignKeySQL({ table, foreignKeys }) + return await executeSql({ + projectRef: projectRef, + connectionString: connectionString, + sql: query, + queryKey: ['foreign-keys'], + }) +} + +export const getRemoveForeignKeySQL = ({ + table, + foreignKeys, +}: { + table: { schema: string; name: string } + foreignKeys: ForeignKey[] +}) => { + return ( + foreignKeys + .map((relation) => + ` +ALTER TABLE IF EXISTS "${table.schema}"."${table.name}" +DROP CONSTRAINT IF EXISTS "${relation.name}" +` + .replace(/\s+/g, ' ') + .trim() + ) + .join(';') + ';' + ) +} + +export const removeForeignKey = async ({ + projectRef, + connectionString, + table, + foreignKeys, +}: { + projectRef: string + connectionString: string | undefined + table: { schema: string; name: string } + foreignKeys: ForeignKey[] +}) => { + const query = getRemoveForeignKeySQL({ table, foreignKeys }) + return await executeSql({ + projectRef: projectRef, + connectionString: connectionString, + sql: query, + queryKey: ['foreign-keys'], + }) +} + +export const updateForeignKey = async ({ + projectRef, + connectionString, + table, + foreignKeys, +}: { + projectRef: string + connectionString: string | undefined + table: { schema: string; name: string } + foreignKeys: ForeignKey[] +}) => { + const query = ` + ${getRemoveForeignKeySQL({ table, foreignKeys })} + ${getAddForeignKeySQL({ table, foreignKeys })} + ` + .replace(/\s+/g, ' ') + .trim() + return await executeSql({ + projectRef: projectRef, + connectionString: connectionString, + sql: query, + queryKey: ['foreign-keys'], + }) +} + +/** + * The methods below involve several contexts due to the UI flow of the + * dashboard and hence do not sit within their own stores + */ +export const createColumn = async ({ + projectRef, + connectionString, + payload, + selectedTable, +}: { + projectRef: string + connectionString: string | undefined + payload: CreateColumnPayload + selectedTable: PostgresTable +}) => { + const toastId = toast.loading(`Creating column "${payload.name}"...`) + try { + // Once pg-meta supports composite keys, we can remove this logic + const { isPrimaryKey, ...formattedPayload } = payload + const column = await createDatabaseColumn({ + projectRef: projectRef, + connectionString: connectionString, + payload: formattedPayload, + }) + + // Firing createColumn in createTable will bypass this block + if (isPrimaryKey) { + toast.loading('Assigning primary key to column...', { id: toastId }) + // Same logic in createTable: Remove any primary key constraints first (we'll add it back later) + const existingPrimaryKeys = selectedTable.primary_keys.map((x) => x.name) + + if (existingPrimaryKeys.length > 0) { + await removePrimaryKey(projectRef, connectionString, column.schema, column.table) + } + + const primaryKeyColumns = existingPrimaryKeys.concat([column.name]) + await addPrimaryKey( + projectRef, + connectionString, + column.schema, + column.table, + primaryKeyColumns + ) + } + toast.success(`Successfully created column "${column.name}"`, { id: toastId }) + } catch (error: any) { + toast.error(`An error occurred while creating the column "${payload.name}"`, { id: toastId }) + return { error } + } +} + +export const updateColumn = async ({ + projectRef, + connectionString, + id, + payload, + selectedTable, + skipPKCreation, + skipSuccessMessage = false, +}: { + projectRef: string + connectionString: string | undefined + id: string + payload: UpdateColumnPayload + selectedTable: PostgresTable + skipPKCreation?: boolean + skipSuccessMessage?: boolean +}) => { + try { + const { isPrimaryKey, ...formattedPayload } = payload + const column = await updateDatabaseColumn({ + projectRef: projectRef, + connectionString: connectionString, + id, + payload: formattedPayload, + }) + + if (!skipPKCreation && isPrimaryKey !== undefined) { + const existingPrimaryKeys = selectedTable.primary_keys.map((x) => x.name) + + // Primary key is getting updated for the column + if (existingPrimaryKeys.length > 0) { + await removePrimaryKey(projectRef, connectionString, column.schema, column.table) + } + + const primaryKeyColumns = isPrimaryKey + ? existingPrimaryKeys.concat([column.name]) + : existingPrimaryKeys.filter((x) => x !== column.name) + + if (primaryKeyColumns.length) { + await addPrimaryKey( + projectRef, + connectionString, + column.schema, + column.table, + primaryKeyColumns + ) + } + } + if (!skipSuccessMessage) toast.success(`Successfully updated column "${column.name}"`) + } catch (error: any) { + return { error } + } +} + +export const duplicateTable = async ( + projectRef: string, + connectionString: string | undefined, + payload: { name: string; comment?: string }, + metadata: { + duplicateTable: PostgresTable + isRLSEnabled: boolean + isDuplicateRows: boolean + foreignKeyRelations: ForeignKey[] + } +) => { + const queryClient = getQueryClient() + const { duplicateTable, isRLSEnabled, isDuplicateRows, foreignKeyRelations } = metadata + const { name: sourceTableName, schema: sourceTableSchema } = duplicateTable + const duplicatedTableName = payload.name + + // The following query will copy the structure of the table along with indexes, constraints and + // triggers. However, foreign key constraints are not duplicated over - has to be done separately + await executeSql({ + projectRef, + connectionString, + sql: [ + `CREATE TABLE "${sourceTableSchema}"."${duplicatedTableName}" (LIKE "${sourceTableSchema}"."${sourceTableName}" INCLUDING ALL);`, + payload.comment !== undefined + ? `comment on table "${sourceTableSchema}"."${duplicatedTableName}" is '${payload.comment}';` + : '', + ].join('\n'), + }) + await queryClient.invalidateQueries(tableKeys.list(projectRef, sourceTableSchema)) + + // Duplicate foreign key constraints over + if (foreignKeyRelations.length > 0) { + await addForeignKey({ + projectRef, + connectionString, + table: { ...duplicateTable, name: payload.name }, + foreignKeys: foreignKeyRelations, + }) + } + + // Duplicate rows if needed + if (isDuplicateRows) { + await executeSql({ + projectRef, + connectionString, + sql: `INSERT INTO "${sourceTableSchema}"."${duplicatedTableName}" SELECT * FROM "${sourceTableSchema}"."${sourceTableName}";`, + }) + + // Insert into does not copy over auto increment sequences, so we manually do it next if any + const columns = duplicateTable.columns ?? [] + const identityColumns = columns.filter((column) => column.identity_generation !== null) + identityColumns.map(async (column) => { + await executeSql({ + projectRef, + connectionString, + sql: `SELECT setval('"${sourceTableSchema}"."${duplicatedTableName}_${column.name}_seq"', (SELECT MAX("${column.name}") FROM "${sourceTableSchema}"."${sourceTableName}"));`, + }) + }) + } + + const tables = await queryClient.fetchQuery({ + queryKey: tableKeys.list(projectRef, sourceTableSchema), + queryFn: ({ signal }) => + getTables({ projectRef, connectionString, schema: sourceTableSchema }, signal), + }) + + const duplicatedTable = find(tables, { schema: sourceTableSchema, name: duplicatedTableName })! + + if (isRLSEnabled) { + await updateTableMutation({ + projectRef, + connectionString, + id: duplicatedTable?.id!, + schema: duplicatedTable?.schema!, + payload: { rls_enabled: isRLSEnabled }, + }) + } + + return duplicatedTable +} + +export const createTable = async ({ + projectRef, + connectionString, + toastId, + payload, + columns = [], + foreignKeyRelations, + isRLSEnabled, + importContent, +}: { + projectRef: string + connectionString: string | undefined + toastId: string + payload: { + name: string + schema: string + comment?: string | undefined + } + columns: ColumnField[] + foreignKeyRelations: ForeignKey[] + isRLSEnabled: boolean + importContent?: ImportContent +}) => { + // Create the table first. Error may be thrown. + const table = await createTableMutation({ + projectRef: projectRef, + connectionString: connectionString, + payload: payload, + }) + + // If we face any errors during this process after the actual table creation + // We'll delete the table as a way to clean up and not leave behind bits that + // got through successfully. This is so that the user can continue editing in + // the table side panel editor conveniently + try { + // Toggle RLS if configured to be + if (isRLSEnabled) { + await updateTableMutation({ + projectRef, + connectionString, + id: table.id, + schema: table.schema, + payload: { rls_enabled: isRLSEnabled }, + }) + } + + // Then insert the columns - we don't do Promise.all as we want to keep the integrity + // of the column order during creation. Note that we add primary key constraints separately + // via the query endpoint to support composite primary keys as pg-meta does not support that OOB + toast.loading(`Adding ${columns.length} columns to ${table.name}...`, { id: toastId }) + + for (const column of columns) { + // We create all columns without primary keys first + const columnPayload = generateCreateColumnPayload(table.id, { + ...column, + isPrimaryKey: false, + }) + await createDatabaseColumn({ + projectRef, + connectionString, + payload: columnPayload, + }) + } + + // Then add the primary key constraints here to support composite keys + const primaryKeyColumns = columns + .filter((column: ColumnField) => column.isPrimaryKey) + .map((column: ColumnField) => column.name) + if (primaryKeyColumns.length > 0) { + await addPrimaryKey(projectRef, connectionString, table.schema, table.name, primaryKeyColumns) + } + + // Then add the foreign key constraints here + if (foreignKeyRelations.length > 0) { + await addForeignKey({ + projectRef, + connectionString, + table: { schema: table.schema, name: table.name }, + foreignKeys: foreignKeyRelations, + }) + } + + // If the user is importing data via a spreadsheet + if (importContent !== undefined) { + if (importContent.file && importContent.rowCount > 0) { + // Via a CSV file + const { error }: any = await insertRowsViaSpreadsheet( + projectRef, + connectionString, + importContent.file, + table as PostgresTable, + importContent.selectedHeaders, + (progress: number) => { + toast.loading( +
    + +
    , + { id: toastId } + ) + } + ) + + // For identity columns, manually raise the sequences + const identityColumns = columns.filter((column) => column.isIdentity) + for (const column of identityColumns) { + await executeSql({ + projectRef, + connectionString, + sql: `SELECT setval('${table.name}_${column.name}_seq', (SELECT MAX("${column.name}") FROM "${table.name}"));`, + }) + } + + if (error !== undefined) { + toast.error('Do check your spreadsheet if there are any discrepancies.') + + const message = `Table ${table.name} has been created but we ran into an error while inserting rows: ${error.message}` + toast.error(message) + console.error('Error:', { error, message }) + } + } else { + // Via text copy and paste + await insertTableRows( + projectRef, + connectionString, + table as PostgresTable, + importContent.rows, + importContent.selectedHeaders, + (progress: number) => { + toast.loading( +
    + +
    , + { id: toastId } + ) + } + ) + + // For identity columns, manually raise the sequences + const identityColumns = columns.filter((column) => column.isIdentity) + for (const column of identityColumns) { + await executeSql({ + projectRef, + connectionString, + sql: `SELECT setval('${table.name}_${column.name}_seq', (SELECT MAX("${column.name}") FROM "${table.name}"));`, + }) + } + } + } + + // Finally, return the created table + return table + } catch (error) { + deleteTableMutation({ + projectRef, + connectionString, + id: table.id, + schema: table.schema, + }) + throw error + } +} + +export const updateTable = async ({ + projectRef, + connectionString, + toastId, + table, + payload, + columns, + foreignKeyRelations, + existingForeignKeyRelations, +}: { + projectRef: string + connectionString: string | undefined + toastId: string + table: PostgresTable + payload: any + columns: ColumnField[] + foreignKeyRelations: ForeignKey[] + existingForeignKeyRelations: ForeignKeyConstraint[] +}) => { + // Prepare a check to see if primary keys to the tables were updated or not + const primaryKeyColumns = columns + .filter((column) => column.isPrimaryKey) + .map((column) => column.name) + + const existingPrimaryKeyColumns = table.primary_keys.map((pk: PostgresPrimaryKey) => pk.name) + const isPrimaryKeyUpdated = !isEqual(primaryKeyColumns, existingPrimaryKeyColumns) + + if (isPrimaryKeyUpdated) { + // Remove any primary key constraints first (we'll add it back later) + // If we do it later, and if the user deleted a PK column, we'd need to do + // an additional check when removing PK if the column in the PK was removed + // So doing this one step earlier, lets us skip that additional check. + if (table.primary_keys.length > 0) { + await removePrimaryKey(projectRef, connectionString, table.schema, table.name) + } + } + + // Update the table + const updatedTable = await updateTableMutation({ + projectRef, + connectionString, + id: table.id, + schema: table.schema, + payload, + }) + + const originalColumns = table.columns ?? [] + const columnIds = columns.map((column) => column.id) + + // Delete any removed columns + const columnsToRemove = originalColumns.filter((column) => !columnIds.includes(column.id)) + for (const column of columnsToRemove) { + toast.loading(`Removing column ${column.name} from ${updatedTable.name}`, { id: toastId }) + await deleteDatabaseColumn({ + projectRef, + connectionString, + id: column.id, + }) + } + + // Add any new columns / Update any existing columns + let hasError = false + for (const column of columns) { + if (!column.id.includes(table.id.toString())) { + toast.loading(`Adding column ${column.name} to ${updatedTable.name}`, { id: toastId }) + // Ensure that columns do not created as primary key first, cause the primary key will + // be added later on further down in the code + const columnPayload = generateCreateColumnPayload(updatedTable.id, { + ...column, + isPrimaryKey: false, + }) + await createColumn({ + projectRef: projectRef, + connectionString: connectionString, + payload: columnPayload, + selectedTable: updatedTable as PostgresTable, + }) + } else { + const originalColumn = find(originalColumns, { id: column.id }) + if (originalColumn) { + const columnPayload = generateUpdateColumnPayload( + originalColumn, + updatedTable as PostgresTable, + column + ) + if (!isEmpty(columnPayload)) { + toast.loading(`Updating column ${column.name} from ${updatedTable.name}`, { id: toastId }) + const skipPKCreation = true + const skipSuccessMessage = true + const res = await updateColumn({ + projectRef: projectRef, + connectionString: connectionString, + id: column.id, + payload: columnPayload, + selectedTable: updatedTable as PostgresTable, + skipPKCreation, + skipSuccessMessage, + }) + if (res?.error) { + hasError = true + toast.error(`Failed to update column "${column.name}": ${res.error.message}`) + } + } + } + } + } + + // Then add back the primary keys again + if (isPrimaryKeyUpdated && primaryKeyColumns.length > 0) { + await addPrimaryKey( + projectRef, + connectionString, + updatedTable.schema, + updatedTable.name, + primaryKeyColumns + ) + } + + // Foreign keys will get updated here accordingly + const relationsToAdd = foreignKeyRelations.filter((x) => x.id === undefined) + if (relationsToAdd.length > 0) { + await addForeignKey({ + projectRef, + connectionString, + table: updatedTable, + foreignKeys: relationsToAdd, + }) + } + + const relationsToRemove = foreignKeyRelations.filter((x) => x.toRemove) + if (relationsToRemove.length > 0) { + await removeForeignKey({ + projectRef, + connectionString, + table: updatedTable, + foreignKeys: relationsToRemove, + }) + } + + const remainingRelations = foreignKeyRelations.filter((x) => x.id !== undefined && !x.toRemove) + const relationsToUpdate = remainingRelations.filter((x) => { + const existingRelation = existingForeignKeyRelations.find((y) => x.id === y.id) + if (existingRelation !== undefined) { + return checkIfRelationChanged(existingRelation as unknown as ForeignKeyConstraint, x) + } else return false + }) + if (relationsToUpdate.length > 0) { + await updateForeignKey({ + projectRef, + connectionString, + table: updatedTable, + foreignKeys: relationsToUpdate, + }) + } + + const queryClient = getQueryClient() + + await Promise.all([ + queryClient.invalidateQueries(sqlKeys.query(projectRef, ['foreign-key-constraints'])), + // invalidate list of tables, as well as visible individual tables + queryClient.invalidateQueries(['projects', projectRef, 'tables']), + queryClient.invalidateQueries(sqlKeys.query(projectRef, [table.schema, table.name])), + queryClient.invalidateQueries( + sqlKeys.query(projectRef, ['table-definition', table.schema, table.name]) + ), + queryClient.invalidateQueries(entityTypeKeys.list(projectRef)), + ]) + + return { + table: await getTable({ + projectRef, + connectionString, + id: table.id, + }), + hasError, + } +} + +export const insertRowsViaSpreadsheet = async ( + projectRef: string, + connectionString: string | undefined, + file: any, + table: PostgresTable, + selectedHeaders: string[], + onProgressUpdate: (progress: number) => void +) => { + let chunkNumber = 0 + let insertError: any = undefined + const t1: any = new Date() + return new Promise((resolve) => { + Papa.parse(file, { + header: true, + // dynamicTyping has to be disabled so that "00001" doesn't get parsed as 1. + dynamicTyping: false, + skipEmptyLines: true, + chunkSize: CHUNK_SIZE, + quoteChar: file.type === 'text/tab-separated-values' ? '' : '"', + chunk: async (results: any, parser: any) => { + parser.pause() + + const formattedData = results.data.map((row: any) => { + const formattedRow: any = {} + selectedHeaders.forEach((header) => { + const column = table.columns?.find((c) => c.name === header) + if ((column?.data_type ?? '') === 'ARRAY' || (column?.format ?? '').includes('json')) { + formattedRow[header] = tryParseJson(row[header]) + } else if (row[header] === '') { + // if the cell is empty string, convert it to NULL + formattedRow[header] = null + } else { + formattedRow[header] = row[header] + } + }) + return formattedRow + }) + + const insertQuery = new Query().from(table.name, table.schema).insert(formattedData).toSql() + try { + await executeSql({ projectRef, connectionString, sql: insertQuery }) + } catch (error) { + console.warn(error) + insertError = error + parser.abort() + } + + chunkNumber += 1 + const progress = (chunkNumber * CHUNK_SIZE) / file.size + const progressPercentage = progress > 1 ? 100 : progress * 100 + onProgressUpdate(progressPercentage) + parser.resume() + }, + complete: () => { + const t2: any = new Date() + console.log(`Total time taken for importing spreadsheet: ${(t2 - t1) / 1000} seconds`) + resolve({ error: insertError }) + }, + }) + }) +} + +export const insertTableRows = async ( + projectRef: string, + connectionString: string | undefined, + table: PostgresTable, + rows: any, + selectedHeaders: string[], + onProgressUpdate: (progress: number) => void +) => { + let insertError = undefined + let insertProgress = 0 + + const formattedRows = rows.map((row: any) => { + const formattedRow: any = {} + selectedHeaders.forEach((header) => { + const column = table.columns?.find((c) => c.name === header) + if ((column?.data_type ?? '') === 'ARRAY' || (column?.format ?? '').includes('json')) { + formattedRow[header] = tryParseJson(row[header]) + } else { + formattedRow[header] = row[header] + } + }) + return formattedRow + }) + + const batches = chunk(formattedRows, BATCH_SIZE) + const promises = batches.map((batch: any) => { + return () => { + return Promise.race([ + new Promise(async (resolve, reject) => { + const insertQuery = new Query().from(table.name, table.schema).insert(batch).toSql() + try { + await executeSql({ projectRef, connectionString, sql: insertQuery }) + } catch (error) { + insertError = error + reject(error) + } + + insertProgress = insertProgress + batch.length / rows.length + resolve({}) + }), + timeout(30000), + ]) + } + }) + + const batchedPromises = chunk(promises, 10) + for (const batchedPromise of batchedPromises) { + const res = await Promise.allSettled(batchedPromise.map((batch) => batch())) + const hasFailedBatch = find(res, { status: 'rejected' }) + if (hasFailedBatch) break + onProgressUpdate(insertProgress * 100) + } + return { error: insertError } +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx index 31fae8ea413..46d002c9d45 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx @@ -107,8 +107,8 @@ const SpreadsheetImportPreview = ({ {previewHeaders.length === 0 ? 'No headers have been selected' : previewRows.length === 0 - ? 'Your CSV contains no data' - : ''} + ? 'Your CSV contains no data' + : ''}

    )} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx index 97700a9c2e3..ccac707e934 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/Column.tsx @@ -1,26 +1,13 @@ -import * as Tooltip from '@radix-ui/react-tooltip' import type { PostgresType } from '@supabase/postgres-meta' - -import { - Button, - Checkbox, - IconArrowRight, - IconLink, - IconMenu, - IconSettings, - IconX, - Input, - Popover, -} from 'ui' -import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' import { noop } from 'lodash' +import { Checkbox, IconMenu, IconSettings, IconX, Input, Popover } from 'ui' + +import { EMPTY_ARR, EMPTY_OBJ } from 'lib/void' import { typeExpressionSuggestions } from '../ColumnEditor/ColumnEditor.constants' import { Suggestion } from '../ColumnEditor/ColumnEditor.types' -import { getForeignKeyCascadeAction } from '../ColumnEditor/ColumnEditor.utils' import ColumnType from '../ColumnEditor/ColumnType' import InputWithSuggestions from '../ColumnEditor/InputWithSuggestions' import { ColumnField } from '../SidePanelEditor.types' -import { EMPTY_ARR, EMPTY_OBJ } from 'lib/void' /** * [Joshen] For context: @@ -44,9 +31,9 @@ interface ColumnProps { column: ColumnField enumTypes: PostgresType[] isNewRecord: boolean + hasForeignKeys: boolean hasImportContent: boolean dragHandleProps?: any - onEditRelation: (column: any) => void onUpdateColumn: (changes: Partial) => void onRemoveColumn: () => void } @@ -55,9 +42,9 @@ const Column = ({ column = EMPTY_OBJ as ColumnField, enumTypes = EMPTY_ARR as PostgresType[], isNewRecord = false, + hasForeignKeys = false, hasImportContent = false, dragHandleProps = EMPTY_OBJ, - onEditRelation = noop, onUpdateColumn = noop, onRemoveColumn = noop, }: ColumnProps) => { @@ -77,7 +64,7 @@ const Column = ({ -
    +
    -
    - - - - - - - -
    - {column.foreignKey === undefined ? ( - Edit foreign key relation - ) : ( -
    -

    Foreign key relation:

    -
    -

    - {column.foreignKey.source_schema}.{column.foreignKey.source_table_name}. - {column.foreignKey.source_column_name} -

    - -

    - {column.foreignKey.target_table_schema}. - {column.foreignKey.target_table_name}.{column.foreignKey.target_column_name} -

    -
    - {column.foreignKey.deletion_action !== FOREIGN_KEY_CASCADE_ACTION.NO_ACTION && ( -

    - On delete: {getForeignKeyCascadeAction(column.foreignKey.deletion_action)} -

    - )} -
    - )} -
    -
    -
    -
    -
    { const defaultValue = format === 'uuid' ? 'gen_random_uuid()' : null onUpdateColumn({ format, defaultValue }) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ColumnManagement.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ColumnManagement.tsx index ddf764cd3e4..aa3b0193ce6 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ColumnManagement.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ColumnManagement.tsx @@ -1,8 +1,6 @@ import * as Tooltip from '@radix-ui/react-tooltip' -import type { PostgresColumn, PostgresTable, PostgresType } from '@supabase/postgres-meta' +import type { PostgresType } from '@supabase/postgres-meta' import { isEmpty, noop, partition } from 'lodash' -import Link from 'next/link' -import { useState } from 'react' import { DragDropContext, Draggable, @@ -14,15 +12,15 @@ import { Alert, Button, IconEdit, IconExternalLink, IconHelpCircle, IconKey, Ico import InformationBox from 'components/ui/InformationBox' import { generateColumnField } from '../ColumnEditor/ColumnEditor.utils' -import ForeignKeySelector from '../ForeignKeySelector/ForeignKeySelector' +import { ForeignKey } from '../ForeignKeySelector/ForeignKeySelector.types' import { TEXT_TYPES } from '../SidePanelEditor.constants' import { ColumnField, ExtendedPostgresRelationship } from '../SidePanelEditor.types' import Column from './Column' import { ImportContent } from './TableEditor.types' interface ColumnManagementProps { - table?: Partial columns?: ColumnField[] + relations: ForeignKey[] enumTypes: PostgresType[] importContent?: ImportContent isNewRecord: boolean @@ -32,8 +30,8 @@ interface ColumnManagementProps { } const ColumnManagement = ({ - table, columns = [], + relations, enumTypes = [], importContent, isNewRecord, @@ -41,44 +39,17 @@ const ColumnManagement = ({ onSelectImportData = noop, onClearImportContent = noop, }: ColumnManagementProps) => { - const [selectedColumnToEditRelation, setSelectedColumnToEditRelation] = useState() - const hasImportContent = !isEmpty(importContent) const [primaryKeyColumns, otherColumns] = partition( columns, (column: ColumnField) => column.isPrimaryKey ) - const saveColumnForeignKey = (foreignKeyConfiguration?: { - table: PostgresTable - column: PostgresColumn - deletionAction: string - updateAction: string - }) => { - if (selectedColumnToEditRelation !== undefined) { - onUpdateColumn(selectedColumnToEditRelation, { - foreignKey: - foreignKeyConfiguration !== undefined - ? { - id: 0, - constraint_name: '', - source_schema: table?.schema ?? '', - source_table_name: table?.name ?? '', - source_column_name: selectedColumnToEditRelation?.name, - target_table_schema: foreignKeyConfiguration.table.schema, - target_table_name: foreignKeyConfiguration.table.name, - target_column_name: foreignKeyConfiguration.column.name, - deletion_action: foreignKeyConfiguration.deletionAction, - update_action: foreignKeyConfiguration.updateAction, - } - : undefined, - ...(foreignKeyConfiguration !== undefined && { - format: foreignKeyConfiguration.column.format, - defaultValue: null, - }), - }) - } - setSelectedColumnToEditRelation(undefined) + const checkIfHaveForeignKeys = (column: ColumnField) => { + return ( + relations.find((relation) => relation.columns.find((x) => x.source === column.name)) !== + undefined + ) } const onUpdateColumn = (columnToUpdate: ColumnField, changes: Partial) => { @@ -143,24 +114,36 @@ const ColumnManagement = ({
    Columns
    - {isNewRecord && ( - <> - {hasImportContent ? ( -
    - + {isNewRecord && ( + <> +
    + {hasImportContent ? ( +
    + + +
    + ) : ( + - -
    - ) : ( - - )} - - )} + )} + + )} +
    {hasImportContent && ( @@ -207,7 +190,7 @@ const ColumnManagement = ({
    @@ -237,7 +220,7 @@ const ColumnManagement = ({
    @@ -280,12 +263,10 @@ const ColumnManagement = ({ { - setSelectedColumnToEditRelation(column) - }} onUpdateColumn={(changes) => onUpdateColumn(column, changes)} onRemoveColumn={() => onRemoveColumn(column)} /> @@ -315,11 +296,9 @@ const ColumnManagement = ({ column={column} enumTypes={enumTypes} isNewRecord={isNewRecord} + hasForeignKeys={checkIfHaveForeignKeys(column)} hasImportContent={hasImportContent} dragHandleProps={draggableProvided.dragHandleProps} - onEditRelation={() => { - setSelectedColumnToEditRelation(column) - }} onUpdateColumn={(changes) => onUpdateColumn(column, changes)} onRemoveColumn={() => onRemoveColumn(column)} /> @@ -334,34 +313,14 @@ const ColumnManagement = ({
    -
    - {!hasImportContent && ( + {!hasImportContent && ( +
    - )} - -
    +
    + )}
    - setSelectedColumnToEditRelation(undefined)} - saveChanges={saveColumnForeignKey} - /> ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeyRow.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeyRow.tsx new file mode 100644 index 00000000000..3787487df2d --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeyRow.tsx @@ -0,0 +1,115 @@ +import clsx from 'clsx' +import { useParams } from 'common' +import Link from 'next/link' +import SVG from 'react-inlinesvg' +import { Badge, Button, IconArrowRight } from 'ui' + +import { BASE_PATH } from 'lib/constants' +import { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.types' + +interface ForeignKeyProps { + foreignKey: ForeignKey + disabled?: boolean + status?: 'ADD' | 'UPDATE' | 'REMOVE' + layout?: 'vertical' | 'horizontal' + closePanel: () => void + onSelectEdit: () => void + onSelectRemove: () => void + onSelectUndoRemove: () => void +} + +export const ForeignKeyRow = ({ + foreignKey, + disabled = false, + status, + layout = 'horizontal', + closePanel, + onSelectEdit, + onSelectRemove, + onSelectUndoRemove, +}: ForeignKeyProps) => { + const { ref } = useParams() + + return ( +
    +
    +
    + {foreignKey.name && ( +

    + {foreignKey.name} +

    + )} +
    + {status !== undefined && ( + + {status} + + )} +

    + {foreignKey.columns.length > 1 ? 'Composite foreign' : 'Foreign'} key relation to: +

    + +
    +
    + +
    + {foreignKey.columns.map((x, idx) => ( +
    + {x.source} + + + {foreignKey.schema}.{foreignKey.table}.{x.target} + +
    + ))} +
    +
    + {!disabled && ( +
    + + {foreignKey.toRemove ? ( + + ) : ( + + )} +
    + )} +
    + ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeysManagement.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeysManagement.tsx new file mode 100644 index 00000000000..fc87f35a88e --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeysManagement.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react' +import { Button } from 'ui' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import AlertError from 'components/ui/AlertError' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query' +import { useTableEditorStateSnapshot } from 'state/table-editor' +import { ResponseError } from 'types' +import { ForeignKeySelector } from '../../ForeignKeySelector/ForeignKeySelector' +import { TableField } from '../TableEditor.types' +import { ForeignKeyRow } from './ForeignKeyRow' +import { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.types' +import { checkIfRelationChanged } from './ForeignKeysManagement.utils' + +interface ForeignKeysManagementProps { + table: TableField + relations: ForeignKey[] + closePanel: () => void + setEditorDirty: () => void + onUpdateFkRelations: (relations: ForeignKey[]) => void +} + +export const ForeignKeysManagement = ({ + table, + relations, + closePanel, + setEditorDirty, + onUpdateFkRelations, +}: ForeignKeysManagementProps) => { + const { project } = useProjectContext() + const snap = useTableEditorStateSnapshot() + + const [open, setOpen] = useState(false) + const [selectedFk, setSelectedFk] = useState() + + const { data, error, isLoading, isSuccess, isError } = useForeignKeyConstraintsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: snap.selectedSchemaName, + }) + + const getRelationStatus = (fk: ForeignKey) => { + const existingRelation = (data ?? []).find((x) => x.id === fk.id) + const stateRelation = relations.find((x) => x.id === fk.id) + + if (stateRelation?.toRemove) return 'REMOVE' + if (existingRelation === undefined && stateRelation !== undefined) return 'ADD' + if (existingRelation !== undefined && stateRelation !== undefined) { + const hasUpdated = checkIfRelationChanged(existingRelation, stateRelation) + if (hasUpdated) return 'UPDATE' + else return undefined + } + } + + return ( + <> +
    +
    Foreign keys
    + + {isLoading && } + + {isError && ( + + )} + + {isSuccess && ( +
    + {relations.map((fk) => { + const status = getRelationStatus(fk) + return ( + { + setOpen(true) + setSelectedFk(fk) + }} + onSelectRemove={() => { + setEditorDirty() + if (fk.id === undefined) { + const updatedRelations = relations.filter((x) => x.id !== fk.id) + onUpdateFkRelations(updatedRelations) + } else { + const updatedRelations = relations.map((x) => { + if (x.id === fk.id) return { ...x, toRemove: true } + else return x + }) + onUpdateFkRelations(updatedRelations) + } + }} + onSelectUndoRemove={() => { + setEditorDirty() + const updatedRelations = relations.map((x) => { + if (x.id === fk.id) return { ...x, toRemove: false } + else return x + }) + onUpdateFkRelations(updatedRelations) + }} + /> + ) + })} +
    + )} + +
    + +
    +
    + { + setOpen(false) + setSelectedFk(undefined) + }} + onSaveRelation={(fk) => { + setEditorDirty() + const existingRelationIds = relations.map((x) => x.id) + if (fk.id !== undefined && existingRelationIds.includes(fk.id)) { + onUpdateFkRelations( + relations.map((x) => { + if (x.id === fk.id) return fk + return x + }) + ) + } else { + onUpdateFkRelations(relations.concat([fk])) + } + }} + /> + + ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeysManagement.utils.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeysManagement.utils.ts new file mode 100644 index 00000000000..7d8c54e726d --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeysManagement.utils.ts @@ -0,0 +1,16 @@ +import { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query' +import { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.types' +import { isEqual } from 'lodash' + +export const checkIfRelationChanged = (existing: ForeignKeyConstraint, state: ForeignKey) => { + const stateSourceColumns = state.columns.map((x) => x.source) + const stateTargetColumns = state.columns.map((x) => x.target) + return ( + existing.deletion_action !== state.deletionAction || + existing.update_action !== state.updateAction || + existing.target_schema !== state.schema || + existing.target_table !== state.table || + !isEqual(existing.source_columns, stateSourceColumns) || + !isEqual(existing.target_columns, stateTargetColumns) + ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.constants.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.constants.ts index 7b924cffdbe..069d613bdf0 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.constants.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.constants.ts @@ -5,6 +5,8 @@ export const DEFAULT_COLUMNS: ColumnField[] = [ { id: uuidv4(), name: 'id', + schema: '', + table: '', format: 'int8', defaultValue: null, check: null, @@ -20,6 +22,8 @@ export const DEFAULT_COLUMNS: ColumnField[] = [ { id: uuidv4(), name: 'created_at', + schema: '', + table: '', format: 'timestamptz', defaultValue: 'now()', check: null, diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index 16793e27741..2433c8c413c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -2,20 +2,26 @@ import type { PostgresTable } from '@supabase/postgres-meta' import { isEmpty, isUndefined, noop } from 'lodash' import Link from 'next/link' import { useEffect, useState } from 'react' +import { Alert, Badge, Button, Checkbox, IconBookOpen, Input, Modal, SidePanel } from 'ui' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ConfirmationModal from 'components/ui/ConfirmationModal' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' -import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query' +import { + ForeignKeyConstraint, + useForeignKeyConstraintsQuery, +} from 'data/database/foreign-key-constraints-query' import { usePostgresTypesQuery } from 'data/database/types-query' import { useIsFeatureEnabled, useStore } from 'hooks' import { EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas' import { useTableEditorStateSnapshot } from 'state/table-editor' -import { Alert, Badge, Button, Checkbox, IconBookOpen, Input, Modal, SidePanel } from 'ui' import { SpreadsheetImport } from '../' import ActionBar from '../ActionBar' -import { ColumnField, CreateTablePayload, UpdateTablePayload } from '../SidePanelEditor.types' +import { ForeignKey } from '../ForeignKeySelector/ForeignKeySelector.types' +import { formatForeignKeys } from '../ForeignKeySelector/ForeignKeySelector.utils' +import { ColumnField } from '../SidePanelEditor.types' import ColumnManagement from './ColumnManagement' +import { ForeignKeysManagement } from './ForeignKeysManagement/ForeignKeysManagement' import HeaderTitle from './HeaderTitle' import RLSDisableModalContent from './RLSDisableModal' import { DEFAULT_COLUMNS } from './TableEditor.constants' @@ -33,8 +39,13 @@ export interface TableEditorProps { visible: boolean closePanel: () => void saveChanges: ( - payload: any, + payload: { + name: string + schema: string + comment?: string | undefined + }, columns: ColumnField[], + foreignKeyRelations: ForeignKey[], isNewRecord: boolean, configuration: { tableId?: number @@ -42,6 +53,7 @@ export interface TableEditorProps { isRLSEnabled: boolean isRealtimeEnabled: boolean isDuplicateRows: boolean + existingForeignKeyRelations: ForeignKeyConstraint[] }, resolve: any ) => void @@ -84,44 +96,21 @@ const TableEditor = ({ const [errors, setErrors] = useState({}) const [tableFields, setTableFields] = useState() + const [fkRelations, setFkRelations] = useState([]) + const [isDuplicateRows, setIsDuplicateRows] = useState(false) const [importContent, setImportContent] = useState() const [isImportingSpreadsheet, setIsImportingSpreadsheet] = useState(false) const [rlsConfirmVisible, setRlsConfirmVisible] = useState(false) - const { data } = useForeignKeyConstraintsQuery({ + const { data: foreignKeyMeta } = useForeignKeyConstraintsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, schema: table?.schema, }) - const foreignKeyMeta = data || [] - - useEffect(() => { - if (visible) { - setErrors({}) - setImportContent(undefined) - setIsDuplicateRows(false) - if (isNewRecord) { - const tableFields = generateTableField() - setTableFields(tableFields) - } else { - const tableFields = generateTableFieldFromPostgresTable( - table!, - foreignKeyMeta, - isDuplicating, - isRealtimeEnabled - ) - setTableFields(tableFields) - } - } - }, [visible]) - - useEffect(() => { - if (importContent && !isEmpty(importContent)) { - const importedColumns = formatImportedContentToColumnFields(importContent) - onUpdateField({ columns: importedColumns }) - } - }, [importContent]) + const foreignKeys = (foreignKeyMeta ?? []).filter( + (fk) => fk.source_schema === table?.schema && fk.source_table === table?.name + ) const onUpdateField = (changes: Partial) => { const updatedTableFields = { ...tableFields, ...changes } as TableField @@ -135,6 +124,33 @@ const TableEditor = ({ setErrors(updatedErrors) } + const onUpdateFkRelations = (relations: ForeignKey[]) => { + if (tableFields === undefined) return + const updatedColumns: ColumnField[] = [] + + relations.forEach((relation) => { + relation.columns.forEach((column) => { + const sourceColumn = tableFields.columns.find((col) => col.name === column.source) + if (sourceColumn?.isNewColumn && column.targetType) { + updatedColumns.push({ ...sourceColumn, format: column.targetType }) + } + }) + }) + + if (updatedColumns.length > 0) { + const updatedTableFields = { + ...tableFields, + columns: tableFields.columns.map((col) => { + const updatedColumn = updatedColumns.find((x) => x.id === col.id) + if (updatedColumn) return updatedColumn + else return col + }), + } + setTableFields(updatedTableFields) + } + setFkRelations(relations) + } + const onSaveChanges = (resolve: any) => { if (tableFields) { const errors: any = validateFields(tableFields) @@ -144,36 +160,57 @@ const TableEditor = ({ setErrors(errors) if (isEmpty(errors)) { - const payload: CreateTablePayload | UpdateTablePayload = { + const payload = { name: tableFields.name, schema: snap.selectedSchemaName, comment: tableFields.comment, ...(!isNewRecord && { rls_enabled: tableFields.isRLSEnabled }), } - const columns = tableFields.columns.map((column) => { - if (column.foreignKey) { - return { - ...column, - foreignKey: { ...column.foreignKey, source_table_name: tableFields.name }, - } - } - return column - }) const configuration = { tableId: table?.id, importContent, isRLSEnabled: tableFields.isRLSEnabled, isRealtimeEnabled: tableFields.isRealtimeEnabled, isDuplicateRows: isDuplicateRows, + existingForeignKeyRelations: foreignKeys, } - saveChanges(payload, columns, isNewRecord, configuration, resolve) + saveChanges(payload, tableFields.columns, fkRelations, isNewRecord, configuration, resolve) } else { resolve() } } } + useEffect(() => { + if (visible) { + setErrors({}) + setImportContent(undefined) + setIsDuplicateRows(false) + if (isNewRecord) { + const tableFields = generateTableField() + setTableFields(tableFields) + setFkRelations([]) + } else { + const tableFields = generateTableFieldFromPostgresTable( + table!, + foreignKeyMeta || [], + isDuplicating, + isRealtimeEnabled + ) + setTableFields(tableFields) + setFkRelations(formatForeignKeys(foreignKeys)) + } + } + }, [visible]) + + useEffect(() => { + if (importContent && !isEmpty(importContent)) { + const importedColumns = formatImportedContentToColumnFields(importContent) + onUpdateField({ columns: importedColumns }) + } + }, [importContent]) + if (!tableFields) return null return ( @@ -196,182 +233,184 @@ const TableEditor = ({ /> } > - <> - -
    - onUpdateField({ name: event.target.value })} - /> - onUpdateField({ comment: event.target.value })} - /> -
    -
    - - -
    + + onUpdateField({ name: event.target.value })} + /> + onUpdateField({ comment: event.target.value })} + /> + + + + + Enable Row Level Security (RLS) + Recommended +
    + } + // @ts-ignore + description={ + <> +

    Restrict access to your table by enabling RLS and writing Postgres policies.

    + + } + checked={tableFields.isRLSEnabled} + onChange={() => { + // if isEnabled, show confirm modal to turn off + // if not enabled, allow turning on without modal confirmation + tableFields.isRLSEnabled + ? setRlsConfirmVisible(true) + : onUpdateField({ isRLSEnabled: !tableFields.isRLSEnabled }) + }} + size="medium" + /> + {tableFields.isRLSEnabled ? ( + +

    + You need to write an access policy before you can query data from this table. Without + a policy, querying this table will result in an empty array of results. +

    + {isNewRecord && ( +

    You can create policies after you create this table.

    + )} +

    + +

    +
    + ) : ( + +

    + {tableFields.name ? `The table ${tableFields.name}` : 'Your table'} will be publicly + writable and readable +

    +

    + +

    +
    + )} + {realtimeEnabled && ( + onUpdateField({ isRealtimeEnabled: !tableFields.isRealtimeEnabled })} + size="medium" + /> + )} +
    + + + {!isDuplicating && ( + onUpdateField({ columns })} + onSelectImportData={() => setIsImportingSpreadsheet(true)} + onClearImportContent={() => { + onUpdateField({ columns: DEFAULT_COLUMNS }) + setImportContent(undefined) + }} + /> + )} + {isDuplicating && ( + <> - Enable Row Level Security (RLS) - Recommended -
    - } - // @ts-ignore - description={ - <> -

    - Restrict access to your table by enabling RLS and writing Postgres policies. -

    - - } - checked={tableFields.isRLSEnabled} - onChange={() => { - // if isEnabled, show confirm modal to turn off - // if not enabled, allow turning on without modal confirmation - tableFields.isRLSEnabled - ? setRlsConfirmVisible(true) - : onUpdateField({ isRLSEnabled: !tableFields.isRLSEnabled }) - }} + id="duplicate-rows" + label="Duplicate table entries" + description="This will copy all the data in the table into the new table" + checked={isDuplicateRows} + onChange={() => setIsDuplicateRows(!isDuplicateRows)} size="medium" /> - {tableFields.isRLSEnabled ? ( - -

    - You need to write an access policy before you can query data from this table. - Without a policy, querying this table will result in an empty array of - results. -

    - {isNewRecord && ( -

    You can create policies after you create this table.

    - )} -

    - -

    -
    - ) : ( - -

    - {tableFields.name ? `The table ${tableFields.name}` : 'Your table'} will be - publicly writable and readable -

    -

    - -

    -
    - )} - {realtimeEnabled && ( - - onUpdateField({ isRealtimeEnabled: !tableFields.isRealtimeEnabled }) - } - size="medium" - /> - )} -
    - - - -
    - {!isDuplicating && ( - onUpdateField({ columns })} - onSelectImportData={() => setIsImportingSpreadsheet(true)} - onClearImportContent={() => { - onUpdateField({ columns: DEFAULT_COLUMNS }) - setImportContent(undefined) - }} - /> - )} - {isDuplicating && ( - <> - setIsDuplicateRows(!isDuplicateRows)} - size="medium" - /> - - )} + + )} - { - setImportContent(prefillData) - setIsImportingSpreadsheet(false) - }} - closePanel={() => setIsImportingSpreadsheet(false)} + { + setImportContent(prefillData) + setIsImportingSpreadsheet(false) + }} + closePanel={() => setIsImportingSpreadsheet(false)} + /> + + setRlsConfirmVisible(false)} + onSelectConfirm={() => { + onUpdateField({ isRLSEnabled: !tableFields.isRLSEnabled }) + setRlsConfirmVisible(false) + }} + > + + + + + + + {!isDuplicating && ( + <> + + + updateEditorDirty()} + onUpdateFkRelations={onUpdateFkRelations} /> - - setRlsConfirmVisible(false)} - onSelectConfirm={() => { - onUpdateField({ isRLSEnabled: !tableFields.isRLSEnabled }) - setRlsConfirmVisible(false) - }} - > - - - - -
    -
    - + + + )} ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/index.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/index.ts index 6b0602c7648..0ce48468acd 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/index.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/index.ts @@ -3,7 +3,6 @@ import RowEditor from './RowEditor/RowEditor' import ColumnEditor from './ColumnEditor/ColumnEditor' import TableEditor from './TableEditor/TableEditor' import SpreadsheetImport from './SpreadsheetImport/SpreadsheetImport' -import ForeignKeySelector from './ForeignKeySelector/ForeignKeySelector' -export { RowEditor, ColumnEditor, TableEditor, ForeignKeySelector, SpreadsheetImport } +export { RowEditor, ColumnEditor, TableEditor, SpreadsheetImport } export default SidePanelEditor diff --git a/apps/studio/components/interfaces/TableGridEditor/TableDefinition.tsx b/apps/studio/components/interfaces/TableGridEditor/TableDefinition.tsx index 24f1b77b044..0487099a77c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableDefinition.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableDefinition.tsx @@ -14,6 +14,7 @@ import useEntityType from 'hooks/misc/useEntityType' import { timeout } from 'lib/helpers' import Link from 'next/link' import { Button } from 'ui' +import Footer from 'components/grid/components/footer' export interface TableDefinitionProps { id?: number @@ -61,17 +62,22 @@ const TableDefinition = ({ id }: TableDefinitionProps) => { entityType?.type === ENTITY_TYPE.VIEW ? `create view ${entityType.schema}.${entityType.name} as\n` : entityType?.type === ENTITY_TYPE.MATERIALIZED_VIEW - ? `create materialized view ${entityType.schema}.${entityType.name} as\n` - : '' + ? `create materialized view ${entityType.schema}.${entityType.name} as\n` + : '' + + const formatDefinition = (value: string) => { + try { + return format(value, { + language: 'postgresql', + keywordCase: 'lower', + }) + } catch (err) { + return value + } + } const formattedDefinition = useMemo( - () => - definition - ? format(prepend + definition, { - language: 'postgresql', - keywordCase: 'lower', - }) - : undefined, + () => (definition ? formatDefinition(prepend + definition) : undefined), [definition] ) @@ -95,39 +101,50 @@ const TableDefinition = ({ id }: TableDefinitionProps) => { if (isLoading) { return ( -
    - +
    +
    + +
    +
    +
    +
    ) } return ( -
    - - -
    + <> +
    + + +
    + +
    + ) } diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 96fd7982412..9ae8ac23b8d 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -10,7 +10,6 @@ import { parseSupaTable, SupabaseGrid, SupaTable } from 'components/grid' import { ERROR_PRIMARY_KEY_NOTFOUND } from 'components/grid/constants' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import Connecting from 'components/ui/Loading/Loading' -import TwoOptionToggle from 'components/ui/TwoOptionToggle' import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' import { ForeignKeyConstraint, @@ -27,7 +26,6 @@ import { EMPTY_ARR } from 'lib/void' import { useGetImpersonatedRole } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' import { Dictionary, SchemaView } from 'types' -import { RoleImpersonationPopover } from '../RoleImpersonationSelector' import GridHeaderActions from './GridHeaderActions' import NotFoundState from './NotFoundState' import SidePanelEditor from './SidePanelEditor' @@ -53,17 +51,8 @@ const TableGridEditor = ({ const { project } = useProjectContext() const snap = useTableEditorStateSnapshot() - const getImpersonatedRole = useGetImpersonatedRole() - - const [{ view: selectedView = 'data' }, setUrlState] = useUrlState() - const setSelectedView = (view: string) => { - if (view === 'data') { - setUrlState({ view: undefined }) - } else { - setUrlState({ view }) - } - } + const [{ view: selectedView = 'data' }] = useUrlState() const canEditTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') const canEditColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') @@ -118,7 +107,7 @@ const TableGridEditor = ({ result: any[] } | undefined - ) + ), ][] } @@ -298,25 +287,6 @@ const TableGridEditor = ({ {canEditViaTableEditor && ( )} - {(isTableSelected || isViewSelected) && ( - <> - {isViewSelected && } - - {canEditViaTableEditor && ( -
    - )} - -
    - -
    - - )} ) : null } @@ -329,9 +299,12 @@ const TableGridEditor = ({ onImportData={snap.onImportData} onError={onError} onSqlQuery={onSqlQuery} - onExpandJSONEditor={(column, row) => + onExpandJSONEditor={(column, row) => { snap.onExpandJSONEditor({ column, row, jsonString: JSON.stringify(row[column]) || '' }) - } + }} + onExpandTextEditor={(column, row) => { + snap.onExpandTextEditor(column, row) + }} onEditForeignKeyColumnValue={snap.onEditForeignKeyColumnValue} showCustomChildren={(isViewSelected || isTableSelected) && selectedView === 'definition'} customHeader={ diff --git a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx index bd5d76de274..c86089cdf14 100644 --- a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx +++ b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx @@ -9,7 +9,6 @@ import { IS_PLATFORM } from 'lib/constants' import SettingsLayout from '../SettingsLayout/SettingsLayout' import { SidebarSection } from './AccountLayout.types' import WithSidebar from './WithSidebar' -import { LayoutWrapper } from '../LayoutWrapper' export interface AccountLayoutProps { title: string @@ -151,17 +150,15 @@ const AccountLayout = ({ children, title, breadcrumbs }: PropsWithChildren{title ? `${title} | Supabase` : 'Supabase'} -
    - - - {children} - - +
    + + {children} +
    ) diff --git a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx index 844aca124fe..e33c0765d0d 100644 --- a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx +++ b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx @@ -4,7 +4,6 @@ import { ReactNode } from 'react' import { Badge, IconArrowUpRight, IconLogOut, Menu } from 'ui' import { useFlag } from 'hooks' -import { LayoutWrapper } from '../LayoutWrapper' import LayoutHeader from '../ProjectLayout/LayoutHeader' import { SidebarLink, SidebarSection } from './AccountLayout.types' @@ -35,12 +34,12 @@ const WithSidebar = ({ const navLayoutV2 = useFlag('navigationLayoutV2') return ( -
    +
    {!hideSidebar && !noContent && ( - @@ -77,11 +76,11 @@ const WithSidebar = ({ })}
    - +
    )}
    {!navLayoutV2 && } -
    {children}
    +
    {children}
    ) diff --git a/apps/studio/components/layouts/AppLayout/AppHeader.tsx b/apps/studio/components/layouts/AppLayout/AppHeader.tsx index fc1e317dd74..7eabf45eb8d 100644 --- a/apps/studio/components/layouts/AppLayout/AppHeader.tsx +++ b/apps/studio/components/layouts/AppLayout/AppHeader.tsx @@ -1,11 +1,11 @@ +import { useParams } from 'common' import Link from 'next/link' import { useRouter } from 'next/router' -import { useParams } from 'common' -import { useFlag, useSelectedOrganization, useSelectedProject } from 'hooks' +import { useSelectedOrganization, useSelectedProject } from 'hooks' import FeedbackDropdown from '../ProjectLayout/LayoutHeader/FeedbackDropdown' import HelpPopover from '../ProjectLayout/LayoutHeader/HelpPopover' -import NotificationsPopover from '../ProjectLayout/LayoutHeader/NotificationsPopover' +import NotificationsPopoverV2 from '../ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover' import BranchDropdown from './BranchDropdown' import EnableBranchingButton from './EnableBranchingButton/EnableBranchingButton' import OrganizationDropdown from './OrganizationDropdown' @@ -13,18 +13,20 @@ import ProjectDropdown from './ProjectDropdown' import SettingsButton from './SettingsButton' import UserSettingsDropdown from './UserSettingsDropdown' +// [Joshen] Just FYI this is only for Nav V2 which is still going through design iteration +// Component is not currently in use + const AppHeader = () => { const router = useRouter() const { ref } = useParams() const project = useSelectedProject() const organization = useSelectedOrganization() - const enableBranchManagement = useFlag('branchManagement') const isBranchingEnabled = project?.is_branch_enabled === true || project?.parent_project_ref !== undefined return ( -
    +
    { {ref !== undefined && } - {ref !== undefined && enableBranchManagement && ( + {ref !== undefined && ( <> {isBranchingEnabled ? : } @@ -47,9 +49,9 @@ const AppHeader = () => {
    - - - + + +
    diff --git a/apps/studio/components/layouts/AppLayout/AppLayout.tsx b/apps/studio/components/layouts/AppLayout/AppLayout.tsx index 7ed065414ee..b8569dfd6c3 100644 --- a/apps/studio/components/layouts/AppLayout/AppLayout.tsx +++ b/apps/studio/components/layouts/AppLayout/AppLayout.tsx @@ -1,25 +1,15 @@ import { PropsWithChildren } from 'react' -import { useIsNoticeBannerShown } from 'components/interfaces/App/AppBannerWrapperContext' import { useFlag } from 'hooks' import AppHeader from './AppHeader' const AppLayout = ({ children }: PropsWithChildren<{}>) => { const navLayoutV2 = useFlag('navigationLayoutV2') - const ongoingIncident = useFlag('ongoingIncident') - const showNoticeBanner = useFlag('showNoticeBanner') - - const noticeAcknowledged = useIsNoticeBannerShown() - - const appHeaderHeight = navLayoutV2 ? 49 : 0 - const bannerHeight = - [ongoingIncident, showNoticeBanner && !noticeAcknowledged].filter(Boolean).length * 44 - const maxHeight = `calc(100vh - ${bannerHeight}px - ${appHeaderHeight}px)` return ( -
    +
    {navLayoutV2 && } -
    {children}
    + {children}
    ) } diff --git a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx index b58ac18b4e0..17f9a772ba0 100644 --- a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -1,4 +1,4 @@ -import { ListTree } from 'lucide-react' +import { ListTree, MessageCircle } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' @@ -15,6 +15,7 @@ import { CommandInput_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, + CommandSeparator_Shadcn_, Command_Shadcn_, IconAlertCircle, IconCheck, @@ -43,7 +44,7 @@ const BranchLink = ({ return ( { setOpen(false) @@ -79,6 +80,8 @@ const BranchDropdown = ({ isNewNav = false }: BranchDropdownProps) => { const [open, setOpen] = useState(false) const selectedBranch = branches?.find((branch) => branch.project_ref === ref) + const BRANCHING_GITHUB_DISCUSSION_LINK = 'https://github.com/orgs/supabase/discussions/18937' + return ( <> {isLoading && } @@ -128,7 +131,8 @@ const BranchDropdown = ({ isNewNav = false }: BranchDropdownProps) => { ))} - + + { @@ -146,6 +150,32 @@ const BranchDropdown = ({ isNewNav = false }: BranchDropdownProps) => { + + + { + setOpen(false) + window?.open(BRANCHING_GITHUB_DISCUSSION_LINK, '_blank')?.focus() + }} + onClick={() => setOpen(false)} + > + { + setOpen(false) + }} + className="w-full flex gap-2" + > + +
    +

    Branching feedback

    +

    Join Github Discussion

    +
    + +
    +
    diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingWaitlistPopover.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingWaitlistPopover.tsx index 5a06a2ce2b0..bf648564688 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingWaitlistPopover.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingWaitlistPopover.tsx @@ -17,7 +17,6 @@ interface BranchingWaitlistPopoverProps { const BranchingWaitlistPopover = ({ isNewNav = false }: BranchingWaitlistPopoverProps) => { const [open, setOpen] = useState(false) - const org = useSelectedOrganization() return ( diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/GithubRepositorySelection.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/GithubRepositorySelection.tsx index 05e34dbde57..e30ee64bfc3 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/GithubRepositorySelection.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/GithubRepositorySelection.tsx @@ -49,8 +49,8 @@ const GithubRepositorySelection = ({ process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' ? `https://github.com/apps/supabase/installations/new?state=${ref}` : process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' - ? `https://github.com/apps/supabase-staging/installations/new?state=${ref}` - : `https://github.com/apps/supabase-local-testing/installations/new?state=${ref}` + ? `https://github.com/apps/supabase-staging/installations/new?state=${ref}` + : `https://github.com/apps/supabase-local-testing/installations/new?state=${ref}` function onSelectConnectRepo() { if (integration) { diff --git a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx index 72e6ffd1208..679a75318a5 100644 --- a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx @@ -1,6 +1,10 @@ +import { useSupabaseClient } from '@supabase/auth-helpers-react' +import { useQuery } from '@tanstack/react-query' +import { useRouter } from 'next/router' +import { useParams } from 'common' + import { useAppBannerContext } from 'components/interfaces/App/AppBannerWrapperContext' import { useProfile } from 'lib/profile' -import { useRouter } from 'next/router' import { Button, IconExternalLink } from 'ui' // [Joshen] For this notice specifically, just FYI @@ -9,12 +13,74 @@ import { Button, IconExternalLink } from 'ui' export const NoticeBanner = () => { const router = useRouter() - const { isLoading } = useProfile() + const { isLoading: isLoadingProfile } = useProfile() const appBannerContext = useAppBannerContext() - const { acknowledged, onUpdateAcknowledged } = appBannerContext + const { + ipv6BannerAcknowledged, + pgbouncerBannerAcknowledged, + vercelBannerAcknowledged, + onUpdateAcknowledged, + } = appBannerContext - if (isLoading || router.pathname.includes('sign-in') || acknowledged) return null + const supabase = useSupabaseClient() + const { ref: projectRef } = useParams() + + // [Alaister]: using inline queries here since this is temporary + const { data, isLoading: isLoadingIpv6Enabled } = useQuery( + ['projects', projectRef, 'pgbouncer-enabled'], + async ({ signal }) => { + let query = supabase.rpc('ipv6_active_status', { project_ref: projectRef }).returns< + { + pgbouncer_active: boolean + vercel_active: boolean + }[] + >() + + if (signal) { + query = query.abortSignal(signal) + } + + const result = await query + + if (result.data === null) { + return { + pgbouncer_active: false, + vercel_active: false, + } + } + + return result.data[0] + }, + { enabled: Boolean(projectRef) } + ) + + const pgbouncerEnabled = data?.pgbouncer_active ?? false + const vercelWithoutSupavisorEnabled = data?.vercel_active ?? false + + // [Joshen] Pgbouncer list and vercel list are mutually exclusive + const pgbouncerProjectAcknowledged = pgbouncerBannerAcknowledged.includes(projectRef ?? '') + const vercelProjectAcknowledged = vercelBannerAcknowledged.includes(projectRef ?? '') + const allAcknowledged = + (!pgbouncerEnabled && !vercelWithoutSupavisorEnabled && ipv6BannerAcknowledged) || + (ipv6BannerAcknowledged && pgbouncerEnabled && pgbouncerProjectAcknowledged) || + (ipv6BannerAcknowledged && vercelWithoutSupavisorEnabled && vercelProjectAcknowledged) + + if ( + isLoadingProfile || + isLoadingIpv6Enabled || + router.pathname.includes('sign-in') || + allAcknowledged + ) { + return null + } + + const currentlyViewing = + pgbouncerEnabled && !pgbouncerProjectAcknowledged + ? ('pgbouncer' as const) + : vercelWithoutSupavisorEnabled && !vercelProjectAcknowledged + ? ('vercel' as const) + : ('ipv6' as const) return (
    { style={{ height: '44px' }} >

    - Prepare for the PgBouncer and IPv4 deprecations on 26th January 2024 + {currentlyViewing === 'pgbouncer' && + 'Our logs on 26th Jan show that you have accessed PgBouncer. Please migrate now. You can ignore this warning if you have already migrated.'} + {currentlyViewing === 'vercel' && + "To prepare for the IPv4 migration, please redeploy your Vercel application to detect the updated environment variables if it hasn't been deployed since 27th January."} + {currentlyViewing === 'ipv6' && + 'We are migrating our infrastructure from IPv4 to IPv6. Please migrate now. You can ignore this warning if you have already migrated.'}

    -
    diff --git a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx index ded658b071d..51ae83afd01 100644 --- a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx @@ -14,6 +14,7 @@ import { CommandInput_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, + CommandSeparator_Shadcn_, Command_Shadcn_, IconCheck, IconCode, @@ -75,12 +76,12 @@ const OrganizationDropdown = ({ isNewNav = false }: OrganizationDropdownProps) = const href = router.pathname.includes('[slug]') ? router.pathname.replace('[slug]', org.slug) : isNewNav - ? `/org/${org.slug}` - : `/org/${org.slug}/general` + ? `/org/${org.slug}` + : `/org/${org.slug}/general` return ( { setOpen(false) @@ -98,21 +99,24 @@ const OrganizationDropdown = ({ isNewNav = false }: OrganizationDropdownProps) = {organizationCreationEnabled && ( - - { - setOpen(false) - router.push(`/new`) - }} - onClick={() => setOpen(false)} - > - - -

    New organization

    - -
    -
    + <> + + + { + setOpen(false) + router.push(`/new`) + }} + onClick={() => setOpen(false)} + > + + +

    New organization

    + +
    +
    + )} diff --git a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx index 9cd404255f5..3d7fa037419 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx @@ -16,6 +16,7 @@ import { CommandInput_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, + CommandSeparator_Shadcn_, Command_Shadcn_, IconCheck, IconCode, @@ -67,7 +68,7 @@ const ProjectLink = ({ return ( { router.push(href) @@ -142,27 +143,30 @@ const ProjectDropdown = ({ isNewNav = false }: ProjectDropdownProps) => { {projectCreationEnabled && ( - - { - setOpen(false) - router.push(`/new/${selectedOrganization?.slug}`) - }} - onClick={() => setOpen(false)} - > - { + <> + + + { setOpen(false) + router.push(`/new/${selectedOrganization?.slug}`) }} - className="w-full flex items-center gap-2" + onClick={() => setOpen(false)} > - -

    New project

    - -
    -
    + { + setOpen(false) + }} + className="w-full flex items-center gap-2" + > + +

    New project

    + +
    +
    + )} diff --git a/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx b/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx index 4d3d62e1312..1a247684ec7 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx +++ b/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx @@ -5,7 +5,7 @@ import { PropsWithChildren } from 'react' import { useIsColumnLevelPrivilegesEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import ProductMenu from 'components/ui/ProductMenu' import { useAuthConfigPrefetch } from 'data/auth/auth-config-query' -import { useFlag, withAuth } from 'hooks' +import { withAuth } from 'hooks' import ProjectLayout from '../' import { generateAuthMenu } from './AuthLayout.utils' @@ -15,7 +15,6 @@ export interface AuthLayoutProps { const AuthLayout = ({ title, children }: PropsWithChildren) => { const { ref: projectRef = 'default' } = useParams() - const hooksReleased = useFlag('authHooksReleased') const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled() useAuthConfigPrefetch({ projectRef }) @@ -30,7 +29,7 @@ const AuthLayout = ({ title, children }: PropsWithChildren) => productMenu={ } isBlocking={false} diff --git a/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts b/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts index e3bfdcfc5b4..4704150201f 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts +++ b/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts @@ -3,7 +3,7 @@ import { IS_PLATFORM } from 'lib/constants' export const generateAuthMenu = ( ref: string, - options: { hooksReleased?: boolean; columnLevelPrivileges?: boolean } = {} + options: { columnLevelPrivileges?: boolean } = {} ): ProductMenuGroup[] => { return [ { @@ -56,17 +56,13 @@ export const generateAuthMenu = ( url: `/project/${ref}/auth/url-configuration`, items: [], }, - ...(options?.hooksReleased - ? [ - { - name: 'Hooks', - key: 'hooks', - url: `/project/${ref}/auth/hooks`, - items: [], - label: 'BETA', - }, - ] - : []), + { + name: 'Hooks', + key: 'hooks', + url: `/project/${ref}/auth/hooks`, + items: [], + label: 'BETA', + }, ] : []), ], diff --git a/apps/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx b/apps/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx index 8e92a748ae4..b36fd094b35 100644 --- a/apps/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx +++ b/apps/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx @@ -4,7 +4,6 @@ import { IconBook, IconLifeBuoy, IconX, LoadingLine, cn } from 'ui' import { withAuth } from 'hooks' import { BASE_PATH } from 'lib/constants' -import { LayoutWrapper } from '../LayoutWrapper' import { ScaffoldContainer } from '../Scaffold' export type IntegrationWindowLayoutProps = { @@ -22,11 +21,11 @@ const IntegrationWindowLayout = ({ docsHref, }: PropsWithChildren) => { return ( - +
    {children}
    - + {docsHref && ( - +
    ) } diff --git a/apps/studio/components/layouts/LayoutWrapper.tsx b/apps/studio/components/layouts/LayoutWrapper.tsx deleted file mode 100644 index 29f9c820395..00000000000 --- a/apps/studio/components/layouts/LayoutWrapper.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useIsNoticeBannerShown } from 'components/interfaces/App/AppBannerWrapperContext' -import { useFlag } from 'hooks' -import { PropsWithChildren } from 'react' - -interface LayoutWrapperProps { - id?: string - className?: string -} - -export const LayoutWrapper = ({ - id, - className, - children, -}: PropsWithChildren) => { - const ongoingIncident = useFlag('ongoingIncident') - const showNoticeBanner = useFlag('showNoticeBanner') - - const noticeAcknowledged = useIsNoticeBannerShown() - - const numBanners = [ongoingIncident, showNoticeBanner && !noticeAcknowledged].filter( - Boolean - ).length - const maxHeight = `calc(100vh - ${numBanners * 44}px)` - - return ( -
    - {children} -
    - ) -} diff --git a/apps/studio/components/layouts/LogsLayout/LogsMenu.utils.ts b/apps/studio/components/layouts/LogsLayout/LogsMenu.utils.ts index 3bd247aecba..d4fd1fd22a2 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsMenu.utils.ts +++ b/apps/studio/components/layouts/LogsLayout/LogsMenu.utils.ts @@ -30,6 +30,7 @@ export const generateLogsMenu = ( })), }, { + title: 'Infrastructure', items: [ { name: IS_PLATFORM ? 'API Edge Network' : 'Kong API', diff --git a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx index 22f7d1d3a79..b139e9a5d34 100644 --- a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx @@ -13,6 +13,7 @@ import { invalidateProjectsQuery } from 'data/projects/projects-query' import { useSelectedProject } from 'hooks' import { getWithTimeout } from 'lib/common/fetch' import { API_URL, PROJECT_STATUS } from 'lib/constants' +import Connect from 'components/interfaces/Home/Connect/Connect' const BuildingState = () => { const { ref } = useParams() diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx index 4222875c6b2..777ede00cce 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx @@ -1,18 +1,8 @@ import { useState } from 'react' -import { - Button, - IconMessageCircle, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, -} from 'ui' +import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_ } from 'ui' import FeedbackWidget from './FeedbackWidget' -interface FeedbackDropdownProps { - alt?: boolean -} - -const FeedbackDropdown = ({ alt = false }: FeedbackDropdownProps) => { +const FeedbackDropdown = () => { const [isOpen, setIsOpen] = useState(false) const [feedback, setFeedback] = useState('') const [screenshot, setScreenshot] = useState() @@ -37,16 +27,7 @@ const FeedbackDropdown = ({ alt = false }: FeedbackDropdownProps) => { }} > - diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPopover.tsx index 492cb55b66a..4a65425fbeb 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPopover.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPopover.tsx @@ -17,11 +17,7 @@ import { Popover_Shadcn_, } from 'ui' -interface HelpPopoverProps { - alt?: boolean -} - -const HelpPopover = ({ alt = false }: HelpPopoverProps) => { +const HelpPopover = () => { const router = useRouter() const projectRef = router.query.ref const supportUrl = `/support/new${projectRef ? `?ref=${projectRef}` : ''}` @@ -34,36 +30,28 @@ const HelpPopover = ({ alt = false }: HelpPopoverProps) => {
    + />
    - {alt ? ( - - - -
    -

    Help

    -
    -
    -
    - ) : null} + + + +
    +

    Help

    +
    +
    +
    diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index c116d26fb6f..8763801d2ef 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -15,7 +15,6 @@ import { IS_PLATFORM } from 'lib/constants' import BreadcrumbsView from './BreadcrumbsView' import FeedbackDropdown from './FeedbackDropdown' import HelpPopover from './HelpPopover' -import NotificationsPopover from './NotificationsPopover' import NotificationsPopoverV2 from './NotificationsPopoverV2/NotificationsPopover' const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = true }: any) => { @@ -23,9 +22,6 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = const selectedProject = useSelectedProject() const selectedOrganization = useSelectedOrganization() - const enableBranchManagement = useFlag('branchManagement') - const notificationsV2 = useFlag('notificationsV2') - const isBranchingEnabled = selectedProject?.is_branch_enabled === true || selectedProject?.parent_project_ref !== undefined @@ -44,9 +40,7 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = const projectHasNoLimits = subscription?.usage_billing_enabled === true const showOverUsageBadge = - useFlag('overusageBadge') && - subscription !== undefined && - (subscription.plan.id === 'free' || subscription.plan.id === 'pro') && + (subscription?.plan.id === 'free' || subscription?.plan.id === 'pro') && !projectHasNoLimits && exceedingLimits @@ -92,7 +86,7 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = )} - {selectedProject && enableBranchManagement && ( + {selectedProject && ( <>
    -
    +
    {customHeaderComponents && customHeaderComponents} - {IS_PLATFORM && } - {IS_PLATFORM && !notificationsV2 && ( - <> - - - - )} - {IS_PLATFORM && notificationsV2 && ( + {IS_PLATFORM && ( <> + - + )}
    diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationActions.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationActions.tsx deleted file mode 100644 index 55e43142abb..00000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationActions.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Action, ActionReason, ActionType } from '@supabase/shared-types/out/notifications' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { Fragment } from 'react' -import { Button, IconExternalLink } from 'ui' - -import { Organization } from 'types' - -interface NotificationActionsProps { - organization: Organization - changelogLink?: string - availableActions: Action[] - onSelectRestartProject: () => void - onSelectApplyMigration: () => void - onSelectRollbackMigration: () => void -} - -const NotificationActions = ({ - organization, - changelogLink, - availableActions, - onSelectRestartProject, - onSelectApplyMigration, - onSelectRollbackMigration, -}: NotificationActionsProps) => { - const router = useRouter() - - const onSelectUpgradePlan = () => { - return router.push(`/org/${organization.slug}/billing?panel=subscriptionPlan`) - } - - const renderActionButton = (action: Action) => { - switch (action.action_type) { - case ActionType.UpgradeProjectToPro: - return ( - - ) - case ActionType.SchedulePostgresRestart: - return ( - - ) - case ActionType.MigratePostgresSchema: - if (action.reason === ActionReason.Rollback) { - return ( - - ) - } else { - return ( - - ) - } - } - } - - return ( -
    - {availableActions.map((action) => { - return ( - - {renderActionButton(action)} - - ) - })} - {changelogLink && ( - - )} -
    - ) -} - -export default NotificationActions diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx deleted file mode 100644 index 514cd5e2ac1..00000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Notification, NotificationStatus } from '@supabase/shared-types/out/notifications' -import dayjs from 'dayjs' -import { Button, IconX } from 'ui' - -import { useNotificationsDismissMutation } from 'data/notifications/notifications-dismiss-mutation' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { ProjectInfo, useProjectsQuery } from 'data/projects/projects-query' -import { useStore } from 'hooks' -import NotificationActions from './NotificationActions' -import { formatNotificationCTAText, formatNotificationText } from './NotificationRows.utils' - -export interface NotificationRowProps { - notification: Notification - onSelectRestartProject: (project: ProjectInfo, notification: Notification) => void - onSelectApplyMigration: (project: ProjectInfo, notification: Notification) => void - onSelectRollbackMigration: (project: ProjectInfo, notification: Notification) => void -} - -const NotificationRow = ({ - notification, - onSelectRestartProject, - onSelectApplyMigration, - onSelectRollbackMigration, -}: NotificationRowProps) => { - const { ui } = useStore() - const { data: projects } = useProjectsQuery() - const { data: organizations } = useOrganizationsQuery() - const project = projects?.find((project) => project.id === notification.project_id) - const organization = organizations?.find((org) => org.id === project?.organization_id) - - const insertedAt = dayjs(notification.inserted_at).format('DD MMM YYYY, HH:mma') - const changelogLink = (notification.data as any).changelog_link - const availableActions = notification.meta?.actions_available ?? [] - - const { mutate: dismissNotifications, isLoading: isDismissing } = useNotificationsDismissMutation( - { - onError: (error) => { - ui.setNotification({ - error, - category: 'error', - message: `Failed to dismiss notification: ${error.message}`, - duration: 4000, - }) - }, - } - ) - - const dismissNotification = async (notificationId: string) => { - if (!notificationId) return - dismissNotifications({ ids: [notificationId] }) - } - - if (!project || !organization) return null - - return ( -
    -
    - {notification.notification_status !== NotificationStatus.Seen && ( -
    - )} -
    -
    -
    -
    - {formatNotificationText(project.name, notification)} - {formatNotificationCTAText(availableActions)} -

    {insertedAt}

    -
    -
    -
    -
    -
    -
    - {(availableActions.length > 0 || changelogLink !== undefined) && ( -
    - onSelectRestartProject(project, notification)} - onSelectApplyMigration={() => onSelectApplyMigration(project, notification)} - onSelectRollbackMigration={() => onSelectRollbackMigration(project, notification)} - /> -
    - )} -
    -
    - ) -} - -export default NotificationRow diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRows.utils.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRows.utils.tsx deleted file mode 100644 index 4e6875d16b1..00000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRows.utils.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { - Action, - ActionType, - ExtensionsUpgrade, - Notification, - NotificationName, - ServerUpgrade, - ServiceUpgrade, - ViolatedLimit, -} from '@supabase/shared-types/out/notifications' -import dayjs from 'dayjs' -import Link from 'next/link' -import { Button, IconArrowRight, IconExternalLink } from 'ui' - -import { Markdown } from 'components/interfaces/Markdown' - -export const formatNotificationText = (projectName: string, notification: Notification) => { - if (notification.data.name === NotificationName.ProjectExceedingTierLimit) { - const { violations } = notification.data - const violationsText = violations - .map((violation: ViolatedLimit) => violation.dimension as string) - .reduce((a: string, b: string) => `${a}, ${b}`) - - return ( -

    - Your project "{projectName}" has exceeded its limits in the following areas:{' '} - {violationsText}. -

    - ) - } else if (notification.data.name === NotificationName.PostgresqlUpgradeAvailable) { - const { upgrade_type, additional } = notification.data - - if (upgrade_type === 'postgresql-server') { - const { version_to } = additional as ServerUpgrade - return ( -

    - New version of Postgres ({version_to}) is now available for project "{projectName}". -

    - ) - } else if (upgrade_type === 'extensions') { - const { name, version_to } = additional as ExtensionsUpgrade - return ( -

    - New version of "{name}" ({version_to}) is now available for project "{projectName}". -

    - ) - } else if (upgrade_type === 'schema-migration') { - const { name, version_to } = additional as ExtensionsUpgrade - return ( -
    -

    A new schema migration is available for your project "{projectName}".

    -
      -
    1. -
      -

      {name}

      - -

      {version_to}

      -
      -
    2. -
    -
    - ) - } - return '' - } else if (notification.data.name === NotificationName.PostgresqlUpgradeCompleted) { - const { upgrade_type, additional } = notification.data - - if (upgrade_type === 'postgresql-server') { - const { version_to } = additional as ServerUpgrade - return ( -

    - Postgres ({version_to}) has been successfully updated to {version_to} for project " - {projectName}". -

    - ) - } else if (upgrade_type === 'extensions') { - const { name, version_to } = additional as ExtensionsUpgrade - return ( -

    - The extension "{name}" has been successfully updated to {version_to} for project " - {projectName}". -

    - ) - } else if (upgrade_type === 'schema-migration') { - const { version_to } = additional - return ( -

    - The schema migration "{version_to}" has been successfully applied for project " - {projectName}". -

    - ) - } - } else if (notification.data.name === NotificationName.ProjectUpdateCompleted) { - const { upgrades } = notification.data - return ( -
    -

    - The following services have been successfully updated for project "{projectName}": -

    -
      - {upgrades.map((upgrade: ServiceUpgrade) => ( -
    1. -
      -

      {upgrade.name}

      - -

      {upgrade.version_to}

      - {upgrade.changelog_link && ( -
      - - - -
      - )} -
      -
    2. - ))} -
    -
    - ) - } else if (notification.data.name === NotificationName.ProjectInformational) { - const buttons = notification.data.linked_buttons ?? [] - return ( - <> - - {buttons.map((button, index) => { - return ( - - - - ) - })} - - ) - } else { - return ( -

    - Unknown notification type: {notification.notification_name} - Please reach out to support - for more information. -

    - ) - } -} - -export const formatNotificationCTAText = (availableActions: Action[]) => { - const [action] = availableActions - if (!action) return

    - - switch (action.action_type) { - case ActionType.SchedulePostgresRestart: - return

    Restart your project to get the latest updates.

    - case ActionType.UpgradeProjectToPro: - return

    Upgrade your project to ensure continued availability.

    - case ActionType.PgBouncerRestart: - return

    Restart your connection pooler to get the latest updates.

    - case ActionType.MigratePostgresSchema: - if (action.deadline) { - return ( -

    - This patch will be automatically applied after{' '} - {dayjs(new Date(action.deadline)).format('DD MMM YYYY, HH:mma')} -

    - ) - } - default: - return '' - } -} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx deleted file mode 100644 index 6ca59e7b333..00000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/index.tsx +++ /dev/null @@ -1,347 +0,0 @@ -import * as Tooltip from '@radix-ui/react-tooltip' -import { - ActionType, - Notification, - NotificationStatus, - PostgresqlUpgradeData, -} from '@supabase/shared-types/out/notifications' -import { useQueryClient } from '@tanstack/react-query' -import dayjs from 'dayjs' -import { useRouter } from 'next/router' -import { Fragment, useState } from 'react' -import { Button, IconArrowRight, IconBell, IconInbox, Popover } from 'ui' - -import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' -import { useNotificationsQuery } from 'data/notifications/notifications-query' -import { useNotificationsUpdateMutation } from 'data/notifications/notifications-update-mutation' -import { getProjectDetail } from 'data/projects/project-detail-query' -import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation' -import { ProjectInfo, setProjectPostgrestStatus } from 'data/projects/projects-query' -import { useStore } from 'hooks' -import { delete_, post } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import NotificationRow from './NotificationRow' - -interface NotificationsPopoverProps { - alt?: boolean -} - -const NotificationsPopover = ({ alt = false }: NotificationsPopoverProps) => { - const router = useRouter() - const { meta, ui } = useStore() - const queryClient = useQueryClient() - - const { data: notifications } = useNotificationsQuery() - const { mutate: updateNotifications } = useNotificationsUpdateMutation({ - onError: () => console.error('Failed to update notifications'), - }) - - const [projectToRestart, setProjectToRestart] = useState() - const [projectToApplyMigration, setProjectToApplyMigration] = useState() - const [projectToRollbackMigration, setProjectToRollbackMigration] = useState() - const [targetNotification, setTargetNotification] = useState() - - const { mutate: restartProjectServices } = useProjectRestartServicesMutation({ - onSuccess: (res, variables) => { - setProjectToRestart(undefined) - setTargetNotification(undefined) - - setProjectPostgrestStatus(queryClient, variables.ref, 'OFFLINE') - ui.setNotification({ category: 'success', message: `Restarting services` }) - router.push(`/project/${variables.ref}`) - }, - }) - - const newNotifications = - notifications?.filter( - (notification) => notification.notification_status === NotificationStatus.New - ) ?? [] - const hasNewNotifications = newNotifications.length > 0 - - const onOpenChange = async (open: boolean) => { - // Mark notifications as seen - if (!open) { - const notificationIds = - notifications - ?.filter((notification) => notification.notification_status === NotificationStatus.New) - .map((notification) => notification.id) ?? [] - if (notificationIds.length > 0) updateNotifications({ ids: notificationIds }) - } - } - - const onConfirmProjectRestart = async () => { - if (!projectToRestart || !targetNotification) return - - const { id } = targetNotification - const { ref, region } = projectToRestart - if (!ref || !region) return console.error('Ref and region required') - - const serviceNamesByActionName: Record = { - [ActionType.PgBouncerRestart]: 'pgbouncer', - [ActionType.SchedulePostgresRestart]: 'postgresql', - [ActionType.MigratePostgresSchema]: 'postgresql', - } - const services: string[] = targetNotification.meta.actions_available - .map((action) => action.action_type) - .filter((actionName) => Object.keys(serviceNamesByActionName).indexOf(actionName) !== -1) - .map((actionName) => serviceNamesByActionName[actionName]) - - restartProjectServices({ ref, region, services: services as any, source_notification_id: id }) - } - - const onConfirmProjectApplyMigration = async () => { - if (!projectToApplyMigration) return - - const data = targetNotification?.data as PostgresqlUpgradeData - const { resource } = data.additional as any - if (!resource) return - - // [Joshen] Leaving this as an exception for RQ due to dynamic URL - const res = await post(`${API_URL}/database/${projectToApplyMigration.ref}/${resource}`, {}) - if (!res.error) { - const project = await getProjectDetail({ ref: projectToApplyMigration.ref }) - if (project) meta.setProjectDetails(project) - - ui.setNotification({ - category: 'success', - message: `Successfully applied migration for project "${projectToApplyMigration.name}"`, - }) - } else { - ui.setNotification({ - error: res.error, - category: 'error', - message: `Failed to apply migration: ${res.error.message}`, - }) - } - setProjectToApplyMigration(undefined) - } - - const onConfirmProjectRollbackMigration = async () => { - if (!projectToRollbackMigration) return - - const data = targetNotification?.data as PostgresqlUpgradeData - const { resource } = data.additional as any - if (!resource) return - - // [Joshen] Leaving this as an exception for RQ due to dynamic URL - const res = await delete_( - `${API_URL}/database/${projectToRollbackMigration.ref}/${resource}`, - {} - ) - if (!res.error) { - const project = await getProjectDetail({ ref: projectToRollbackMigration.ref }) - if (project) meta.setProjectDetails(project) - - ui.setNotification({ - category: 'success', - message: `Successfully rolled back migration for project "${projectToRollbackMigration.name}"`, - }) - } else { - ui.setNotification({ - error: res.error, - category: 'error', - message: `Failed to roll back migration: ${res.error.message}`, - }) - } - setProjectToRollbackMigration(undefined) - } - - if (!notifications || !Array.isArray(notifications)) return null - - return ( - <> - -
    -

    Notifications

    - {/* Area for improvement: Paginate notifications and show in a side panel */} - {/*

    - See all{' '} - {notifications.length > MAX_NOTIFICATIONS_TO_SHOW && `(${notifications.length})`} -

    */} -
    -
    - {notifications.length === 0 ? ( -
    -

    No notifications available

    -
    - ) : ( - <> - {notifications.map((notification, i: number) => ( - - { - setProjectToRestart(project) - setTargetNotification(notification) - }} - onSelectApplyMigration={(project, notification) => { - setProjectToApplyMigration(project) - setTargetNotification(notification) - }} - onSelectRollbackMigration={(project, notification) => { - setProjectToRollbackMigration(project) - setTargetNotification(notification) - }} - /> - {i !== notifications.length - 1 && } - - ))} - - )} -
    -
    - } - > - - -
    - {hasNewNotifications && ( - <> - {alt ? null : ( -
    -
    -
    -
    - )} - - )} -
    - ) : alt ? ( - - ) : ( - - ) - } - iconRight={ - hasNewNotifications ? ( - <> - {alt ? ( - - ) : ( - - )} - - ) : null - } - > - - -
    - - - - -
    - Notifications -
    -
    -
    - - - - setProjectToRestart(undefined)} - onSelectConfirm={onConfirmProjectRestart} - /> - - -
    -

    The following schema migration will be applied to the project

    -
      -
    1. -
      -

      {(targetNotification?.data as any)?.additional?.name}

      - -

      {(targetNotification?.data as any)?.additional?.version_to}

      -
      -
    2. -
    -
    -

    - This change can be rolled back anytime up till{' '} - {dayjs( - new Date(targetNotification?.meta.actions_available?.[0]?.deadline ?? 0) - ).format('DD MMM YYYY, HH:mma ZZ')} - , after which the changes will be finalized and can no longer be undone. -

    -
    - } - buttonLabel="Confirm" - buttonLoadingLabel="Confirm" - onSelectCancel={() => setProjectToApplyMigration(undefined)} - onSelectConfirm={onConfirmProjectApplyMigration} - /> - - -
    -

    The following schema migration will be rolled back for the project

    -
      -
    1. -
      -

      {(targetNotification?.data as any)?.additional?.name}

      - -

      {(targetNotification?.data as any)?.additional?.version_to}

      -
      -
    2. -
    -
    -

    - This migration however will still be applied and finalized after{' '} - {dayjs( - new Date(targetNotification?.meta.actions_available?.[0]?.deadline ?? 0) - ).format('DD MMM YYYY, HH:mma ZZ')} - , after which the changes can no longer be undone. -

    -
    - } - buttonLabel="Confirm" - buttonLoadingLabel="Confirm" - onSelectCancel={() => setProjectToRollbackMigration(undefined)} - onSelectConfirm={onConfirmProjectRollbackMigration} - /> - - ) -} - -export default NotificationsPopover diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx index 2557ef355fb..c525b3c1011 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx @@ -1,18 +1,18 @@ import * as Tooltip from '@radix-ui/react-tooltip' import clsx from 'clsx' import dayjs from 'dayjs' +import { ArchiveRestoreIcon } from 'lucide-react' import Link from 'next/link' import { useEffect, useRef } from 'react' import { useInView } from 'react-intersection-observer' import { Button, IconArchive, IconExternalLink } from 'ui' import { Markdown } from 'components/interfaces/Markdown' +import { CriticalIcon, WarningIcon } from 'components/ui/Icons' import { ItemRenderer } from 'components/ui/InfiniteList' import { Notification, NotificationData } from 'data/notifications/notifications-v2-query' import { ProjectInfo } from 'data/projects/projects-query' -import { ArchiveRestoreIcon } from 'lucide-react' import { Organization } from 'types' -import { CriticalIcon, WarningIcon } from './NotificationsPopover.constants' interface NotificationRowProps { setRowHeight: (idx: number, height: number) => void @@ -67,8 +67,8 @@ const NotificationRow: ItemRenderer = ({ ref={ref} className={clsx( `p-4 flex justify-between gap-x-3 group`, - index !== 0 ? 'border-t' : '', - status !== 'new' ? 'bg-background' : '' + index !== 0 ? 'border-t border-overlay' : '', + status === 'new' ? 'bg-surface-100/50' : 'bg-background' )} >
    @@ -130,13 +130,14 @@ const NotificationRow: ItemRenderer = ({ const url = action.url.includes('[ref]') ? action.url.replace('[ref]', project?.ref ?? '_') : action.url.includes('[slug]') - ? action.url.replace('[slug]', organization?.slug ?? '_') - : action.url + ? action.url.replace('[slug]', organization?.slug ?? '_') + : action.url return ( -
    -

    Filter notifications

    -
    - - - -
    -

    Status

    -
    - + + + + Status + snap.setFilters('unread', 'status')} - /> -

    Unread

    -
    -
    - -
    -

    Priority

    -
    - snap.setFilters('Warning', 'priority')} - /> - -

    Warning

    -
    -
    - snap.setFilters('Critical', 'priority')} - /> - -

    Critical

    -
    -
    - -
    -

    Organizations

    - {(organizations ?? []).map((org) => ( -
    - snap.setFilters(org.slug, 'organizations')} - /> -

    {org.name}

    -
    - ))} -
    - -
    -

    Projects

    - {(projects ?? []).map((project) => ( -
    - snap.setFilters(project.ref, 'projects')} - /> -

    {project.name}

    -
    - ))} -
    -
    - - -
    - -
    + onSelect={() => { + snap.setFilters('unread', 'status') + }} + > + + + Unread + + + + + + Priority + { + snap.setFilters('Warning', 'priority') + }} + className="flex items-center gap-x-2" + > + + + + Warning + + + { + snap.setFilters('Critical', 'priority') + }} + > + + + + Critical + + + + + + Organizations + {(organizations ?? []).map((org) => ( + { + snap.setFilters(org.slug, 'organizations') + }} + > + + + {org.name} + + + ))} + + + + Projects + {(projects ?? []).map((project) => ( + { + snap.setFilters(project.ref, 'projects') + }} + > + + + {project.name} + + + ))} + + + + + snap.resetFilters()} + className="flex gap-x-2 items-center" + > + + Reset filters + + +
    ) diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx index f2f89535ba2..b3c47c85b3a 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx @@ -8,10 +8,13 @@ import { PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, - Tabs, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, + Tabs_Shadcn_, } from 'ui' import AlertError from 'components/ui/AlertError' +import { CriticalIcon, WarningIcon } from 'components/ui/Icons' import InfiniteList from 'components/ui/InfiniteList' import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useNotificationsArchiveAllMutation } from 'data/notifications/notifications-v2-archive-all-mutation' @@ -23,7 +26,6 @@ import { useProjectsQuery } from 'data/projects/projects-query' import { useNotificationsStateSnapshot } from 'state/notifications' import NotificationRow from './NotificationRow' import { NotificationsFilter } from './NotificationsFilter' -import { CriticalIcon, WarningIcon } from './NotificationsPopover.constants' const NotificationsPopoverV2 = () => { const [open, setOpen] = useState(false) @@ -55,15 +57,15 @@ const NotificationsPopoverV2 = () => { activeTab === 'archived' ? 'archived' : snap.filterStatuses.includes('unread') - ? 'new' - : undefined, + ? 'new' + : undefined, filters: { priority: snap.filterPriorities, organizations: snap.filterOrganizations, projects: snap.filterProjects, }, }) - const { data: summary, isSuccess: isSuccessSummary } = useNotificationsSummaryQuery() + const { data: summary } = useNotificationsSummaryQuery() const { mutate: updateNotifications } = useNotificationsV2UpdateMutation() const { mutate: archiveAllNotifications, isLoading: isArchiving } = useNotificationsArchiveAllMutation({ @@ -82,184 +84,188 @@ const NotificationsPopoverV2 = () => { } return ( - <> - { - setOpen(open) - if (!open) markNotificationsRead() - }} - > - - +
    + )}
    )} - - - +
    + {notifications.length > 0 && activeTab === 'inbox' && ( +
    + +
    + )} + + ) } diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx index 022d8c3cbec..90aa9060350 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx @@ -5,7 +5,6 @@ import { FlaskConical } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' import { useRouter } from 'next/router' - import { Button, DropdownMenu, @@ -51,7 +50,6 @@ const NavigationBar = () => { const { project } = useProjectContext() const navLayoutV2 = useFlag('navigationLayoutV2') - const showFeaturePreviews = useFlag('featurePreviews') const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() const { @@ -80,7 +78,7 @@ const NavigationBar = () => {
      @@ -218,17 +216,15 @@ const NavigationBar = () => {

      Account preferences

      - {showFeaturePreviews && ( - snap.setShowFeaturePreviewModal(true)} - onSelect={() => snap.setShowFeaturePreviewModal(true)} - > - -

      Feature previews

      -
      - )} + snap.setShowFeaturePreviewModal(true)} + onSelect={() => snap.setShowFeaturePreviewModal(true)} + > + +

      Feature previews

      +
      )} @@ -240,11 +236,15 @@ const NavigationBar = () => { setTheme(value) }} > - {themes.map((theme: Theme) => ( - - {theme.name} - - ))} + {themes + .filter( + (x) => x.value === 'light' || x.value === 'dark' || x.value === 'system' + ) + .map((theme: Theme) => ( + + {theme.name} + + ))} diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconButton.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconButton.tsx index ee6c1a57b15..d9d466bb5cc 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconButton.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconButton.tsx @@ -21,9 +21,9 @@ const NavigationIconButton = ({ route, isActive = false }: NavigationIconButtonP className={[ 'transition-colors duration-200', 'flex items-center justify-center h-10 w-10 rounded', // Layout - 'bg-background hover:bg-overlay-hover', // Light mode 'text-foreground-lighter hover:text-foreground ', // Dark mode - `${isActive ? 'bg-selection shadow-sm text-foreground' : ''}`, + 'bg-studio hover:bg-surface-200', // Light mode + `${isActive ? '!bg-surface-300 !text-foreground shadow-sm' : ''}`, ].join(' ')} > {route.icon} @@ -33,7 +33,6 @@ const NavigationIconButton = ({ route, isActive = false }: NavigationIconButtonP -
      @@ -19,9 +19,7 @@ const ProductMenuBar = ({ title, children }: PropsWithChildren

      {title}

      -
      - {children} -
      +
      {children}
    ) } diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 88fc29dfe29..bf99be9868e 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -92,12 +92,12 @@ const ProjectLayout = ({ {title ? `${title} | Supabase` : selectedTable - ? `${selectedTable} | ${projectName} | ${organizationName} | Supabase` - : projectName - ? `${projectName} | ${organizationName} | Supabase` - : organizationName - ? `${organizationName} | Supabase` - : 'Supabase'} + ? `${selectedTable} | ${projectName} | ${organizationName} | Supabase` + : projectName + ? `${projectName} | ${organizationName} | Supabase` + : organizationName + ? `${organizationName} | Supabase` + : 'Supabase'} diff --git a/apps/studio/components/layouts/ProjectLayout/UpgradingState/UpgradingState.tsx b/apps/studio/components/layouts/ProjectLayout/UpgradingState/UpgradingState.tsx index bbbfae28bb5..989c9927934 100644 --- a/apps/studio/components/layouts/ProjectLayout/UpgradingState/UpgradingState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/UpgradingState/UpgradingState.tsx @@ -161,8 +161,8 @@ const UpgradingState = () => { (progressStage - 2 <= 0 ? 0 : progressStage > 6 - ? 5 - : progressStage - 2) * -28 + ? 5 + : progressStage - 2) * -28 }px`, }} > @@ -191,15 +191,15 @@ const UpgradingState = () => { isCurrent ? 'text-foreground' : isCompleted - ? 'text-foreground-light' - : 'text-foreground-lighter' + ? 'text-foreground-light' + : 'text-foreground-lighter' } hover:text-foreground transition`} > {isCurrent ? message.progress : isCompleted - ? message.completed - : message.initial} + ? message.completed + : message.initial}

    ) diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx index 248e1ff7772..33dcf5e41e5 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx @@ -45,6 +45,7 @@ const SettingsLayout = ({ title, children }: PropsWithChildren} diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts index 07410c0cb32..ca762cb2df0 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts @@ -65,7 +65,7 @@ export const generateSettingsMenu = ( ], }, { - title: '', + title: 'Configuration', items: [ { name: 'Database', diff --git a/apps/studio/components/layouts/ReportsLayout/ReportsLayout.tsx b/apps/studio/components/layouts/ReportsLayout/ReportsLayout.tsx index 3ec45efeff2..d0d9168bbce 100644 --- a/apps/studio/components/layouts/ReportsLayout/ReportsLayout.tsx +++ b/apps/studio/components/layouts/ReportsLayout/ReportsLayout.tsx @@ -1,78 +1,15 @@ -import { useRouter } from 'next/router' import { PropsWithChildren } from 'react' - -import ProductMenu from 'components/ui/ProductMenu' -import { useIsFeatureEnabled, useSelectedProject, withAuth } from 'hooks' +import { withAuth } from 'hooks' import ProjectLayout from '../' +import ReportsMenu from './ReportsMenu' interface ReportsLayoutProps { title?: string } const ReportsLayout = ({ title, children }: PropsWithChildren) => { - const router = useRouter() - - const project = useSelectedProject() - const page = router.pathname.split('/')[4] || '' - const ref = project?.ref ?? 'default' - - const storageEnabled = useIsFeatureEnabled('project_storage:all') - - const menuItems = [ - { - key: 'reports', - items: [ - { - name: 'Custom reports', - key: '[id]', - url: `/project/${ref}/reports`, - items: [], - }, - ], - }, - { - key: 'builtin-reports', - items: [ - { - name: 'API', - key: 'api-overview', - url: `/project/${ref}/reports/api-overview`, - items: [], - }, - ...(storageEnabled - ? [ - { - name: 'Storage', - key: 'storage', - url: `/project/${ref}/reports/storage`, - items: [], - label: 'NEW', - }, - ] - : []), - - { - name: 'Database', - key: 'database', - url: `/project/${ref}/reports/database`, - items: [], - }, - { - name: 'Query Performance', - key: 'query-performance', - url: `/project/${ref}/reports/query-performance`, - items: [], - }, - ], - }, - ] - return ( - } - > + }>
    {children}
    diff --git a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx new file mode 100644 index 00000000000..07201122f3e --- /dev/null +++ b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx @@ -0,0 +1,331 @@ +import { CreateReportModal } from 'components/interfaces/Reports/Reports.CreateReportModal' +import { UpdateCustomReportModal } from 'components/interfaces/Reports/Reports.UpdateModal' +import ConfirmationModal from 'components/ui/ConfirmationModal' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useContentDeleteMutation } from 'data/content/content-delete-mutation' +import { useContentInsertMutation } from 'data/content/content-insert-mutation' +import { Content, useContentQuery } from 'data/content/content-query' +import { useContentUpsertMutation } from 'data/content/content-upsert-mutation' +import { useIsFeatureEnabled, useSelectedProject } from 'hooks' +import { uuidv4 } from 'lib/helpers' +import Link from 'next/link' +import { useRouter } from 'next/router' +import React from 'react' +import toast from 'react-hot-toast' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + IconChevronDown, + IconEdit2, + IconPlus, + IconTrash, + Menu, + Modal, + cn, +} from 'ui' + +const ReportsMenu = () => { + const router = useRouter() + const insertReport = useContentInsertMutation() + const deleteReport = useContentDeleteMutation() + const updateReport = useContentUpsertMutation() + + const project = useSelectedProject() + const { id } = router.query + const pageKey = (id || router.pathname.split('/')[4]) as string + const ref = project?.ref ?? 'default' + + const [deleteModalOpen, setDeleteModalOpen] = React.useState(false) + const [showNewReportModal, setShowNewReportModal] = React.useState(false) + const [showUpdateReportModal, setShowUpdateReportModal] = React.useState(false) + const [selectedReport, setSelectedReport] = React.useState() + + const storageEnabled = useIsFeatureEnabled('project_storage:all') + + const { data: content, isLoading } = useContentQuery(ref) + + function getReportMenuItems() { + if (!content) return [] + + const reports = content?.content.filter((c) => c.type === 'report') + + const sortedReports = reports?.sort((a, b) => { + if (a.name < b.name) { + return -1 + } + if (a.name > b.name) { + return 1 + } + return 0 + }) + + const reportMenuItems = sortedReports.map((r, idx) => ({ + id: r.id, + name: r.name, + description: r.description || '', + key: r.id || idx + '-report', + url: `/project/${ref}/reports/${r.id}`, + hasDropdownActions: true, + report: r, + })) + + return reportMenuItems + } + + const reportMenuItems = getReportMenuItems() + + const menuItems = [ + { + title: 'Built-in reports', + key: 'builtin-reports', + items: [ + { + name: 'API', + key: 'api-overview', + url: `/project/${ref}/reports/api-overview`, + }, + ...(storageEnabled + ? [ + { + name: 'Storage', + key: 'storage', + url: `/project/${ref}/reports/storage`, + }, + ] + : []), + + { + name: 'Database', + key: 'database', + url: `/project/${ref}/reports/database`, + }, + { + name: 'Query Performance', + key: 'query-performance', + url: `/project/${ref}/reports/query-performance`, + }, + ], + }, + ] + + async function createCustomReport({ name, description }: { name: string; description?: string }) { + try { + if (!ref) return + + const res = await insertReport.mutateAsync({ + projectRef: ref, + payload: { + id: uuidv4(), + type: 'report', + name, + description: description || '', + visibility: 'project', + content: { + schema_version: 1, + period_start: { + time_period: '7d', + date: '', + }, + period_end: { + time_period: 'today', + date: '', + }, + interval: '1d', + layout: [], + }, + }, + }) + toast.success('New report created') + + const newReportId = res[0].id + setShowNewReportModal(false) + router.push(`/project/${ref}/reports/${newReportId}`) + } catch (error) { + toast.error(`Failed to create report. Check console for more details.`) + console.error(error) + } + } + + return ( + + {isLoading ? ( +
    + + + +
    + ) : ( +
    +
    + +
    + {reportMenuItems.length > 0 ? ( +
    + + {reportMenuItems.map((item, idx) => ( + +
    {item.name}
    + + + +
    + ) : null} + {menuItems.map((item, idx) => ( +
    + {item.items ? ( + <> + +
    + {item.items.map((subItem) => ( +
  • + + {subItem.name} + +
  • + ))} +
    + + ) : null} +
    + ))} + { + console.log('debug', newVals) + try { + if (!selectedReport) return + if (!selectedReport.id) return + if (!selectedReport.project_id) return + + await updateReport.mutateAsync({ + projectRef: ref, + payload: { + ...selectedReport, + project_id: selectedReport.project_id, + id: selectedReport.id, + name: newVals.name, + description: newVals.description || '', + }, + }) + toast.success('Report updated') + setShowUpdateReportModal(false) + } catch (error) { + toast.error(`Failed to update report. Check console for more details.`) + console.error(error) + } + }} + onCancel={() => setShowUpdateReportModal(false)} + visible={showUpdateReportModal} + initialValues={{ + name: selectedReport?.name || '', + description: selectedReport?.description || '', + }} + /> + { + if (selectedReport) { + if (!selectedReport.id) return + await deleteReport.mutateAsync({ + projectRef: ref, + ids: [selectedReport.id], + }) + toast.success('Report deleted') + router.push(`/project/${ref}/reports`) + } + setDeleteModalOpen(false) + }} + onSelectCancel={() => setDeleteModalOpen(false)} + > + +
    +
    +
    +

    Are you sure you want to delete '{selectedReport?.name}'?

    +
    +
    +
    +
    +
    + { + setShowNewReportModal(false) + }} + afterSubmit={() => { + setShowNewReportModal(false) + }} + /> +
    + )} +
    + ) +} + +export default ReportsMenu diff --git a/apps/studio/components/layouts/SQLEditorLayout/QueryItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/QueryItem.tsx index 32218fbf595..3f5cb817baf 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/QueryItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/QueryItem.tsx @@ -1,29 +1,19 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import clsx from 'clsx' import { useParams } from 'common' -import { observer } from 'mobx-react-lite' +import { noop } from 'lodash' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useRef, useState } from 'react' +import toast from 'react-hot-toast' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, + Checkbox, + Checkbox_Shadcn_, IconAlertCircle, IconAlertTriangle, - IconChevronDown, - IconCopy, - IconDownload, - IconEdit2, IconEye, - IconShare, - IconTrash, IconUnlock, Modal, TooltipContent_Shadcn_, @@ -33,28 +23,67 @@ import { import DownloadSnippetModal from 'components/interfaces/SQLEditor/DownloadSnippetModal' import RenameQueryModal from 'components/interfaces/SQLEditor/RenameQueryModal' -import { createSqlSnippetSkeleton } from 'components/interfaces/SQLEditor/SQLEditor.utils' import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' import ConfirmationModal from 'components/ui/ConfirmationModal' import CopyButton from 'components/ui/CopyButton' import { useContentDeleteMutation } from 'data/content/content-delete-mutation' import { SqlSnippet } from 'data/content/sql-snippets-query' -import { useCheckPermissions, useSelectedProject, useStore } from 'hooks' -import { IS_PLATFORM } from 'lib/constants' -import { uuidv4 } from 'lib/helpers' -import { useProfile } from 'lib/profile' import { useSqlEditorStateSnapshot } from 'state/sql-editor' +import { QueryItemActions } from './QueryItemActions' export interface QueryItemProps { tabInfo: SqlSnippet + isSelected?: boolean + hasQueriesSelected?: boolean + onSelectQuery?: (isShiftHeld: boolean) => void + onDeleteQuery?: (ids: string[]) => void } -const QueryItem = ({ tabInfo }: QueryItemProps) => { +const QueryItem = ({ + tabInfo, + isSelected = false, + hasQueriesSelected = false, + onSelectQuery = noop, + onDeleteQuery = noop, +}: QueryItemProps) => { + const [open, setOpen] = useState(false) + const [renameModalOpen, setRenameModalOpen] = useState(false) + const [shareModalOpen, setShareModalOpen] = useState(false) + const [deleteModalOpen, setDeleteModalOpen] = useState(false) + const [isDownloadSnippetModalOpen, setIsDownloadSnippetModalOpen] = useState(false) + const { ref, id: activeId } = useParams() - const { id, name, description, content } = tabInfo || {} + const snap = useSqlEditorStateSnapshot() + const { id, name, description, content, visibility } = tabInfo || {} const isActive = id === activeId const activeItemRef = useRef(null) + const hideTooltip = + open || + isActive || + renameModalOpen || + shareModalOpen || + deleteModalOpen || + isDownloadSnippetModalOpen + + const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({ + onSuccess: (data) => onDeleteQuery(data), + onError: (error, data) => { + console.log({ error }) + if (error.message.includes('Contents not found')) { + onDeleteQuery(data.ids) + } else { + toast.error(`Failed to delete query: ${error.message}`) + } + }, + }) + + const onConfirmDelete = async () => { + if (!ref) return console.error('Project ref is required') + if (!id) return console.error('Snippet ID is required') + deleteContent({ projectRef: ref, ids: [id] }) + } + useEffect(() => { // scroll to active item if (isActive && activeItemRef.current) { @@ -65,262 +94,113 @@ const QueryItem = ({ tabInfo }: QueryItemProps) => { } }) - return ( - - -
    ) : null} - > - -

    - {name} -

    - -
    {}
    -
    -
    - {!isActive && ( - -

    Query preview:

    -
    - {content.sql.trim() ? ( - - {content.sql.replaceAll('\n', ' ').replaceAll(/\s+/g, ' ').slice(0, 43) + - `${content.sql.length > 43 ? '...' : ''}`} - - ) : ( -

    This query is empty

    - )} - {content.sql.trim() && ( - - )} -
    -
    - )} -
    - ) -} - -export default QueryItem - -interface QueryItemActionsProps { - tabInfo: SqlSnippet - activeId: string | undefined -} - -const QueryItemActions = observer(({ tabInfo, activeId }: QueryItemActionsProps) => { - const { ui } = useStore() - const { ref } = useParams() - const router = useRouter() - const { profile } = useProfile() - - const snap = useSqlEditorStateSnapshot() - const project = useSelectedProject() - - const { id: snippetID } = tabInfo || {} - const snippet = - snippetID !== undefined && snap.snippets && snap.snippets[snippetID] !== undefined - ? snap.snippets[snippetID] - : null - - const isSnippetOwner = profile?.id === snippet?.snippet.owner_id - - const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({ - onSuccess(data) { - if (data.id) snap.removeSnippet(data.id) - - const existingSnippetIds = (snap.orders[ref!] ?? []).filter((x) => x !== id) - if (existingSnippetIds.length === 0) { - router.push(`/project/${ref}/sql/new`) - } else { - router.push(`/project/${ref}/sql/${existingSnippetIds[0]}`) - } - }, - onError(error, data) { - if (error.code === 404 && error.message.includes('Content not found')) { - if (data.id) snap.removeSnippet(data.id) - const existingSnippetIds = (snap.orders[ref!] ?? []).filter((x) => x !== id) - if (existingSnippetIds.length === 0) { - router.push(`/project/${ref}/sql/new`) - } else { - router.push(`/project/${ref}/sql/${existingSnippetIds[0]}`) - } - } else { - ui.setNotification({ - category: 'error', - message: `Failed to delete query: ${error.message}`, - }) - } - }, - }) - - const { id, name, visibility, content } = tabInfo || {} - const [renameModalOpen, setRenameModalOpen] = useState(false) - const [shareModalOpen, setShareModalOpen] = useState(false) - const [deleteModalOpen, setDeleteModalOpen] = useState(false) - const [isDownloadSnippetModalOpen, setIsDownloadSnippetModalOpen] = useState(false) - const isActive = id === activeId - - const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'sql', owner_id: profile?.id }, - subject: { id: profile?.id }, - }) - - const onCloseRenameModal = () => { - setRenameModalOpen(false) - } - - const onClickRename = (e: any) => { - e.stopPropagation() - setRenameModalOpen(true) - } - - const onClickShare = (e: any) => { - e.stopPropagation() - setShareModalOpen(true) - } - - const onClickDelete = (e: any) => { - e.stopPropagation() - setDeleteModalOpen(true) - } - const onConfirmShare = async () => { if (id) { try { snap.shareSnippet(id, 'project') return Promise.resolve() } catch (error: any) { - ui.setNotification({ - error, - category: 'error', - message: `Failed to share query: ${error.message}`, - }) + toast.error(`Failed to share query: ${error.message}`) } } } - const onConfirmDelete = async () => { - if (!ref) return console.error('Project ref is required') - if (!id) return console.error('Snippet ID is required') - deleteContent({ projectRef: ref, id }) - } - - const createPersonalCopy = async () => { - if (!ref) return console.error('Project ref is required') - if (!id) return console.error('Snippet ID is required') - try { - const snippet = createSqlSnippetSkeleton({ - id: uuidv4(), - name, - sql: content.sql, - owner_id: profile?.id, - project_id: project?.id, - }) - snap.addSnippet(snippet as SqlSnippet, ref) - snap.addNeedsSaving(snippet.id!) - router.push(`/project/${ref}/sql/${snippet.id}`) - } catch (error: any) { - ui.setNotification({ - category: 'error', - message: `Failed to create a personal copy of this query: ${error.message}`, - }) - } - } - return ( -
    - {IS_PLATFORM ? ( - - - - - - - - {isSnippetOwner && ( - - -

    Rename query

    -
    + <> + + +
    - -

    Share query

    - + ref={isActive ? (activeItemRef as React.RefObject) : null} + > + {visibility === 'user' && ( + input]:border-foreground-lighter', + hasQueriesSelected ? '' : 'opacity-0 group-hover:opacity-100' + )} + checked={isSelected} + onChange={(event) => { + onSelectQuery((event.nativeEvent as KeyboardEvent).shiftKey) + }} + /> )} - {visibility === 'project' && canCreateSQLSnippet && ( - - -

    Duplicate personal copy

    -
    - )} - - {IS_PLATFORM && ( - setIsDownloadSnippetModalOpen(!isDownloadSnippetModalOpen)} - className="space-x-2" +
    + - -

    Download as migration file

    - - )} - {isSnippetOwner && ( - <> - - - -

    Delete query

    -
    - - )} - - - ) : ( - - )} + {name} + + {!hasQueriesSelected && ( + setDeleteModalOpen(true)} + onSelectRenameQuery={() => setRenameModalOpen(true)} + onSelectShareQuery={() => setShareModalOpen(true)} + onSelectDownloadQuery={() => setIsDownloadSnippetModalOpen(true)} + /> + )} +
    +
    +
    + {!hideTooltip && ( + +

    Query preview:

    +
    + {content.sql.trim() ? ( + + {content.sql.replaceAll('\n', ' ').replaceAll(/\s+/g, ' ').slice(0, 43) + + `${content.sql.length > 43 ? '...' : ''}`} + + ) : ( +

    This query is empty

    + )} + {content.sql.trim() && ( + + )} +
    +
    + )} +
    setRenameModalOpen(false)} + onComplete={() => setRenameModalOpen(false)} /> setIsDownloadSnippetModalOpen(false)} /> -
    + ) -}) +} + +export default QueryItem diff --git a/apps/studio/components/layouts/SQLEditorLayout/QueryItemActions.tsx b/apps/studio/components/layouts/SQLEditorLayout/QueryItemActions.tsx new file mode 100644 index 00000000000..a01b5a7c930 --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/QueryItemActions.tsx @@ -0,0 +1,164 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import clsx from 'clsx' +import { IS_PLATFORM, useParams } from 'common' +import { useRouter } from 'next/router' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + IconChevronDown, + IconCopy, + IconDownload, + IconEdit2, + IconShare, + IconTrash, +} from 'ui' + +import { createSqlSnippetSkeleton } from 'components/interfaces/SQLEditor/SQLEditor.utils' +import { SqlSnippet } from 'data/content/sql-snippets-query' +import { useCheckPermissions, useSelectedProject, useStore } from 'hooks' +import { uuidv4 } from 'lib/helpers' +import { useProfile } from 'lib/profile' +import { useSqlEditorStateSnapshot } from 'state/sql-editor' + +interface QueryItemActionsProps { + tabInfo: SqlSnippet + activeId: string | undefined + open: boolean + setOpen: (value: boolean) => void + onSelectRenameQuery: () => void + onSelectDeleteQuery: () => void + onSelectShareQuery: () => void + onSelectDownloadQuery: () => void +} + +export const QueryItemActions = ({ + tabInfo, + activeId, + open, + setOpen, + onSelectRenameQuery, + onSelectDeleteQuery, + onSelectShareQuery, + onSelectDownloadQuery, +}: QueryItemActionsProps) => { + const { ui } = useStore() + const { ref } = useParams() + const router = useRouter() + const { profile } = useProfile() + + const snap = useSqlEditorStateSnapshot() + const project = useSelectedProject() + + const { id, name, visibility, content } = tabInfo || {} + + const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, + }) + + const { id: snippetID } = tabInfo || {} + const snippet = + snippetID !== undefined && snap.snippets && snap.snippets[snippetID] !== undefined + ? snap.snippets[snippetID] + : null + + const isSnippetOwner = profile?.id === snippet?.snippet.owner_id + + const onClickRename = (e: any) => { + e.stopPropagation() + onSelectRenameQuery() + } + + const onClickShare = (e: any) => { + e.stopPropagation() + onSelectShareQuery() + } + + const onClickDelete = (e: any) => { + e.stopPropagation() + onSelectDeleteQuery() + } + + const createPersonalCopy = async () => { + if (!ref) return console.error('Project ref is required') + if (!id) return console.error('Snippet ID is required') + try { + const snippet = createSqlSnippetSkeleton({ + id: uuidv4(), + name, + sql: content.sql, + owner_id: profile?.id, + project_id: project?.id, + }) + snap.addSnippet(snippet as SqlSnippet, ref) + snap.addNeedsSaving(snippet.id!) + router.push(`/project/${ref}/sql/${snippet.id}`) + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to create a personal copy of this query: ${error.message}`, + }) + } + } + + return ( +
    + {IS_PLATFORM ? ( + + + + )} +
    + ) +} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.utils.ts b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.utils.ts new file mode 100644 index 00000000000..f11d4973e29 --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.utils.ts @@ -0,0 +1,23 @@ +import { uniq } from 'lodash' + +export const selectItemsInRange = (id: string, snippets: any[], selectedSnippets: string[]) => { + const lastSelectedItemIndex = snippets.findIndex( + (x) => x.id === selectedSnippets[selectedSnippets.length - 1] + ) + const selectedItemIndex = snippets.findIndex((x) => x.id === id) + + // Get the start and end index of the range to select + const start = Math.min(selectedItemIndex, lastSelectedItemIndex) + const end = Math.max(selectedItemIndex, lastSelectedItemIndex) + + // Get the range to select and reverse the order if necessary + const rangeToSelect = snippets.slice(start, end + 1) + if (selectedItemIndex < lastSelectedItemIndex) rangeToSelect.reverse() + + if (selectedSnippets.includes(id)) { + const rangeToDeselectIds = rangeToSelect.map((item) => item.id) + return selectedSnippets.filter((x) => x === id || !rangeToDeselectIds.includes(x)) + } else { + return uniq(selectedSnippets.concat(rangeToSelect.map((x) => x.id) as string[])) + } +} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index f186c21f9d2..2be0ab6e683 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -3,32 +3,50 @@ import { useParams } from 'common' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' import { useMemo, useState } from 'react' -import { Button, IconPlus, IconSearch, IconX, Input, Menu, cn } from 'ui' +import toast from 'react-hot-toast' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + IconPlus, + IconSearch, + IconX, + Input, + Menu, + Modal, + Separator, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, + Tooltip_Shadcn_, +} from 'ui' import { untitledSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor.constants' import { createSqlSnippetSkeleton } from 'components/interfaces/SQLEditor/SQLEditor.utils' +import ConfirmationModal from 'components/ui/ConfirmationModal' +import { WarningIcon } from 'components/ui/Icons' import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem' import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useContentDeleteMutation } from 'data/content/content-delete-mutation' import { SqlSnippet, useSqlSnippetsQuery } from 'data/content/sql-snippets-query' import { useCheckPermissions, useSelectedProject, useStore } from 'hooks' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' import { useSnippets, useSqlEditorStateSnapshot } from 'state/sql-editor' import QueryItem from './QueryItem' +import { partition, uniq, uniqBy } from 'lodash' +import { selectItemsInRange } from './SQLEditorLayout.utils' const SideBarContent = observer(() => { const { ui } = useStore() - const { ref } = useParams() + const { ref, id: activeId } = useParams() const router = useRouter() const { profile } = useProfile() const project = useSelectedProject() - const [personalSnippetsFilterString, setPersonalSnippetsFilterString] = useState('') - const [projectSnippetsFilterString, setProjectSnippetsFilterString] = useState('') - const [favoritesFilterString, setFavoritesFilterString] = useState('') - const [isPersonalSnippetsFilterOpen, setIsPersonalSnippetsFilterOpen] = useState(false) - const [isProjectSnippetsFilterOpen, setIsProjectSnippetsFilterOpen] = useState(false) - const [isFavoritesFilterOpen, setIsFavoritesFilterOpen] = useState(false) + const [searchText, setSearchText] = useState('') + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [selectedQueries, setSelectedQueries] = useState([]) const snap = useSqlEditorStateSnapshot() const { isLoading, isSuccess } = useSqlSnippetsQuery(ref, { @@ -39,6 +57,29 @@ const SideBarContent = observer(() => { }, }) + const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({ + onSuccess: (data) => postDeleteCleanup(data), + onError: (error, data) => { + if (error.code === 404 && error.message.includes('Content not found')) { + postDeleteCleanup(data.ids) + } else { + toast.error(`Failed to delete queries: ${error.message}`) + } + }, + }) + + const postDeleteCleanup = (ids: string[]) => { + setShowDeleteModal(false) + setSelectedQueries([]) + if (ids.length > 0) ids.forEach((id) => snap.removeSnippet(id)) + const existingSnippetIds = (snap.orders[ref!] ?? []).filter((x) => !ids.includes(x)) + if (existingSnippetIds.length === 0) { + router.push(`/project/${ref}/sql/new`) + } else if (ids.includes(activeId as string)) { + router.push(`/project/${ref}/sql/${existingSnippetIds[0]}`) + } + } + const snippets = useSnippets(ref) const projectSnippets = useMemo(() => { @@ -46,45 +87,90 @@ const SideBarContent = observer(() => { }, [snippets]) const filteredProjectSnippets = useMemo(() => { - if (projectSnippetsFilterString.length > 0) { + if (searchText.length > 0) { return projectSnippets.filter((tab) => - tab.name.toLowerCase().includes(projectSnippetsFilterString.toLowerCase()) + tab.name.toLowerCase().includes(searchText.toLowerCase()) ) } return projectSnippets - }, [projectSnippets, projectSnippetsFilterString]) + }, [projectSnippets, searchText]) const personalSnippets = useMemo(() => { const ss = snippets.filter( (snippet) => snippet.visibility === 'user' && !snippet.content.favorite ) - if (personalSnippetsFilterString.length > 0) { - return ss.filter((tab) => - tab.name.toLowerCase().includes(personalSnippetsFilterString.toLowerCase()) - ) + if (searchText.length > 0) { + return ss.filter((tab) => tab.name.toLowerCase().includes(searchText.toLowerCase())) } return ss - }, [personalSnippetsFilterString, snippets]) + }, [searchText, snippets]) const favoriteSnippets = useMemo(() => { return snippets.filter((snippet) => snippet.content.favorite) }, [snippets]) const filteredFavoriteSnippets = useMemo(() => { - if (favoritesFilterString.length > 0) { + if (searchText.length > 0) { return favoriteSnippets.filter((tab) => - tab.name.toLowerCase().includes(favoritesFilterString.toLowerCase()) + tab.name.toLowerCase().includes(searchText.toLowerCase()) ) } return favoriteSnippets - }, [favoriteSnippets, favoritesFilterString]) + }, [favoriteSnippets, searchText]) const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'sql', owner_id: profile?.id }, subject: { id: profile?.id }, }) + const onSelectQuery = (id?: string, isShiftHeld: boolean = false) => { + if (id === undefined) return + + if (isShiftHeld) { + const [selectedFavSnippets, selectedOtherSnippets] = partition( + selectedQueries, + (id) => snippets.find((x) => x.id === id)?.content.favorite + ) + const selectedSnippet = snippets.find((x) => x.id === id) + const isFavourite = selectedSnippet?.content.favorite + + if (isFavourite) { + if (selectedFavSnippets.length > 0) { + const updatedSelectedFavSnippets = selectItemsInRange( + id, + filteredFavoriteSnippets, + selectedFavSnippets + ) + setSelectedQueries(updatedSelectedFavSnippets.concat(selectedOtherSnippets)) + } else { + selectQuery(id) + } + } else { + if (selectedOtherSnippets.length > 0) { + const updatedSelectedOtherSnippets = selectItemsInRange( + id, + personalSnippets, + selectedOtherSnippets + ) + setSelectedQueries(updatedSelectedOtherSnippets.concat(selectedFavSnippets)) + } else { + selectQuery(id) + } + } + } else { + selectQuery(id) + } + } + + const selectQuery = (id: string) => { + if (selectedQueries.includes(id)) { + setSelectedQueries(selectedQueries.filter((x) => x !== id)) + } else { + setSelectedQueries(selectedQueries.concat([id])) + } + } + const handleNewQuery = async () => { if (!ref) return console.error('Project is required') if (!profile) return console.error('Profile is required') @@ -106,13 +192,7 @@ const SideBarContent = observer(() => { snap.addSnippet(snippet as SqlSnippet, ref) router.push(`/project/${ref}/sql/${snippet.id}`) - // reset all search inputs when a new query is added - setPersonalSnippetsFilterString('') - setProjectSnippetsFilterString('') - setFavoritesFilterString('') - setIsPersonalSnippetsFilterOpen(false) - setIsProjectSnippetsFilterOpen(false) - setIsFavoritesFilterOpen(false) + setSearchText('') } catch (error: any) { ui.setNotification({ category: 'error', @@ -121,254 +201,206 @@ const SideBarContent = observer(() => { } } + const onConfirmDelete = async () => { + if (!ref) return console.error('Project ref is required') + deleteContent({ projectRef: ref, ids: selectedQueries }) + } + return ( -
    - - {isLoading ? ( -
    - - - -
    - ) : isSuccess ? ( -
    -
    - + <> +
    + + {isLoading ? ( +
    + + +
    -
    -
    - - +
    + + + } + placeholder="Search queries" + onChange={(e) => setSearchText(e.target.value.trim())} + value={searchText} + size="tiny" + actions={ + searchText && ( + + ) + } />
    - {favoriteSnippets.length >= 1 && ( -
    -
    - - +
    + {searchText.length === 0 && ( +
    + +
    - {isFavoritesFilterOpen && ( -
    - } - placeholder="Filter" - disabled={isLoading} - onChange={(e) => setFavoritesFilterString(e.target.value)} - value={favoritesFilterString} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setIsFavoritesFilterOpen(false) - setFavoritesFilterString('') - } - }} - actions={ - favoritesFilterString && ( - setFavoritesFilterString('')} - /> - ) - } - /> -
    - )} - {filteredFavoriteSnippets.length > 0 ? ( -
    - {filteredFavoriteSnippets.map((tabInfo) => { - const { id } = tabInfo || {} - return - })} -
    - ) : ( -
    - No queries found -
    - )} -
    - )} + )} - {projectSnippets.length >= 1 && ( -
    -
    - - -
    - {isProjectSnippetsFilterOpen && ( -
    - } - placeholder="Filter" - disabled={isLoading} - onChange={(e) => setProjectSnippetsFilterString(e.target.value)} - value={projectSnippetsFilterString} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setIsProjectSnippetsFilterOpen(false) - setProjectSnippetsFilterString('') - } - }} - actions={ - projectSnippetsFilterString && ( - setProjectSnippetsFilterString('')} - /> - ) - } - /> + {searchText.length > 0 && + filteredFavoriteSnippets.length === 0 && + filteredProjectSnippets.length === 0 && + personalSnippets.length === 0 && ( +
    +
    +

    No queries found

    +

    + Your search for "{searchText}" did not return any results +

    +
    +
    )} - {filteredProjectSnippets.length > 0 ? ( + + {filteredProjectSnippets.length > 0 ? ( +
    + +
    {filteredProjectSnippets.map((tabInfo) => { const { id } = tabInfo || {} - return - })} -
    - ) : ( -
    - No queries found -
    - )} -
    - )} - -
    -
    - - -
    - {isPersonalSnippetsFilterOpen && ( -
    - } - placeholder="Filter" - disabled={isLoading} - onChange={(e) => setPersonalSnippetsFilterString(e.target.value)} - value={personalSnippetsFilterString} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setIsPersonalSnippetsFilterOpen(false) - setPersonalSnippetsFilterString('') - } - }} - actions={ - personalSnippetsFilterString && ( - setPersonalSnippetsFilterString('')} + return ( + 0} /> ) - } - /> + })} +
    + ) : null} + + {selectedQueries.length > 0 && ( + <> + +
    + + + +
    + )} + + {filteredFavoriteSnippets.length > 0 ? ( +
    + +
    + {filteredFavoriteSnippets.map((tabInfo) => { + const { id } = tabInfo || {} + return ( + 0} + onSelectQuery={(isShiftHeld) => onSelectQuery(id, isShiftHeld)} + onDeleteQuery={postDeleteCleanup} + /> + ) + })} +
    +
    + ) : null} + {personalSnippets.length > 0 ? ( -
    - {personalSnippets.map((tabInfo) => { - const { id } = tabInfo || {} - return - })} +
    + +
    + {personalSnippets.map((tabInfo) => { + const { id } = tabInfo || {} + return ( + 0} + onSelectQuery={(isShiftHeld) => onSelectQuery(id, isShiftHeld)} + onDeleteQuery={postDeleteCleanup} + /> + ) + })} +
    - ) : ( -
    - {filteredFavoriteSnippets.length === 0 && ( - No queries found - )} - -
    - )} + ) : null} +
    +
    + ) : ( +
    + )} +
    +
    + setShowDeleteModal(false)} + > + +
    +
    +
    + + + This action cannot be undone + + The selected SQL snippets cannot be recovered once deleted + + +

    + Are you sure you want to delete the selected {selectedQueries.length} quer + {selectedQueries.length > 1 ? 'ies' : 'y'}? +

    - ) : ( -
    - )} -
    -
    + + + ) }) diff --git a/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx b/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx index 10be4f94b9e..e16d6be6636 100644 --- a/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx +++ b/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx @@ -22,7 +22,7 @@ const ForgotPasswordLayout = ({ const { resolvedTheme } = useTheme() return ( -
    +
    () + const { data: selectedTableData } = useTable(selectedTable?.id) // [Joshen] Separate state required to handle edit/delete table // since selectedTable above handles the state for ColumnList @@ -54,9 +57,12 @@ const DatabaseTables: NextPageWithLayout = () => { + - ) } diff --git a/apps/studio/pages/project/[ref]/index.tsx b/apps/studio/pages/project/[ref]/index.tsx index b0ae789bcc6..0fd15adb53b 100644 --- a/apps/studio/pages/project/[ref]/index.tsx +++ b/apps/studio/pages/project/[ref]/index.tsx @@ -2,7 +2,9 @@ import { useEffect, useRef } from 'react' import { useParams } from 'common' import { ClientLibrary, ExampleProject } from 'components/interfaces/Home' +import Connect from 'components/interfaces/Home/Connect/Connect' import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' +import ProjectUsageSection from 'components/interfaces/Home/ProjectUsageSection' import ServiceStatus from 'components/interfaces/Home/ServiceStatus' import { ProjectLayoutWithAuth } from 'components/layouts' import ProjectPausedState from 'components/layouts/ProjectLayout/ProjectPausedState' @@ -11,7 +13,6 @@ import { useSelectedProject } from 'hooks' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' import { NextPageWithLayout } from 'types' -import ProjectUsageSection from 'components/interfaces/Home/ProjectUsageSection' const Home: NextPageWithLayout = () => { const project = useSelectedProject() @@ -37,7 +38,10 @@ const Home: NextPageWithLayout = () => {

    {projectName}

    - {IS_PLATFORM && project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && } +
    + {IS_PLATFORM && project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && } + {IS_PLATFORM && project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && } +
    diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index d7e4d80c505..2f4c666aba0 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -3,7 +3,7 @@ import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { Button, Form, Input, Modal } from 'ui' - +import Split from 'react-split' import { useParams } from 'common/hooks' import { DatePickerToFrom, @@ -21,7 +21,6 @@ import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt' import { LogsLayout } from 'components/layouts' import CodeEditor from 'components/ui/CodeEditor' import LoadingOpacity from 'components/ui/LoadingOpacity' -import LogsExplorerHeader from 'components/ui/Logs/LogsExplorerHeader' import ShimmerLine from 'components/ui/ShimmerLine' import { useContentInsertMutation } from 'data/content/content-insert-mutation' import { useLocalStorage, useSelectedOrganization, useStore } from 'hooks' @@ -136,7 +135,11 @@ export const LogsExplorerPage: NextPageWithLayout = () => { } const handleInsertSource = (source: LogsTableName) => { - setEditorValue((prev) => prev + source) + setEditorValue((prev) => { + const index = prev.indexOf('from') + if (index === -1) return `${prev}${source}` + return `${prev.substring(0, index + 4)} ${source} ${prev.substring(index + 5)}` + }) setEditorId(uuidv4()) } @@ -183,18 +186,24 @@ export const LogsExplorerPage: NextPageWithLayout = () => { }) return ( -
    - - -
    -
    +
    + +
    template.mode === 'custom')} onSelectTemplate={onSelectTemplate} @@ -202,21 +211,23 @@ export const LogsExplorerPage: NextPageWithLayout = () => { isLoading={isLoading} warnings={warnings} /> -
    - - setEditorValue(v || '')} - onInputRun={handleRun} - /> -
    + + + setEditorValue(v || '')} + onInputRun={handleRun} + />
    -
    +
    -
    +
    {
    -
    + + setSaveModalOpen(!saveModalOpen)} diff --git a/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx b/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx index e3e876d3942..e55fdcb38af 100644 --- a/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx @@ -9,22 +9,17 @@ import Connecting from 'components/ui/Loading/Loading' export const LogPage: NextPageWithLayout = () => { const { ref } = useParams() - const { data: poolingConfiguration, isLoading } = usePoolingConfigurationQuery({ - projectRef: ref ?? 'default', - }) + const { isLoading } = usePoolingConfigurationQuery({ projectRef: ref ?? 'default' }) - // this prevents initial load of pgbouncer logs before config has been retrieved - if (isLoading) { - return - } - const isSupavisorEnabled = poolingConfiguration?.supavisor_enabled ?? false + // this prevents initial load of pooler logs before config has been retrieved + if (isLoading) return return ( ) } diff --git a/apps/studio/pages/project/[ref]/reports/[id].tsx b/apps/studio/pages/project/[ref]/reports/[id].tsx index 01101070ba6..9d61091362b 100644 --- a/apps/studio/pages/project/[ref]/reports/[id].tsx +++ b/apps/studio/pages/project/[ref]/reports/[id].tsx @@ -6,7 +6,7 @@ import { NextPageWithLayout } from 'types' const PageLayout: NextPageWithLayout = () => ( <> -
    +
    diff --git a/apps/studio/pages/project/[ref]/reports/index.tsx b/apps/studio/pages/project/[ref]/reports/index.tsx index 0b98abcba3e..05111098d05 100644 --- a/apps/studio/pages/project/[ref]/reports/index.tsx +++ b/apps/studio/pages/project/[ref]/reports/index.tsx @@ -11,6 +11,7 @@ import { useCheckPermissions, useStore } from 'hooks' import { useProfile } from 'lib/profile' import { useProjectContentStore } from 'stores/projectContentStore' import { NextPageWithLayout } from 'types' +import { CreateReportModal } from 'components/interfaces/Reports/Reports.CreateReportModal' export const UserReportPage: NextPageWithLayout = () => { const [loading, setLoading] = useState(true) @@ -20,6 +21,7 @@ export const UserReportPage: NextPageWithLayout = () => { const { profile } = useProfile() const { ui } = useStore() + const [showCreateReportModal, setShowCreateReportModal] = useState(false) const contentStore = useProjectContentStore(ref) const canCreateReport = useCheckPermissions(PermissionAction.CREATE, 'user_content', { @@ -47,28 +49,34 @@ export const UserReportPage: NextPageWithLayout = () => { {loading ? ( ) : ( - { - try { - createReport({ router }) - } catch (error: any) { - ui.setNotification({ - category: 'error', - message: `Failed to create report: ${error.message}`, - }) - } - }} - disabled={!canCreateReport} - disabledMessage="You need additional permissions to create a report" - > -

    Create custom reports for your projects.

    -

    - Get a high level overview of your network traffic, user actions, and infrastructure - health. -

    -
    + <> + { + setShowCreateReportModal(true) + }} + disabled={!canCreateReport} + disabledMessage="You need additional permissions to create a report" + > +

    + Create custom reports for your projects. +

    +

    + Get a high level overview of your network traffic, user actions, and infrastructure + health. +

    +
    + { + setShowCreateReportModal(false) + }} + afterSubmit={() => { + setShowCreateReportModal(false) + }} + /> + )}
    ) diff --git a/apps/studio/pages/project/[ref]/reports/query-performance.tsx b/apps/studio/pages/project/[ref]/reports/query-performance.tsx index e48b7e18ef3..a4be321eece 100644 --- a/apps/studio/pages/project/[ref]/reports/query-performance.tsx +++ b/apps/studio/pages/project/[ref]/reports/query-performance.tsx @@ -1,46 +1,75 @@ import { useParams } from 'common' import ReportHeader from 'components/interfaces/Reports/ReportHeader' -import ReportPadding from 'components/interfaces/Reports/ReportPadding' +import ReportQueryPerformanceTableRow from 'components/interfaces/Reports/ReportQueryPerformanceTableRow' import { PRESET_CONFIG } from 'components/interfaces/Reports/Reports.constants' +import { useQueryPerformanceQuery } from 'components/interfaces/Reports/Reports.queries' import { Presets } from 'components/interfaces/Reports/Reports.types' import { queriesFactory } from 'components/interfaces/Reports/Reports.utils' import { ReportsLayout } from 'components/layouts' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import Table from 'components/to-be-cleaned/Table' -import CopyButton from 'components/ui/CopyButton' import ConfirmModal from 'components/ui/Dialogs/ConfirmDialog' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { executeSql } from 'data/sql/execute-sql-query' import { useFlag } from 'hooks' +import { sortBy } from 'lodash' import { observer } from 'mobx-react-lite' import Link from 'next/link' +import { useRouter } from 'next/router' import { useState } from 'react' import ReactMarkdown from 'react-markdown' import { NextPageWithLayout } from 'types' -import { Accordion, Button, IconAlertCircle, IconCheckCircle, Tabs } from 'ui' +import { + Accordion, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, + IconAlertCircle, + IconArrowDown, + IconArrowUp, + IconCheckCircle, + IconList, + IconRefreshCw, + IconSearch, + Input, + Tabs, +} from 'ui' +type QueryPerformancePreset = 'time' | 'frequent' | 'slowest' const QueryPerformanceReport: NextPageWithLayout = () => { const { project } = useProjectContext() const [showResetgPgStatStatements, setShowResetgPgStatStatements] = useState(false) const tableIndexEfficiencyEnabled = useFlag('tableIndexEfficiency') const config = PRESET_CONFIG[Presets.QUERY_PERFORMANCE] const { ref: projectRef } = useParams() + const router = useRouter() const hooks = queriesFactory(config.queries, projectRef ?? 'default') - const mostFrequentlyInvoked = hooks.mostFrequentlyInvoked() - const mostTimeConsuming = hooks.mostTimeConsuming() - const slowestExecutionTime = hooks.slowestExecutionTime() const queryHitRate = hooks.queryHitRate() - const isLoading = [ - mostFrequentlyInvoked.isLoading, - mostTimeConsuming.isLoading, - slowestExecutionTime.isLoading, - queryHitRate.isLoading, - ].every((value) => value) + const orderBy = (router.query.sort as 'lat_desc' | 'lat_asc') || 'lat_desc' + const searchQuery = (router.query.search as string) || '' + const presetMap = { + time: 'mostTimeConsuming', + frequent: 'mostFrequentlyInvoked', + slowest: 'slowestExecutionTime', + } as const + const preset = presetMap[router.query.preset as QueryPerformancePreset] || 'mostTimeConsuming' + + const queryPerformanceQuery = useQueryPerformanceQuery({ + searchQuery, + orderBy, + preset, + }) + + const isLoading = [queryPerformanceQuery.isLoading, queryHitRate.isLoading].every( + (value) => value + ) const handleRefresh = async () => { - mostFrequentlyInvoked.runQuery() - mostTimeConsuming.runQuery() - slowestExecutionTime.runQuery() + queryPerformanceQuery.runQuery() queryHitRate.runQuery() } @@ -98,7 +127,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => { const helperTextClassNames = 'prose text-sm max-w-2xl text-foreground-light' return ( - +
    {tableIndexEfficiencyEnabled && ( { {indexHitRate >= 0.99 ? checkAlert : indexHitRate >= 0.95 - ? warnAlert - : dangerAlert} + ? warnAlert + : dangerAlert}
    - {(queryHitRate?.data![0]?.ratio * 100).toFixed(2)} + {queryHitRate?.data && (queryHitRate?.data[0]?.ratio * 100).toFixed(2)} %
    @@ -142,11 +171,11 @@ const QueryPerformanceReport: NextPageWithLayout = () => { {tableHitRate >= 0.99 ? checkAlert : tableHitRate >= 0.95 - ? warnAlert - : dangerAlert} + ? warnAlert + : dangerAlert}
    - {(queryHitRate?.data![1]?.ratio * 100).toFixed(2)} + {queryHitRate?.data && (queryHitRate?.data[1]?.ratio * 100).toFixed(2)} %
    @@ -199,179 +228,180 @@ const QueryPerformanceReport: NextPageWithLayout = () => { />
    - - + { + // To reset the search and sort query params when switching tabs + const { sort, search, ...rest } = router.query + router.push({ + ...router, + query: { + ...rest, + preset: e, + }, + }) + }} + > +
    {TimeConsumingHelperText} -
    +
    +
    - Role - Time Consumed - Calls - Total Time - Query + Role + Query + Calls + Time Consumed + Total Time (Latency) } body={ - !isLoading && mostTimeConsuming && mostTimeConsuming?.data ? ( - mostTimeConsuming?.data?.map((item, i) => { + !queryPerformanceQuery.isLoading ? ( + queryPerformanceQuery?.data?.map((item, i) => { return ( - - + + {item.rolname} - + +

    {item.query}

    +
    + {item.calls} + {item.prop_total_time} - - {item.calls} - - + {item.total_time.toFixed(2)}ms - -

    {item.query}

    - -
    -
    + ) }) ) : ( - <> + ) } /> - +
    {MostFrequentHelperText} -
    +
    +
    - {/* source */} - Role - Avg. Roles - Calls - Max Time - Mean Time - Min Time - Total Time - Query + Role + Query + Avg. Roles + Calls + Max Time + Mean Time + Min Time + Total Time (Latency) } body={ - !isLoading && mostFrequentlyInvoked && mostFrequentlyInvoked?.data ? ( - mostFrequentlyInvoked.data?.map((item, i) => { + queryPerformanceQuery.isLoading ? ( + + ) : ( + queryPerformanceQuery.data?.map((item, i) => { return ( - - + + {item.rolname} - - {item.avg_rows} + +

    {item.query}

    - - {item.calls} + {item.avg_rows} + {item.calls} + + {item.max_time?.toFixed(2)}ms - - {item.max_time.toFixed(2)}ms + + {item.mean_time?.toFixed(2)}ms - - {item.mean_time.toFixed(2)}ms + + {item.min_time?.toFixed(2)}ms - - {item.min_time.toFixed(2)}ms + + {item.total_time?.toFixed(2)}ms - - {item.total_time.toFixed(2)}ms - - -

    {item.query}

    - -
    -
    + ) }) - ) : ( - <> ) } /> - +
    {SlowestExecutionHelperText} -
    +
    +
    Role + QueryAvg RowsCallsMax TimeMean TimeMin Time - Total Time - Query + Total Time (Latency) } body={ - !isLoading && slowestExecutionTime && slowestExecutionTime?.data ? ( - slowestExecutionTime.data?.map((item, i) => { + queryPerformanceQuery.isLoading ? ( + + ) : ( + queryPerformanceQuery.data?.map((item, i) => { return ( - - + + {item.rolname} - - {item.avg_rows} + +

    {item.query}

    - - {item.calls} + {item.avg_rows} + {item.calls} + {item.max_time?.toFixed(2)}ms + {item.mean_time?.toFixed(2)}ms + {item.min_time?.toFixed(2)}ms + + {item.total_time?.toFixed(2)}ms - - {item.max_time.toFixed(2)}ms - - - {item.mean_time.toFixed(2)}ms - - - {item.min_time.toFixed(2)}ms - - - {item.total_time.toFixed(2)}ms - - -

    {item.query}

    - -
    -
    + ) }) - ) : ( - <> ) } /> @@ -380,16 +410,6 @@ const QueryPerformanceReport: NextPageWithLayout = () => { - - ) -} - -const QueryActions = ({ sql, className }: { sql: string; className: string }) => { - if (sql.includes('insufficient privilege')) return null - - return ( -
    -
    ) } @@ -399,3 +419,149 @@ QueryPerformanceReport.getLayout = (page) => ( ) export default observer(QueryPerformanceReport) + +function QueryPerformanceFilterBar({ + isLoading, + onRefreshClick, +}: { + isLoading: boolean + onRefreshClick: () => void +}) { + const router = useRouter() + const defaultSearchQueryValue = router.query.search ? String(router.query.search) : '' + const defaultSortByValue = router.query.sort ? String(router.query.sort) : 'lat_desc' + const [searchInputVal, setSearchInputVal] = useState(defaultSearchQueryValue) + const [sortByValue, setSortByValue] = useState(defaultSortByValue) + + function getSortButtonLabel() { + const sort = router.query.sort as 'lat_desc' | 'lat_asc' + + if (sort === 'lat_desc') { + return 'Sorted by latency - high to low' + } else { + return 'Sorted by latency - low to high' + } + } + + function onSortChange(sort: string) { + setSortByValue(sort) + router.push({ + ...router, + query: { + ...router.query, + sort, + }, + }) + } + + const ButtonIcon = sortByValue === 'lat_desc' ? IconArrowDown : IconArrowUp + + return ( + <> +
    + { + e.preventDefault() + const formData = new FormData(e.target as HTMLFormElement) + const searchQuery = formData.get('search') + + if (!searchQuery || typeof searchQuery !== 'string') { + // if user has deleted the search query, remove it from the url + const { search, ...rest } = router.query + router.push({ + ...router, + query: { + ...rest, + }, + }) + return + } + + router.push({ + ...router, + query: { + ...router.query, + search: searchQuery, + }, + }) + }} + > + setSearchInputVal(e.target.value)} + autoComplete="off" + icon={ +
    + +
    + } + actions={ + searchInputVal !== '' && ( + + ) + } + /> + + + + + + + + Sort by latency - high to low + + + Sort by latency - low to high + + + + + +
    + +
    +
    + + ) +} + +function QueryPerformanceLoadingRow({ colSpan }: { colSpan: number }) { + return ( + <> + {Array(4) + .fill('') + .map((_, i) => ( +
    + + + ))} + + ) +} diff --git a/apps/studio/pages/project/[ref]/settings/api.tsx b/apps/studio/pages/project/[ref]/settings/api.tsx index 2289ea5c7b3..658356e69e4 100644 --- a/apps/studio/pages/project/[ref]/settings/api.tsx +++ b/apps/studio/pages/project/[ref]/settings/api.tsx @@ -1,32 +1,14 @@ -import { IconAlertCircle } from 'ui' - import ServiceList from 'components/interfaces/Settings/API/ServiceList' import { SettingsLayout } from 'components/layouts' -import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext' import { NextPageWithLayout } from 'types' const ApiSettings: NextPageWithLayout = () => { - const isActive = useIsProjectActive() - return (
    - {isActive ? ( - - ) : ( -
    -

    API Settings

    -
    - -

    - API settings are unavailable as the project is not active -

    -
    -
    - )} +
    ) } ApiSettings.getLayout = (page) => {page} - export default ApiSettings diff --git a/apps/studio/pages/project/[ref]/settings/auth.tsx b/apps/studio/pages/project/[ref]/settings/auth.tsx index ae125ab2768..a96017e7b15 100644 --- a/apps/studio/pages/project/[ref]/settings/auth.tsx +++ b/apps/studio/pages/project/[ref]/settings/auth.tsx @@ -6,28 +6,37 @@ import { AdvancedAuthSettingsForm, } from 'components/interfaces/Auth' import { SettingsLayout } from 'components/layouts' +import { FormHeader } from 'components/ui/Forms' import NoPermission from 'components/ui/NoPermission' -import { useCheckPermissions } from 'hooks' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks' import { NextPageWithLayout } from 'types' -const PageLayout: NextPageWithLayout = () => { +const AuthSettings: NextPageWithLayout = () => { + const isPermissionsLoaded = usePermissionsLoaded() const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') - if (!canReadAuthSettings) { - return - } else { - return ( -
    - - - -
    - ) - } + return ( +
    + + {!isPermissionsLoaded ? ( + + ) : !canReadAuthSettings ? ( + + ) : ( + <> + + + + + )} +
    + ) } -PageLayout.getLayout = (page) => { - return {page} -} - -export default PageLayout +AuthSettings.getLayout = (page) => {page} +export default AuthSettings diff --git a/apps/studio/pages/project/[ref]/settings/database.tsx b/apps/studio/pages/project/[ref]/settings/database.tsx index f52bcd36543..86395618170 100644 --- a/apps/studio/pages/project/[ref]/settings/database.tsx +++ b/apps/studio/pages/project/[ref]/settings/database.tsx @@ -22,7 +22,7 @@ const ProjectSettings: NextPageWithLayout = () => {
    - +
    diff --git a/apps/studio/pages/project/[ref]/settings/general.tsx b/apps/studio/pages/project/[ref]/settings/general.tsx index 25f4ebb5d2c..7d15f69fe2b 100644 --- a/apps/studio/pages/project/[ref]/settings/general.tsx +++ b/apps/studio/pages/project/[ref]/settings/general.tsx @@ -16,7 +16,7 @@ const ProjectSettings: NextPageWithLayout = () => { // [Joshen] Opting for larger gap instead of gap-8 as compared to other pages for better grouping of content return ( -
    +
    {!isBranch ? ( <> diff --git a/apps/studio/pages/project/[ref]/settings/integrations.tsx b/apps/studio/pages/project/[ref]/settings/integrations.tsx index 03a074c5f88..bb9bbc89de2 100644 --- a/apps/studio/pages/project/[ref]/settings/integrations.tsx +++ b/apps/studio/pages/project/[ref]/settings/integrations.tsx @@ -7,5 +7,4 @@ const OrgIntegrationSettings: NextPageWithLayout = () => { } OrgIntegrationSettings.getLayout = (page) => {page} - export default OrgIntegrationSettings diff --git a/apps/studio/pages/project/[ref]/settings/storage.tsx b/apps/studio/pages/project/[ref]/settings/storage.tsx index cbfdd992d1d..96caad2fbdb 100644 --- a/apps/studio/pages/project/[ref]/settings/storage.tsx +++ b/apps/studio/pages/project/[ref]/settings/storage.tsx @@ -1,21 +1,14 @@ -import { useParams } from 'common' - import { SettingsLayout } from 'components/layouts' import { StorageSettings } from 'components/to-be-cleaned/Storage' -import { useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' const PageLayout: NextPageWithLayout = () => { - const { ref: projectRef } = useParams() - const organization = useSelectedOrganization() - return (
    - +
    ) } PageLayout.getLayout = (page) => {page} - export default PageLayout diff --git a/apps/studio/pages/projects.tsx b/apps/studio/pages/projects.tsx index 4db32bd542e..08b3a08ead2 100644 --- a/apps/studio/pages/projects.tsx +++ b/apps/studio/pages/projects.tsx @@ -50,7 +50,6 @@ const ProjectsPage: NextPageWithLayout = () => {
    )} - {!navLayoutV2 && (
    {IS_PLATFORM && projectCreationEnabled && isSuccess && organizations.length !== 0 && ( diff --git a/apps/studio/pages/sign-in-fly-tos.tsx b/apps/studio/pages/sign-in-fly-tos.tsx index 509eee18942..b173eca40ba 100644 --- a/apps/studio/pages/sign-in-fly-tos.tsx +++ b/apps/studio/pages/sign-in-fly-tos.tsx @@ -47,8 +47,8 @@ const SignInFlyTos = () => { fly_extension_id ? getProjectByFlyExtensionId({ flyExtensionId: fly_extension_id as string }) : fly_organization_id - ? getOrgByFlyOrgId({ flyOrganizationId: fly_organization_id as string }) - : setLoading(false) + ? getOrgByFlyOrgId({ flyOrganizationId: fly_organization_id as string }) + : setLoading(false) }, [isReady]) const onSignInWithFly = async () => { diff --git a/apps/studio/pages/sign-in.tsx b/apps/studio/pages/sign-in.tsx index b1e4b2bacb6..6609705ddb5 100644 --- a/apps/studio/pages/sign-in.tsx +++ b/apps/studio/pages/sign-in.tsx @@ -31,7 +31,7 @@ const SignInPage: NextPageWithLayout = () => {
    - or + or
    diff --git a/apps/studio/pages/sign-up.tsx b/apps/studio/pages/sign-up.tsx index 8f2379aadcd..81e9aff76a3 100644 --- a/apps/studio/pages/sign-up.tsx +++ b/apps/studio/pages/sign-up.tsx @@ -15,7 +15,7 @@ const SignUpPage: NextPageWithLayout = () => {
    - or + or
    diff --git a/apps/studio/pages/support/new.tsx b/apps/studio/pages/support/new.tsx index 76ffea0a429..90ef4691592 100644 --- a/apps/studio/pages/support/new.tsx +++ b/apps/studio/pages/support/new.tsx @@ -7,7 +7,6 @@ import { Button, IconLoader, IconTool } from 'ui' import Success from 'components/interfaces/Support/Success' import SupportForm from 'components/interfaces/Support/SupportForm' -import { LayoutWrapper } from 'components/layouts/LayoutWrapper' import { usePlatformStatusQuery } from 'data/platform/platform-status-query' import { withAuth } from 'hooks' import { BASE_PATH } from 'lib/constants' @@ -18,10 +17,10 @@ const SupportPage = () => { const isHealthy = data?.isHealthy return ( - -
    +
    +
    -
    +

    Supabase support

    @@ -55,8 +54,8 @@ const SupportPage = () => { {isLoading ? 'Checking status' : isHealthy - ? 'All systems operational' - : 'Active incident ongoing'} + ? 'All systems operational' + : 'Active incident ongoing'} @@ -78,7 +77,7 @@ const SupportPage = () => {
    {
    - +
    ) } diff --git a/apps/studio/pages/vercel/integrate.tsx b/apps/studio/pages/vercel/integrate.tsx index 38b349d0a5a..0c04b7dcf41 100644 --- a/apps/studio/pages/vercel/integrate.tsx +++ b/apps/studio/pages/vercel/integrate.tsx @@ -616,8 +616,8 @@ const ProjectLinkItem = observer( result.status === 'waiting' ? 'text-foreground-light' : result.status === 'fail' - ? 'text-foreground-light' - : 'text-foreground' + ? 'text-foreground-light' + : 'text-foreground' }`} > {result?.message ?? 'Processing...'} diff --git a/apps/studio/public/img/libraries/drizzle-dark-icon.svg b/apps/studio/public/img/libraries/drizzle-dark-icon.svg new file mode 100644 index 00000000000..4976aafadf0 --- /dev/null +++ b/apps/studio/public/img/libraries/drizzle-dark-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/libraries/drizzle-icon.svg b/apps/studio/public/img/libraries/drizzle-icon.svg new file mode 100644 index 00000000000..05d25f1cdbb --- /dev/null +++ b/apps/studio/public/img/libraries/drizzle-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/libraries/graphql-icon.svg b/apps/studio/public/img/libraries/graphql-icon.svg new file mode 100644 index 00000000000..f57033c1858 --- /dev/null +++ b/apps/studio/public/img/libraries/graphql-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/libraries/nuxt-icon.svg b/apps/studio/public/img/libraries/nuxt-icon.svg new file mode 100644 index 00000000000..22096977c69 --- /dev/null +++ b/apps/studio/public/img/libraries/nuxt-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/libraries/postgres-icon.svg b/apps/studio/public/img/libraries/postgres-icon.svg new file mode 100644 index 00000000000..4371d2d1a90 --- /dev/null +++ b/apps/studio/public/img/libraries/postgres-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/libraries/prisma-dark-icon.svg b/apps/studio/public/img/libraries/prisma-dark-icon.svg new file mode 100644 index 00000000000..1f32018b675 --- /dev/null +++ b/apps/studio/public/img/libraries/prisma-dark-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/libraries/prisma-icon.svg b/apps/studio/public/img/libraries/prisma-icon.svg new file mode 100644 index 00000000000..ac2530cdf9f --- /dev/null +++ b/apps/studio/public/img/libraries/prisma-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/libraries/redwoodjs-icon.svg b/apps/studio/public/img/libraries/redwoodjs-icon.svg new file mode 100644 index 00000000000..1e18e419cdd --- /dev/null +++ b/apps/studio/public/img/libraries/redwoodjs-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/libraries/refine-icon.svg b/apps/studio/public/img/libraries/refine-icon.svg new file mode 100644 index 00000000000..81ceebe54a4 --- /dev/null +++ b/apps/studio/public/img/libraries/refine-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/libraries/solidjs-icon.svg b/apps/studio/public/img/libraries/solidjs-icon.svg new file mode 100644 index 00000000000..ef329ccd3d9 --- /dev/null +++ b/apps/studio/public/img/libraries/solidjs-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/libraries/supabase-icon.svg b/apps/studio/public/img/libraries/supabase-icon.svg new file mode 100644 index 00000000000..5eddab72892 --- /dev/null +++ b/apps/studio/public/img/libraries/supabase-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/studio/public/img/libraries/svelte-icon.svg b/apps/studio/public/img/libraries/sveltekit-icon.svg similarity index 100% rename from apps/studio/public/img/libraries/svelte-icon.svg rename to apps/studio/public/img/libraries/sveltekit-icon.svg diff --git a/apps/studio/public/img/themes/dark.svg b/apps/studio/public/img/themes/dark.svg new file mode 100644 index 00000000000..ea74324a835 --- /dev/null +++ b/apps/studio/public/img/themes/dark.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/studio/public/img/themes/deep-dark.svg b/apps/studio/public/img/themes/deep-dark.svg new file mode 100644 index 00000000000..05bab64b78d --- /dev/null +++ b/apps/studio/public/img/themes/deep-dark.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/studio/public/img/themes/light.svg b/apps/studio/public/img/themes/light.svg new file mode 100644 index 00000000000..0ef07743552 --- /dev/null +++ b/apps/studio/public/img/themes/light.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/studio/public/img/themes/system.svg b/apps/studio/public/img/themes/system.svg new file mode 100644 index 00000000000..386cd717702 --- /dev/null +++ b/apps/studio/public/img/themes/system.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/studio/state/app-state.ts b/apps/studio/state/app-state.ts index 87cab4d00fa..e06e54f0e4d 100644 --- a/apps/studio/state/app-state.ts +++ b/apps/studio/state/app-state.ts @@ -50,6 +50,10 @@ export const appState = proxy({ setShowFeaturePreviewModal: (value: boolean) => { appState.showFeaturePreviewModal = value }, + selectedFeaturePreview: '', + setSelectedFeaturePreview: (value: string) => { + appState.selectedFeaturePreview = value + }, showAiSettingsModal: false, setShowAiSettingsModal: (value: boolean) => { appState.showAiSettingsModal = value diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index 3c2d33012b4..4103dfcbfd1 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -13,6 +13,7 @@ type ForeignKey = { } export type SidePanel = + | { type: 'cell'; value?: { column: string; row: Dictionary } } | { type: 'row'; row?: Dictionary } | { type: 'column'; column?: PostgresColumn } | { type: 'table'; mode: 'new' | 'edit' | 'duplicate' } @@ -170,6 +171,12 @@ export const createTableEditorState = () => { sidePanel: { type: 'json', jsonValue }, } }, + onExpandTextEditor: (column: string, row: Dictionary) => { + state.ui = { + open: 'side-panel', + sidePanel: { type: 'cell', value: { column, row } }, + } + }, onEditForeignKeyColumnValue: (foreignKey: ForeignKey) => { state.ui = { open: 'side-panel', diff --git a/apps/studio/stores/RootStore.ts b/apps/studio/stores/RootStore.ts index dfa48a70ff4..c428eec6a4b 100644 --- a/apps/studio/stores/RootStore.ts +++ b/apps/studio/stores/RootStore.ts @@ -14,16 +14,12 @@ export interface IRootStore { ui: IUiStore meta: IMetaStore - selectedProjectRef?: string - setProject: (project: Project) => void } export class RootStore implements IRootStore { ui: IUiStore meta: IMetaStore - selectedProjectRef: string | undefined - constructor() { this.ui = new UiStore(this) this.meta = new MetaStore(this, { projectRef: '', connectionString: '' }) @@ -35,16 +31,8 @@ export class RootStore implements IRootStore { * This method will also trigger project detail loading when it's not available */ setProject(project: Project) { - if (this.selectedProjectRef === project.ref) return - - // reset ui projectRef in case of switching projects - // this will show the loading screen instead of showing the previous project - this.ui.setProjectRef(undefined) - this.meta.setProjectDetails(project) // ui set must come last this.ui.setProjectRef(project.ref) - - this.selectedProjectRef = project.ref } } diff --git a/apps/studio/stores/common/PostgresMetaInterface.ts b/apps/studio/stores/common/PostgresMetaInterface.ts deleted file mode 100644 index c932165fc2f..00000000000 --- a/apps/studio/stores/common/PostgresMetaInterface.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { action, computed, makeObservable, observable } from 'mobx' -import { get, patch, post, delete_, isResponseOk } from 'lib/common/fetch' -import { keyBy } from 'lodash' -import { IRootStore } from '../RootStore' -import { ResponseError, SupaResponse } from 'types' - -type DataKeys = number | string - -export interface IPostgresMetaInterface { - error: Error | null - - count: number - hasError: boolean - isLoading: boolean - isInitialized: boolean - - load: () => void - loadBySchema: (schema: string) => Promise> - create: (payload: any) => Promise> - update: (id: number | string, updates: any) => Promise> - del: (id: number | string, cascade?: boolean) => Promise> - list: (filter?: any) => T[] - find: (filter?: any) => T | undefined - byId: (id: number | string) => T | undefined - initialDataArray: (value: T[]) => void - - setUrl: (url: string) => void - setHeaders: (headers: { [prop: string]: any }) => void -} - -// [TODO] Need to refactor the logic for 'isInitialized' -// Should avoid page level "loaded" state like TableEditorLayout -// The logic should be - state should be INITIAL at first, and only -// taken as initialized after the first trigger of the load method. -// https://github.com/supabase/supabase/pull/5128#pullrequestreview-860706726 -export default class PostgresMetaInterface implements IPostgresMetaInterface { - STATES = { - INITIAL: 'initial', - LOADING: 'loading', - ERROR: 'error', - LOADED: 'loaded', - } - rootStore: IRootStore - url: string - error: Error | null = null - identifier: string - state = this.STATES.INITIAL - data: { [key in DataKeys]: T } = {} - headers: any = {} - isInitialized: boolean = false - - constructor( - rootStore: IRootStore, - dataUrl: string, - headers?: { - [prop: string]: any - }, - options?: { identifier: string } - ) { - this.rootStore = rootStore - this.url = dataUrl - this.headers = headers - this.identifier = options?.identifier ?? 'id' - - makeObservable(this, { - state: observable, - data: observable, - error: observable, - count: computed, - hasError: computed, - isLoading: computed, - isInitialized: observable, - load: action, - create: action, - update: action, - del: action, - setState: action, - setDataArray: action, - setError: action, - }) - } - - get count() { - return Object.keys(this.data).length - } - - get hasError() { - return this.state === this.STATES.ERROR - } - - get isLoading() { - return this.state === this.STATES.INITIAL || this.state === this.STATES.LOADING - } - - async fetchData() { - const headers = { 'Content-Type': 'application/json', ...this.headers } - const response = await get(this.url, { headers }) - if (!isResponseOk(response)) { - throw response.error - } - - this.setDataArray(response) - return response - } - - async load() { - let { LOADING, ERROR, LOADED } = this.STATES - try { - this.setError(null) - this.setState(LOADING) - await this.fetchData() - this.setState(LOADED) - } catch (e: any) { - console.error('Load error message', e.message) - this.setError(e) - this.setState(ERROR) - } finally { - if (!this.isInitialized) this.isInitialized = true - } - } - - // [Joshen] Only used for tables and views for now - async loadBySchema(schema: string) { - let { LOADING, ERROR, LOADED } = this.STATES - try { - this.setError(null) - this.setState(LOADING) - - const url = this.url.includes('?') - ? `${this.url}&included_schemas=${schema}` - : `${this.url}?included_schemas=${schema}` - const response = await get(url, { headers: this.headers }) - if (response.error) throw response.error - - const data = response as T[] - const formattedData = keyBy(data, this.identifier) - - // Purge existing data that belongs to given schema, otherwise - // stale data will persist - const purgedData = Object.keys(this.data) - .map((identifier: any) => this.data[identifier]) - .filter((item: any) => item.schema !== schema) - const formattedPurgedData = keyBy(purgedData, this.identifier) - - this.data = { ...formattedPurgedData, ...formattedData } as any - this.setState(LOADED) - - return data - } catch (error: any) { - console.error('Error in loadBySchema:', error.message) - this.setError(error) - this.setState(ERROR) - return { error } - } - } - - initialDataArray(value: T[]) { - if (this.state === this.STATES.INITIAL) { - this.data = keyBy(value, this.identifier) as any - this.state = this.STATES.LOADED - } - } - - setState(value: string) { - this.state = value - } - - setDataArray(value: T[]) { - this.data = keyBy(value, this.identifier) as any - } - - setError(value: any) { - this.error = value - } - - list(filter?: any) { - let arr = Object.values(this.data) - if (!!filter) { - return arr.filter(filter).sort((a: any, b: any) => a.name.localeCompare(b.name)) - } else { - return arr.sort((a: any, b: any) => a.name.localeCompare(b.name)) - } - } - - find(filter?: any) { - let arr = Object.values(this.data) - if (!!filter) { - return arr.find(filter) - } else { - return undefined - } - } - - byId(id: number | string) { - return this.data[id] - } - - async create(payload: any) { - try { - const headers = { 'Content-Type': 'application/json', ...this.headers } - const response = await post(this.url, payload, { headers }) - if (response.error) throw response.error - - const data = response as T - const identity = response[this.identifier] - this.data[identity] = data - return data - } catch (error: any) { - return { data: null, error } - } - } - - async update(id: number | string, payload: any) { - try { - const headers = { 'Content-Type': 'application/json', ...this.headers } - const url = `${this.url}?id=${id}` - const response = await patch(url, payload, { headers }) - if (!isResponseOk(response)) { - throw response.error - } - - this.data[id] = response - return response - } catch (error: any) { - return { error } - } - } - - async del(id: number | string, cascade: boolean = false) { - try { - const headers = { 'Content-Type': 'application/json', ...this.headers } - const url = cascade ? `${this.url}?id=${id}&cascade=${cascade}` : `${this.url}?id=${id}` - const response = await delete_(url, {}, { headers }) - if (!isResponseOk(response)) { - throw response.error - } - - delete this.data[id] - return true - } catch (error: any) { - return { error } - } - } - - setUrl(url: string) { - this.url = url - - // if the url changes, we need to reset the state - this.state = this.STATES.INITIAL - this.data = {} - this.error = null - } - - setHeaders(headers: { [prop: string]: any }) { - this.headers = headers - } -} diff --git a/apps/studio/stores/pgmeta/MetaStore.ts b/apps/studio/stores/pgmeta/MetaStore.ts index 4c619a1d28e..7dee87467ad 100644 --- a/apps/studio/stores/pgmeta/MetaStore.ts +++ b/apps/studio/stores/pgmeta/MetaStore.ts @@ -1,50 +1,12 @@ -import { Query } from 'components/grid/query/Query' -import { chunk, find, isEmpty, isEqual } from 'lodash' import { makeObservable } from 'mobx' -import Papa from 'papaparse' - -import type { - PostgresPrimaryKey, - PostgresRelationship, - PostgresTable, -} from '@supabase/postgres-meta' import { post } from 'lib/common/fetch' import { API_URL, IS_PLATFORM } from 'lib/constants' -import { timeout, tryParseJson } from 'lib/helpers' import { ResponseError } from 'types' import { IRootStore } from '../RootStore' -import TableStore, { ITableStore } from './TableStore' - -import { - generateCreateColumnPayload, - generateUpdateColumnPayload, -} from 'components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils' -import { ColumnField } from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.types' -import { - addForeignKey, - addPrimaryKey, - createColumn, - removePrimaryKey, - updateColumn, -} from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils' -import { ImportContent } from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types' -import { createDatabaseColumn } from 'data/database-columns/database-column-create-mutation' -import { deleteDatabaseColumn } from 'data/database-columns/database-column-delete-mutation' -import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' -import { getCachedProjectDetail } from 'data/projects/project-detail-query' -import { getQueryClient } from 'data/query-client' -import { tableKeys } from 'data/tables/keys' -import { getTable } from 'data/tables/table-query' -import { getTables } from 'data/tables/tables-query' - -const BATCH_SIZE = 1000 -const CHUNK_SIZE = 1024 * 1024 * 0.1 // 0.1MB export interface IMetaStore { - tables: ITableStore - projectRef?: string connectionString?: string @@ -52,39 +14,10 @@ export interface IMetaStore { validateQuery: (value: string) => Promise formatQuery: (value: string) => Promise - duplicateTable: ( - payload: any, - metadata: { - duplicateTable: PostgresTable - isRLSEnabled: boolean - isDuplicateRows: boolean - } - ) => void - createTable: ( - toastId: string, - payload: any, - columns: ColumnField[], - isRLSEnabled: boolean, - importContent?: ImportContent - ) => any - updateTable: (toastId: string, table: PostgresTable, payload: any, columns: ColumnField[]) => any - insertRowsViaSpreadsheet: ( - file: any, - table: PostgresTable, - selectedHeaders: string[], - onProgressUpdate: (progress: number) => void - ) => void - insertTableRows: ( - table: PostgresTable, - rows: any, - selectedHeaders: string[], - onProgressUpdate: (progress: number) => void - ) => any setProjectDetails: (details: { ref: string; connectionString?: string }) => void } export default class MetaStore implements IMetaStore { rootStore: IRootStore - tables: TableStore projectRef: string connectionString?: string @@ -103,8 +36,6 @@ export default class MetaStore implements IMetaStore { this.headers['x-connection-encrypted'] = connectionString } - this.tables = new TableStore(this.rootStore, `${this.baseUrl}/tables`, this.headers) - makeObservable(this, {}) } @@ -153,470 +84,6 @@ export default class MetaStore implements IMetaStore { } } - async duplicateTable( - payload: any, - metadata: { - duplicateTable: PostgresTable - isRLSEnabled: boolean - isDuplicateRows: boolean - } - ) { - const { duplicateTable, isRLSEnabled, isDuplicateRows } = metadata - const { name: sourceTableName, schema: sourceTableSchema } = duplicateTable - const duplicatedTableName = payload.name - - // The following query will copy the structure of the table along with indexes, constraints and - // triggers. However, foreign key constraints are not duplicated over - has to be done separately - const table = await this.rootStore.meta.query( - `CREATE TABLE "${sourceTableSchema}"."${duplicatedTableName}" (LIKE "${sourceTableSchema}"."${sourceTableName}" INCLUDING ALL);` - ) - if (table.error) throw table.error - - // Duplicate foreign key constraints over - const relationships = duplicateTable.relationships - if (relationships.length > 0) { - relationships.map(async (relationship: PostgresRelationship) => { - await addForeignKey(this.projectRef, this.connectionString, { - ...relationship, - source_table_name: duplicatedTableName, - deletion_action: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - update_action: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - }) - }) - } - - // Duplicate rows if needed - if (isDuplicateRows) { - const rows = await this.rootStore.meta.query( - `INSERT INTO "${sourceTableSchema}"."${duplicatedTableName}" SELECT * FROM "${sourceTableSchema}"."${sourceTableName}";` - ) - if (rows.error) throw rows.error - - // Insert into does not copy over auto increment sequences, so we manually do it next if any - const columns = duplicateTable.columns ?? [] - const identityColumns = columns.filter((column) => column.identity_generation !== null) - identityColumns.map(async (column) => { - const identity = await this.rootStore.meta.query( - `SELECT setval('"${sourceTableSchema}"."${duplicatedTableName}_${column.name}_seq"', (SELECT MAX("${column.name}") FROM "${sourceTableSchema}"."${sourceTableName}"));` - ) - if (identity.error) throw identity.error - }) - } - - const queryClient = getQueryClient() - const project = await getCachedProjectDetail(queryClient, this.rootStore.ui.selectedProjectRef) - const projectRef = project?.ref - const connectionString = project?.connectionString - const tables = await queryClient.fetchQuery({ - queryKey: tableKeys.list(projectRef, sourceTableSchema), - queryFn: ({ signal }) => - getTables({ projectRef, connectionString, schema: sourceTableSchema }, signal), - }) - - const duplicatedTable = find(tables, { schema: sourceTableSchema, name: duplicatedTableName }) - - if (isRLSEnabled) { - const updateTable: any = await this.tables.update(duplicatedTable!.id, { - rls_enabled: isRLSEnabled, - }) - if (updateTable.error) throw updateTable.error - } - - return duplicatedTable - } - - async createTable( - toastId: string, - payload: any, - columns: ColumnField[] = [], - isRLSEnabled: boolean, - importContent?: ImportContent - ) { - // Create the table first - const table = await this.tables.create(payload) - if ('error' in table) throw table.error - - // If we face any errors during this process after the actual table creation - // We'll delete the table as a way to clean up and not leave behind bits that - // got through successfully. This is so that the user can continue editing in - // the table side panel editor conveniently - try { - // Toggle RLS if configured to be - if (isRLSEnabled) { - const updatedTable: any = await this.tables.update(table.id, { - rls_enabled: isRLSEnabled, - }) - if (updatedTable.error) throw updatedTable.error - } - - // Then insert the columns - we don't do Promise.all as we want to keep the integrity - // of the column order during creation. Note that we add primary key constraints separately - // via the query endpoint to support composite primary keys as pg-meta does not support that OOB - this.rootStore.ui.setNotification({ - id: toastId, - category: 'loading', - message: `Adding ${columns.length} columns to ${table.name}...`, - }) - - for (const column of columns) { - // We create all columns without primary keys first - const columnPayload = generateCreateColumnPayload(table.id, { - ...column, - isPrimaryKey: false, - }) - await createDatabaseColumn({ - projectRef: this.projectRef, - connectionString: this.connectionString, - payload: columnPayload, - }) - } - - // Then add the primary key constraints here to support composite keys - const primaryKeyColumns = columns - .filter((column: ColumnField) => column.isPrimaryKey) - .map((column: ColumnField) => column.name) - if (primaryKeyColumns.length > 0) { - await addPrimaryKey( - this.projectRef, - this.connectionString, - table.schema, - table.name, - primaryKeyColumns - ) - } - - // Then add the foreign key constraints here - for (const column of columns) { - if (column.foreignKey !== undefined) { - await addForeignKey(this.projectRef, this.connectionString, column.foreignKey) - } - } - - // If the user is importing data via a spreadsheet - if (importContent !== undefined) { - if (importContent.file && importContent.rowCount > 0) { - // Via a CSV file - const { error }: any = await this.insertRowsViaSpreadsheet( - importContent.file, - table, - importContent.selectedHeaders, - (progress: number) => { - this.rootStore.ui.setNotification({ - id: toastId, - progress, - category: 'loading', - message: `Adding ${importContent.rowCount.toLocaleString()} rows to ${table.name}`, - }) - } - ) - - // For identity columns, manually raise the sequences - const identityColumns = columns.filter((column) => column.isIdentity) - for (const column of identityColumns) { - const identity = await this.rootStore.meta.query( - `SELECT setval('${table.name}_${column.name}_seq', (SELECT MAX("${column.name}") FROM "${table.name}"));` - ) - if (identity.error) throw identity.error - } - - if (error !== undefined) { - this.rootStore.ui.setNotification({ - category: 'error', - message: 'Do check your spreadsheet if there are any discrepancies.', - }) - this.rootStore.ui.setNotification({ - category: 'error', - message: `Table ${table.name} has been created but we ran into an error while inserting rows: - ${error.message}`, - error, - }) - } - } else { - // Via text copy and paste - await this.insertTableRows( - table, - importContent.rows, - importContent.selectedHeaders, - (progress: number) => { - this.rootStore.ui.setNotification({ - id: toastId, - progress, - category: 'loading', - message: `Adding ${importContent.rows.length.toLocaleString()} rows to ${ - table.name - }`, - }) - } - ) - - // For identity columns, manually raise the sequences - const identityColumns = columns.filter((column) => column.isIdentity) - for (const column of identityColumns) { - const identity = await this.rootStore.meta.query( - `SELECT setval('${table.name}_${column.name}_seq', (SELECT MAX("${column.name}") FROM "${table.name}"));` - ) - if (identity.error) throw identity.error - } - } - } - - // Finally, return the created table - return table - } catch (error: any) { - this.tables.del(table.id) - throw error - } - } - - async updateTable(toastId: string, table: PostgresTable, payload: any, columns: ColumnField[]) { - // Prepare a check to see if primary keys to the tables were updated or not - const primaryKeyColumns = columns - .filter((column) => column.isPrimaryKey) - .map((column) => column.name) - - const existingPrimaryKeyColumns = table.primary_keys.map((pk: PostgresPrimaryKey) => pk.name) - const isPrimaryKeyUpdated = !isEqual(primaryKeyColumns, existingPrimaryKeyColumns) - - if (isPrimaryKeyUpdated) { - // Remove any primary key constraints first (we'll add it back later) - // If we do it later, and if the user deleted a PK column, we'd need to do - // an additional check when removing PK if the column in the PK was removed - // So doing this one step earlier, lets us skip that additional check. - if (table.primary_keys.length > 0) { - await removePrimaryKey(this.projectRef, this.connectionString, table.schema, table.name) - } - } - - // Update the table - const updatedTable: any = await this.tables.update(table.id, payload) - if (updatedTable.error) throw updatedTable.error - - const originalColumns = table.columns ?? [] - const columnIds = columns.map((column) => column.id) - - // Delete any removed columns - const columnsToRemove = originalColumns.filter((column) => !columnIds.includes(column.id)) - for (const column of columnsToRemove) { - this.rootStore.ui.setNotification({ - id: toastId, - category: 'loading', - message: `Removing column ${column.name} from ${updatedTable.name}`, - }) - await deleteDatabaseColumn({ - projectRef: this.projectRef, - connectionString: this.connectionString, - id: column.id, - }) - } - - // Add any new columns / Update any existing columns - let hasError = false - for (const column of columns) { - if (!column.id.includes(table.id.toString())) { - this.rootStore.ui.setNotification({ - id: toastId, - category: 'loading', - message: `Adding column ${column.name} to ${updatedTable.name}`, - }) - // Ensure that columns do not created as primary key first, cause the primary key will - // be added later on further down in the code - const columnPayload = generateCreateColumnPayload(updatedTable.id, { - ...column, - isPrimaryKey: false, - }) - await createColumn({ - projectRef: this.projectRef, - connectionString: this.connectionString, - payload: columnPayload, - selectedTable: updatedTable, - foreignKey: column.foreignKey, - }) - } else { - const originalColumn = find(originalColumns, { id: column.id }) - if (originalColumn) { - const columnPayload = generateUpdateColumnPayload(originalColumn, updatedTable, column) - const originalForeignKey = find(table.relationships, { - source_schema: originalColumn.schema, - source_table_name: originalColumn.table, - source_column_name: originalColumn.name, - }) - const hasForeignKeyUpdated = !isEqual(originalForeignKey, column.foreignKey) - if (!isEmpty(columnPayload) || hasForeignKeyUpdated) { - this.rootStore.ui.setNotification({ - id: toastId, - category: 'loading', - message: `Updating column ${column.name} from ${updatedTable.name}`, - }) - const skipPKCreation = true - const skipSuccessMessage = true - const res = await updateColumn({ - projectRef: this.projectRef, - connectionString: this.connectionString, - id: column.id, - payload: columnPayload, - selectedTable: updatedTable, - foreignKey: column.foreignKey, - skipPKCreation, - skipSuccessMessage, - }) - if (res?.error) { - hasError = true - this.rootStore.ui.setNotification({ - category: 'error', - message: `Failed to update column "${column.name}": ${res.error.message}`, - }) - } - } - } - } - } - - // Then add back the primary keys again - if (isPrimaryKeyUpdated && primaryKeyColumns.length > 0) { - await addPrimaryKey( - this.projectRef, - this.connectionString, - updatedTable.schema, - updatedTable.name, - primaryKeyColumns - ) - } - - const queryClient = getQueryClient() - const project = await getCachedProjectDetail(queryClient, this.rootStore.ui.selectedProjectRef) - const projectRef = project?.ref - const connectionString = project?.connectionString - - queryClient.invalidateQueries(tableKeys.table(projectRef, table.id)) - - return { - table: await getTable({ - projectRef, - connectionString, - id: table.id, - }), - hasError, - } - } - - async insertRowsViaSpreadsheet( - file: any, - table: PostgresTable, - selectedHeaders: string[], - onProgressUpdate: (progress: number) => void - ) { - let chunkNumber = 0 - let insertError: any = undefined - const t1: any = new Date() - return new Promise((resolve) => { - Papa.parse(file, { - header: true, - // dynamicTyping has to be disabled so that "00001" doesn't get parsed as 1. - dynamicTyping: false, - skipEmptyLines: true, - chunkSize: CHUNK_SIZE, - quoteChar: file.type === 'text/tab-separated-values' ? '' : '"', - chunk: async (results: any, parser: any) => { - parser.pause() - - const formattedData = results.data.map((row: any) => { - const formattedRow: any = {} - selectedHeaders.forEach((header) => { - const column = table.columns?.find((c) => c.name === header) - if ( - (column?.data_type ?? '') === 'ARRAY' || - (column?.format ?? '').includes('json') - ) { - formattedRow[header] = tryParseJson(row[header]) - } else if (row[header] === '') { - // if the cell is empty string, convert it to NULL - formattedRow[header] = null - } else { - formattedRow[header] = row[header] - } - }) - return formattedRow - }) - - const insertQuery = new Query() - .from(table.name, table.schema) - .insert(formattedData) - .toSql() - const res = await this.query(insertQuery) - - if (res.error) { - console.warn(res.error) - insertError = res.error - parser.abort() - } else { - chunkNumber += 1 - const progress = (chunkNumber * CHUNK_SIZE) / file.size - const progressPercentage = progress > 1 ? 100 : progress * 100 - onProgressUpdate(progressPercentage) - parser.resume() - } - }, - complete: () => { - const t2: any = new Date() - console.log(`Total time taken for importing spreadsheet: ${(t2 - t1) / 1000} seconds`) - resolve({ error: insertError }) - }, - }) - }) - } - - async insertTableRows( - table: PostgresTable, - rows: any, - selectedHeaders: string[], - onProgressUpdate: (progress: number) => void - ) { - let insertError = undefined - let insertProgress = 0 - - const formattedRows = rows.map((row: any) => { - const formattedRow: any = {} - selectedHeaders.forEach((header) => { - const column = table.columns?.find((c) => c.name === header) - if ((column?.data_type ?? '') === 'ARRAY' || (column?.format ?? '').includes('json')) { - formattedRow[header] = tryParseJson(row[header]) - } else { - formattedRow[header] = row[header] - } - }) - return formattedRow - }) - - const batches = chunk(formattedRows, BATCH_SIZE) - const promises = batches.map((batch: any) => { - return () => { - return Promise.race([ - new Promise(async (resolve, reject) => { - const insertQuery = new Query().from(table.name, table.schema).insert(batch).toSql() - const res = await this.query(insertQuery) - - if (res.error) { - insertError = res.error - reject(res.error) - } - insertProgress = insertProgress + batch.length / rows.length - resolve({}) - }), - timeout(30000), - ]) - } - }) - - const batchedPromises = chunk(promises, 10) - for (const batchedPromise of batchedPromises) { - const res = await Promise.allSettled(batchedPromise.map((batch) => batch())) - const hasFailedBatch = find(res, { status: 'rejected' }) - if (hasFailedBatch) break - onProgressUpdate(insertProgress * 100) - } - return { error: insertError } - } - setProjectDetails({ ref, connectionString }: { ref: string; connectionString?: string }) { this.projectRef = ref this.baseUrl = `${API_URL}/pg-meta/${ref}` @@ -624,8 +91,5 @@ export default class MetaStore implements IMetaStore { this.connectionString = connectionString this.headers['x-connection-encrypted'] = connectionString } - - this.tables.setUrl(`${this.baseUrl}/tables`) - this.tables.setHeaders(this.headers) } } diff --git a/apps/studio/stores/pgmeta/TableStore.ts b/apps/studio/stores/pgmeta/TableStore.ts deleted file mode 100644 index 23088868c23..00000000000 --- a/apps/studio/stores/pgmeta/TableStore.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { action, makeObservable } from 'mobx' -import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta' - -import PostgresMetaInterface, { IPostgresMetaInterface } from '../common/PostgresMetaInterface' -import { IRootStore } from '../RootStore' -import { get } from 'lib/common/fetch' - -export interface ITableStore extends IPostgresMetaInterface {} - -export default class TableStore extends PostgresMetaInterface { - constructor( - rootStore: IRootStore, - dataUrl: string, - headers?: { - [prop: string]: any - }, - options?: { identifier: string } - ) { - super(rootStore, dataUrl, headers, options) - } - - // Customize TableStore fetchData method to improve request performance - async fetchData() { - const headers = { 'Content-Type': 'application/json', ...this.headers } - // load all tables w/o columns info - const urlTables = this.url.includes('?') - ? `${this.url}&include_columns=false` - : `${this.url}?include_columns=false` - // load all columns - const urlColumns = this.url.replace('/tables', '/columns') - const [tablesResponse, columnsResponse] = await Promise.all([ - get(urlTables, { headers }), - get(urlColumns, { headers }), - ]) - if (tablesResponse.error) throw tablesResponse.error - if (columnsResponse.error) throw columnsResponse.error - - // merge 2 response to create the final array - const columnsByTableId = (columnsResponse as PostgresColumn[]).reduce((acc, curr) => { - acc[curr.table_id] ??= [] - acc[curr.table_id].push(curr) - return acc - }, {} as Record) - const tables: PostgresTable[] = [] - tablesResponse.forEach((table: PostgresTable) => { - const columns = columnsByTableId[table.id] - tables.push({ ...table, columns }) - }) - - this.setDataArray(tables) - return tables as any - } -} diff --git a/apps/studio/styles/contextMenu.scss b/apps/studio/styles/contextMenu.scss index 76d63f0e2e5..1924b3acf44 100644 --- a/apps/studio/styles/contextMenu.scss +++ b/apps/studio/styles/contextMenu.scss @@ -17,6 +17,6 @@ @apply bg-overlay-hover text-foreground; } .react-contexify__separator { - @apply bg-overlay; + @apply bg-surface-300; } } diff --git a/apps/studio/styles/date-picker.scss b/apps/studio/styles/date-picker.scss index 4cd28cf8760..f21c7c88557 100644 --- a/apps/studio/styles/date-picker.scss +++ b/apps/studio/styles/date-picker.scss @@ -17,7 +17,7 @@ } .react-datepicker__portal { - @apply absolute z-10 w-72 text-sm transform-none bg-background shadow px-3 py-2 top-12 right-0 border-2 border-background rounded; + @apply absolute z-10 w-72 text-sm transform-none bg-studio shadow px-3 py-2 top-12 right-0 border-2 border-background rounded; } .react-datepicker__month-container { @@ -50,11 +50,11 @@ } .react-datepicker__navigation--previous { - @apply right-12 w-10 h-10 rounded transition flex items-center justify-center hover:bg-background; + @apply right-12 w-10 h-10 rounded transition flex items-center justify-center hover:bg-studio; } .react-datepicker__navigation--next { - @apply right-4 w-10 h-10 rounded transition flex items-center justify-center hover:bg-background; + @apply right-4 w-10 h-10 rounded transition flex items-center justify-center hover:bg-studio; } .react-datepicker__day { @@ -92,7 +92,7 @@ .react-datepicker__day--range-start, .react-datepicker__day--range-end { - @apply bg-brand-accent text-foreground; + @apply bg-brand text-foreground; } .react-datepicker__day--highlighted { diff --git a/apps/studio/styles/grid.scss b/apps/studio/styles/grid.scss index 5ff392910c6..592079fd88f 100644 --- a/apps/studio/styles/grid.scss +++ b/apps/studio/styles/grid.scss @@ -38,7 +38,7 @@ .rdg { @apply box-border select-none overflow-x-auto overflow-y-scroll bg-transparent; - @apply border-default border-t border-b border-r-0 border-l-0; + @apply border-default border-t border-r-0 border-l-0; contain: strict; -webkit-user-select: none; font-size: 14px; @@ -114,16 +114,16 @@ */ .rdg-row { - @apply bg-table-body-light [[data-theme*=dark]_&]:bg-table-body-dark transition-colors; - @apply hover:bg-table-header-light [[data-theme*=dark]_&]:hover:bg-table-header-dark; + @apply bg-studio transition-colors; + @apply hover:bg-surface-100; } /* edit button */ .rdg-row__select-column__edit-action { - opacity: 0; + @apply opacity-0; } .rdg-row:hover .rdg-row__select-column__edit-action { - opacity: 0.5; + @apply opacity-100; } .rdg-row__select-column__edit-action:hover { @@ -174,7 +174,7 @@ } [type='checkbox']:checked { - @apply bg-brand; + @apply bg-foreground; border-color: transparent; background-size: 100% 100%; background-position: center; @@ -184,8 +184,8 @@ [type='checkbox'] { @apply cursor-pointer rounded border border-solid; - @apply text-brand-600 border-default transition-all; - @apply hover:border-brand focus:ring-brand-300 focus:outline-none; + @apply text-foreground border-default transition-all; + @apply hover:border-foreground focus:ring-foreground focus:outline-none; @apply bg-transparent; @@ -229,13 +229,6 @@ margin-left: auto; font-size: 12px; } -.react-contexify__separator { - width: 100%; - height: 1px; - cursor: default; - margin: 4px 0; - background-color: rgba(0, 0, 0, 0.2); -} .react-contexify__will-leave--disabled { pointer-events: none; } diff --git a/apps/studio/styles/main.scss b/apps/studio/styles/main.scss index f8a5b3088ef..a04e6b6b8ba 100644 --- a/apps/studio/styles/main.scss +++ b/apps/studio/styles/main.scss @@ -4,6 +4,7 @@ @import '~ui/build/css/source/global.css'; @import '~ui/build/css/themes/dark.css'; +@import '~ui/build/css/themes/deep-dark.css'; @import '~ui/build/css/themes/light.css'; @layer utilities { @@ -25,7 +26,7 @@ html, body, #__next, .main { - @apply bg-background; + @apply bg-studio; @apply text-foreground; height: 100%; padding: 0; @@ -75,7 +76,7 @@ input.form-control, @apply focus:border-stronger; @apply focus:ring-border-overlay; - @apply bg-background; + @apply bg-studio; @apply border-strong border; @apply outline-none; diff --git a/apps/studio/styles/markdown-preview.scss b/apps/studio/styles/markdown-preview.scss new file mode 100644 index 00000000000..742983e6b76 --- /dev/null +++ b/apps/studio/styles/markdown-preview.scss @@ -0,0 +1,1195 @@ +// [Joshen] https://github.com/sindresorhus/github-markdown-css + +@media (prefers-color-scheme: dark) { + .markdown-body, + [data-theme="dark"] { + /*dark*/ + color-scheme: dark; + --color-prettylights-syntax-comment: #8b949e; + --color-prettylights-syntax-constant: #79c0ff; + --color-prettylights-syntax-entity: #d2a8ff; + --color-prettylights-syntax-storage-modifier-import: #c9d1d9; + --color-prettylights-syntax-entity-tag: #7ee787; + --color-prettylights-syntax-keyword: #ff7b72; + --color-prettylights-syntax-string: #a5d6ff; + --color-prettylights-syntax-variable: #ffa657; + --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; + --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; + --color-prettylights-syntax-invalid-illegal-bg: #8e1519; + --color-prettylights-syntax-carriage-return-text: #f0f6fc; + --color-prettylights-syntax-carriage-return-bg: #b62324; + --color-prettylights-syntax-string-regexp: #7ee787; + --color-prettylights-syntax-markup-list: #f2cc60; + --color-prettylights-syntax-markup-heading: #1f6feb; + --color-prettylights-syntax-markup-italic: #c9d1d9; + --color-prettylights-syntax-markup-bold: #c9d1d9; + --color-prettylights-syntax-markup-deleted-text: #ffdcd7; + --color-prettylights-syntax-markup-deleted-bg: #67060c; + --color-prettylights-syntax-markup-inserted-text: #aff5b4; + --color-prettylights-syntax-markup-inserted-bg: #033a16; + --color-prettylights-syntax-markup-changed-text: #ffdfb6; + --color-prettylights-syntax-markup-changed-bg: #5a1e02; + --color-prettylights-syntax-markup-ignored-text: #c9d1d9; + --color-prettylights-syntax-markup-ignored-bg: #1158c7; + --color-prettylights-syntax-meta-diff-range: #d2a8ff; + --color-prettylights-syntax-brackethighlighter-angle: #8b949e; + --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58; + --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; + --color-fg-default: #e6edf3; + --color-fg-muted: #848d97; + --color-fg-subtle: #6e7681; + --color-canvas-default: #0d1117; + --color-canvas-subtle: #161b22; + --color-border-default: #30363d; + --color-border-muted: #21262d; + --color-neutral-muted: rgba(110,118,129,0.4); + --color-accent-fg: #2f81f7; + --color-accent-emphasis: #1f6feb; + --color-success-fg: #3fb950; + --color-success-emphasis: #238636; + --color-attention-fg: #d29922; + --color-attention-emphasis: #9e6a03; + --color-attention-subtle: rgba(187,128,9,0.15); + --color-danger-fg: #f85149; + --color-danger-emphasis: #da3633; + --color-done-fg: #a371f7; + --color-done-emphasis: #8957e5; + } +} + +@media (prefers-color-scheme: light) { + .markdown-body, + [data-theme="light"] { + /*light*/ + color-scheme: light; + --color-prettylights-syntax-comment: #57606a; + --color-prettylights-syntax-constant: #0550ae; + --color-prettylights-syntax-entity: #6639ba; + --color-prettylights-syntax-storage-modifier-import: #24292f; + --color-prettylights-syntax-entity-tag: #116329; + --color-prettylights-syntax-keyword: #cf222e; + --color-prettylights-syntax-string: #0a3069; + --color-prettylights-syntax-variable: #953800; + --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; + --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; + --color-prettylights-syntax-invalid-illegal-bg: #82071e; + --color-prettylights-syntax-carriage-return-text: #f6f8fa; + --color-prettylights-syntax-carriage-return-bg: #cf222e; + --color-prettylights-syntax-string-regexp: #116329; + --color-prettylights-syntax-markup-list: #3b2300; + --color-prettylights-syntax-markup-heading: #0550ae; + --color-prettylights-syntax-markup-italic: #24292f; + --color-prettylights-syntax-markup-bold: #24292f; + --color-prettylights-syntax-markup-deleted-text: #82071e; + --color-prettylights-syntax-markup-deleted-bg: #ffebe9; + --color-prettylights-syntax-markup-inserted-text: #116329; + --color-prettylights-syntax-markup-inserted-bg: #dafbe1; + --color-prettylights-syntax-markup-changed-text: #953800; + --color-prettylights-syntax-markup-changed-bg: #ffd8b5; + --color-prettylights-syntax-markup-ignored-text: #eaeef2; + --color-prettylights-syntax-markup-ignored-bg: #0550ae; + --color-prettylights-syntax-meta-diff-range: #8250df; + --color-prettylights-syntax-brackethighlighter-angle: #57606a; + --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f; + --color-prettylights-syntax-constant-other-reference-link: #0a3069; + --color-fg-default: #1F2328; + --color-fg-muted: #656d76; + --color-fg-subtle: #6e7781; + --color-canvas-default: #ffffff; + --color-canvas-subtle: #f6f8fa; + --color-border-default: #d0d7de; + --color-border-muted: hsla(210,18%,87%,1); + --color-neutral-muted: rgba(175,184,193,0.2); + --color-accent-fg: #0969da; + --color-accent-emphasis: #0969da; + --color-success-fg: #1a7f37; + --color-success-emphasis: #1f883d; + --color-attention-fg: #9a6700; + --color-attention-emphasis: #9a6700; + --color-attention-subtle: #fff8c5; + --color-danger-fg: #d1242f; + --color-danger-emphasis: #cf222e; + --color-done-fg: #8250df; + --color-done-emphasis: #8250df; + } +} + +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + margin: 0; + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; +} + +.markdown-body .octicon { + display: inline-block; + fill: currentColor; + vertical-align: text-bottom; +} + +.markdown-body h1:hover .anchor .octicon-link:before, +.markdown-body h2:hover .anchor .octicon-link:before, +.markdown-body h3:hover .anchor .octicon-link:before, +.markdown-body h4:hover .anchor .octicon-link:before, +.markdown-body h5:hover .anchor .octicon-link:before, +.markdown-body h6:hover .anchor .octicon-link:before { + width: 16px; + height: 16px; + content: ' '; + display: inline-block; + background-color: currentColor; + -webkit-mask-image: url("data:image/svg+xml,"); + mask-image: url("data:image/svg+xml,"); +} + +.markdown-body details, +.markdown-body figcaption, +.markdown-body figure { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body [hidden] { + display: none !important; +} + +.markdown-body a { + background-color: transparent; + color: var(--color-accent-fg); + text-decoration: none; +} + +.markdown-body abbr[title] { + border-bottom: none; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +.markdown-body b, +.markdown-body strong { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dfn { + font-style: italic; +} + +.markdown-body h1 { + margin: .67em 0; + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 2em; + @apply border-b; +} + +.markdown-body mark { + background-color: var(--color-attention-subtle); + color: var(--color-fg-default); +} + +.markdown-body small { + font-size: 90%; +} + +.markdown-body sub, +.markdown-body sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +.markdown-body sub { + bottom: -0.25em; +} + +.markdown-body sup { + top: -0.5em; +} + +.markdown-body img { + border-style: none; + max-width: 100%; + box-sizing: content-box; + background-color: var(--color-canvas-default); +} + +.markdown-body code, +.markdown-body kbd, +.markdown-body pre, +.markdown-body samp { + font-family: monospace; + font-size: 1em; +} + +.markdown-body figure { + margin: 1em 40px; +} + +.markdown-body hr { + box-sizing: content-box; + overflow: hidden; + background: transparent; + @apply border-b; + height: .25em; + padding: 0; + margin: 24px 0; + background-color: var(--color-border-default); + border: 0; +} + +.markdown-body input { + font: inherit; + margin: 0; + overflow: visible; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.markdown-body [type=button], +.markdown-body [type=reset], +.markdown-body [type=submit] { + -webkit-appearance: button; + appearance: button; +} + +.markdown-body [type=checkbox], +.markdown-body [type=radio] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body [type=number]::-webkit-inner-spin-button, +.markdown-body [type=number]::-webkit-outer-spin-button { + height: auto; +} + +.markdown-body [type=search]::-webkit-search-cancel-button, +.markdown-body [type=search]::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; +} + +.markdown-body ::-webkit-input-placeholder { + color: inherit; + opacity: .54; +} + +.markdown-body ::-webkit-file-upload-button { + -webkit-appearance: button; + appearance: button; + font: inherit; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body ::placeholder { + color: var(--color-fg-subtle); + opacity: 1; +} + +.markdown-body hr::before { + display: table; + content: ""; +} + +.markdown-body hr::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body details:not([open])>*:not(summary) { + display: none !important; +} + +.markdown-body a:focus, +.markdown-body [role=button]:focus, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=checkbox]:focus { + outline: 2px solid var(--color-accent-fg); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:focus:not(:focus-visible), +.markdown-body [role=button]:focus:not(:focus-visible), +.markdown-body input[type=radio]:focus:not(:focus-visible), +.markdown-body input[type=checkbox]:focus:not(:focus-visible) { + outline: solid 1px transparent; +} + +.markdown-body a:focus-visible, +.markdown-body [role=button]:focus-visible, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus-visible { + outline: 2px solid var(--color-accent-fg); + outline-offset: -2px; + box-shadow: none; +} + +.markdown-body a:not([class]):focus, +.markdown-body a:not([class]):focus-visible, +.markdown-body input[type=radio]:focus, +.markdown-body input[type=radio]:focus-visible, +.markdown-body input[type=checkbox]:focus, +.markdown-body input[type=checkbox]:focus-visible { + outline-offset: 0; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: 11px ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + line-height: 10px; + color: var(--color-fg-default); + vertical-align: middle; + background-color: var(--color-canvas-subtle); + border: solid 1px var(--color-neutral-muted); + border-bottom-color: var(--color-neutral-muted); + border-radius: 6px; + box-shadow: inset 0 -1px 0 var(--color-neutral-muted); +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: var(--base-text-weight-semibold, 600); + line-height: 1.25; +} + +.markdown-body h2 { + font-weight: var(--base-text-weight-semibold, 600); + padding-bottom: .3em; + font-size: 1.5em; + @apply border-b; +} + +.markdown-body h3 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1.25em; +} + +.markdown-body h4 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: 1em; +} + +.markdown-body h5 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .875em; +} + +.markdown-body h6 { + font-weight: var(--base-text-weight-semibold, 600); + font-size: .85em; + color: var(--color-fg-muted); +} + +.markdown-body p { + margin-top: 0; + margin-bottom: 10px; +} + +.markdown-body blockquote { + margin: 0; + padding: 0 1em; + color: var(--color-fg-muted); + border-left: .25em solid var(--color-border-default); +} + +.markdown-body ul, +.markdown-body ol { + margin-top: 0; + margin-bottom: 0; + padding-left: 2em; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ul ul ol, +.markdown-body ul ol ol, +.markdown-body ol ul ol, +.markdown-body ol ol ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body tt, +.markdown-body code, +.markdown-body samp { + font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + font-size: 12px; +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; + font-family: ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace; + font-size: 12px; + word-wrap: normal; +} + +.markdown-body .octicon { + display: inline-block; + overflow: visible !important; + vertical-align: text-bottom; + fill: currentColor; +} + +.markdown-body input::-webkit-outer-spin-button, +.markdown-body input::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; +} + +.markdown-body .mr-2 { + margin-right: var(--base-size-8, 8px) !important; +} + +.markdown-body::before { + display: table; + content: ""; +} + +.markdown-body::after { + display: table; + clear: both; + content: ""; +} + +.markdown-body>*:first-child { + margin-top: 0 !important; +} + +.markdown-body>*:last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body .absent { + color: var(--color-danger-fg); +} + +.markdown-body .anchor { + float: left; + padding-right: 4px; + margin-left: -20px; + line-height: 1; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body p, +.markdown-body blockquote, +.markdown-body ul, +.markdown-body ol, +.markdown-body dl, +.markdown-body table, +.markdown-body pre, +.markdown-body details { + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body blockquote>:first-child { + margin-top: 0; +} + +.markdown-body blockquote>:last-child { + margin-bottom: 0; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: var(--color-fg-default); + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1 tt, +.markdown-body h1 code, +.markdown-body h2 tt, +.markdown-body h2 code, +.markdown-body h3 tt, +.markdown-body h3 code, +.markdown-body h4 tt, +.markdown-body h4 code, +.markdown-body h5 tt, +.markdown-body h5 code, +.markdown-body h6 tt, +.markdown-body h6 code { + padding: 0 .2em; + font-size: inherit; +} + +.markdown-body summary h1, +.markdown-body summary h2, +.markdown-body summary h3, +.markdown-body summary h4, +.markdown-body summary h5, +.markdown-body summary h6 { + display: inline-block; +} + +.markdown-body summary h1 .anchor, +.markdown-body summary h2 .anchor, +.markdown-body summary h3 .anchor, +.markdown-body summary h4 .anchor, +.markdown-body summary h5 .anchor, +.markdown-body summary h6 .anchor { + margin-left: -40px; +} + +.markdown-body summary h1, +.markdown-body summary h2 { + padding-bottom: 0; + border-bottom: 0; +} + +.markdown-body ul.no-list, +.markdown-body ol.no-list { + padding: 0; + list-style-type: none; +} + +.markdown-body ol[type="a s"] { + list-style-type: lower-alpha; +} + +.markdown-body ol[type="A s"] { + list-style-type: upper-alpha; +} + +.markdown-body ol[type="i s"] { + list-style-type: lower-roman; +} + +.markdown-body ol[type="I s"] { + list-style-type: upper-roman; +} + +.markdown-body ol[type="1"] { + list-style-type: decimal; +} + +.markdown-body div>ol:not([type]) { + list-style-type: decimal; +} + +.markdown-body ul ul, +.markdown-body ul ol, +.markdown-body ol ol, +.markdown-body ol ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li>p { + margin-top: 16px; +} + +.markdown-body li+li { + margin-top: .25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +.markdown-body table th { + font-weight: var(--base-text-weight-semibold, 600); +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid var(--color-border-default); +} + +.markdown-body table td>:last-child { + margin-bottom: 0; +} + +.markdown-body table tr { + @apply bg-surface-100; + border-top: 1px solid var(--color-border-muted); +} + +.markdown-body table tr:nth-child(2n) { + @apply bg-surface-200; +} + +.markdown-body table img { + background-color: transparent; +} + +.markdown-body img[align=right] { + padding-left: 20px; +} + +.markdown-body img[align=left] { + padding-right: 20px; +} + +.markdown-body .emoji { + max-width: none; + vertical-align: text-top; + background-color: transparent; +} + +.markdown-body span.frame { + display: block; + overflow: hidden; +} + +.markdown-body span.frame>span { + display: block; + float: left; + width: auto; + padding: 7px; + margin: 13px 0 0; + overflow: hidden; + border: 1px solid var(--color-border-default); +} + +.markdown-body span.frame span img { + display: block; + float: left; +} + +.markdown-body span.frame span span { + display: block; + padding: 5px 0 0; + clear: both; + color: var(--color-fg-default); +} + +.markdown-body span.align-center { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-center>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: center; +} + +.markdown-body span.align-center span img { + margin: 0 auto; + text-align: center; +} + +.markdown-body span.align-right { + display: block; + overflow: hidden; + clear: both; +} + +.markdown-body span.align-right>span { + display: block; + margin: 13px 0 0; + overflow: hidden; + text-align: right; +} + +.markdown-body span.align-right span img { + margin: 0; + text-align: right; +} + +.markdown-body span.float-left { + display: block; + float: left; + margin-right: 13px; + overflow: hidden; +} + +.markdown-body span.float-left span { + margin: 13px 0 0; +} + +.markdown-body span.float-right { + display: block; + float: right; + margin-left: 13px; + overflow: hidden; +} + +.markdown-body span.float-right>span { + display: block; + margin: 13px auto 0; + overflow: hidden; + text-align: right; +} + +.markdown-body code, +.markdown-body tt { + padding: .2em .4em; + margin: 0; + font-size: 85%; + white-space: break-spaces; + background-color: var(--color-neutral-muted); + border-radius: 6px; +} + +.markdown-body code br, +.markdown-body tt br { + display: none; +} + +.markdown-body del code { + text-decoration: inherit; +} + +.markdown-body samp { + font-size: 85%; +} + +.markdown-body pre code { + font-size: 100%; +} + +.markdown-body pre>code { + padding: 0; + margin: 0; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 16px; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + color: var(--color-fg-default); + background-color: var(--color-canvas-subtle); + border-radius: 6px; +} + +.markdown-body pre code, +.markdown-body pre tt { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body .csv-data td, +.markdown-body .csv-data th { + padding: 5px; + overflow: hidden; + font-size: 12px; + line-height: 1; + text-align: left; + white-space: nowrap; +} + +.markdown-body .csv-data .blob-num { + padding: 10px 8px 9px; + text-align: right; + background: var(--color-canvas-default); + border: 0; +} + +.markdown-body .csv-data tr { + border-top: 0; +} + +.markdown-body .csv-data th { + font-weight: var(--base-text-weight-semibold, 600); + background: var(--color-canvas-subtle); + border-top: 0; +} + +.markdown-body [data-footnote-ref]::before { + content: "["; +} + +.markdown-body [data-footnote-ref]::after { + content: "]"; +} + +.markdown-body .footnotes { + font-size: 12px; + color: var(--color-fg-muted); + border-top: 1px solid var(--color-border-default); +} + +.markdown-body .footnotes ol { + padding-left: 16px; +} + +.markdown-body .footnotes ol ul { + display: inline-block; + padding-left: 16px; + margin-top: 16px; +} + +.markdown-body .footnotes li { + position: relative; +} + +.markdown-body .footnotes li:target::before { + position: absolute; + top: -8px; + right: -8px; + bottom: -8px; + left: -24px; + pointer-events: none; + content: ""; + border: 2px solid var(--color-accent-emphasis); + border-radius: 6px; +} + +.markdown-body .footnotes li:target { + color: var(--color-fg-default); +} + +.markdown-body .footnotes .data-footnote-backref g-emoji { + font-family: monospace; +} + +.markdown-body .pl-c { + color: var(--color-prettylights-syntax-comment); +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: var(--color-prettylights-syntax-constant); +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: var(--color-prettylights-syntax-entity); +} + +.markdown-body .pl-smi, +.markdown-body .pl-s .pl-s1 { + color: var(--color-prettylights-syntax-storage-modifier-import); +} + +.markdown-body .pl-ent { + color: var(--color-prettylights-syntax-entity-tag); +} + +.markdown-body .pl-k { + color: var(--color-prettylights-syntax-keyword); +} + +.markdown-body .pl-s, +.markdown-body .pl-pds, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sre, +.markdown-body .pl-sr .pl-sra { + color: var(--color-prettylights-syntax-string); +} + +.markdown-body .pl-v, +.markdown-body .pl-smw { + color: var(--color-prettylights-syntax-variable); +} + +.markdown-body .pl-bu { + color: var(--color-prettylights-syntax-brackethighlighter-unmatched); +} + +.markdown-body .pl-ii { + color: var(--color-prettylights-syntax-invalid-illegal-text); + background-color: var(--color-prettylights-syntax-invalid-illegal-bg); +} + +.markdown-body .pl-c2 { + color: var(--color-prettylights-syntax-carriage-return-text); + background-color: var(--color-prettylights-syntax-carriage-return-bg); +} + +.markdown-body .pl-sr .pl-cce { + font-weight: bold; + color: var(--color-prettylights-syntax-string-regexp); +} + +.markdown-body .pl-ml { + color: var(--color-prettylights-syntax-markup-list); +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-heading); +} + +.markdown-body .pl-mi { + font-style: italic; + color: var(--color-prettylights-syntax-markup-italic); +} + +.markdown-body .pl-mb { + font-weight: bold; + color: var(--color-prettylights-syntax-markup-bold); +} + +.markdown-body .pl-md { + color: var(--color-prettylights-syntax-markup-deleted-text); + background-color: var(--color-prettylights-syntax-markup-deleted-bg); +} + +.markdown-body .pl-mi1 { + color: var(--color-prettylights-syntax-markup-inserted-text); + background-color: var(--color-prettylights-syntax-markup-inserted-bg); +} + +.markdown-body .pl-mc { + color: var(--color-prettylights-syntax-markup-changed-text); + background-color: var(--color-prettylights-syntax-markup-changed-bg); +} + +.markdown-body .pl-mi2 { + color: var(--color-prettylights-syntax-markup-ignored-text); + background-color: var(--color-prettylights-syntax-markup-ignored-bg); +} + +.markdown-body .pl-mdr { + font-weight: bold; + color: var(--color-prettylights-syntax-meta-diff-range); +} + +.markdown-body .pl-ba { + color: var(--color-prettylights-syntax-brackethighlighter-angle); +} + +.markdown-body .pl-sg { + color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: var(--color-prettylights-syntax-constant-other-reference-link); +} + +.markdown-body g-emoji { + display: inline-block; + min-width: 1ch; + font-family: "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; + font-size: 1em; + font-style: normal !important; + font-weight: var(--base-text-weight-normal, 400); + line-height: 1; + vertical-align: -0.075em; +} + +.markdown-body g-emoji img { + width: 1em; + height: 1em; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item label { + font-weight: var(--base-text-weight-normal, 400); +} + +.markdown-body .task-list-item.enabled label { + cursor: pointer; +} + +.markdown-body .task-list-item+.task-list-item { + margin-top: 4px; +} + +.markdown-body .task-list-item .handle { + display: none; +} + +.markdown-body .task-list-item-checkbox { + margin: 0 .2em .25em -1.4em; + vertical-align: middle; +} + +.markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox { + margin: 0 -1.6em .25em .2em; +} + +.markdown-body .contains-task-list { + position: relative; +} + +.markdown-body .contains-task-list:hover .task-list-item-convert-container, +.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { + display: block; + width: auto; + height: 24px; + overflow: visible; + clip: auto; +} + +.markdown-body ::-webkit-calendar-picker-indicator { + filter: invert(50%); +} + +.markdown-body .markdown-alert { + padding: var(--base-size-8) var(--base-size-16); + margin-bottom: 16px; + color: inherit; + border-left: .25em solid var(--color-border-default); +} + +.markdown-body .markdown-alert>:first-child { + margin-top: 0; +} + +.markdown-body .markdown-alert>:last-child { + margin-bottom: 0; +} + +.markdown-body .markdown-alert .markdown-alert-title { + display: flex; + font-weight: var(--base-text-weight-medium, 500); + align-items: center; + line-height: 1; +} + +.markdown-body .markdown-alert.markdown-alert-note { + border-left-color: var(--color-accent-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { + color: var(--color-accent-fg); +} + +.markdown-body .markdown-alert.markdown-alert-important { + border-left-color: var(--color-done-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { + color: var(--color-done-fg); +} + +.markdown-body .markdown-alert.markdown-alert-warning { + border-left-color: var(--color-attention-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { + color: var(--color-attention-fg); +} + +.markdown-body .markdown-alert.markdown-alert-tip { + border-left-color: var(--color-success-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { + color: var(--color-success-fg); +} + +.markdown-body .markdown-alert.markdown-alert-caution { + border-left-color: var(--color-danger-emphasis); +} + +.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { + color: var(--color-danger-fg); +} \ No newline at end of file diff --git a/apps/studio/tailwind.config.js b/apps/studio/tailwind.config.js index b2412b17860..b7bfebe5339 100644 --- a/apps/studio/tailwind.config.js +++ b/apps/studio/tailwind.config.js @@ -2,12 +2,11 @@ const config = require('config/tailwind.config') module.exports = config({ content: [ - './../../packages/ui/src/**/*.{tsx,ts,js}', './pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}', // purge styles from grid library - './../node_modules/@supabase/grid/src/components/**/*.{js,ts,jsx,tsx}', - './../node_modules/@supabase/grid/src/components/**/**/*.{js,ts,jsx,tsx}', + './../../packages/ui/src/**/*.{tsx,ts,js}', + './../../packages/ui-patterns/**/*.{tsx,ts,js}', ], theme: { extend: { diff --git a/apps/studio/tests/components/Editor/RemoveJSONTrailingComma.utils.test.js b/apps/studio/tests/components/Editor/RemoveJSONTrailingComma.utils.test.js new file mode 100644 index 00000000000..61a84165c3b --- /dev/null +++ b/apps/studio/tests/components/Editor/RemoveJSONTrailingComma.utils.test.js @@ -0,0 +1,36 @@ +import { removeJSONTrailingComma } from '/apps/studio/lib/helpers.ts' + +describe('removeJSONTrailingComma', () => { + it('should handle an empty object', () => { + const jsonString = '{}' + expect(removeJSONTrailingComma(jsonString)).toEqual(jsonString) + }) + + it('should handle an empty array', () => { + const jsonString = '[]' + expect(removeJSONTrailingComma(jsonString)).toEqual(jsonString) + }) + + it('should handle a JSON string without a trailing comma', () => { + const jsonString = '{"name": "John", "age": 25}' + expect(removeJSONTrailingComma(jsonString)).toEqual(jsonString) + }) + + it('should remove a trailing comma for JSON object', () => { + const jsonString = '{"name": "John", "age": 25,}' + const expectedOutput = '{"name": "John", "age": 25}' + expect(removeJSONTrailingComma(jsonString)).toEqual(expectedOutput) + }) + + it('should remove a trailing commas in an array of objects', () => { + const jsonString = '[{"fruit1": "apple","fruit2": "banana",}]' + const expectedOutput = '[{"fruit1": "apple","fruit2": "banana"}]' + expect(removeJSONTrailingComma(jsonString)).toEqual(expectedOutput) + }) + + it('should remove all trailing commas in an array of objects', () => { + const jsonString = '[{"fruit1": "apple","fruit2": "banana",},]' + const expectedOutput = '[{"fruit1": "apple","fruit2": "banana"}]' + expect(removeJSONTrailingComma(jsonString)).toEqual(expectedOutput) + }) +}) diff --git a/apps/studio/types/userContent.ts b/apps/studio/types/userContent.ts index 24e4a25c382..83ea9647e77 100644 --- a/apps/studio/types/userContent.ts +++ b/apps/studio/types/userContent.ts @@ -1,5 +1,5 @@ export interface UserContent< - T = Dashboards.Content | SqlSnippets.Content | LogSqlSnippets.Content + T = Dashboards.Content | SqlSnippets.Content | LogSqlSnippets.Content, > { id?: string name: string diff --git a/apps/www/_blog/2021-12-03-pg-graphql.mdx b/apps/www/_blog/2021-12-03-pg-graphql.mdx index 2a05315a4ec..6744a597d20 100644 --- a/apps/www/_blog/2021-12-03-pg-graphql.mdx +++ b/apps/www/_blog/2021-12-03-pg-graphql.mdx @@ -5,8 +5,8 @@ author: oli_rice author_url: https://github.com/olirice author_image_url: https://github.com/olirice.png authorURL: https://github.com/olirice -image: launch-week-three/five-more-things/supabase-pg-graphql-og.png -thumb: launch-week-three/five-more-things/supabase-pg-graphql-thumb.png +image: launch-week-three/five-more-things/supabase-pg-graphql-og.jpg +thumb: launch-week-three/five-more-things/supabase-pg-graphql-thumb.jpg categories: - postgres tags: diff --git a/apps/www/_blog/2022-03-08-postgres-audit.mdx b/apps/www/_blog/2022-03-08-postgres-audit.mdx index bf5ad751917..3ea7ac3d7cb 100644 --- a/apps/www/_blog/2022-03-08-postgres-audit.mdx +++ b/apps/www/_blog/2022-03-08-postgres-audit.mdx @@ -104,20 +104,20 @@ So instead, we'll lean on PostgreSQL's schema-less [`JSONB` data type](https://w That approach has the added benefit of allowing us to store multiple tables' audit history in a single audit table. ```sql -create table audit.record_version( - id bigserial primary key, +create table audit.record_version ( + id bigserial primary key, -- auditing metadata - record_id uuid, -- identifies a new record by it's table + primary key - old_record_id uuid, -- ^ - op varchar(8) not null, -- INSERT/UPDATE/DELETE/TRUNCATE - ts timestamptz not null default now(), + record_id uuid, -- identifies a new record by it's table + primary key + old_record_id uuid, -- ^ + op varchar(8) not null, -- INSERT/UPDATE/DELETE/TRUNCATE + ts timestamptz not null default now(), -- table identifiers - table_oid oid not null, -- pg internal id for a table - table_schema name not null, -- audited table's schema name e.g. 'public' - table_name name not null, -- audited table's table name e.g. 'account' + table_oid oid not null, -- pg internal id for a table + table_schema name not null, -- audited table's schema name e.g. 'public' + table_name name not null, -- audited table's table name e.g. 'account' -- record data - record jsonb, -- contents of the new record - old_record jsonb -- previous record contents (for UPDATE/DELETE) + record jsonb, -- contents of the new record + old_record jsonb -- previous record contents (for UPDATE/DELETE) ); ``` diff --git a/apps/www/_blog/2022-03-31-supabase-edge-functions.mdx b/apps/www/_blog/2022-03-31-supabase-edge-functions.mdx index e18258044e6..616132ce632 100644 --- a/apps/www/_blog/2022-03-31-supabase-edge-functions.mdx +++ b/apps/www/_blog/2022-03-31-supabase-edge-functions.mdx @@ -11,7 +11,7 @@ tags: - functions date: '2022-03-31' toc_depth: 3 -video: https://www.youtube.com/v/rzglqRdZUQE +video: https://www.youtube.com/v/5OWH9c4u68M ---
    @@ -115,14 +115,14 @@ To round off the end-to-end experience, `supabase-js` now works in Deno, making
    -Can't wait to get started? Develop, test, and deploy your first Supabase Edge Function in less than 7 minutes with our [Quickstart Video Tutorial](https://youtu.be/rzglqRdZUQE)! +Can't wait to get started? Develop, test, and deploy your first Supabase Edge Function in less than 7 minutes with our [Quickstart Video Tutorial](https://youtu.be/5OWH9c4u68M)! Check the [Edge Functions Docs](https://supabase.com/docs/guides/functions) for the steps. ## Example apps: Mobile payments with React Native and Flutter diff --git a/apps/www/_blog/2022-04-01-hackathon-bring-the-func.mdx b/apps/www/_blog/2022-04-01-hackathon-bring-the-func.mdx index bc1d8912cf2..eb56c546e71 100644 --- a/apps/www/_blog/2022-04-01-hackathon-bring-the-func.mdx +++ b/apps/www/_blog/2022-04-01-hackathon-bring-the-func.mdx @@ -130,7 +130,7 @@ Here's a collection of resources that will help you get started building with Su - Getting started with [GraphQL on Supabase](https://supabase.com/blog/graphql-now-available#getting-started) - [GraphQL HackerNews clone](https://supabase-graphql-example.vercel.app/) - [Edge Functions Guide](https://supabase.com/docs/guides/functions) -- [Edge Functions Quickstart video](https://youtu.be/rzglqRdZUQE) +- [Edge Functions Quickstart video](https://youtu.be/5OWH9c4u68M) - [Supabase CLI local development guide](https://supabase.com/docs/guides/local-development) - [Edge Functions examples](https://github.com/supabase/supabase/tree/master/examples/edge-functions) diff --git a/apps/www/_blog/2022-04-18-bring-the-func-hackathon-winners.mdx b/apps/www/_blog/2022-04-18-bring-the-func-hackathon-winners.mdx index de6ded9d33e..a4f41165054 100644 --- a/apps/www/_blog/2022-04-18-bring-the-func-hackathon-winners.mdx +++ b/apps/www/_blog/2022-04-18-bring-the-func-hackathon-winners.mdx @@ -99,7 +99,7 @@ If you're inspired to build, check out some of the latest resources: - Getting started with [GraphQL on Supabase](https://supabase.com/blog/graphql-now-available#getting-started) - [GraphQL HackerNews clone](https://supabase-graphql-example.vercel.app/) - [Edge Functions Guide](https://supabase.com/docs/guides/functions) -- [Edge Functions Quickstart video](https://youtu.be/rzglqRdZUQE) +- [Edge Functions Quickstart video](https://youtu.be/5OWH9c4u68M) - [Supabase CLI local development guide](https://supabase.com/docs/guides/local-development) - [Edge Functions examples](https://github.com/supabase/supabase/tree/master/examples/edge-functions) diff --git a/apps/www/_blog/2022-06-29-visualizing-supabase-data-using-metabase.mdx b/apps/www/_blog/2022-06-29-visualizing-supabase-data-using-metabase.mdx index 116cf211d91..cf4f8921442 100644 --- a/apps/www/_blog/2022-06-29-visualizing-supabase-data-using-metabase.mdx +++ b/apps/www/_blog/2022-06-29-visualizing-supabase-data-using-metabase.mdx @@ -2,8 +2,8 @@ title: Visualizing Supabase Data using Metabase description: How to create different kinds of charts out of data stored in Supabase using Metabase. author: ant_wilson -image: python-1/supabase-python-metabase.png -thumb: python-1/supabase-python-metabase.png +image: python-1/supabase-python-metabase.jpg +thumb: python-1/supabase-python-metabase.jpg categories: - engineering tags: diff --git a/apps/www/_blog/2022-06-30-flutter-tutorial-building-a-chat-app.mdx b/apps/www/_blog/2022-06-30-flutter-tutorial-building-a-chat-app.mdx index 0292a32bf59..3dc1b5f6679 100644 --- a/apps/www/_blog/2022-06-30-flutter-tutorial-building-a-chat-app.mdx +++ b/apps/www/_blog/2022-06-30-flutter-tutorial-building-a-chat-app.mdx @@ -2,8 +2,8 @@ title: 'Flutter Tutorial: building a Flutter chat app' description: Learn how to build a Flutter chat app with open source and scalable backend (inc. auth, realtime, database, and more). author: tyler_shukert -image: flutter-chat/flutter-chat-app.png -thumb: flutter-chat/flutter-chat-app.png +image: flutter-chat/flutter-chat-app.jpg +thumb: flutter-chat/flutter-chat-app.jpg categories: - engineering tags: diff --git a/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx b/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx index 82f13e1dda8..cd6380cef06 100644 --- a/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx +++ b/apps/www/_blog/2022-08-24-building-a-realtime-trello-board-with-supabase-and-angular.mdx @@ -2,8 +2,8 @@ title: 'Building a Realtime Trello Board with Supabase and Angular' description: Go beyond the hello world example with this real world project. author: simon_grimm -image: simon-grimm-angular-realtime/angular-supabase.png -thumb: simon-grimm-angular-realtime/angular-supabase.png +image: simon-grimm-angular-realtime/angular-supabase.jpg +thumb: simon-grimm-angular-realtime/angular-supabase.jpg categories: - engineering tags: @@ -614,7 +614,10 @@ import { filter, map, take } from 'rxjs/operators' providedIn: 'root', }) export class AuthGuard implements CanActivate { - constructor(private auth: AuthService, private router: Router) {} + constructor( + private auth: AuthService, + private router: Router + ) {} canActivate(): Observable { return this.auth.currentUser.pipe( diff --git a/apps/www/_blog/2022-11-08-authentication-in-ionic-angular.mdx b/apps/www/_blog/2022-11-08-authentication-in-ionic-angular.mdx index e34c4d02fa7..d4440080197 100644 --- a/apps/www/_blog/2022-11-08-authentication-in-ionic-angular.mdx +++ b/apps/www/_blog/2022-11-08-authentication-in-ionic-angular.mdx @@ -1479,7 +1479,11 @@ import { App, URLOpenListenerEvent } from '@capacitor/app' styleUrls: ['app.component.scss'], }) export class AppComponent { - constructor(private zone: NgZone, private router: Router, private authService: AuthService) { + constructor( + private zone: NgZone, + private router: Router, + private authService: AuthService + ) { this.setupListener() } diff --git a/apps/www/_blog/2022-11-22-flutter-authorization-with-rls.mdx b/apps/www/_blog/2022-11-22-flutter-authorization-with-rls.mdx index 34badf23844..a0ac18ff5a2 100644 --- a/apps/www/_blog/2022-11-22-flutter-authorization-with-rls.mdx +++ b/apps/www/_blog/2022-11-22-flutter-authorization-with-rls.mdx @@ -2,8 +2,8 @@ title: 'Flutter Authorization with RLS' description: Learn how you can secure your Flutter app using Supabase Row Level Security. author: tyler_shukert -image: flutter-chat-auth/supabase-flutter-auth.jpeg -thumb: flutter-chat-auth/supabase-flutter-auth.jpeg +image: flutter-chat-auth/supabase-flutter-auth.jpg +thumb: flutter-chat-auth/supabase-flutter-auth.jpg categories: - developers tags: diff --git a/apps/www/_blog/2023-02-14-flutter-real-time-multiplayer-game.mdx b/apps/www/_blog/2023-02-14-flutter-real-time-multiplayer-game.mdx index 2e8840fa656..98b26ce6d0e 100644 --- a/apps/www/_blog/2023-02-14-flutter-real-time-multiplayer-game.mdx +++ b/apps/www/_blog/2023-02-14-flutter-real-time-multiplayer-game.mdx @@ -2,8 +2,8 @@ title: How to build a real-time multiplayer game with Flutter Flame description: Build a real-time multiplayer game using Flutter, Flame, and Supabase realtime. author: tyler_shukert -image: flutter-realtime-game/flutter-flame-realtime-multiplayer-game.png -thumb: flutter-realtime-game/flutter-flame-realtime-multiplayer-game.png +image: flutter-realtime-game/flutter-flame-realtime-multiplayer-game.jpg +thumb: flutter-realtime-game/flutter-flame-realtime-multiplayer-game.jpg categories: - developers tags: diff --git a/apps/www/_blog/2023-02-16-case-study-happyteams.mdx b/apps/www/_blog/2023-02-16-case-study-happyteams.mdx index 661409b0ddc..4685fa5116d 100644 --- a/apps/www/_blog/2023-02-16-case-study-happyteams.mdx +++ b/apps/www/_blog/2023-02-16-case-study-happyteams.mdx @@ -2,8 +2,8 @@ title: HappyTeams unlocks better performance and reduces cost with Supabase description: How a bootstrapped startup migrated from Heroku to Supabase in 30 minutes and never looked back author: rory_wilding -image: 2023-02-16-case-study-happyteams/case-study-happyteams.png -thumb: 2023-02-16-case-study-happyteams/case-study-happyteams.png +image: 2023-02-16-case-study-happyteams/case-study-happyteams.jpg +thumb: 2023-02-16-case-study-happyteams/case-study-happyteams.jpg categories: - product tags: diff --git a/apps/www/_blog/2023-03-01-geo-queries-with-postgis-in-ionic-angular.mdx b/apps/www/_blog/2023-03-01-geo-queries-with-postgis-in-ionic-angular.mdx index 1fe257b49b2..f8b6a3b7900 100644 --- a/apps/www/_blog/2023-03-01-geo-queries-with-postgis-in-ionic-angular.mdx +++ b/apps/www/_blog/2023-03-01-geo-queries-with-postgis-in-ionic-angular.mdx @@ -2,8 +2,8 @@ title: Geo Queries with PostGIS in Ionic Angular description: Using the PostGIS extension to build a cross-platform application with Ionic Angular. author: simon_grimm -image: 2023-02-28-geoqueries-postgis/postgis-ionic-angular.png -thumb: 2023-02-28-geoqueries-postgis/postgis-ionic-angular.png +image: 2023-02-28-geoqueries-postgis/postgis-ionic-angular.jpg +thumb: 2023-02-28-geoqueries-postgis/postgis-ionic-angular.jpg categories: - developers tags: @@ -501,7 +501,10 @@ export class StorePage implements OnInit { long: undefined, } - constructor(public modalCtrl: ModalController, private storesService: StoresService) {} + constructor( + public modalCtrl: ModalController, + private storesService: StoresService + ) {} ngOnInit() {} @@ -629,7 +632,10 @@ export class HomePage { selectedMarker: StoreMarker | null = null selectedStore: StoreResult | null = null - constructor(private storesService: StoresService, private modalCtrl: ModalController) {} + constructor( + private storesService: StoresService, + private modalCtrl: ModalController + ) {} ionViewDidEnter() { this.createMap() diff --git a/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx b/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx index 1df7693fb76..645ba324930 100644 --- a/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx +++ b/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx @@ -143,6 +143,6 @@ As always, one of our favorite memes from last month. [Follow us on Twitter](htt ![Best meme from February 2023](/images/blog/2023-03-09-beta-update-february/beta-update-february-meme.jpeg) -## It's happenning! Coming in April +## It's happening! Coming in April ![Supabase Launch Week 7](/images/blog/2023-03-09-beta-update-february/supabase-launch-week-7.png) diff --git a/apps/www/_blog/2023-05-04-flutter-multi-factor-authentication.mdx b/apps/www/_blog/2023-05-04-flutter-multi-factor-authentication.mdx index 4251119825d..436c345aca7 100644 --- a/apps/www/_blog/2023-05-04-flutter-multi-factor-authentication.mdx +++ b/apps/www/_blog/2023-05-04-flutter-multi-factor-authentication.mdx @@ -2,7 +2,7 @@ title: 'Securing your Flutter apps with Multi-Factor Authentication' description: Build a Flutter app where the user is required to authenticate using Multi-Factor Authentication. author: tyler_shukert -image: flutter-mfa/flutter-mfa-thumb-og.png +image: flutter-mfa/flutter-mfa-thumb.jpg thumb: flutter-mfa/flutter-mfa-thumb.jpg categories: - developers diff --git a/apps/www/_blog/2023-05-29-flutter-hackathon-winners.mdx b/apps/www/_blog/2023-05-29-flutter-hackathon-winners.mdx index a9240b846a3..2be79647879 100644 --- a/apps/www/_blog/2023-05-29-flutter-hackathon-winners.mdx +++ b/apps/www/_blog/2023-05-29-flutter-hackathon-winners.mdx @@ -42,7 +42,7 @@ Syno changes the way you consume video content. Say goodbye to lengthy videos an [Study Genius](https://github.com/Salomondiei08/StudyAI-App.git) - by [@salomon_diei](https://twitter.com/salomon_diei) -Study Genius is a mobile application that build a personalized learning experience for students. It provies a comprehensive platform for effective study, collaboration and personalized support for students. +Study Genius is a mobile application that build a personalized learning experience for students. It provides a comprehensive platform for effective study, collaboration and personalized support for students. ![Study Genius](/images/blog/flutter-hackathon-winners/study-genius.jpg) diff --git a/apps/www/_blog/2023-07-18-flutter-authentication.mdx b/apps/www/_blog/2023-07-18-flutter-authentication.mdx index c1842d4dbe6..b5956ef9371 100644 --- a/apps/www/_blog/2023-07-18-flutter-authentication.mdx +++ b/apps/www/_blog/2023-07-18-flutter-authentication.mdx @@ -9,8 +9,8 @@ tags: date: '2023-07-18' toc_depth: 2 author: tyler_shukert -image: flutter-authentication/flutter-authentication.png -thumb: flutter-authentication/flutter-authentication.png +image: flutter-authentication/flutter-authentication.jpg +thumb: flutter-authentication/flutter-authentication.jpg --- Flutter is Google’s open-source framework to develop cross-platform applications. In this article, we will take a look at how we can implement authentication using Google sign-in to secure our application using the [Supabase SDK for Flutter](https://supabase.com/docs/reference/dart/introduction). diff --git a/apps/www/_blog/2023-08-01-react-native-storage.mdx b/apps/www/_blog/2023-08-01-react-native-storage.mdx index 36c783a33ad..a871528e296 100644 --- a/apps/www/_blog/2023-08-01-react-native-storage.mdx +++ b/apps/www/_blog/2023-08-01-react-native-storage.mdx @@ -2,8 +2,8 @@ title: 'React Native file upload with Supabase Storage' description: Learn how to implement authentication and file upload in a React Native app. author: simon_grimm -image: 2023-08-01-react-native-storage/react-native-storage.png -thumb: 2023-08-01-react-native-storage/react-native-storage.png +image: 2023-08-01-react-native-storage/react-native-storage.jpg +thumb: 2023-08-01-react-native-storage/react-native-storage.jpg categories: - developers tags: diff --git a/apps/www/_blog/2023-10-03-postgres-dynamic-table-partitioning.mdx b/apps/www/_blog/2023-10-03-postgres-dynamic-table-partitioning.mdx index a757bb9c220..986c0133551 100644 --- a/apps/www/_blog/2023-10-03-postgres-dynamic-table-partitioning.mdx +++ b/apps/www/_blog/2023-10-03-postgres-dynamic-table-partitioning.mdx @@ -38,20 +38,20 @@ Partitioning is a simple solution, but for Postgres this usually requires settin Let’s explore a working example showing how to escape this situation using dynamic table partitioning. We’ll setup a simulated “chat app” that has to store possibly millions of chat messages per day. The first step is to set up a fake “Large Table” problem to simulate a data set that has grown past where Postgres can handle it all in one table. So first, we’ll add a couple of new tables for our example chat app: ```sql -CREATE TABLE chats( +create table chats ( id bigserial, - created_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (id) + created_at timestamptz not null default now(), + primary key (id) ); -CREATE TABLE chat_messages( +create table chat_messages ( id bigserial, - created_at timestamptz NOT NULL, - chat_id bigint NOT NULL, - chat_created_at timestamptz NOT NULL, - message text NOT NULL, - PRIMARY KEY (id), - FOREIGN KEY (chat_id) REFERENCES chats(id) + created_at timestamptz not null, + chat_id bigint not null, + chat_created_at timestamptz not null, + message text not null, + primary key (id), + foreign key (chat_id) references chats (id) ); ``` @@ -250,7 +250,7 @@ BEGIN ALTER INDEX app."chats_created_at" ATTACH PARTITION app."chats_%1$s_created_at"; - -- Droping the now unnecessary check constraint they were just needed + -- Dropping the now unnecessary check constraint they were just needed -- to prevent the attachment from forcing a scan to do the same check ALTER TABLE app."chats_%1$s" DROP CONSTRAINT "chats_partition_by_range_check_%1$s"; @@ -314,7 +314,7 @@ END; $$; -- -- This procedure loops over all days in the old table, copying each day --- and then commiting the transaction. +-- and then committing the transaction. -- CREATE OR REPLACE PROCEDURE app.load_chats_partitions() LANGUAGE plpgsql AS diff --git a/apps/www/_blog/2023-10-08-react-native-offline-first-watermelon-db.mdx b/apps/www/_blog/2023-10-08-react-native-offline-first-watermelon-db.mdx index 8f60daee717..2090e1c29e6 100644 --- a/apps/www/_blog/2023-10-08-react-native-offline-first-watermelon-db.mdx +++ b/apps/www/_blog/2023-10-08-react-native-offline-first-watermelon-db.mdx @@ -2,8 +2,8 @@ title: Offline-first React Native Apps with Expo, WatermelonDB, and Supabase description: Store your data locally and sync it with Postgres using WatermelonDB! author: benediktmueller -image: 2023-10-08-react-native-offline-first-watermelon-db/expo-watermelondb-supabase.png -thumb: 2023-10-08-react-native-offline-first-watermelon-db/expo-watermelondb-supabase.png +image: 2023-10-08-react-native-offline-first-watermelon-db/expo-watermelondb-supabase.jpg +thumb: 2023-10-08-react-native-offline-first-watermelon-db/expo-watermelondb-supabase.jpg categories: - developers tags: diff --git a/apps/www/_blog/2023-11-01-supabase-is-now-compatible-with-nextjs-14.mdx b/apps/www/_blog/2023-11-01-supabase-is-now-compatible-with-nextjs-14.mdx index 19b2c3ab183..9fb473003ad 100644 --- a/apps/www/_blog/2023-11-01-supabase-is-now-compatible-with-nextjs-14.mdx +++ b/apps/www/_blog/2023-11-01-supabase-is-now-compatible-with-nextjs-14.mdx @@ -9,8 +9,8 @@ tags: - intgrations date: '2023-11-01' toc_depth: 3 -image: 2023-11-01-supabase-is-now-compatible-with-nextjs-14/nextjs-compatible.png -thumb: 2023-11-01-supabase-is-now-compatible-with-nextjs-14/nextjs-compatible.png +image: 2023-11-01-supabase-is-now-compatible-with-nextjs-14/nextjs-compatible.jpg +thumb: 2023-11-01-supabase-is-now-compatible-with-nextjs-14/nextjs-compatible.jpg --- As part of [Next.js Conf 2023](https://nextjs.org/conf), the team at Vercel released [Next.js 14](https://nextjs.org/blog/next-14). The huge headline feature was... diff --git a/apps/www/_blog/2023-11-21-oauth2-login-python-flask-apps.mdx b/apps/www/_blog/2023-11-21-oauth2-login-python-flask-apps.mdx index 59d335e8ae3..6027b02023e 100644 --- a/apps/www/_blog/2023-11-21-oauth2-login-python-flask-apps.mdx +++ b/apps/www/_blog/2023-11-21-oauth2-login-python-flask-apps.mdx @@ -9,8 +9,8 @@ tags: date: '2023-11-21' toc_depth: 3 author: andrew_smith -image: oauth2-login-python-flask-apps/OG-image.jpg -thumb: oauth2-login-python-flask-apps/OG-image.jpg +image: oauth2-login-python-flask-apps/flask-supabase-auth.jpg +thumb: oauth2-login-python-flask-apps/flask-supabase-auth.jpg --- In this guide we'll learn how to quickly build an OAuth2.0 integration into a simple Flask app using Supabase-py. This will enable your users to login to your web app using their GitHub account. diff --git a/apps/www/_blog/2023-12-14-supabase-wrappers-v02.mdx b/apps/www/_blog/2023-12-14-supabase-wrappers-v02.mdx index 600e7fa8f5c..fd0b0e5a8ea 100644 --- a/apps/www/_blog/2023-12-14-supabase-wrappers-v02.mdx +++ b/apps/www/_blog/2023-12-14-supabase-wrappers-v02.mdx @@ -46,9 +46,11 @@ To start using Wrappers on the Supabase platform, check out the [Wrappers docs]( - [Airtable](https://supabase.com/docs/guides/database/extensions/wrappers/airtable) (Read-only): query your Airtable bases with [support for jsonb](https://github.com/supabase/wrappers/pull/181) - [AWS S3](https://supabase.com/docs/guides/database/extensions/wrappers/s3) (Read-only): with support for CSV, JSON, and Parquet -- [Clerk](https://github.com/tembo-io/clerk_fdw) (from the team at [Tembo](https://tembo.io/)) +- [Clerk](https://github.com/tembo-io/clerk_fdw) (from the team at [Tembo](https://tembo.io/)). Not yet available on our platform since it is a community wrapper, see below for details. - And a WIP Wrapper for [Auth0](https://github.com/supabase/wrappers/pull/183) +We cannot support community Wrappers inside the Supabase Dashboard until the Wrappers API is stabilized. You can vote your favorite Wrapper if you'd like it to be added to Supabase in the future. If you have developed a Wrapper that you want inside the Supabase Dashboard, please contribute it as a PR in [the wrappers repo](https://github.com/supabase/wrappers). Once we release Wrappers 1.0, we will support community Wrappers within the Supabase Dashboard. + More improvements and updates can be found on [Wrappers release page](https://github.com/supabase/wrappers/releases), including support for Query Pushdown, Remote Subqueries, and Usage Statistics which we’ll explore below. ## Support for Query Pushdown diff --git a/apps/www/_blog/2024-01-09-elixir-clustering-using-postgres.mdx b/apps/www/_blog/2024-01-09-elixir-clustering-using-postgres.mdx index 5b8dc0fb195..79a425993ea 100644 --- a/apps/www/_blog/2024-01-09-elixir-clustering-using-postgres.mdx +++ b/apps/www/_blog/2024-01-09-elixir-clustering-using-postgres.mdx @@ -2,8 +2,8 @@ title: Elixir clustering using Postgres description: 'Learn about our approach to connecting multiple nodes in Elixir using Postgres' author: filipe -image: elixir-clustering-using-postgres/elixir-clustering-OG.png -thumb: elixir-clustering-using-postgres/elixir-clustering-thumb.png +image: elixir-clustering-using-postgres/elixir-clustering-OG.jpg +thumb: elixir-clustering-using-postgres/elixir-clustering-thumb.jpg categories: - engineering tags: diff --git a/apps/www/_blog/2024-01-17-what-is-saml-authentication.mdx b/apps/www/_blog/2024-01-17-what-is-saml-authentication.mdx index aa911c9b5e4..2fd73b52598 100644 --- a/apps/www/_blog/2024-01-17-what-is-saml-authentication.mdx +++ b/apps/www/_blog/2024-01-17-what-is-saml-authentication.mdx @@ -130,7 +130,7 @@ create table invited_users ( We can create an RLS policy to enforce that a developer in ACME Inc. can only invite someone who is also a developer in the same company: ```sql -create policy "View " +create policy "Can only invite developers in the same organization" on invited_users as restrictive for insert diff --git a/apps/www/_blog/2024-01-22-laravel-postgres.mdx b/apps/www/_blog/2024-01-22-laravel-postgres.mdx index 1a1a78fe677..33cc579da1f 100644 --- a/apps/www/_blog/2024-01-22-laravel-postgres.mdx +++ b/apps/www/_blog/2024-01-22-laravel-postgres.mdx @@ -71,7 +71,7 @@ Laravel ships with a Postgres adapter out of the box, you can simply configure i ```bash .env DB_CONNECTION=pgsql -DATABASE_URL=postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:6543/postgres +DATABASE_URL=postgres://postgres.xxxx:password@xxxx.pooler.supabase.com:5432/postgres ``` ### Change the default schema @@ -111,7 +111,7 @@ php artisan migrate php artisan serve ``` -Run the development server. Go to http://127.0.0.1:8000 in a browser to see your application. You can also nvaigate to http://127.0.0.1:8000/register and http://127.0.0.1:8000/login to register and log in users. +Run the development server. Go to http://127.0.0.1:8000 in a browser to see your application. You can also navigate to http://127.0.0.1:8000/register and http://127.0.0.1:8000/login to register and log in users. ## Conclusion diff --git a/apps/www/_blog/2024-01-24-how-pg-graphql-works.mdx b/apps/www/_blog/2024-01-24-how-pg-graphql-works.mdx index c58840cbea7..cdfd1cb98f0 100644 --- a/apps/www/_blog/2024-01-24-how-pg-graphql-works.mdx +++ b/apps/www/_blog/2024-01-24-how-pg-graphql-works.mdx @@ -9,6 +9,7 @@ categories: tags: - pg-graphql - supabase-engineering + - planetpg date: '2024-01-24' toc_depth: 3 --- diff --git a/apps/www/_blog/2024-01-26-flutter-figma-clone.mdx b/apps/www/_blog/2024-01-26-flutter-figma-clone.mdx new file mode 100644 index 00000000000..8640a76f8c2 --- /dev/null +++ b/apps/www/_blog/2024-01-26-flutter-figma-clone.mdx @@ -0,0 +1,865 @@ +--- +title: Create a Figma Clone app with Flutter and Supabase Realtime +description: 'A tutorial on how to build a collaborative design app like Figma using Flutter and Supabase Realtime.' +author: tyler_shukert +image: flutter-figma-clone/figma-clone-og.jpg +thumb: flutter-figma-clone/figma-clone-og.jpg +categories: + - developers +tags: + - flutter + - realtime +date: '2024-01-26' +toc_depth: 3 +--- + +Supabase has a low latency real-time communication feature called [Broadcast](https://supabase.com/docs/guides/realtime/broadcast). With it, you can have your clients communicate with other clients with low latencies. This is useful for creating apps with connected experiences. Flutter has a [CustomPainter](https://api.flutter.dev/flutter/rendering/CustomPainter-class.html) class, which allows developers to interact with the low-level canvas API allowing us to render virtually anything on the app. Combining these two tools allows us to create interactive apps. + +In this article, I am combining the Supabase Realtime Broadcast with Flutter’s `CustomPainter` to create a collaborative design board app like Figma. + +You can find the full code example [here](https://github.com/supabase/supabase/tree/master/examples/realtime/flutter-figma-clone). + +## Overview of the Figma clone app + +We are building an interactive design canvas app where multiple users can collaborate in real time. We will add the following features to the app: + +- Draw shapes such as circles or rectangles +- Move those shapes around +- Sync the cursor position and the design objects with other clients in real-time +- Persist the state of the canvas in a Postgres database + + + +Okay, Figma clone might be an overstatement. However, the point of this article is to demonstrate how to build a collaborative app with all the fundamental elements of a collaborative design canvas. You can take the concepts of this app, add features, refine it, and make it as sophisticated as Figma. + +## Setting up the app + +### Create a blank Flutter application + +Let’s start by creating a blank Flutter app. + +```bash +flutter create canvas --empty --platforms=web +``` + +`--empty` flag creates a blank Flutter project without the initial counter template. `--platforms` specify which platform to support with this Flutter application. Because we are working on an app that involves cursors, we are going to focus on the web for this example, but you can certainly run the same code on other platforms as well. + +### Install the dependencies + +We will use two dependencies for this app. + +- [supabase_flutter](https://pub.dev/packages/supabase_flutter): Used to interact with the Supabase instance for real-time communication and storing canvas data. +- [uuid](https://pub.dev/packages/uuid): Used to generate unique identifiers for each user and canvas objects. To keep this example simple, we will not add authentication, and will just assign randomly generated UUIDs to each user. + +Run the following command to add the dependencies to your app. + +```bash +flutter pub add supabase_flutter uuid +``` + +### Setup the Supabase project + +In this example, we will be using a remote Supabase instance, but if you would like to follow along with a [local Supabase instance](https://supabase.com/docs/guides/cli/local-development), that is fine too. + +You can head to [database.new](https://database.new) to create a new Supabase project for free. It will only take a minute or two to set up your project with a fully-fledged Postgres database. + +Once your project is ready, run the following SQL from the SQL editor of your dashboard to set up the table and [RLS policies](https://supabase.com/docs/guides/auth/row-level-security) for this app. To keep this article simple, we will not implement auth, so the policies you see are fairly simple. + +```sql +create table canvas_objects ( + id uuid primary key default gen_random_uuid() not null, + "object" jsonb not null, + created_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +alter table canvas_objects enable row level security; +create policy select_canvas_objects on canvas_objects as permissive for select to anon using (true); +create policy insert_canvas_objects on canvas_objects as permissive for insert to anon with check (true); +create policy update_canvas_objects on canvas_objects as permissive for update to anon using (true); +``` + +## Building the Figma clone app + +The app that we will build will have the following structure. + +``` +lib/ +├── canvas/ # A folder containing the main canvas-related files. +│ ├── canvas_object.dart # Data model definitions. +│ ├── canvas_page.dart # The canvas page widget. +│ └── canvas_painter.dart # Custom painter definitions. +├── utils/ +│ └── constants.dart # A file to hold Supabase Realtime specific constants +└── main.dart # Entry point of the app +``` + +### Step1: Initialize Supabase + +Open the `lib/main.dart` file and add the following. You should replace the credentials with your own from the Supabase dashboard under `settings > API`. You should see an error with the import of the `canvas_page.dart` file, but we will create it momentarily. + +```dart +import 'package:canvas/canvas/canvas_page.dart'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() async { + Supabase.initialize( + // TODO: Replace the credentials with your own + url: 'YOUR_SUPABASE_URL', + anonKey: 'YOUR_SUPABASE_ANON_KEY', + ); + runApp(const MyApp()); +} + +final supabase = Supabase.instance.client; + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + title: 'Figma Clone', + debugShowCheckedModeBanner: false, + home: CanvasPage(), + ); + } +} +``` + +### Step 2: Create the constants file + +It is nice to organize the app’s constants in a file. Create `lib/utils/constants.dart` file and add the following. These values will later be used when we are setting up Supabase Realtime listeners. + +```dart +abstract class Constants { + /// Name of the Realtime channel + static const String channelName = 'canvas'; + + /// Name of the broadcast event + static const String broadcastEventName = 'canvas'; +} +``` + +### Step 3: Create the data model + +We will need to create data models for each of the following: + +- The cursor position of the user. +- The objects we can draw on the canvas. Includes: + - Circle + - Rectangle + +Create `lib/canvas/canvas_object.dart` file. The file is a bit long, so I will break it down in each component below. Add all of the code into the `canvas_object.dart` file as we step through them. + +At the top of the file, we have an extension method to generate random colors. One of the methods generates a random color, which will be used to set the color of a newly created object, and the other generates a random with a seed of a UUID, which will be used to determine the user’s cursor color. + +```dart +import 'dart:convert'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:uuid/uuid.dart'; + +/// Handy extension method to create random colors +extension RandomColor on Color { + static Color getRandom() { + return Color((Random().nextDouble() * 0xFFFFFF).toInt()).withOpacity(1.0); + } + + /// Quick and dirty method to create a random color from the userID + static Color getRandomFromId(String id) { + final seed = utf8.encode(id).reduce((value, element) => value + element); + return Color((Random(seed).nextDouble() * 0xFFFFFF).toInt()) + .withOpacity(1.0); + } +} +``` + +We then have the `SyncedObject` class. `SyncedObject` class is the base class for anything that will be synced in real time, this includes both the cursor and the objects. It has an `id` property, which will be UUID, and it has `toJson` method, which is required to pass the object information over Supabase’s broadcast feature. + +```dart +/// Objects that are being synced in realtime over broadcast +/// +/// Includes mouse cursor and design objects +abstract class SyncedObject { + /// UUID unique identifier of the object + final String id; + + factory SyncedObject.fromJson(Map json) { + final objectType = json['object_type']; + if (objectType == UserCursor.type) { + return UserCursor.fromJson(json); + } else { + return CanvasObject.fromJson(json); + } + } + + SyncedObject({ + required this.id, + }); + + Map toJson(); +} +``` + +Now to sync the user’s cursor with other clients, we have the `UserCursor` class. It inherits the `SyncedObject` class and has JSON parsing implemented. + +```dart +/// Data model for the cursors displayed on the canvas. +class UserCursor extends SyncedObject { + static String type = 'cursor'; + + final Offset position; + final Color color; + + UserCursor({ + required super.id, + required this.position, + }) : color = RandomColor.getRandomFromId(id); + + UserCursor.fromJson(Map json) + : position = Offset(json['position']['x'], json['position']['y']), + color = RandomColor.getRandomFromId(json['id']), + super(id: json['id']); + + @override + Map toJson() { + return { + 'object_type': type, + 'id': id, + 'position': { + 'x': position.dx, + 'y': position.dy, + } + }; + } +} +``` + +There is an additional set of data that we want to sync in real-time, and that is the individual shapes within the canvas. We create the `CanvasObject` abstract class, which is the base class for any shapes within the canvas. This class extends the `SyncedObject` because we want to sync it to other clients. In addition to the `id` property, we have a `color` property, because every shape needs a color. We also have a few methods. + +- `intersectsWith()` takes a point within the canvas and returns whether the point intersects with the shape or not. This is used when grabbing the shape on the canvas. +- `copyWith()` is a standard method to create a copy of the instance. +- `move()` is a method to create a version of the instance that is moved by `delta`. This will be used when the shape is being dragged on canvas. + +```dart +/// Base model for any design objects displayed on the canvas. +abstract class CanvasObject extends SyncedObject { + final Color color; + + CanvasObject({ + required super.id, + required this.color, + }); + + factory CanvasObject.fromJson(Map json) { + if (json['object_type'] == CanvasCircle.type) { + return CanvasCircle.fromJson(json); + } else if (json['object_type'] == CanvasRectangle.type) { + return CanvasRectangle.fromJson(json); + } else { + throw UnimplementedError('Unknown object_type: ${json['object_type']}'); + } + } + + /// Whether or not the object intersects with the given point. + bool intersectsWith(Offset point); + + CanvasObject copyWith(); + + /// Moves the object to a new position + CanvasObject move(Offset delta); +} +``` + +Now that we have the base class for the canvas objects, let’s define the actual shapes we will support in this application. Each object will inherit `CanvasObject` and will have additional properties like `center` and `radius` for the circle. + +In this article, we are only supporting circles and rectangles, but you can easily expand this and add support for other shapes. + +```dart +/// Circle displayed on the canvas. +class Circle extends CanvasObject { + static String type = 'circle'; + + final Offset center; + final double radius; + + Circle({ + required super.id, + required super.color, + required this.radius, + required this.center, + }); + + Circle.fromJson(Map json) + : radius = json['radius'], + center = Offset(json['center']['x'], json['center']['y']), + super(id: json['id'], color: Color(json['color'])); + + /// Constructor to be used when first starting to draw the object on the canvas + Circle.createNew(this.center) + : radius = 0, + super(id: const Uuid().v4(), color: RandomColor.getRandom()); + + @override + Map toJson() { + return { + 'object_type': type, + 'id': id, + 'color': color.value, + 'center': { + 'x': center.dx, + 'y': center.dy, + }, + 'radius': radius, + }; + } + + @override + Circle copyWith({ + double? radius, + Offset? center, + Color? color, + }) { + return Circle( + radius: radius ?? this.radius, + center: center ?? this.center, + id: id, + color: color ?? this.color, + ); + } + + @override + bool intersectsWith(Offset point) { + final centerToPointerDistance = (point - center).distance; + return radius > centerToPointerDistance; + } + + @override + Circle move(Offset delta) { + return copyWith(center: center + delta); + } +} + +/// Rectangle displayed on the canvas. +class Rectangle extends CanvasObject { + static String type = 'rectangle'; + + final Offset topLeft; + final Offset bottomRight; + + Rectangle({ + required super.id, + required super.color, + required this.topLeft, + required this.bottomRight, + }); + + Rectangle.fromJson(Map json) + : bottomRight = + Offset(json['bottom_right']['x'], json['bottom_right']['y']), + topLeft = Offset(json['top_left']['x'], json['top_left']['y']), + super(id: json['id'], color: Color(json['color'])); + + /// Constructor to be used when first starting to draw the object on the canvas + Rectangle.createNew(Offset startingPoint) + : topLeft = startingPoint, + bottomRight = startingPoint, + super(color: RandomColor.getRandom(), id: const Uuid().v4()); + + @override + Map toJson() { + return { + 'object_type': type, + 'id': id, + 'color': color.value, + 'top_left': { + 'x': topLeft.dx, + 'y': topLeft.dy, + }, + 'bottom_right': { + 'x': bottomRight.dx, + 'y': bottomRight.dy, + }, + }; + } + + @override + Rectangle copyWith({ + Offset? topLeft, + Offset? bottomRight, + Color? color, + }) { + return Rectangle( + topLeft: topLeft ?? this.topLeft, + id: id, + bottomRight: bottomRight ?? this.bottomRight, + color: color ?? this.color, + ); + } + + @override + bool intersectsWith(Offset point) { + final minX = min(topLeft.dx, bottomRight.dx); + final maxX = max(topLeft.dx, bottomRight.dx); + final minY = min(topLeft.dy, bottomRight.dy); + final maxY = max(topLeft.dy, bottomRight.dy); + return minX < point.dx && + point.dx < maxX && + minY < point.dy && + point.dy < maxY; + } + + @override + Rectangle move(Offset delta) { + return copyWith( + topLeft: topLeft + delta, + bottomRight: bottomRight + delta, + ); + } +} +``` + +That is it for the `canvas_object.dart` file. + +### Step 4: Create the custom painter + +`CustomPainter` is a low-level API to interact with the canvas within a Flutter application. We will create our own `CustomPainter` that takes the cursor positions and the objects within the app and draws them on a canvas. + +Create `lib/canvas/canvas_painter.dart` file and add the following. + +```dart +import 'package:canvas/canvas/canvas_object.dart'; +import 'package:flutter/material.dart'; + +class CanvasPainter extends CustomPainter { + final Map userCursors; + final Map canvasObjects; + + CanvasPainter({ + required this.userCursors, + required this.canvasObjects, + }); + + @override + void paint(Canvas canvas, Size size) { + // Draw each canvas objects + for (final canvasObject in canvasObjects.values) { + if (canvasObject is Circle) { + final position = canvasObject.center; + final radius = canvasObject.radius; + canvas.drawCircle( + position, radius, Paint()..color = canvasObject.color); + } else if (canvasObject is Rectangle) { + final position = canvasObject.topLeft; + final bottomRight = canvasObject.bottomRight; + canvas.drawRect( + Rect.fromLTRB( + position.dx, position.dy, bottomRight.dx, bottomRight.dy), + Paint()..color = canvasObject.color); + } + } + + // Draw the cursors + for (final userCursor in userCursors.values) { + final position = userCursor.position; + canvas.drawPath( + Path() + ..moveTo(position.dx, position.dy) + ..lineTo(position.dx + 14.29, position.dy + 44.84) + ..lineTo(position.dx + 20.35, position.dy + 25.93) + ..lineTo(position.dx + 39.85, position.dy + 24.51) + ..lineTo(position.dx, position.dy), + Paint()..color = userCursor.color); + } + } + + @override + bool shouldRepaint(oldPainter) => true; +} +``` + +`userCursors` and `canvasObjects` represent the cursors and the objects within the canvas respectively. The key of the `Map` is the UUID unique identifiers. + +The `paint()` method is where the drawing on the canvas happens. It first loops through the objects and draws them on the canvas. Each shape has its drawing method, so we will check the type of the object in each loop and apply the respective drawing method. + +Once we have all the objects drawn, we draw the cursors. The reason why we draw the cursors after the objects is because within a custom painter, whatever is drawn later draws over the previously drawn objects. Because we do not want the cursors to be hidden behind the objects, we draw all the cursors after all of the objects are done being drawn. + +`shouldRepaint()` defines whether we want the canvas to be repainted when the `CustomPainter` receives a new set of properties. In our case, we want to redraw the painter whenever we receive a new set of properties, so we always return true. + +### Step 5: Create the canvas page + +Now that we have the data models and our custom painter ready, it is time to put everything together. We will create a canvas page, the only page of this app, which allows users to draw shapes and move those shapes around while keeping the states in sync with other users. + +Create `lib/canvas/canvas_page.dart` file. Add all of the code shown within this step into `canvas_page.dart`. Start by adding all the necessary imports for this app. + +```dart +import 'dart:math'; + +import 'package:canvas/canvas/canvas_object.dart'; +import 'package:canvas/canvas/canvas_painter.dart'; +import 'package:canvas/main.dart'; +import 'package:canvas/utils/constants.dart'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; +``` + +We can then create an enum to represent the three different actions we can perform in this app, `pointer` for moving objects around, `circle` for drawing circles, and `rectangle` for drawing rectangles. + +```dart +/// Different input modes users can perform +enum _DrawMode { + /// Mode to move around existing objects + pointer(iconData: Icons.pan_tool_alt), + + /// Mode to draw circles + circle(iconData: Icons.circle_outlined), + + /// Mode to draw rectangles + rectangle(iconData: Icons.rectangle_outlined); + + const _DrawMode({required this.iconData}); + + /// Icon used in the IconButton to toggle the mode + final IconData iconData; +} +``` + +Finally, we can get to the meat of the app, creating the `CanvasPage` widget. Create an empty `StatefulWidget` with a blank `Scaffold`. We will be adding properties, methods, and widgets to it. + +```dart + +/// Interactive art board page to draw and collaborate with other users. +class CanvasPage extends StatefulWidget { + const CanvasPage({super.key}); + + @override + State createState() => _CanvasPageState(); +} + +class _CanvasPageState extends State { + // TODO: Add properties + + // TODO: Add methods + + @override + Widget build(BuildContext context) { + return Scaffold(); + } +} +``` + +First, we can define all of the properties we need for this widget. `_userCursors` and `_canvasObjects` will hold the cursors and canvas objects the app receives from the real-time listener. `_canvasChanel` is the gateway for the client to communicate with other clients using [Supabase Realtime](https://supabase.com/docs/guides/realtime). We will later implement the logic to send and receive information about the canvas. Then there are a few states that will be used when we implement the drawing on the canvas. + +```dart +class _CanvasPageState extends State { + /// Holds the cursor information of other users + final Map _userCursors = {}; + + /// Holds the list of objects drawn on the canvas + final Map _canvasObjects = {}; + + /// Supabase Realtime channel to communicate to other clients + late final RealtimeChannel _canvasChanel; + + /// Randomly generated UUID for the user + late final String _myId; + + /// Whether the user is using the pointer to move things around, or in drawing mode. + _DrawMode _currentMode = _DrawMode.pointer; + + /// A single Canvas object that is being drawn by the user if any. + String? _currentlyDrawingObjectId; + + /// The point where the pan started + Offset? _panStartPoint; + + /// Cursor position of the user. + Offset _cursorPosition = const Offset(0, 0); + + // TODO: Add methods + + @override + Widget build(BuildContext context) { + return Scaffold(); + } +} +``` + +Now that we have the properties defined, we can run some initialization code to set up the scene. There are a few things we are doing in this initialization step. + +One, assigning a randomly generated UUID to the user. Two, setting up the real-time listener for Supabase. We are listening to [Realtime Broadcast events](https://supabase.com/docs/guides/realtime/broadcast?language=dart), which are low-latency real-time communication mechanisms that Supabase offers. Within the callback of the broadcast event, we obtain the cursor and object information sent from other clients and set the state accordingly. And three, we load the initial state of the canvas from the database and set it as the initial state of the widget. + +Now that the app has been initialized, we are ready to implement the logic of the user drawing and interacting with the canvas. + +```dart +class _CanvasPageState extends State { + ... + + @override + void initState() { + super.initState(); + _initialize(); + } + + Future _initialize() async { + // Generate a random UUID for the user. + // We could replace this with Supabase auth user ID if we want to make it + // more like Figma. + _myId = const Uuid().v4(); + + // Start listening to broadcast messages to display other users' cursors and objects. + _canvasChanel = supabase + .channel(Constants.channelName) + .onBroadcast( + event: Constants.broadcastEventName, + callback: (payload) { + final cursor = UserCursor.fromJson(payload['cursor']); + _userCursors[cursor.id] = cursor; + + if (payload['object'] != null) { + final object = CanvasObject.fromJson(payload['object']); + _canvasObjects[object.id] = object; + } + setState(() {}); + }) + .subscribe(); + + final initialData = await supabase + .from('canvas_objects') + .select() + .order('created_at', ascending: true); + for (final canvasObjectData in initialData) { + final canvasObject = CanvasObject.fromJson(canvasObjectData['object']); + _canvasObjects[canvasObject.id] = canvasObject; + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Scaffold(); + } +} +``` + +We have three methods triggered by user actions, `_onPanDown()`, `_onPanUpdate()`, and `_onPanEnd()`, and a method to sync the user action with other clients `_syncCanvasObject()`. + +What the three pan methods do could be two things, either to draw the object or to move the object. + +When drawing an object, on pan down it will add the object to the canvas with size 0, essentially a point. As the user drags the mouse, the pan update method is called which gives the object some size while syncing the object to other clients along the way. + +When the user is in `pointer` mode, the pan-down method first determines if there is an object under where the user’s pointer currently is located. If there is an object, it holds the object’s id as the widget’s state. As the user drags the screen, the position of the object is moved the same amount the user’s cursor moves, while syncing the object’s information through broadcast along the way. + +In both cases, when the user is done dragging, the pan end is called which does some clean-ups of the local state and stores the object information in the database to store the canvas data permanently. + +```dart +class _CanvasPageState extends State { + ... + + /// Syncs the user's cursor position and the currently drawing object with + /// other users. + Future _syncCanvasObject(Offset cursorPosition) { + final myCursor = UserCursor( + position: cursorPosition, + id: _myId, + ); + return _canvasChanel.sendBroadcastMessage( + event: Constants.broadcastEventName, + payload: { + 'cursor': myCursor.toJson(), + if (_currentlyDrawingObjectId != null) + 'object': _canvasObjects[_currentlyDrawingObjectId]?.toJson(), + }, + ); + } + + /// Called when pan starts. + /// + /// For [_DrawMode.pointer], it will find the first object under the cursor. + /// + /// For other draw modes, it will start drawing the respective canvas objects. + void _onPanDown(DragDownDetails details) { + switch (_currentMode) { + case _DrawMode.pointer: + // Loop through the canvas objects to find if there are any + // that intersects with the current mouse position. + for (final canvasObject in _canvasObjects.values.toList().reversed) { + if (canvasObject.intersectsWith(details.globalPosition)) { + _currentlyDrawingObjectId = canvasObject.id; + break; + } + } + break; + case _DrawMode.circle: + final newObject = Circle.createNew(details.globalPosition); + _canvasObjects[newObject.id] = newObject; + _currentlyDrawingObjectId = newObject.id; + break; + case _DrawMode.rectangle: + final newObject = Rectangle.createNew(details.globalPosition); + _canvasObjects[newObject.id] = newObject; + _currentlyDrawingObjectId = newObject.id; + break; + } + _cursorPosition = details.globalPosition; + _panStartPoint = details.globalPosition; + setState(() {}); + } + + /// Called when the user clicks and drags the canvas. + /// + /// Performs different actions depending on the current mode. + void _onPanUpdate(DragUpdateDetails details) { + switch (_currentMode) { + // Moves the object to [details.delta] amount. + case _DrawMode.pointer: + if (_currentlyDrawingObjectId != null) { + _canvasObjects[_currentlyDrawingObjectId!] = + _canvasObjects[_currentlyDrawingObjectId!]!.move(details.delta); + } + break; + + // Updates the size of the Circle + case _DrawMode.circle: + final currentlyDrawingCircle = + _canvasObjects[_currentlyDrawingObjectId!]! as Circle; + _canvasObjects[_currentlyDrawingObjectId!] = + currentlyDrawingCircle.copyWith( + center: (details.globalPosition + _panStartPoint!) / 2, + radius: min((details.globalPosition.dx - _panStartPoint!.dx).abs(), + (details.globalPosition.dy - _panStartPoint!.dy).abs()) / + 2, + ); + break; + + // Updates the size of the rectangle + case _DrawMode.rectangle: + _canvasObjects[_currentlyDrawingObjectId!] = + (_canvasObjects[_currentlyDrawingObjectId!] as Rectangle).copyWith( + bottomRight: details.globalPosition, + ); + break; + } + + if (_currentlyDrawingObjectId != null) { + setState(() {}); + } + _cursorPosition = details.globalPosition; + _syncCanvasObject(_cursorPosition); + } + + void onPanEnd(DragEndDetails _) async { + if (_currentlyDrawingObjectId != null) { + _syncCanvasObject(_cursorPosition); + } + + final drawnObjectId = _currentlyDrawingObjectId; + + setState(() { + _panStartPoint = null; + _currentlyDrawingObjectId = null; + }); + + // Save whatever was drawn to Supabase DB + if (drawnObjectId == null) { + return; + } + await supabase.from('canvas_objects').upsert({ + 'id': drawnObjectId, + 'object': _canvasObjects[drawnObjectId]!.toJson(), + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold(); + } +} +``` + +With all the properties and methods defined, we can proceed to add content to the build method. The entire region is covered in `MouseRegion`, which is used to get the cursor position and share it with other clients. Within the mouse region, we have the `GestureDetector` and the three buttons representing each action. Because the heavy lifting was done in the methods we have already defined, the build method is fairly simple. + +```dart +class _CanvasPageState extends State { + ... + + @override + Widget build(BuildContext context) { + return Scaffold( + body: MouseRegion( + onHover: (event) { + _syncCanvasObject(event.position); + }, + child: Stack( + children: [ + // The main canvas + GestureDetector( + onPanDown: _onPanDown, + onPanUpdate: _onPanUpdate, + onPanEnd: onPanEnd, + child: CustomPaint( + size: MediaQuery.of(context).size, + painter: CanvasPainter( + userCursors: _userCursors, + canvasObjects: _canvasObjects, + ), + ), + ), + + // Buttons to change the current mode. + Positioned( + top: 0, + left: 0, + child: Row( + children: _DrawMode.values + .map((mode) => IconButton( + iconSize: 48, + onPressed: () { + setState(() { + _currentMode = mode; + }); + }, + icon: Icon(mode.iconData), + color: _currentMode == mode ? Colors.green : null, + )) + .toList(), + ), + ), + ], + ), + ), + ); + } +} +``` + +### Step 6: Run the application + +At this point, we have implemented everything we need to create a collaborative design canvas. Run the app with `flutter run` and run it in your browser. There is currently a bug in Flutter where `MouseRegion` cannot detect the position of a cursor in two different Chrome windows at the same time, so open it in two different browsers like Chrome and Safari, and enjoy interacting with your design elements in real time. + + + +## Conclusion + +In this article, we learned how we can combine the [Supabase Realtime Broadcast](https://supabase.com/docs/guides/realtime/broadcast) feature with Flutter’s `CustomPainter` to create a collaborative design app. We learned how to implement real-time communication between multiple clients using the Broadcast feature, and how we can broadcast the shape and cursor data to other connected clients in real-time. + +This article only used circles and rectangles to keep things simple, but you can easily add support for other types of objects like texts or arrows just by extending the `CanvasObject` class to make the app more like Figma. Another fun way to expand this app would be to add authentication using [Supabase Auth](https://supabase.com/docs/guides/auth) so that we can add proper authorizations. Adding an image upload feature using [Supabase Storage](https://supabase.com/docs/guides/storage) would certainly open up more creative options for the app. + +## Resources + +- [Create live cursor app with Flutter and Supabase video](https://www.youtube.com/watch?v=QhRNXlFLaeE) +- [How to build a real-time multiplayer game with Flutter Flame](https://supabase.com/blog/flutter-real-time-multiplayer-game) +- [Getting started with Flutter authentication](https://supabase.com/blog/flutter-authentication) diff --git a/apps/www/_blog/2024-01-29-ruby-on-rails-postgres.mdx b/apps/www/_blog/2024-01-29-ruby-on-rails-postgres.mdx new file mode 100644 index 00000000000..25214352417 --- /dev/null +++ b/apps/www/_blog/2024-01-29-ruby-on-rails-postgres.mdx @@ -0,0 +1,155 @@ +--- +title: Getting started with Ruby on Rails and Postgres on Supabase +description: 'Learn how to create a new Rails app and connect it to a Supabase Postgres database.' +author: thor_schaeff +image: getting-started/rails/rails.jpg +thumb: getting-started/rails/rails.jpg +categories: + - engineering +tags: + - postgres + - developers + - ruby +date: '2024-01-29' +toc_depth: 3 +--- + +Every Supabase project comes with a full [Postgres](https://www.postgresql.org/) database, a free and open source database which is considered one of the world's most stable and advanced databases. + +Postgres is an ideal choice for your Ruby on Rails applications as Rails ships with a built-in Postgres adapter! + +In this post we'll start from scratch, creating a new Rails project, connecting it to our Supabase Postgres database, and interacting with the database using the Rails Console. + + + +Supabase is one of the best [free alternatives to Heroku Postgres](/alternatives/supabase-vs-heroku-postgres). See [this guide](/docs/guides/resources/migrating-to-supabase/heroku) to learn how to migrate from Heroku to Supabase. + +There's also a [Heroku to Supabase migration tool](https://migrate.supabase.com/) available to migrate in just a few clicks. + + + +If you prefer video guide, you can follow along below. And make sure to subscribe to the [Supabase YouTube channel](https://www.youtube.com/channel/UCNTVzV1InxHV-YR0fSajqPQ?view_as=subscriber&sub_confirmation=1)! + +
    +
    + +