diff --git a/.cursor/rules/studio/forms/RULE.md b/.cursor/rules/studio/forms/RULE.md index b9b729111b5..74f8383f862 100644 --- a/.cursor/rules/studio/forms/RULE.md +++ b/.cursor/rules/studio/forms/RULE.md @@ -1,5 +1,5 @@ --- -description: "Studio: form patterns (page layouts + side panels) and react-hook-form conventions" +description: 'Studio: form patterns (page layouts + side panels) and react-hook-form conventions' globs: - apps/studio/**/*.{ts,tsx} alwaysApply: false @@ -29,7 +29,6 @@ Use the Design System UI pattern docs as the source of truth: ## Actions and state -- Handle dirty state (`form.formState.isDirty`) to show Cancel and to disable Save. +- Handle dirty state by destructuring `isDirty` from `formState` (`const { isDirty } = form.formState`) then use it to show Cancel and to disable Save. - Show loading on submit buttons via `loading`. - When submit button is outside the `
`, set a stable `formId` and use the button’s `form` prop. - diff --git a/.cursor/rules/testing/e2e-studio/RULE.md b/.cursor/rules/testing/e2e-studio/RULE.md index 354cd86ac20..4d98bbfc35a 100644 --- a/.cursor/rules/testing/e2e-studio/RULE.md +++ b/.cursor/rules/testing/e2e-studio/RULE.md @@ -117,10 +117,6 @@ await waitForApiResponse(page, 'pg-meta', ref, 'tables') // ✅ Acceptable - waiting for client-side debounce await page.getByRole('textbox').fill('search term') await page.waitForTimeout(300) // Allow debounce to complete - -// ✅ Acceptable - waiting for clipboard API -await page.evaluate(() => navigator.clipboard.readText()) -await page.waitForTimeout(500) ``` ## Test Structure @@ -274,6 +270,22 @@ import { } from '../utils/wait-for-response.js' ``` +### Use the existing assertions utilities + +#### Clipboard assertions + +```ts +// ❌ Avoid - brittle hard coded timeout +await page.evaluate(() => navigator.clipboard.readText()) +await page.waitForTimeout(500) + +// ✅ Good - this utility function uses Playwright auto-retries mechanisms +await expectClipboardValue({ + page, + value: 'expectedValue' +}) +``` + ## API Mocking ### Mock APIs for isolated testing diff --git a/.github/workflows/autofix_linters.yml b/.github/workflows/autofix_linters.yml index f86ce2e573f..93e62ecb14e 100644 --- a/.github/workflows/autofix_linters.yml +++ b/.github/workflows/autofix_linters.yml @@ -22,7 +22,7 @@ jobs: - name: Generate token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ secrets.GH_AUTOFIX_APP_ID }} private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }} diff --git a/.github/workflows/braintrust-evals.yml b/.github/workflows/braintrust-evals.yml index 46b7b7c9276..cf3e7ee8fa2 100644 --- a/.github/workflows/braintrust-evals.yml +++ b/.github/workflows/braintrust-evals.yml @@ -52,7 +52,7 @@ jobs: run: cd apps/studio && pnpm evals:setup - name: Run Evals - uses: braintrustdata/eval-action@v1 + uses: braintrustdata/eval-action@c0dd75b29984a0cc63a827d6e8da2f23f2752be4 # v1.0.16 with: api_key: ${{ secrets.BRAINTRUST_API_KEY }} runtime: node diff --git a/.github/workflows/docs-lint-v2.yml b/.github/workflows/docs-lint-v2.yml index 2d1315d9a8e..da3c07edba2 100644 --- a/.github/workflows/docs-lint-v2.yml +++ b/.github/workflows/docs-lint-v2.yml @@ -46,7 +46,7 @@ jobs: - name: cache cargo id: cache-cargo if: steps.filter.outputs.docs == 'true' - uses: actions/cache@v4 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5 with: path: | ~/.cargo/bin/ diff --git a/.github/workflows/docs-mgmt-api-update.yml b/.github/workflows/docs-mgmt-api-update.yml index 61c2a31bc31..e04b9354904 100644 --- a/.github/workflows/docs-mgmt-api-update.yml +++ b/.github/workflows/docs-mgmt-api-update.yml @@ -21,6 +21,7 @@ jobs: sparse-checkout: | apps/docs patches + packages/generator - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index b6e9f301197..8f423322ea9 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1963,6 +1963,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "key-value-field-array-demo": { + name: "key-value-field-array-demo", + type: "components:example", + registryDependencies: ["button","form","input"], + component: React.lazy(() => import("@/registry/default/example/key-value-field-array-demo")), + source: "", + files: ["registry/default/example/key-value-field-array-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "field-choice-card": { name: "field-choice-card", type: "components:example", @@ -2678,6 +2689,28 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "error-display-demo": { + name: "error-display-demo", + type: "components:example", + registryDependencies: ["error-display"], + component: React.lazy(() => import("@/registry/default/example/error-display-demo")), + source: "", + files: ["registry/default/example/error-display-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "error-display-with-children": { + name: "error-display-with-children", + type: "components:example", + registryDependencies: ["error-display"], + component: React.lazy(() => import("@/registry/default/example/error-display-with-children")), + source: "", + files: ["registry/default/example/error-display-with-children.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "logs-bar-chart": { name: "logs-bar-chart", type: "components:example", diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index cc1b8f213f7..d15ae959d9a 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -212,6 +212,11 @@ export const docsConfig: DocsConfig = { href: '/docs/fragments/status-codes', items: [], }, + { + title: 'Key/Value Field Array', + href: '/docs/fragments/key-value-field-array', + items: [], + }, ], }, { diff --git a/apps/design-system/content/docs/fragments/key-value-field-array.mdx b/apps/design-system/content/docs/fragments/key-value-field-array.mdx new file mode 100644 index 00000000000..2ae4b87649e --- /dev/null +++ b/apps/design-system/content/docs/fragments/key-value-field-array.mdx @@ -0,0 +1,43 @@ +--- +title: Key/Value Field Array +description: A shared form fragment for repeated text key/value pairs. +component: true +fragment: true +--- + + + +## Usage + +Use `KeyValueFieldArray` when each row is two text inputs backed by `react-hook-form`, such as HTTP headers, query parameters, or configuration parameters. + +```tsx +import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray' +``` + +```tsx + ({ name: '', value: '' })} + keyPlaceholder="Header name" + valuePlaceholder="Header value" + addLabel="Add header" +/> +``` + +`KeyValueFieldArray` owns the row add/remove behavior and renders the per-input form messages for you. Compose it inside `FormItemLayout` when you want the standard label, description, and message treatment around the entire section. + +## When to use it + +- Use a plain field array for repeated single values such as redirect URIs. +- Use `KeyValueFieldArray` for repeated text/text pairs such as headers, parameters, and config entries. +- Build a custom row UI instead when each row mixes different controls, such as a text input paired with a `Select`. diff --git a/apps/design-system/content/docs/ui-patterns/forms.mdx b/apps/design-system/content/docs/ui-patterns/forms.mdx index 8c2794495f5..f37ab471ce9 100644 --- a/apps/design-system/content/docs/ui-patterns/forms.mdx +++ b/apps/design-system/content/docs/ui-patterns/forms.mdx @@ -27,6 +27,15 @@ Forms in side panels (Sheets) use `FormItemLayout` with `layout="horizontal"` on wide /> +## Field Arrays + +The form previews above include both repeated-field patterns used across Studio: + +- **Field Array** for repeated single-value rows such as redirect URIs. +- **Key/Value Field Array** for repeated text pairs such as headers, parameters, and config entries. + +Use the shared [Key/Value Field Array](../fragments/key-value-field-array) fragment when each row is two text inputs managed by `react-hook-form`. Keep using a plain field array when each row is just one input, and build a custom row when the cells are mixed controls. + ## Best Practices 1. **Always use FormItemLayout**: Use `FormItemLayout` instead of manually composing `FormItem`, `FormLabel`, `FormMessage`, and `FormDescription`. @@ -40,7 +49,7 @@ Forms in side panels (Sheets) use `FormItemLayout` with `layout="horizontal"` on 4. **Use Cards for grouping**: Wrap form sections in `Card` components with `CardContent` and `CardFooter` for actions. -5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`. +5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`. Make sure you destructure `isDirty` from `form.formState` (see https://react-hook-form.com/docs/useform/formstate) 6. **Error handling**: Always use mutations with `onSuccess` and `onError` callbacks that show toast notifications. diff --git a/apps/design-system/content/docs/ui-patterns/modality.mdx b/apps/design-system/content/docs/ui-patterns/modality.mdx index 9c4358cce0c..63938e858f5 100644 --- a/apps/design-system/content/docs/ui-patterns/modality.mdx +++ b/apps/design-system/content/docs/ui-patterns/modality.mdx @@ -104,8 +104,13 @@ Studio implementation (preferred in Studio code): import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' +const form = useForm(...) +// Always destructure formState values otherwise they won't be updated +// See https://react-hook-form.com/docs/useform/formstate +const { isDirty } = form.formState + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ - checkIsDirty: () => form.formState.isDirty, + checkIsDirty: () => isDirty, onClose, }) diff --git a/apps/design-system/package.json b/apps/design-system/package.json index 4694e992bfd..b9cf2f78252 100644 --- a/apps/design-system/package.json +++ b/apps/design-system/package.json @@ -8,7 +8,7 @@ "dev": "next dev --turbopack --port 3003", "dev:full": "concurrently \"pnpm dev\" \"pnpm content:dev\"", "build": "pnpm run content:build && pnpm run build:registry && next build --turbopack", - "build:registry": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,mdx}\" --cache", + "build:registry": "tsx ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,mdx}\" --cache", "start": "next start", "lint": "eslint .", "content:dev": "contentlayer2 dev", diff --git a/apps/design-system/registry/default/example/error-display-demo.tsx b/apps/design-system/registry/default/example/error-display-demo.tsx index e994c90bd4e..f297f9317f2 100644 --- a/apps/design-system/registry/default/example/error-display-demo.tsx +++ b/apps/design-system/registry/default/example/error-display-demo.tsx @@ -1,4 +1,4 @@ -import { ErrorDisplay } from 'ui-patterns/ErrorDisplay' +import { ErrorDisplay } from 'ui-patterns/ErrorDisplay/ErrorDisplay' export default function ErrorDisplayDemo() { return ( diff --git a/apps/design-system/registry/default/example/error-display-with-children.tsx b/apps/design-system/registry/default/example/error-display-with-children.tsx index 9c2f1cf46bc..b40e55f1f40 100644 --- a/apps/design-system/registry/default/example/error-display-with-children.tsx +++ b/apps/design-system/registry/default/example/error-display-with-children.tsx @@ -1,4 +1,4 @@ -import { ErrorDisplay } from 'ui-patterns/ErrorDisplay' +import { ErrorDisplay } from 'ui-patterns/ErrorDisplay/ErrorDisplay' export default function ErrorDisplayWithChildren() { return ( diff --git a/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx b/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx index 078a36d49af..49f00b4bee9 100644 --- a/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx +++ b/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { format } from 'date-fns' -import { CalendarIcon, ExternalLink, Plus, Trash2, Upload } from 'lucide-react' +import { CalendarIcon, ExternalLink, Plus, Trash, Upload } from 'lucide-react' import { useRef, useState } from 'react' import { useFieldArray, useForm } from 'react-hook-form' import { @@ -30,6 +30,7 @@ import { } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray' import { MultiSelector, MultiSelectorContent, @@ -61,6 +62,7 @@ const formSchema = z.object({ password: z.string().min(8, 'Password must be at least 8 characters'), duration: z.number().min(5).max(30), redirectUris: z.array(z.object({ value: z.string().url('Must be a valid URL') })), + httpHeaders: z.array(z.object({ key: z.string(), value: z.string() })), apiKey: z.string().optional(), }) @@ -91,6 +93,7 @@ export default function FormPatternsPageLayout() { password: '', duration: 10, redirectUris: [{ value: '' }], + httpHeaders: [{ key: '', value: '' }], apiKey: fakeApiKey, }, }) @@ -285,7 +288,7 @@ export default function FormPatternsPageLayout() { + + + + ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 34a31c697b7..cc3215c8990 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -1107,6 +1107,12 @@ export const examples: Registry = [ type: 'components:example', files: ['example/form-item-layout-demo.tsx'], }, + { + name: 'key-value-field-array-demo', + type: 'components:example', + registryDependencies: ['button', 'form', 'input'], + files: ['example/key-value-field-array-demo.tsx'], + }, { name: 'field-choice-card', type: 'components:example', diff --git a/apps/design-system/tsconfig.base.json b/apps/design-system/tsconfig.base.json deleted file mode 100644 index d72a9f3a278..00000000000 --- a/apps/design-system/tsconfig.base.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "display": "Default", - "compilerOptions": { - "composite": false, - "declaration": true, - "declarationMap": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "inlineSources": false, - "isolatedModules": true, - "moduleResolution": "node", - "noUnusedLocals": false, - "noUnusedParameters": false, - "preserveWatchOutput": true, - "skipLibCheck": true, - "strict": true - }, - "exclude": ["node_modules"] -} diff --git a/apps/design-system/tsconfig.json b/apps/design-system/tsconfig.json index 85f33731c8b..d96c6722dbf 100644 --- a/apps/design-system/tsconfig.json +++ b/apps/design-system/tsconfig.json @@ -1,40 +1,23 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "extends": "tsconfig/base.json", + "extends": "tsconfig/nextjs.json", "compilerOptions": { - "target": "es5", + "allowJs": false, "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "incremental": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", "paths": { "@/*": ["./*"], "@ui/*": ["./../../packages/ui/src/*"], // handle ui package paths "contentlayer/generated": ["./.contentlayer/generated"], "icons/*": ["./../../packages/icons/*"] }, - "plugins": [ - { - "name": "next" - } - ] + "plugins": [{ "name": "next" }] }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - ".contentlayer/generated", - "./../../packages/ui/src/**/*.d.ts" + ".contentlayer/generated" ], "exclude": ["node_modules", "./scripts/build-registry.mts"] } diff --git a/apps/design-system/tsconfig.scripts.json b/apps/design-system/tsconfig.scripts.json deleted file mode 100644 index 7d81d8fcb5f..00000000000 --- a/apps/design-system/tsconfig.scripts.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "extends": "./tsconfig.json", - "compilerOptions": { - "target": "es6", - "module": "ESNext", - "moduleResolution": "node", - "esModuleInterop": true, - "isolatedModules": false - }, - "include": [".contentlayer/generated", "scripts/**/*.ts"], - "exclude": ["node_modules"] -} diff --git a/apps/design-system/types/assets.d.ts b/apps/design-system/types/assets.d.ts new file mode 100644 index 00000000000..70be4aae409 --- /dev/null +++ b/apps/design-system/types/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.scss' { + export const styles: Record + export default styles +} diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index 19731c8f0d6..574d054827f 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -32,6 +32,7 @@ public/llms.txt public/llms/ # Generated guide markdown files public/docs/ +public/docs.tar.gz # Copied examples folder /examples/ diff --git a/apps/docs/app/api/guides-md/[...slug]/route.ts b/apps/docs/app/api/guides-md/[...slug]/route.ts index f09a7854f25..b942ff2f73c 100644 --- a/apps/docs/app/api/guides-md/[...slug]/route.ts +++ b/apps/docs/app/api/guides-md/[...slug]/route.ts @@ -14,7 +14,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu try { const content = await fs.readFile(filePath, 'utf-8') return new NextResponse(content, { - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600', + }, }) } catch { return new NextResponse('Not found', { status: 404 }) diff --git a/apps/docs/app/guides/ai/python/[slug]/page.tsx b/apps/docs/app/guides/ai/python/[slug]/page.tsx index b65b95f86a1..e31bc6b1595 100644 --- a/apps/docs/app/guides/ai/python/[slug]/page.tsx +++ b/apps/docs/app/guides/ai/python/[slug]/page.tsx @@ -4,7 +4,7 @@ import rehypeSlug from 'rehype-slug' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import { genGuideMeta, removeRedundantH1 } from '~/features/docs/GuidesMdx.utils' -import { fetchRevalidatePerDay } from '~/features/helpers.fetch' +import { getGitHubFileContents } from '~/lib/octokit' import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' @@ -83,23 +83,9 @@ const getContent = async ({ slug }: Params) => { const editLink = newEditLink(`${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`) - let response: Response - try { - response = await fetchRevalidatePerDay( - `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}` - ) - } catch (err) { - throw new Error(`Failed to fetch Python vecs docs from GitHub (network error): ${err}`) - } - - if (!response.ok) { - throw new Error( - `Failed to fetch Python vecs docs from GitHub: ${response.status} ${response.statusText}` - ) - } - - let content = await response.text() - content = removeRedundantH1(content) + const content = removeRedundantH1( + await getGitHubFileContents({ org, repo, path: `${docsDir}/${remoteFile}`, branch }) + ) return { pathname: `/guides/ai/python/${slug}` satisfies `/${string}`, diff --git a/apps/docs/app/guides/database/database-advisors/page.tsx b/apps/docs/app/guides/database/database-advisors/page.tsx index bba7532c3de..334be03cd77 100644 --- a/apps/docs/app/guides/database/database-advisors/page.tsx +++ b/apps/docs/app/guides/database/database-advisors/page.tsx @@ -1,4 +1,3 @@ -import { Octokit } from '@octokit/core' import { capitalize } from 'lodash-es' import rehypeSlug from 'rehype-slug' import { Heading } from 'ui' @@ -7,7 +6,7 @@ import { Admonition } from 'ui-patterns' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import { genGuideMeta } from '~/features/docs/GuidesMdx.utils' import { MDXRemoteBase } from '~/features/docs/MdxBase' -import { fetchRevalidatePerDay } from '~/features/helpers.fetch' +import { OCTOKIT_RETRY_OPTIONS, getGitHubFileContents, octokit } from '~/lib/octokit' import { TabPanel, Tabs } from '~/features/ui/Tabs' import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' @@ -144,9 +143,7 @@ const urlTransform: (lints: Array<{ path: string }>) => UrlTransformFunction = ( * Fetch lint remediation Markdown from external repo */ const getLints = async () => { - const octokit = new Octokit({ request: { fetch: fetchRevalidatePerDay } }) - - const response = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', { + const response = await octokit().request('GET /repos/{owner}/{repo}/contents/{path}', { owner: org, repo: repo, path: docsDir, @@ -154,10 +151,13 @@ const getLints = async () => { headers: { 'X-GitHub-Api-Version': '2022-11-28', }, + request: OCTOKIT_RETRY_OPTIONS, }) if (response.status >= 400) { - throw Error(`Could not get contents of repo ${org}/${repo}`) + throw new Error( + `Failed to fetch ${org}/${repo}/contents/${docsDir} docs from GitHub: ${response.status}` + ) } if (!Array.isArray(response.data)) { @@ -170,15 +170,7 @@ const getLints = async () => { const lints = await Promise.all( lintsList.map(async ({ path }) => { - const fileResponse = await fetchRevalidatePerDay( - `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${path}` - ) - - if (fileResponse.status >= 400) { - throw Error(`Could not get contents of file ${org}/${repo}/${path}`) - } - - const content = await fileResponse.text() + const content = await getGitHubFileContents({ org, repo, path, branch }) return { path: getBasename(path), diff --git a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx index dae36f2de1e..475818f2e8e 100644 --- a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx @@ -360,10 +360,22 @@ const getContent = async (params: Params) => { const repoPath = `${org}/${repo}/${tag}/${docsDir}/${remoteFile}` editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}` - const response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, { - cache: 'force-cache', - next: { tags: [REVALIDATION_TAGS.WRAPPERS] }, - }) + let response: Response + try { + response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, { + cache: 'force-cache', + next: { tags: [REVALIDATION_TAGS.WRAPPERS] }, + }) + } catch (err) { + throw new Error(`Failed to fetch wrappers docs from GitHub (network error): ${err}`) + } + + if (!response.ok) { + throw new Error( + `Failed to fetch wrappers docs from GitHub: ${response.status} ${response.statusText}` + ) + } + const rawContent = await response.text() assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${tag}/docs/assets/` diff --git a/apps/docs/app/guides/deployment/ci/[slug]/page.tsx b/apps/docs/app/guides/deployment/ci/[slug]/page.tsx index 9876928553e..76152dc0aa1 100644 --- a/apps/docs/app/guides/deployment/ci/[slug]/page.tsx +++ b/apps/docs/app/guides/deployment/ci/[slug]/page.tsx @@ -4,7 +4,7 @@ import rehypeSlug from 'rehype-slug' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import { genGuideMeta, removeRedundantH1 } from '~/features/docs/GuidesMdx.utils' -import { fetchRevalidatePerDay } from '~/features/helpers.fetch' +import { getGitHubFileContents } from '~/lib/octokit' import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' @@ -82,22 +82,9 @@ const getContent = async ({ slug }: Params) => { const editLink = newEditLink(`${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`) - let response: Response - try { - response = await fetchRevalidatePerDay( - `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}` - ) - } catch (err) { - throw new Error(`Failed to fetch CI docs from GitHub (network error): ${err}`) - } - - if (!response.ok) { - throw new Error( - `Failed to fetch CI docs from GitHub: ${response.status} ${response.statusText}` - ) - } - - const content = removeRedundantH1(await response.text()) + const content = removeRedundantH1( + await getGitHubFileContents({ org, repo, path: `${docsDir}/${remoteFile}`, branch }) + ) return { pathname: `/guides/cli/github-action/${slug}` satisfies `/${string}`, diff --git a/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx b/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx index 8fd187bfeb2..c97957170c2 100644 --- a/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/deployment/terraform/[[...slug]]/page.tsx @@ -4,7 +4,7 @@ import rehypeSlug from 'rehype-slug' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import { genGuideMeta, removeRedundantH1 } from '~/features/docs/GuidesMdx.utils' -import { fetchRevalidatePerDay } from '~/features/helpers.fetch' +import { getGitHubFileContents } from '~/lib/octokit' import { isValidGuideFrontmatter } from '~/lib/docs' import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' @@ -115,22 +115,12 @@ const getContent = async ({ slug }: Params) => { `${terraformDocsOrg}/${terraformDocsRepo}/blob/${terraformDocsBranch}/${useRoot ? '' : `${terraformDocsDocsDir}/`}${remoteFile}` ) - let response: Response - try { - response = await fetchRevalidatePerDay( - `https://raw.githubusercontent.com/${terraformDocsOrg}/${terraformDocsRepo}/${terraformDocsBranch}/${useRoot ? '' : `${terraformDocsDocsDir}/`}${remoteFile}` - ) - } catch (err) { - throw new Error(`Failed to fetch Terraform docs from GitHub (network error): ${err}`) - } - - if (!response.ok) { - throw new Error( - `Failed to fetch Terraform docs from GitHub: ${response.status} ${response.statusText}` - ) - } - - let rawContent = await response.text() + let rawContent = await getGitHubFileContents({ + org: terraformDocsOrg, + repo: terraformDocsRepo, + path: useRoot ? remoteFile : `${terraformDocsDocsDir}/${remoteFile}`, + branch: terraformDocsBranch, + }) // Strip out HTML comments rawContent = rawContent.replace(//, '') let { content, data } = matter(rawContent) diff --git a/apps/docs/app/guides/deployment/terraform/reference/page.tsx b/apps/docs/app/guides/deployment/terraform/reference/page.tsx index ae9c9b66daa..b8877c2e92a 100644 --- a/apps/docs/app/guides/deployment/terraform/reference/page.tsx +++ b/apps/docs/app/guides/deployment/terraform/reference/page.tsx @@ -13,7 +13,7 @@ import { import { genGuideMeta } from '~/features/docs/GuidesMdx.utils' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' -import { fetchRevalidatePerDay } from '~/features/helpers.fetch' +import { getGitHubFileContents } from '~/lib/octokit' import { TabPanel, Tabs } from '~/features/ui/Tabs' import { terraformDocsBranch, @@ -394,21 +394,14 @@ const TerraformReferencePage = async () => { * Fetch JSON schema from external repo */ const getSchema = async () => { - let response: Response - try { - response = await fetchRevalidatePerDay( - `https://raw.githubusercontent.com/${terraformDocsOrg}/${terraformDocsRepo}/${terraformDocsBranch}/${terraformDocsDocsDir}/schema.json` - ) - } catch (err) { - throw new Error(`Failed to fetch Terraform JSON schema from GitHub (network error): ${err}`) - } - - if (!response.ok) - throw Error( - `Failed to fetch Terraform JSON schema from GitHub: ${response.status} ${response.statusText}` - ) - - const schema = await response.json() + const schema = JSON.parse( + await getGitHubFileContents({ + org: terraformDocsOrg, + repo: terraformDocsRepo, + path: `${terraformDocsDocsDir}/schema.json`, + branch: terraformDocsBranch, + }) + ) return { schema, diff --git a/apps/docs/app/guides/getting-started/ai-skills/AiSkills.utils.ts b/apps/docs/app/guides/getting-started/ai-skills/AiSkills.utils.ts new file mode 100644 index 00000000000..b6b68853082 --- /dev/null +++ b/apps/docs/app/guides/getting-started/ai-skills/AiSkills.utils.ts @@ -0,0 +1,62 @@ +import matter from 'gray-matter' +import { cache } from 'react' + +import { OCTOKIT_RETRY_OPTIONS, getGitHubFileContents, octokit } from '~/lib/octokit' + +const SKILLS_REPO = { + org: 'supabase', + repo: 'agent-skills', + branch: 'main', + path: 'skills', +} + +interface SkillMetadata { + name?: string + title?: string + description?: string +} + +interface SkillSummary { + name: string + description: string + installCommand: string +} + +async function getAiSkillsImpl(): Promise { + const { data: contents } = await octokit().request('GET /repos/{owner}/{repo}/contents/{path}', { + owner: SKILLS_REPO.org, + repo: SKILLS_REPO.repo, + path: SKILLS_REPO.path, + ref: SKILLS_REPO.branch, + request: OCTOKIT_RETRY_OPTIONS, + }) + + if (!Array.isArray(contents)) { + throw new Error('Expected directory listing from GitHub agent skills repo') + } + + const skillDirs = contents.filter((item) => item.type === 'dir') + + const skills = await Promise.all( + skillDirs.map(async (item) => { + const skillPath = `${SKILLS_REPO.path}/${item.name}/SKILL.md` + const rawContent = await getGitHubFileContents({ + org: SKILLS_REPO.org, + repo: SKILLS_REPO.repo, + branch: SKILLS_REPO.branch, + path: skillPath, + }) + const { data } = matter(rawContent) as { data: SkillMetadata } + + return { + name: item.name, + description: data.description || '', + installCommand: `npx skills add supabase/agent-skills --skill ${item.name}`, + } + }) + ) + + return skills.sort((a, b) => a.name.localeCompare(b.name)) +} + +export const getAiSkills = cache(getAiSkillsImpl) diff --git a/apps/docs/app/guides/getting-started/ai-skills/AiSkillsIndex.tsx b/apps/docs/app/guides/getting-started/ai-skills/AiSkillsIndex.tsx new file mode 100644 index 00000000000..1f5311a563c --- /dev/null +++ b/apps/docs/app/guides/getting-started/ai-skills/AiSkillsIndex.tsx @@ -0,0 +1,60 @@ +import { getAiSkills } from './AiSkills.utils' +import { CopyButton } from './CopyButton' + +export async function AiSkillsIndex() { + let skills: Awaited> = [] + + try { + skills = await getAiSkills() + } catch { + // Swallow errors from getAiSkills to keep the page usable + } + + if (!skills.length) { + return ( +
+ Unable to load AI skills at the moment. +
+ ) + } + return ( +
+ + + + + + + + + + {skills.map((skill) => ( + + + + + + ))} + +
SkillDescriptionInstall command
+ + {skill.name} + + {skill.description} +
+
+ + + {skill.installCommand} + +
+
+
+
+ ) +} diff --git a/apps/docs/app/guides/getting-started/ai-skills/CopyButton.tsx b/apps/docs/app/guides/getting-started/ai-skills/CopyButton.tsx new file mode 100644 index 00000000000..1f974067655 --- /dev/null +++ b/apps/docs/app/guides/getting-started/ai-skills/CopyButton.tsx @@ -0,0 +1,29 @@ +'use client' + +import { useState } from 'react' +import { Check, Copy } from 'lucide-react' +import { cn } from 'ui' + +export function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} diff --git a/apps/docs/app/guides/getting-started/layout.tsx b/apps/docs/app/guides/getting-started/layout.tsx index ba83678f3b7..431edbe4588 100644 --- a/apps/docs/app/guides/getting-started/layout.tsx +++ b/apps/docs/app/guides/getting-started/layout.tsx @@ -3,7 +3,9 @@ import Layout from '~/layouts/guides' import { getAiPrompts } from '../getting-started/ai-prompts/[slug]/AiPrompts.utils' export default async function GettingStartedLayout({ children }: { children: React.ReactNode }) { - const additionalNavItems = { prompts: await getPrompts() } + const additionalNavItems = { + prompts: await getPrompts(), + } return {children} } diff --git a/apps/docs/app/guides/graphql/[[...slug]]/page.tsx b/apps/docs/app/guides/graphql/[[...slug]]/page.tsx index 57ca0b26eae..5368816194c 100644 --- a/apps/docs/app/guides/graphql/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/graphql/[[...slug]]/page.tsx @@ -4,7 +4,7 @@ import rehypeSlug from 'rehype-slug' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import { genGuideMeta } from '~/features/docs/GuidesMdx.utils' -import { REVALIDATION_TAGS } from '~/features/helpers.fetch' +import { getGitHubFileContents } from '~/lib/octokit' import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' @@ -136,23 +136,12 @@ const getContent = async ({ slug }: Params) => { const editLink = newEditLink(`${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`) - let response: Response - try { - response = await fetch( - `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}`, - { cache: 'force-cache', next: { tags: [REVALIDATION_TAGS.GRAPHQL] } } - ) - } catch (err) { - throw new Error(`Failed to fetch GraphQL docs from GitHub (network error): ${err}`) - } - - if (!response.ok) { - throw new Error( - `Failed to fetch GraphQL docs from GitHub: ${response.status} ${response.statusText}` - ) - } - - const content = await response.text() + const content = await getGitHubFileContents({ + org, + repo, + path: `${docsDir}/${remoteFile}`, + branch, + }) return { pathname: `/guides/graphql${slug?.length ? `/${slug.join('/')}` : ''}` satisfies `/${string}`, diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index 3d23504a92f..fbde324a0a4 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -1,18 +1,17 @@ -import '@code-hike/mdx/styles' +import '@code-hike/mdx/styles.css' import 'config/code-hike.scss' import 'ui-patterns/ShimmeringLoader/index.css' import '../styles/main.scss' import '../styles/new-docs.scss' import '../styles/prism-okaidia.scss' -import { TelemetryTagManager } from 'common' - -import { genFaviconData } from 'common/MetaFavicons/app-router' -import type { Metadata, Viewport } from 'next' import { GlobalProviders } from '~/features/app.providers' import { TopNavSkeleton } from '~/layouts/MainSkeleton' import { BASE_PATH, IS_PRODUCTION } from '~/lib/constants' import { getCustomContent } from '~/lib/custom-content/getCustomContent' +import { TelemetryTagManager } from 'common' +import { genFaviconData } from 'common/MetaFavicons/app-router' +import type { Metadata, Viewport } from 'next' const { metadataApplicationName, metadataTitle } = getCustomContent([ 'metadata:application_name', diff --git a/apps/docs/components/Breadcrumbs.tsx b/apps/docs/components/Breadcrumbs.tsx index 2f04f9c1869..b9bde64e769 100644 --- a/apps/docs/components/Breadcrumbs.tsx +++ b/apps/docs/components/Breadcrumbs.tsx @@ -176,6 +176,18 @@ function useBreadcrumbs() { return breadcrumbs } + // TODO: Breadcrumbs currently can't infer the "AI Tools" parent for /guides/getting-started/ai-* routes, + // so we special-case these paths here. Remove when Breadcrumbs can derive this hierarchy from NavigationMenu. + const isAiSkillsPage = pathname.startsWith('/guides/getting-started/ai-skills') + if (isAiSkillsPage) { + const breadcrumbs = [ + { name: 'Getting started', url: '/guides/getting-started' }, + { name: 'AI Tools' }, + { name: 'Agent Skills', url: '/guides/getting-started/ai-skills' }, + ] + return breadcrumbs + } + const menuId = getMenuId(pathname) const menu = NavItems[menuId] return findMenuItemByUrl(menu, pathname, []) diff --git a/apps/docs/components/GuidesSidebar.tsx b/apps/docs/components/GuidesSidebar.tsx index 7945943892a..544efe3fe2f 100644 --- a/apps/docs/components/GuidesSidebar.tsx +++ b/apps/docs/components/GuidesSidebar.tsx @@ -30,7 +30,15 @@ function AiTools({ className }: { className?: string }) { try { const res = await fetch(mdUrl) - const text = await res.text() + let text: string + + if (res.ok) { + text = await res.text() + } else { + // Default to HTML content within the article when no .md file is available. + text = document.getElementById('sb-docs-guide-main-article')?.innerHTML ?? '' + } + await navigator.clipboard.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 2000) diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 6b5a82aa503..4f7b1408e8a 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -499,6 +499,10 @@ export const gettingstarted: NavMenuConstant = { name: 'Prompts', url: '/guides/getting-started/ai-prompts' as `/${string}`, }, + { + name: 'Agent Skills', + url: '/guides/getting-started/ai-skills' as `/${string}`, + }, { name: 'Supabase MCP server', url: '/guides/getting-started/mcp' as `/${string}`, @@ -773,6 +777,12 @@ export const auth: NavMenuConstant = { ], }, + { + name: 'Custom OAuth/OIDC Providers', + url: '/guides/auth/custom-oauth-providers', + enabled: allAuthProvidersEnabled, + }, + { name: 'Anonymous Sign-Ins', url: '/guides/auth/auth-anonymous', @@ -2852,6 +2862,7 @@ export const self_hosting: NavMenuConstant = { }, { name: 'Configure S3 Storage', url: '/guides/self-hosting/self-hosted-s3' }, { name: 'Copy Storage from Platform', url: '/guides/self-hosting/copy-from-platform-s3' }, + { name: 'Custom Email Templates', url: '/guides/self-hosting/custom-email-templates' }, { name: 'Configure Social Login (OAuth)', url: '/guides/self-hosting/self-hosted-oauth' }, { name: 'Configure Phone Login & MFA', url: '/guides/self-hosting/self-hosted-phone-mfa' }, { name: 'Enable MCP server', url: '/guides/self-hosting/enable-mcp' }, diff --git a/apps/docs/content/_partials/providers.mdx b/apps/docs/content/_partials/providers.mdx index 4f4fe7f2def..bb489af03d9 100644 --- a/apps/docs/content/_partials/providers.mdx +++ b/apps/docs/content/_partials/providers.mdx @@ -6,6 +6,12 @@ Supabase Auth works with many popular Auth methods, including Social and Phone A + + +You can also add any OAuth2 or OIDC-compatible identity provider using [Custom OAuth/OIDC Providers](/docs/guides/auth/custom-oauth-providers). + + + ### Phone Auth diff --git a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx index 23ff0a707bc..ac5a90549ef 100644 --- a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx +++ b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx @@ -372,7 +372,7 @@ Email sending depends on two settings: Email Provider and Auth Hook status. When `email_action_type` is `email_change`, the hook payload can include one or two OTPs and their hashes. This depends on your [Secure Email Change](/dashboard/project/_/auth/providers?provider=Email) setting. -- Secure Email Change enabled: two OTPs are generated, one for the current email (`user.email`) and one for the new email (`user.email_new`). You must send two emails. +- Secure Email Change enabled: two OTPs are generated, one for the current email (`user.email`) and one for the new email (`user.new_email`). You must send two emails. - Secure Email Change disabled: only one OTP is generated for the new email. You send a single email. @@ -380,7 +380,7 @@ When `email_action_type` is `email_change`, the hook payload can include one or The token hash field names are reversed due to backward compatibility. Pay careful attention to which token/hash pair goes with which email address: - `token_hash_new` → use with the **current** email address (`user.email`) and `token` -- `token_hash` → use with the **new** email address (`user.email_new`) and `token_new` +- `token_hash` → use with the **new** email address (`user.new_email`) and `token_new` Do not assume the `_new` suffix refers to the new email address. @@ -391,7 +391,7 @@ Do not assume the `_new` suffix refers to the new email address. When Secure Email Change is enabled (both token/hash pairs present): - Send to **current** email address (`user.email`): use `token` with `token_hash_new` -- Send to **new** email address (`user.email_new`): use `token_new` with `token_hash` +- Send to **new** email address (`user.new_email`): use `token_new` with `token_hash` When Secure Email Change is **disabled** (only one token/hash pair present): diff --git a/apps/docs/content/guides/auth/custom-oauth-providers.mdx b/apps/docs/content/guides/auth/custom-oauth-providers.mdx new file mode 100644 index 00000000000..4ecb6379cab --- /dev/null +++ b/apps/docs/content/guides/auth/custom-oauth-providers.mdx @@ -0,0 +1,320 @@ +--- +id: 'custom-oauth-providers' +title: 'Custom OAuth/OIDC Providers' +description: 'Add any OAuth2 or OIDC-compatible identity provider to your Supabase project' +--- + +Custom OAuth/OIDC providers let you integrate any standards-compliant identity provider with Supabase Auth, beyond the ones Supabase supports out of the box. + +Each custom provider uses a `custom:` prefix in its identifier (for example, `custom:my-idp` or `custom:github-enterprise`). This prefix distinguishes custom providers from built-in providers. + +There are two provider types: + +- **OAuth2**: for generic OAuth2 providers where you supply the authorization, token, and userinfo endpoints manually. +- **OIDC**: for providers that support [OpenID Connect](https://openid.net/connect/) discovery. You supply only the issuer URL and endpoints are resolved automatically. + + + +You can add up to 3 custom providers per project. If you need more, [contact support](/dashboard/support/new). + + + +## Creating a provider + +The create form displays a read-only **Callback URL**. Copy this URL and configure it as the redirect/callback URI in your external identity provider before completing setup. + +### OAuth2 provider + +Use an OAuth2 provider when your identity provider does not support OpenID Connect discovery. You must supply the authorization, token, and userinfo endpoint URLs explicitly. + + + + +1. Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard. +2. Click **New Provider**. Select **Manual configuration** as the configuration method. +3. Enter a unique identifier (must start with `custom:`, for example `custom:my-oauth-provider`). +4. Enter the provider's **Client ID** and **Client Secret**. +5. Enter the **Authorization URL**, **Token URL**, and **UserInfo URL**. +6. Click **Create and enable provider**. + + + + +```js +const { data, error } = await supabase.auth.admin.customProviders.createProvider({ + provider_type: 'oauth2', + identifier: 'custom:my-oauth-provider', + name: 'My OAuth Provider', + client_id: 'your-client-id', + client_secret: 'your-client-secret', + authorization_url: 'https://provider.example.com/oauth/authorize', + token_url: 'https://provider.example.com/oauth/token', + userinfo_url: 'https://provider.example.com/oauth/userinfo', + scopes: ['profile', 'email'], +}) +``` + + + + +### OIDC provider + +Use an OIDC provider when your identity provider supports OpenID Connect. Supply the `issuer` URL and the discovery document, JWKS, and endpoints are resolved automatically. + + + + +1. Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard. +2. Click **New Provider**. Select **Auto-discovery (OIDC)** as the configuration method. +3. Enter a unique identifier (must start with `custom:`, for example `custom:my-regional-provider`). +4. Enter the provider's **Client ID** and **Client Secret**. +5. Enter the **Issuer URL**. The discovery document and endpoints are resolved automatically. +6. Click **Create and enable provider**. + + + + +```js +const { data, error } = await supabase.auth.admin.customProviders.createProvider({ + provider_type: 'oidc', + identifier: 'custom:my-regional-provider', + name: 'Regional Provider', + client_id: 'your-client-id', + client_secret: 'your-client-secret', + issuer: 'https://auth.example.com', + scopes: ['openid', 'profile', 'email'], +}) +``` + + + + +OIDC providers have the following automatic behavior: + +- The discovery document is fetched from `{issuer}/.well-known/openid-configuration` (or from `discovery_url` if set). +- The `openid` scope is always included. It is automatically added if missing from the `scopes` array. +- ID tokens are verified against the provider's JWKS (fetched from the discovery document's `jwks_uri`). + +## Provider identifiers + +Every custom provider identifier must start with the `custom:` prefix. Identifiers are 2–50 characters, lowercase alphanumeric with hyphens and colons allowed. Examples: + +- `custom:my-provider` +- `custom:github-enterprise` + +## User sign-in + +Once a custom provider is created and enabled, users sign in via the standard OAuth authorize endpoint: + +``` +GET https://your-project.supabase.co/auth/v1/authorize?provider=custom:my-provider +``` + +Or using the Supabase client libraries: + + + + +```js +const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'custom:my-provider', +}) +``` + + + + +```dart +await supabase.auth.signInWithOAuth( + OAuthProvider('custom:my-provider'), +); +``` + + + + +```swift +try await supabase.auth.signInWithOAuth( + provider: "custom:my-provider", + redirectTo: URL(string: "my-custom-scheme://my-app-host") +) +``` + + + + +```kotlin +supabase.auth.signInWith(CustomProvider("custom:my-provider")) +``` + + + + +## Managing providers + +### List providers + + + + +Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard. All custom providers are listed under **Custom OAuth Providers**. + + + + +```js +// List all custom providers +const { data, error } = await supabase.auth.admin.customProviders.listProviders() + +// Filter by provider type +const { data, error } = await supabase.auth.admin.customProviders.listProviders({ + type: 'oidc', +}) +``` + + + + +### Update a provider + +Update any provider fields except `provider_type` and `identifier`. Only provided fields are changed (partial update). To rotate a client secret, update only the `client_secret` field. + + + + +1. Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard. +2. Click the three-dot menu (⋮) next to the provider and select **Update**. +3. Modify the fields you want to change. +4. Click **Update provider**. + + + + +```js +const { data, error } = await supabase.auth.admin.customProviders.updateProvider( + 'custom:my-provider', + { + name: 'Updated Provider Name', + scopes: ['profile', 'email', 'groups'], + enabled: false, + } +) +``` + + + + +### Delete a provider + + + + +1. Go to [Auth Providers](/dashboard/project/_/auth/providers) in the Dashboard. +2. Click the three-dot menu (⋮) next to the provider and select **Delete**. +3. Confirm the deletion. + + + + +```js +const { data, error } = + await supabase.auth.admin.customProviders.deleteProvider('custom:my-provider') +``` + + + + +## Advanced configuration + +### PKCE + +PKCE (Proof Key for Code Exchange) is enabled by default (`pkce_enabled: true`) for all custom providers. The auth server automatically generates a code challenge and verifier during the authorization flow, protecting against authorization code interception attacks. This is handled entirely server-side, no client-side PKCE logic is needed. + +To disable PKCE for a specific provider, set `pkce_enabled: false` when creating or updating it. This is not recommended unless the identity provider does not support PKCE. + +### Authorization params + +Extra query parameters appended to the provider's authorization URL during the OAuth flow. All values must be strings. + +```json +{ + "prompt": "consent", + "access_type": "offline", + "login_hint": "user@example.com" +} +``` + +The following reserved parameters are managed by the auth server and cannot be overridden: `client_id`, `client_secret`, `redirect_uri`, `response_type`, `state`, `code_challenge`, `code_challenge_method`, `code_verifier`, `nonce`. + +### Multi-platform apps + +If your app uses different client IDs for different platforms (for example, web vs mobile), use `acceptable_client_ids` to list additional client IDs that should be accepted for audience validation in OIDC ID tokens: + +```js +const { data, error } = await supabase.auth.admin.customProviders.createProvider({ + provider_type: 'oidc', + identifier: 'custom:multi-platform-app', + name: 'Multi-Platform App', + client_id: 'web-client-id', + client_secret: 'your-client-secret', + issuer: 'https://app.example.com', + scopes: ['openid', 'profile', 'email'], + acceptable_client_ids: ['ios-client-id', 'android-client-id'], +}) +``` + +### Email-optional providers + +By default, providers must return an email address. Set `email_optional` to `true` when creating or updating a provider to allow sign-in without an email. This applies to both OAuth2 and OIDC providers. + +### OIDC-specific options + +| Field | Type | Default | Description | +| ------------------ | -------- | ------- | ------------------------------------------------------------------------------------- | +| `discovery_url` | `string` | `null` | Override the discovery document URL if the provider uses a non-standard location. | +| `skip_nonce_check` | `bool` | `false` | Skip nonce validation on ID tokens. Use only for providers that do not support nonce. | + +## Error reference + +| Error code | HTTP status | Description | +| ---------------------------- | ----------- | ------------------------------------------------------------------------------------------ | +| `validation_failed` | 400 | Invalid parameters: missing required fields, bad format, reserved params, or invalid URLs. | +| `conflict` | 400 | A provider with the same identifier already exists. | +| `over_custom_provider_quota` | 400 | Maximum number of custom providers reached. | +| `custom_provider_not_found` | 404 | No provider exists with the given identifier. | diff --git a/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx b/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx index 797a8094bc4..7b890e169b6 100644 --- a/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx +++ b/apps/docs/content/guides/auth/native-mobile-deep-linking.mdx @@ -97,7 +97,7 @@ With Deep Linking, you can configure this redirect to open a specific page. This export default function Auth() { // Handle linking into app from email app. - const url = Linking.useURL(); + const url = Linking.useLinkingURL(); if (url) createSessionFromUrl(url); return ( diff --git a/apps/docs/content/guides/auth/social-login.mdx b/apps/docs/content/guides/auth/social-login.mdx index 75a2b809d3c..c0b642296d3 100644 --- a/apps/docs/content/guides/auth/social-login.mdx +++ b/apps/docs/content/guides/auth/social-login.mdx @@ -45,6 +45,12 @@ Supabase supports a suite of social providers. Follow these guides to configure + + +Need to integrate with a provider not listed here? You can add any OAuth2 or OIDC-compatible provider using [Custom OAuth/OIDC Providers](/docs/guides/auth/custom-oauth-providers). + + + ## 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. diff --git a/apps/docs/content/guides/database/functions.mdx b/apps/docs/content/guides/database/functions.mdx index 0f0c0517489..abea38ae77d 100644 --- a/apps/docs/content/guides/database/functions.mdx +++ b/apps/docs/content/guides/database/functions.mdx @@ -416,7 +416,7 @@ language plpgsql security definer set search_path = '' as $$ begin - select 'hello world'; + return 'hello world'; end; $$; ``` diff --git a/apps/docs/content/guides/database/replication/manual-replication-monitoring.mdx b/apps/docs/content/guides/database/replication/manual-replication-monitoring.mdx index 3742b2eaf8c..72675da17ba 100644 --- a/apps/docs/content/guides/database/replication/manual-replication-monitoring.mdx +++ b/apps/docs/content/guides/database/replication/manual-replication-monitoring.mdx @@ -8,7 +8,7 @@ sidebar_label: 'Monitoring' Monitoring replication lag is important and there are 3 ways to do this: -1. Dashboard - Under the [Reports](/docs/guides/platform/reports) of the dashboard, you can view the replication lag of your project +1. Dashboard - Under the [Reports](/docs/guides/telemetry/reports) of the dashboard, you can view the replication lag of your project 2. Database - - pg_stat_subscription (subscriber) - if PID is null, then the subscription is not active - pg_stat_subscription_stats - look here for error_count to see if there were issues applying or syncing (if yes, check the logs for why) diff --git a/apps/docs/content/guides/deployment/branching.mdx b/apps/docs/content/guides/deployment/branching.mdx index ab6c6dff990..798e2fffcc0 100644 --- a/apps/docs/content/guides/deployment/branching.mdx +++ b/apps/docs/content/guides/deployment/branching.mdx @@ -27,4 +27,4 @@ When you merge any branch into your main project, Supabase automatically runs a 6. **Seed** - Runs seed files to populate your branch with initial data (must be [enabled in config.toml](/docs/guides/deployment/branching/configuration#branch-configuration-with-remotes) for persistent branches) 7. **Deploy** - Deploys any changed Edge Functions and updates function secrets -If a parent deployment step fails, all dependent children steps will be skipped. For e.g., if your database migrations failed at step 5, our runner will not seed your branch because step 6 is skipped. If you are using GitHub integration, the same deployment workflow will be run on every commit pushed to your git branch. +If a parent deployment step fails, all dependent child steps will be skipped. For instance, if your database migrations failed at step 5, our runner will not seed your branch because step 6 is skipped. If you are using GitHub integration, the same deployment workflow will be run on every commit pushed to your git branch. diff --git a/apps/docs/content/guides/deployment/going-into-prod.mdx b/apps/docs/content/guides/deployment/going-into-prod.mdx index a44b3d0cc56..a66683497cc 100644 --- a/apps/docs/content/guides/deployment/going-into-prod.mdx +++ b/apps/docs/content/guides/deployment/going-into-prod.mdx @@ -6,71 +6,90 @@ description: 'Things to do before making your app publicly available' After developing your project and deciding it's production ready, you should run through this checklist to ensure that your project: -- is secure -- won't falter under the expected load -- remains available whilst in production +- [Is secure](#security) +- [Won't falter under the expected load](#performance) +- [Remains available whilst in production](#availability) ## Security -- Ensure RLS is enabled + + +Check and review issues in your database using [Security Advisor](/dashboard/project/_/database/security-advisor). + + + +- Ensure you have enabled row level security (RLS) on all tables from the [**Database > Tables**](/dashboard/project/_/database/tables) section of the Supabase Dashboard. - Tables that do not have RLS enabled with reasonable policies allow any client to access and modify their data. This is usually not what you want. - [Learn more about RLS](/docs/guides/database/postgres/row-level-security). -- Enable replication on tables containing sensitive data by enabling Row Level Security (RLS) and setting row security policies: - - Go to the Authentication > Policies page in the Supabase Dashboard to enable RLS and create security policies. - - Go to the Database > Publications page in the Supabase Dashboard to manage replication tables. -- Turn on [SSL Enforcement](/docs/guides/platform/ssl-enforcement) (see: [dashboard](/dashboard/project/_/auth/policies)) -- Enable [Network Restrictions](/docs/guides/platform/network-restrictions) for your database (see: [dashboard](/dashboard/project/_/database/settings#network-restrictions)). -- Ensure that your Supabase Account is protected with multi-factor authentication (MFA). - - If using a GitHub sign-in, [enable 2FA on GitHub](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication). Since your GitHub account gives you administrative rights to your Supabase org, you should protect it with a strong password and 2FA using a U2F key or a TOTP app. +- Enable replication on tables containing sensitive data by enabling RLS and setting row security policies: + - Go to the [**Authentication > Policies**](/dashboard/project/_/auth/policies) section of the Supabase Dashboard to enable RLS and create security policies. + - Go to the [**Database > Publications**](/dashboard/project/_/database/publications) section of the Supabase Dashboard to manage replication tables. +- Turn on [SSL Enforcement](/docs/guides/platform/ssl-enforcement) from the [**Database > Settings > SSL Configuration**](/dashboard/project/_/database/settings#ssl-configuration) section of the dashboard. +- Enable [Network Restrictions](/docs/guides/platform/network-restrictions) for the database from the [**Database > Settings > Network Restrictions**](/dashboard/project/_/database/settings#network-restrictions) section of the dashboard. +- Ensure that you protect your Supabase Account with multi-factor authentication (MFA). + - If using a GitHub sign-in, [enable 2FA on GitHub](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication). + - Since your GitHub account gives you administrative rights to your Supabase org, you should protect it with a strong password and 2FA using a U2F key or a TOTP app. - If using email+password sign-in, set up [MFA for your Supabase account](/docs/guides/platform/multi-factor-authentication#enable-mfa). -- Enable [MFA enforcement on your organization](/docs/guides/platform/network-restrictions). This ensures all users must have a valid MFA backed session to interact with organization and project resources. -- Consider [adding multiple owners on your Supabase org](/dashboard/org/_/team). This ensures that if one of the owners is unreachable or loses access to their account, you still have Owner access to your org. -- Ensure email confirmations are [enabled](/dashboard/project/_/auth/providers) in the `Settings > Auth` page. -- Ensure that you've [set the expiry](/dashboard/project/_/auth/providers) for one-time passwords (OTPs) to a reasonable value that you are comfortable with. We recommend setting this to 3600 seconds (1 hour) or lower. -- Increase the length of the OTP if you need a higher level of entropy. +- Consider enabling [MFA enforcement on your organization](/docs/guides/platform/mfa/org-mfa-enforcement). This ensures all users must have a valid MFA-backed session to interact with organization and project resources. +- Consider adding multiple owners on your Supabase org from [the **Organization > Team**](/dashboard/org/_/team) section of the Supabase Dashboard. This ensures that if one of the owners is unreachable or loses access to their account, you still have Owner access to your org. +- Enable email confirmations in the [**Authentication > Providers**](/dashboard/project/_/auth/providers) section of the dashboard. +- Set the expiry in the [**Authentication > Providers**](/dashboard/project/_/auth/providers) section of the dashboard for one-time passwords (OTPs) to a reasonable value that you are comfortable with. + - We recommend setting this to 3600 seconds (1 hour) or lower. + - Increase the length of the OTP if you need a higher level of entropy. - If your application requires a higher level of security, consider setting up [multi-factor authentication](/docs/guides/auth/auth-mfa) (MFA) for your users. - Use a custom SMTP server for auth emails so that your users can see that the mails are coming from a trusted domain (preferably the same domain that your app is hosted on). Grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc. - Consider how _you_ might abuse your service as an attacker, and take steps to mitigate it. - Review these [common cybersecurity threats](https://auth0.com/docs/security/prevent-threats). -- Check and review issues in your database using [Security Advisor](/dashboard/project/_/database/security-advisor). ## Performance + + +Check and review issues in your database using [Performance Advisor](/dashboard/project/_/database/performance-advisor). + + + - Ensure that you have suitable indices to cater to your common query patterns - [Learn more about indexes in Postgres](https://www.enterprisedb.com/postgres-tutorials/overview-postgresql-indexes). - `pg_stat_statements` can help you [identify hot or slow queries](https://www.virtual-dba.com/blog/postgresql-performance-identifying-hot-and-slow-queries/). - Perform load testing (preferably on a staging env) - Tools like [k6](https://k6.io/) can simulate traffic from many different users. -- Upgrade your database if you require more resources. If you need anything beyond what is listed, contact enterprise@supabase.io. +- Upgrade your database if you require more resources. If you need anything beyond what is listed in [the compute and disk table](/docs/guides/platform/compute-and-disk), contact [enterprise@supabase.io](mailto:enterprise@supabase.io). - If you are expecting a surge in traffic (for a big launch) and are on a Team or Enterprise Plan, [contact support](/dashboard/support/new) with more details about your launch and we'll help keep an eye on your project. -- If you expect your database size to be > 4 GB, [enable](/dashboard/project/_/settings/addons?panel=pitr) the Point in Time Recovery (PITR) add-on. Daily backups can take up resources from your database when the backup is in progress. PITR is more resource efficient, since only the changes to the database are backed up. -- Check and review issues in your database using [Performance Advisor](/dashboard/project/_/database/performance-advisor). +- If you expect your database size to be > 4 GB, enable the Point in Time Recovery (PITR) add-on in the [**Settings > Add-ons**](/dashboard/project/_/settings/addons?panel=pitr) section of the dashboard. ## Availability -- Use your own SMTP credentials so that you have full control over the deliverability of your transactional auth emails (see Settings > Auth) - - you can grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc. You can refer to our [SMTP guide](/docs/guides/auth/auth-smtp) for more details. - - The default rate limit for auth emails when using a custom SMTP provider is 30 new users per hour, if doing a major public announcement you will likely require more than this. -- Applications on the Free Plan that exhibit extremely low activity in a 7 day period may be paused by Supabase to save on server resources. - - You can restore paused projects from the Supabase dashboard. - - Upgrade to Pro to guarantee that your project will not be paused for inactivity. +- Use your own SMTP credentials so that you have full control over the deliverability of your transactional auth emails in the [**Authentication > Emails > SMTP Settings**](/dashboard/project/_/auth/smtp) section of the dashboard. + - You can grab SMTP credentials from any major email provider such as SendGrid, AWS SES, etc. Read our [SMTP guide](/docs/guides/auth/auth-smtp) for more setup details. + - The default rate limit for auth emails when using a custom SMTP provider is _30 new users per hour_. If you are doing a major public announcement, you will likely require more than this. +- We may pause applications on the Free Plan that exhibit low activity in a 7-day period to save on server resources. + - You can restore paused projects from [the Supabase dashboard](/dashboard/project/_). + - Upgrade to Pro to guarantee that we won't pause your project for inactivity. - Database backups are not available for download for Free Plan projects. - - You can set up your own backup systems using tools like [pg_dump](https://www.postgresql.org/docs/current/app-pgdump) or [wal-g](https://github.com/wal-g/wal-g). - - Nightly backups for Pro Plan projects are available on the Supabase dashboard for up to 7 days. - - Point-in-Time Recovery (PITR) allows a project to be backed up at much shorter intervals. This provides users an option to restore to any chosen point of up to seconds in granularity. If a lower RPO is required, enable PITR. + - Read [the Database Backups guide](/docs/guides/platform/backups) for more options and retention details. + - If you need a lower recovery point objective (RPO), enable Point-in-Time Recovery (PITR). PITR allows you to back up a project at shorter intervals. This provides users an option to restore to any chosen point in time with second-level granularity. - Supabase Projects use disks that offer 99.8-99.9% durability by default. - - Use Read Replicas if you require availability resilience to a disk failure event + - Use [Read Replicas](/docs/guides/platform/read-replicas) if you require availability resilience to a disk failure event - Use PITR if you require durability resilience to a disk failure event -- Upgrading to the Supabase Pro Plan will give you [access to our support team](/dashboard/support/new). +- Upgrading to the Supabase Pro Plan gives you [access to our support team](/dashboard/support/new). ## Rate limiting, resource allocation, & abuse prevention + + +Running databases is a shared responsibility between you and Supabase. There are some things that we can take care of for you, and some things that you are responsible for. + +Read more details in our [Shared Responsibility Model guide](/docs/guides/platform/shared-responsibility-model). + + + - Supabase employs a number of safeguards against bursts of incoming traffic to prevent abuse and help maximize stability across the platform - If you're on a Team or Enterprise Plan and expect high load events, such as production launches, heavy load testing, or prolonged high resource usage, open a ticket via the [support form](https://supabase.help) for help. Provide at least 2 weeks notice. ### Auth rate limits -- The table below shows the rate limit quotas on the following authentication endpoints. You can configure the auth rate limits for your project [here](/dashboard/project/_/auth/rate-limits). +- The table below shows the rate limit quotas on the following authentication endpoints. You can configure the authentication rate limits for your project in the [**Authentication > Rate Limits**](/dashboard/project/_/auth/rate-limits) section of the dashboard. | Endpoint | Path | Limited By | Rate Limit | | ------------------------------------------------ | -------------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -87,39 +106,13 @@ After developing your project and deciding it's production ready, you should run ### Realtime limits - Review the [Realtime limits](/docs/guides/realtime/limits). -- If you need limits increased you can always [contact support](/dashboard/support/new). +- If you need limits increased, [contact support](/dashboard/support/new). ### Abuse prevention -- Supabase provides CAPTCHA protection on the signup, sign-in and password reset endpoints. Refer to [our guide](/docs/guides/auth/auth-captcha) on how to protect against abuse using this method. +- Supabase provides CAPTCHA protection on the signup, sign-in and password reset endpoints. Read [the Auth CAPTCHA guide](/docs/guides/auth/auth-captcha) for more details on how to protect against abuse using this method. ### Email link validity -- When working with enterprise systems, email scanners may scan and make a `GET` request to the reset password link or sign up link in your email. Since links in Supabase Auth are single use, a user who opens an email post-scan to click on a link will receive an error. To get around this problem, - consider altering the email template to replace the original magic link with a link to a domain you control. The domain can present the user with a "Sign-in" button which redirect the user to the original magic link URL when clicked. - -- When using a custom SMTP service, some services might have link tracking enabled which may overwrite or disform the email confirmation links sent by Supabase Auth. To prevent this from happening, we recommend that you disable link tracking when using a custom SMTP service. - -## Subscribe to Supabase status page - -Stay informed about Supabase service status by subscribing to the [Status Page](https://status.supabase.com/). We recommend setting up Slack notifications through an RSS feed to ensure your team receives timely updates about service status changes. - -### Setting up Slack notifications - -1. Install the RSS app in Slack: - - Visit the [RSS app page](https://slack.com/marketplace/A0F81R7U7-rss) in the Slack marketplace - - Click `Add to Slack` if not already installed - - Otherwise you will get straight to next step, no need to reinstall the app - -2. Configure the Supabase status feed: - - Create a channel (e.g., `#supabase-status-alerts`) for status updates - - On the [RSS app page](https://slack.com/marketplace/A0F81R7U7-rss) go to _Add a Feed_ section and set Feed URL to `https://status.supabase.com/history.rss` - - Select your designated channel and click "Subscribe to this feed" - -Once configured, your team will receive automatic notifications in Slack whenever the Supabase Status Page is updated. - -For detailed setup instructions, see the [Add RSS feeds to Slack](https://slack.com/intl/en-nz/help/articles/218688467-Add-RSS-feeds-to-Slack). - -## Next steps - -This checklist is always growing so be sure to check back frequently, and also feel free to suggest additions and amendments by making a PR on [GitHub](https://github.com/supabase/supabase). +- When working with enterprise systems, email scanners may scan and make a `GET` request to the reset password link or sign-up link in your email. Since links in Supabase Auth are single-use, a user who opens an email post-scan to click on a link will receive an error. To get around this problem, consider altering the email template to replace the original magic link with a link to a domain you control. The domain can present the user with a "Sign-in" button, which redirects the user to the original magic link URL when clicked. +- When using a custom SMTP service, some services might have link tracking enabled which may overwrite or deform the email confirmation links sent by Supabase Auth. To prevent this from happening, we recommend that you disable link tracking when using a custom SMTP service. diff --git a/apps/docs/content/guides/getting-started/ai-skills.mdx b/apps/docs/content/guides/getting-started/ai-skills.mdx new file mode 100644 index 00000000000..1552d6ce8ca --- /dev/null +++ b/apps/docs/content/guides/getting-started/ai-skills.mdx @@ -0,0 +1,48 @@ +--- +title: Agent Skills +--- + +Agent Skills are folders of instructions, scripts, and resources that agents can discover and use to do things more accurately and efficiently. Agents are increasingly capable, but often don't have the context they need to do real work reliably. Skills solve this by giving agents access to procedural knowledge and company-, team-, and user-specific context they can load on demand. Agents with access to a set of skills can extend their capabilities based on the task they're working on. + +## Installing skills + +Install all Supabase skills using the skills CLI: + +```bash +npx skills add supabase/agent-skills +``` + +To install a specific skill from the repository: + +```bash +npx skills add supabase/agent-skills --skill SKILL_NAME +``` + +### Claude Code plugin + +You can also install the skills as Claude Code plugins: + +```bash +/plugin marketplace add supabase/agent-skills +/plugin install postgres-best-practices@supabase-agent-skills +``` + +Skills work with 18+ AI agents including Claude Code, GitHub Copilot, Cursor, Cline, and many others. + +## Available skills + + + +## Finding more skills + +Browse the [skills.sh directory](https://skills.sh) to discover skills from the community. You can also search for skills using the CLI: + +```bash +npx skills find QUERY +``` + +## Learn more + +- [Agent Skills Repository](https://github.com/supabase/agent-skills) +- [Agent Skills Documentation](https://agentskills.io/home) +- [Agent Skills Overview](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) diff --git a/apps/docs/content/guides/getting-started/tutorials/with-ionic-react.mdx b/apps/docs/content/guides/getting-started/tutorials/with-ionic-react.mdx index 56ad0ee998f..0f5b6ecaee8 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-ionic-react.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-ionic-react.mdx @@ -9,7 +9,7 @@ description: 'Learn how to use Supabase in your Ionic React App.' -If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/mhartington/supabase-ionic-react). +If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/ionic-react-user-management). @@ -17,11 +17,11 @@ If you get stuck while working through this guide, refer to the [full example on ## Building the app -Let's start building the React app from scratch. +Start building the React app from scratch. ### Initialize an Ionic React app -We can use the [Ionic CLI](https://ionicframework.com/docs/cli) to initialize +Use the [Ionic CLI](https://ionicframework.com/docs/cli) to initialize an app called `supabase-ionic-react`: ```bash @@ -30,352 +30,63 @@ ionic start supabase-ionic-react blank --type react cd supabase-ionic-react ``` -Then let's install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js) +Install the only additional dependency: [supabase-js](https://github.com/supabase/supabase-js) ```bash npm install @supabase/supabase-js ``` -And finally we want to save the environment variables in a `.env`. -All we need are the API URL and the key that you copied [earlier](#get-api-details). +Save the environment variables in a `.env`. You need the API URL and the key that you copied [earlier](#get-api-details). <$CodeTabs> ```bash name=.env VITE_SUPABASE_URL=YOUR_SUPABASE_URL -VITE_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY +VITE_SUPABASE_KEY=YOUR_SUPABASE_KEY ``` -Now that we have the API credentials in place, let's create a helper file to initialize the Supabase client. These variables will be exposed -on the browser, and that's completely fine since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database. +With the API credentials in place, create a helper file to initialize the Supabase client. These variables will be exposed +in the browser, which is safe because they use a restricted publishable key and the SQL quickstart enables [Row Level Security](/docs/guides/auth#row-level-security) on the `profiles` table. -<$CodeTabs> - -```js name=src/supabaseClient.ts -import { createClient } from '@supabase/supabase-js' - -const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || '' -const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || '' - -export const supabase = createClient(supabaseUrl, supabasePublishableKey) -``` - - +<$CodeSample +path="/user-management/ionic-react-user-management/src/supabaseClient.ts" +lines={[[1, -1]]} +meta="name=src/supabaseClient.ts" +/> ### Set up a login route -Let's set up a React component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords. +Set up a React component to manage logins and sign ups which uses Magic Links, so users can sign in with their email without using passwords. -<$CodeTabs> - -```jsx name=/src/pages/Login.tsx -import { useState } from 'react'; -import { - IonButton, - IonContent, - IonHeader, - IonInput, - IonItem, - IonLabel, - IonList, - IonPage, - IonTitle, - IonToolbar, - useIonToast, - useIonLoading, -} from '@ionic/react'; - -import {supabase} from '../supabaseClient' - -export function LoginPage() { - const [email, setEmail] = useState(''); - - const [showLoading, hideLoading] = useIonLoading(); - const [showToast ] = useIonToast(); - const handleLogin = async (e: React.FormEvent) => { - console.log() - e.preventDefault(); - await showLoading(); - try { - await supabase.auth.signInWithOtp({ - "email": email - }); - await showToast({ message: 'Check your email for the login link!' }); - } catch (e: any) { - await showToast({ message: e.error_description || e.message , duration: 5000}); - } finally { - await hideLoading(); - } - }; - return ( - - - - Login - - - - -
-

Supabase + Ionic React

-

Sign in via magic link with your email below

-
- -
- - Email - setEmail(e.detail.value ?? '')} - type="email" - > - -
- - Login - -
-
-
-
-
- ); -} -``` - - +<$CodeSample +path="/user-management/ionic-react-user-management/src/pages/Login.tsx" +lines={[[1, -1]]} +meta="name=src/pages/Login.tsx" +/> ### Account page -After a user is signed in we can allow them to edit their profile details and manage their account. +After a user signs in, they should be able to edit their profile details and manage their account. -Let's create a new component for that called `Account.tsx`. +Create a new component for that called `Account.tsx`. -<$CodeTabs> - -```jsx name=src/pages/Account.tsx -import { - IonButton, - IonContent, - IonHeader, - IonInput, - IonItem, - IonLabel, - IonPage, - IonTitle, - IonToolbar, - useIonLoading, - useIonToast, - useIonRouter -} from '@ionic/react'; -import { useEffect, useState } from 'react'; -import { supabase } from '../supabaseClient'; -import { Session } from '@supabase/supabase-js'; - -export function AccountPage() { - const [showLoading, hideLoading] = useIonLoading(); - const [showToast] = useIonToast(); - const [session, setSession] = useState(null) - const router = useIonRouter(); - const [profile, setProfile] = useState({ - username: '', - website: '', - avatar_url: '', - }); - - useEffect(() => { - const getSession = async () => { - setSession(await supabase.auth.getSession().then((res) => res.data.session)) - } - getSession() - supabase.auth.onAuthStateChange((_event, session) => { - setSession(session) - }) - }, []) - - useEffect(() => { - getProfile(); - }, [session]); - const getProfile = async () => { - console.log('get'); - await showLoading(); - try { - const user = await supabase.auth.getUser(); - const { data, error, status } = await supabase - .from('profiles') - .select(`username, website, avatar_url`) - .eq('id', user!.data.user?.id) - .single(); - - if (error && status !== 406) { - throw error; - } - - if (data) { - setProfile({ - username: data.username, - website: data.website, - avatar_url: data.avatar_url, - }); - } - } catch (error: any) { - showToast({ message: error.message, duration: 5000 }); - } finally { - await hideLoading(); - } - }; - const signOut = async () => { - await supabase.auth.signOut(); - router.push('/', 'forward', 'replace'); - } - const updateProfile = async (e?: any, avatar_url: string = '') => { - e?.preventDefault(); - - console.log('update '); - await showLoading(); - - try { - const user = await supabase.auth.getUser(); - - const updates = { - id: user!.data.user?.id, - ...profile, - avatar_url: avatar_url, - updated_at: new Date(), - }; - - const { error } = await supabase.from('profiles').upsert(updates); - - if (error) { - throw error; - } - } catch (error: any) { - showToast({ message: error.message, duration: 5000 }); - } finally { - await hideLoading(); - } - }; - return ( - - - - Account - - - - -
- - -

Email

-

{session?.user?.email}

-
-
- - - Name - - setProfile({ ...profile, username: e.detail.value ?? '' }) - } - > - - - - Website - - setProfile({ ...profile, website: e.detail.value ?? '' }) - } - > - -
- - Update Profile - -
-
- -
- - Log Out - -
-
-
- ); -} -``` - - +<$CodeSample +path="/user-management/ionic-react-user-management/src/pages/Account.tsx" +lines={[[1, -1]]} +meta="name=src/pages/Account.tsx" +/> ### Launch! -Now that we have all the components in place, let's update `App.tsx`: +Now that you have all the components in place, update `App.tsx`: -<$CodeTabs> - -```jsx name=src/App.tsx -import { Redirect, Route } from 'react-router-dom' -import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react' -import { IonReactRouter } from '@ionic/react-router' -import { supabase } from './supabaseClient' - -import '@ionic/react/css/ionic.bundle.css' - -/* Theme variables */ -import './theme/variables.css' -import { LoginPage } from './pages/Login' -import { AccountPage } from './pages/Account' -import { useEffect, useState } from 'react' -import { Session } from '@supabase/supabase-js' - -setupIonicReact() - -const App: React.FC = () => { - const [session, setSession] = useState(null) - useEffect(() => { - const getSession = async () => { - setSession(await supabase.auth.getSession().then((res) => res.data.session)) - } - getSession() - supabase.auth.onAuthStateChange((_event, session) => { - setSession(session) - }) - }, []) - return ( - - - - { - return session ? : - }} - /> - - - - - - - ) -} - -export default App -``` - - +<$CodeSample +path="/user-management/ionic-react-user-management/src/App.tsx" +lines={[[1, -1]]} +meta="name=src/App.tsx" +/> Once that's done, run this in a terminal window: @@ -383,7 +94,7 @@ Once that's done, run this in a terminal window: ionic serve ``` -And then open the browser to [localhost:3000](http://localhost:3000) and you should see the completed app. +Then open your browser to the URL printed by `ionic serve` (by default, [http://localhost:8100](http://localhost:8100)) and you should see the completed app. ![Supabase Ionic React](/docs/img/ionic-demos/ionic-react.png) @@ -403,142 +114,30 @@ npm install @ionic/pwa-elements @capacitor/camera Ionic PWA elements is a companion package that will polyfill certain browser APIs that provide no user interface with custom Ionic UI. -With those packages installed we can update our `index.tsx` to include an additional bootstrapping call for the Ionic PWA Elements. +With those packages installed update `index.tsx` to include an additional bootstrapping call for the Ionic PWA Elements. -<$CodeTabs> - -```ts name=src/index.tsx -import React from 'react' -import ReactDOM from 'react-dom' -import App from './App' -import * as serviceWorkerRegistration from './serviceWorkerRegistration' -import reportWebVitals from './reportWebVitals' - -import { defineCustomElements } from '@ionic/pwa-elements/loader' -defineCustomElements(window) - -ReactDOM.render( - - - , - document.getElementById('root') -) - -serviceWorkerRegistration.unregister() -reportWebVitals() -``` - - +<$CodeSample +path="/user-management/ionic-react-user-management/src/index.tsx" +lines={[[1, -1]]} +meta="name=src/index.tsx" +/> Then create an `AvatarComponent`. -<$CodeTabs> - -```jsx name=src/components/Avatar.tsx -import { IonIcon } from '@ionic/react'; -import { person } from 'ionicons/icons'; -import { Camera, CameraResultType } from '@capacitor/camera'; -import { useEffect, useState } from 'react'; -import { supabase } from '../supabaseClient'; -import './Avatar.css' -export function Avatar({ - url, - onUpload, -}: { - url: string; - onUpload: (e: any, file: string) => Promise; -}) { - const [avatarUrl, setAvatarUrl] = useState(); - - useEffect(() => { - if (url) { - downloadImage(url); - } - }, [url]); - const uploadAvatar = async () => { - try { - const photo = await Camera.getPhoto({ - resultType: CameraResultType.DataUrl, - }); - - const file = await fetch(photo.dataUrl!) - .then((res) => res.blob()) - .then( - (blob) => - new File([blob], 'my-file', { type: `image/${photo.format}` }) - ); - - const fileName = `${Math.random()}-${new Date().getTime()}.${ - photo.format - }`; - const { error: uploadError } = await supabase.storage - .from('avatars') - .upload(fileName, file); - if (uploadError) { - throw uploadError; - } - onUpload(null, fileName); - } catch (error) { - console.log(error); - } - }; - - const downloadImage = async (path: string) => { - try { - const { data, error } = await supabase.storage - .from('avatars') - .download(path); - if (error) { - throw error; - } - const url = URL.createObjectURL(data!); - setAvatarUrl(url); - } catch (error: any) { - console.log('Error downloading image: ', error.message); - } - }; - - return ( -
-
- {avatarUrl ? ( - - ) : ( - - )} -
- -
- ); -} -``` - - +<$CodeSample +path="/user-management/ionic-react-user-management/src/components/Avatar.tsx" +lines={[[1, -1]]} +meta="name=src/components/Avatar.tsx" +/> ### Add the new widget -And then we can add the widget to the Account page: +And then add the widget to the Account page: -<$CodeTabs> - -```jsx name=src/pages/Account.tsx -// Import the new component - -import { Avatar } from '../components/Avatar'; - -// ... -return ( - - - - Account - - - - - -``` - - +<$CodeSample +path="/user-management/ionic-react-user-management/src/pages/Account.tsx" +lines={[[16, 16], [101, 110]]} +meta="name=src/pages/Account.tsx" +/> At this stage you have a fully functional application! diff --git a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx index e34045aac65..50c6c67fd7f 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx @@ -44,7 +44,7 @@ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY ### App styling (optional) -An optional step is to update the CSS file `app/globals.css` to make the app look nice. +An optional step is to update the CSS file `app/globals.css` to make the app look better. You can find the full contents of this file [in the example repository](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/nextjs-user-management/app/globals.css). ### Supabase Server-Side Auth @@ -66,7 +66,7 @@ There are two different types of clients in Supabase: 1. **Client Component client** - To access Supabase from Client Components, which run in the browser. 2. **Server Component client** - To access Supabase from Server Components, Server Actions, and Route Handlers, which run only on the server. -It is recommended to create the following essential utilities files for creating clients, and organize them within `lib/supabase` at the root of the project. +We recommend creating the following essential utilities files for creating clients, and organize them within `lib/supabase` at the root of the project. Create a `client.ts` and a `server.ts` with the following functionalities for client-side Supabase and server-side Supabase, respectively. @@ -88,9 +88,11 @@ meta="name=lib/supabase/server.ts" ### Next.js proxy -Since Server Components can't write cookies, you need [Proxy](https://nextjs.org/docs/app/getting-started/proxy) to refresh expired Auth tokens and store them. This is accomplished by: +Since Server Components can't write cookies, you need [Proxy](https://nextjs.org/docs/app/getting-started/proxy) to refresh expired Auth tokens and store them. -- Refreshing the Auth token with the call to `supabase.auth.getUser`. +You can accomplish this by: + +- Refreshing the Auth token with the call to `supabase.auth.getClaims`. - Passing the refreshed Auth token to Server Components through `request.cookies.set`, so they don't attempt to refresh the same token themselves. - Passing the refreshed Auth token to the browser, so it replaces the old token. This is done with `response.cookies.set`. @@ -100,15 +102,15 @@ You could also add a matcher, so that the Proxy only runs on routes that access Be careful when protecting pages. The server gets the user session from the cookies, which anyone can spoof. -Always use `supabase.auth.getUser()` to protect pages and user data. +Most of the time, use `supabase.auth.getClaims()` to protect pages and user data. _Never_ trust `supabase.auth.getSession()` inside server code such as proxy. 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. +It's safe to trust `getClaims()` because it validates the token in storage, either directly or by calling `getUser()` solely to check the result. It doesn't use the response from `getUser()` itself, only whether the validation succeeded.
-Create a `proxy.ts` file at the project root and another one within the `lib/supabase` folder. The `lib/supabase` file contains the logic for updating the session. This is used by the `proxy.ts` file, which is a Next.js convention. +Create a `proxy.ts` file at the project root and another one within the `lib/supabase` folder. The `lib/supabase` file contains the logic for updating the session. The `proxy.ts` file uses this, which is a Next.js convention. <$CodeTabs> @@ -184,9 +186,9 @@ Before proceeding, change the email template to support support a server-side au - Select the **Confirm signup** template. - Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`. - + -**Did you know?** You can also customize other emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates). +You can also customize other emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates). diff --git a/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx b/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx index f83a941e777..08d723c8ce0 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-sveltekit.mdx @@ -162,16 +162,16 @@ meta="name=src/routes/+page.server.ts" Change the email template to support a server-side authentication flow. -Before we proceed, let's change the email template to support sending a token hash: +Before proceeding, change the email template to support sending a token hash: - Go to the [**Auth** > **Emails**](/dashboard/project/_/auth/templates) page in the project dashboard. - Select the **Confirm signup** template. - Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`. - Repeat the previous step for **Magic link** template. - + -**Did you know?** You can also customize emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates). +You can also customize emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates). diff --git a/apps/docs/content/guides/local-development/cli/getting-started.mdx b/apps/docs/content/guides/local-development/cli/getting-started.mdx index 1597264bcd9..4d9ab20346c 100644 --- a/apps/docs/content/guides/local-development/cli/getting-started.mdx +++ b/apps/docs/content/guides/local-development/cli/getting-started.mdx @@ -72,12 +72,28 @@ The Supabase CLI requires **Node.js 20 or later** when run via `npx` or `npm`. O + + +Installing the Supabase CLI globally using `npm install -g supabase` is **not supported**. + +For global usage, install the CLI via Homebrew, Scoop, or the standalone binary. + +Alternatively, you can run the CLI using `npx supabase` or install it locally as a dev dependency. + + + You can also install the CLI as dev dependency via [npm](https://www.npmjs.com/package/supabase): ```sh npm install supabase --save-dev ``` + + +Global installation using `npm install -g supabase` is not supported. For global CLI usage, install via [Homebrew](/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=macos), [Scoop](/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=windows), or the [standalone binary](/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=linux). + + + diff --git a/apps/docs/content/guides/platform/billing-faq.mdx b/apps/docs/content/guides/platform/billing-faq.mdx index 24b50c0ceba..6ad349f2e25 100644 --- a/apps/docs/content/guides/platform/billing-faq.mdx +++ b/apps/docs/content/guides/platform/billing-faq.mdx @@ -140,7 +140,7 @@ The Fair Use Policy is generally applied to all projects of the restricted organ To remove restrictions, you will need to address the issue that caused the restriction. This could be reducing your usage, paying overdue invoices, updating your payment method, or any other issue that caused the restriction. Once the issue is resolved, the restriction will be lifted. -Restrictions due to usage limits are lifted with the next billing cycle as your quota refills at the beginning of each cycle. You can see when your current billing cycle ends on the [billing page](/dashboard/org/_/billing) under "Upcoming Invoice". You can also lift restrictions immediately by [upgrading](/dashboard/org/_/billing?panel=subscriptionPlan) to Pro (if on Free Plan) or by [disabling spend cap](/dashboard/org/_/billing?panel=costControl) (if on Pro Plan with spend cap enabled). +Restrictions due to usage limits are lifted once your quota refills at the start of the next billing cycle. Note that there may be a short delay after your billing period resets before restrictions are fully lifted. You can see when your current billing cycle ends on the [billing page](/dashboard/org/_/billing) under "Upcoming Invoice". You can also lift restrictions immediately by [upgrading](/dashboard/org/_/billing?panel=subscriptionPlan) to Pro (if on Free Plan) or by [disabling spend cap](/dashboard/org/_/billing?panel=costControl) (if on Pro Plan with spend cap enabled). ## Reports and invoices diff --git a/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx b/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx index a012aaa51aa..8bcf027f606 100644 --- a/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx +++ b/apps/docs/content/guides/platform/migrating-within-supabase/backup-restore.mdx @@ -4,6 +4,8 @@ subtitle: 'Learn how to backup and restore projects using the Supabase CLI' breadcrumb: 'Migrations' --- +# Migrating the database + ## Backup database using the CLI @@ -21,11 +23,11 @@ breadcrumb: 'Migrations' - On your project dashboard, click [Connect](/dashboard/project/_?showConnect=true). + On your project dashboard, click [Connect](/dashboard/project/_?showConnect=true&method=session). - Use the [Session pooler](/dashboard/project/_?showConnect=true&method=session) connection string by default. If your ISP supports IPv6 or you have the IPv4 add-on enabled, use the direct connection string. + Use the [Session pooler](/dashboard/project/_?showConnect=true&method=session) connection string by default. If your network supports [IPv6](https://test-ipv6.com/) or you have the [IPv4 add-on](/docs/guides/platform/ipv4-address) enabled, use the direct connection string. @@ -65,7 +67,7 @@ breadcrumb: 'Migrations' ``` ```bash - supabase db dump --db-url [CONNECTION_STRING] -f data.sql --use-copy --data-only + supabase db dump --db-url [CONNECTION_STRING] -f data.sql --use-copy --data-only -x "storage.buckets_vectors" -x "storage.vector_indexes" ``` @@ -104,18 +106,17 @@ breadcrumb: 'Migrations' - If Webhooks were used in the old database, enable [Database Webhooks](/dashboard/project/_/database/hooks). - If any non-default extensions were used in the old database, enable the [Extensions](/dashboard/project/_/database/extensions). - - If Replication for Realtime was used in the old database, enable [Publication](/dashboard/project/_/database/publications) on the tables necessary - Go to the [project page](/dashboard/project/_/) and click the "**Connect**" button at the top of the page for the connection string. + Go to [the **Connect** panel](/dashboard/project/_?showConnect=true&method=session) for the connection string. - Use the Session pooler connection string by default. If your ISP supports IPv6, use the direct connection string. + Use the Session pooler connection string by default. If your ISP [supports IPv6](https://test-ipv6.com/), use the direct connection string. @@ -135,15 +136,14 @@ breadcrumb: 'Migrations' - Reset the password in the [project connect page](/dashboard/project/_?showConnect=true). - Replace ```[YOUR-PASSWORD]``` in the connection string with the database password. + Replace ```[YOUR-PASSWORD]``` in the connection string with the database password. If you do not remember your password, you can reset it on [the **Database > Settings**](/dashboard/project/_/database/settings) page of the Dashboard. - + + + + If replication for Supabase Realtime was used in the old database, enable publication on [the **Database > Publications**](/dashboard/project/_/database/publications) section of the Dashboard on the tables necessary. + + + + -## Important project restoration notes +## Special considerations -### Troubleshooting notes - -- Setting the `session_replication_role` to `replica` disables all triggers so that columns are not double encrypted. -- If you have created any [custom roles](/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 +#### 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. @@ -213,7 +214,7 @@ psql \ --dbname "$NEW_DB_URL" ``` -### Schema changes to `auth` and `storage` +#### Schema changes to `auth` and `storage` If you have modified the `auth` and `storage` schemas in your old project, such as adding triggers or Row Level Security(RLS) policies, you have to restore them separately. The Supabase CLI can help you diff the changes to these schemas using the following commands. @@ -222,61 +223,591 @@ supabase link --project-ref "$OLD_PROJECT_REF" supabase db diff --linked --schema auth,storage > changes.sql ``` -### Migrate storage objects +## Troubleshooting notes -The new project has the old project's Storage buckets, but the Storage objects need to be migrated manually. Use this script to move storage objects from one project to another. +#### Disabling triggers during restore: -```js -// npm install @supabase/supabase-js@2 -const { createClient } = require('@supabase/supabase-js') +Setting `session_replication_role` to `replica` disables triggers during the migration, preventing columns from being double encrypted. -const OLD_PROJECT_URL = 'https://xxx.supabase.co' -const OLD_PROJECT_SERVICE_KEY = 'old-project-service-key-xxx' +#### Custom roles require passwords -const NEW_PROJECT_URL = 'https://yyy.supabase.co' -const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy' +If you created any [custom roles](/dashboard/project/_/database/roles) with the `LOGIN` attribute, you must manually set their passwords in the new project. This can be done with the SQL command: -;(async () => { - const oldSupabaseRestClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY, { - db: { - schema: 'storage', - }, - }) - const oldSupabaseClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY) - const newSupabaseClient = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY) - - // make sure you update max_rows in postgrest settings if you have a lot of objects - // or paginate here - const { data: oldObjects, error } = await oldSupabaseRestClient.from('objects').select() - if (error) { - console.log('error getting objects from old bucket') - throw error - } - - for (const objectData of oldObjects) { - console.log(`moving ${objectData.id}`) - try { - const { data, error: downloadObjectError } = await oldSupabaseClient.storage - .from(objectData.bucket_id) - .download(objectData.name) - if (downloadObjectError) { - throw downloadObjectError - } - - const { _, error: uploadObjectError } = await newSupabaseClient.storage - .from(objectData.bucket_id) - .upload(objectData.name, data, { - upsert: true, - contentType: objectData.metadata.mimetype, - cacheControl: objectData.metadata.cacheControl, - }) - if (uploadObjectError) { - throw uploadObjectError - } - } catch (err) { - console.log('error moving ', objectData) - console.log(err) - } - } -})() +```sql +alter user "YOUR_USER" with password 'SOME_NEW_PASSWORD'; ``` + +#### `supabase_admin` permission errors + +If you encounter permission errors related to `supabase_admin` during restore: + +- Open `schema.sql` +- Comment out any lines containing: + +```sql + ALTER ... OWNER TO "supabase_admin" +``` + +#### `cli_login_postgres` role grant error + +If you encounter the error: + +```sh +ERROR: permission denied to grant role "postgres" +DETAIL: Only roles with the ADMIN option on role "postgres" may grant this role. +``` + +- Open `roles.sql` +- Comment out the line: + +```sql +GRANT "postgres" TO "cli_login_postgres" WITH INHERIT FALSE GRANTED BY "supabase_admin"; +``` + +#### `cli_login_postgres` role issues after cloning + +The `cli_login_role` must be created by the `supabase_admin` role. If the migration process cloned over the role before the CLI could generate its own version, it may encounter the error: + +```sh +"message":"Failed to create login role: +ERROR: 0LP01: role "postgres" is a member of role "cli_login_postgres" +``` + +To resolve the issue, drop the custom `cli_login_postgres` role. Then the CLI can recreate it with the right privileges: + +```sql +DROP ROLE IF EXISTS cli_login_postgres; +``` + +# Migrating edge functions + +## Steps (using the Supabase CLI): + + + + + With the Supabase CLI [Supabase CLI](/docs/guides/local-development/cli/getting-started), run: + ```bash + supabase login + ``` + + + + + ```bash + supabase functions list --project-ref your_project_ref + ``` + + + + + You can download an individual function with the following command: + ```bash + supabase functions download YOUR_FUNCTION_NAME --project-ref your_project_ref + ``` + + + The command will not download [import maps](/docs/guides/functions/dependencies#using-import-maps-legacy) nor [deno.json](/docs/guides/functions/dependencies#using-denojson-recommended) files. If your edge functions rely on them for dependency management, you will have to add them back manually. + + + + + + + + + ```bash + supabase functions deploy --project-ref your_target_project_ref + ``` + This deploys all functions within the `supabase/functions` to the target project. You can confirm by checking your Edge Functions on [the project dashboard](/dashboard/project/_/functions) + + + + + + +## Steps (using the Supabase Dashboard): + + + +Dependencies defined through [import maps](/docs/guides/functions/dependencies#using-import-maps-legacy) and [deno.json](/docs/guides/functions/dependencies#using-denojson-recommended) files will need to be rewritten to rely on their [direct import paths](/docs/guides/functions/dependencies#importing-dependencies) when using this approach. + + + + + + + In the source project, navigate to **Edge Functions** from the side menu + + + + + Using the `Download` button, download your desired function as zip: ![Download Edge + Function](/docs/img/troubleshooting/download-edge-function-via-dashboard.gif) + + + + + In the target project, navigate to **Edge Functions** from the side menu + + + + + Click on the `Deploy a new function` button, select **Via Editor** operation + + + + + Drag and drop your downloaded function (the zip function from step 2) into the editor + + + + + Add your function name and click on the `Deploy function` button to deploy the function: + ![Upload Edge Function](/docs/img/troubleshooting/upload-edge-function-via-dashboard.gif) + + + + +# Migrating storage objects + + + + + Using your preferred JavaScript package manager, create a new project with the `supabase` client package + + + + + + + ```bash + npm init -y + npm install @supabase/supabase-js + ``` + + + ```bash + pnpm init -y + pnpm install @supabase/supabase-js + ``` + + + ```bash + yarn init -y + yarn add @supabase/supabase-js + ``` + + + ```bash + bun init -y + bun install @supabase/supabase-js + ``` + + + + + + + + + Add the example script to it. + + + ```js name=index.js + // npm install @supabase/supabase-js@2 + const { createClient } = require('@supabase/supabase-js') + + const OLD_PROJECT_URL = 'https://xxx.supabase.co' + const OLD_PROJECT_SERVICE_KEY = 'old-project-service-key-xxx' + + const NEW_PROJECT_URL = 'https://yyy.supabase.co' + const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy' + + const oldSupabase = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY) + const newSupabase = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY) + + function createLoadingAnimation(message) { + const readline = require('readline') + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + let i = 0 + let timer + let stopped = false + + const animate = () => { + if (stopped) return + process.stdout.write(`\r${frames[i]} ${message}`) + i = (i + 1) % frames.length + timer = setTimeout(animate, 80) + } + + animate() + + return { + stop: (finalMessage = '') => { + stopped = true + clearTimeout(timer) + readline.clearLine(process.stdout, 0) + readline.cursorTo(process.stdout, 0) + process.stdout.write(`✓ ${finalMessage || message}\n`) + }, + } + } + + /** + * Lists all files in a bucket, handling nested folders recursively. + */ + async function listAllFiles(bucket, path = '') { + const loader = createLoadingAnimation(`Listing files in '${bucket}${path ? '/' + path : ''}'...`) + + try { + const { data, error } = await oldSupabase.storage.from(bucket).list(path, { limit: 1000 }) + if (error) { + loader.stop(`Error listing files in '${bucket}${path ? '/' + path : ''}'`) + throw new Error(`❌ Error listing files in bucket '${bucket}': ${error.message}`) + } + + if (!data || data.length === 0) { + loader.stop(`No files found in '${bucket}${path ? '/' + path : ''}'`) + return [] + } + + let files = [] + for (const item of data) { + if (!item.metadata) { + loader.stop(`Found folder '${item.name}' in '${bucket}${path ? '/' + path : ''}'`) + const subFiles = await listAllFiles(bucket, `${path}${item.name}/`) + files = files.concat(subFiles) + } else { + files.push({ fullPath: `${path}${item.name}`, metadata: item.metadata }) + } + } + + loader.stop(`Found ${files.length} files in '${bucket}${path ? '/' + path : ''}'`) + return files + } catch (error) { + loader.stop() + throw error + } + } + + /** + * Creates a bucket in the new Supabase project if it doesn't exist. + */ + async function ensureBucketExists(bucketName, options = {}) { + const { data: existingBucket, error: getBucketError } = + await newSupabase.storage.getBucket(bucketName) + + if (getBucketError && !getBucketError.message.includes('not found')) { + throw new Error(`❌ Error checking if bucket '${bucketName}' exists: ${getBucketError.message}`) + } + + if (!existingBucket) { + console.log(`🪣 Creating bucket '${bucketName}' in new project...`) + const { error } = await newSupabase.storage.createBucket(bucketName, options) + if (error) throw new Error(`❌ Failed to create bucket '${bucketName}': ${error.message}`) + console.log(`✅ Created bucket '${bucketName}'`) + } else { + console.log(`ℹ️ Bucket '${bucketName}' already exists in new project`) + } + } + + /** + * Migrates a single file from the old project to the new one. + */ + async function migrateFile(sourceBucketName, targetBucketName, file) { + const loader = createLoadingAnimation( + `Migrating ${file.fullPath} in bucket '${sourceBucketName}' to '${targetBucketName}'...` + ) + + try { + const { data, error: downloadError } = await oldSupabase.storage + .from(sourceBucketName) + .download(file.fullPath) + if (downloadError) { + loader.stop(`Failed to migrate ${file.fullPath}: Download error`) + throw new Error(`Download failed: ${downloadError.message}`) + } + + // Preserve all available metadata from the original file + const uploadOptions = { + upsert: true, + contentType: file.metadata?.mimetype, + cacheControl: file.metadata?.cacheControl, + } + + const { error: uploadError } = await newSupabase.storage + .from(targetBucketName) + .upload(file.fullPath, data, uploadOptions) + if (uploadError) { + loader.stop(`Failed to migrate ${file.fullPath}: Upload error`) + throw new Error(`Upload failed: ${uploadError.message}`) + } + + loader.stop( + `Migrated ${file.fullPath} in bucket '${sourceBucketName}' to '${targetBucketName}'` + ) + return { success: true, path: file.fullPath } + } catch (err) { + console.error( + `❌ Error migrating ${file.fullPath} in bucket '${targetBucketName}':`, + err.message + ) + return { success: false, path: file.fullPath, error: err.message } + } + } + + function chunkArray(array, size) { + const chunks = [] + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)) + } + return chunks + } + + /** + * Migrates all buckets and files from the old Supabase project to the new one. + * Processes files in parallel within batches for efficiency. + */ + async function migrateBuckets() { + console.log('🔄 Starting Supabase Storage migration...') + console.log(`📦 Source project: ${OLD_PROJECT_URL}`) + console.log(`📦 Target project: ${NEW_PROJECT_URL}`) + + const readline = require('readline').createInterface({ + input: process.stdin, + output: process.stdout, + }) + + console.log( + '\n⚠️ WARNING: This migration may overwrite files in the target project if they have the same paths.' + ) + console.log('⚠️ It is recommended to back up your target project before proceeding.') + + const answer = await new Promise((resolve) => { + readline.question('Do you want to proceed with the migration? (yes/no): ', resolve) + }) + + readline.close() + + if (answer.toLowerCase() !== 'yes') { + console.log('Migration canceled by user.') + return { canceled: true } + } + + console.log('\n📦 Fetching all buckets from old project...') + + const { data: oldBuckets, error: bucketListError } = await oldSupabase.storage.listBuckets() + + if (bucketListError) throw new Error(`❌ Error fetching buckets: ${bucketListError.message}`) + console.log(`✅ Found ${oldBuckets.length} buckets to migrate.`) + + const { data: existingBuckets, error: existingBucketsError } = + await newSupabase.storage.listBuckets() + if (existingBucketsError) + throw new Error(`❌ Error fetching existing buckets: ${existingBucketsError.message}`) + + const existingBucketNames = existingBuckets.map((b) => b.name) + const conflictingBuckets = oldBuckets.filter((b) => existingBucketNames.includes(b.name)) + + let conflictStrategy = 2 + + if (conflictingBuckets.length > 0) { + console.log('\n⚠️ The following buckets already exist in the target project:') + conflictingBuckets.forEach((b) => console.log(` - ${b.name}`)) + + const conflictAnswer = await new Promise((resolve) => { + const rl = require('readline').createInterface({ + input: process.stdin, + output: process.stdout, + }) + rl.question( + '\nHow do you want to handle existing buckets?\n' + + '1. Skip existing buckets\n' + + '2. Merge files (may overwrite existing files)\n' + + '3. Rename buckets in target (add suffix "_migrated")\n' + + '4. Cancel migration\n' + + 'Enter your choice (1-4): ', + (answer) => { + rl.close() + resolve(answer) + } + ) + }) + + if (conflictAnswer === '4') { + console.log('Migration canceled by user.') + return { canceled: true } + } + + conflictStrategy = parseInt(conflictAnswer) + if (isNaN(conflictStrategy) || conflictStrategy < 1 || conflictStrategy > 3) { + console.log('Invalid choice. Migration canceled.') + return { canceled: true } + } + } + + const migrationStats = { + totalBuckets: oldBuckets.length, + processedBuckets: 0, + skippedBuckets: 0, + totalFiles: 0, + successfulFiles: 0, + failedFiles: 0, + failedFilesList: [], + } + + for (const bucket of oldBuckets) { + const bucketName = bucket.name + console.log(`\n📁 Processing bucket: ${bucketName}`) + + let targetBucketName = bucketName + + if (existingBucketNames.includes(bucketName)) { + if (conflictStrategy === 1) { + console.log(`⏩ Skipping bucket '${bucketName}' as it already exists in target project`) + migrationStats.skippedBuckets++ + continue + } else if (conflictStrategy === 3) { + targetBucketName = `${bucketName}_migrated` + console.log(`🔄 Renaming bucket to '${targetBucketName}' in target project`) + } else { + console.log(`🔄 Merging files into existing bucket '${bucketName}' in target project`) + } + } + + // Preserve bucket configuration when creating in the new project + if (targetBucketName !== bucketName || !existingBucketNames.includes(bucketName)) { + await ensureBucketExists(targetBucketName, { + public: bucket.public, + fileSizeLimit: bucket.file_size_limit, + allowedMimeTypes: bucket.allowed_mime_types, + }) + } + + const files = await listAllFiles(bucketName) + console.log(`✅ Found ${files.length} files in bucket '${bucketName}'.`) + migrationStats.totalFiles += files.length + + const batches = chunkArray(files, 10) + + for (let i = 0; i < batches.length; i++) { + console.log(`\n🚀 Processing batch ${i + 1}/${batches.length} (${batches[i].length} files)`) + + const results = await Promise.all( + batches[i].map((file) => migrateFile(bucketName, targetBucketName, file)) + ) + + const batchSuccesses = results.filter((r) => r.success).length + const batchFailures = results.filter((r) => !r.success) + + migrationStats.successfulFiles += batchSuccesses + migrationStats.failedFiles += batchFailures.length + migrationStats.failedFilesList.push(...batchFailures.map((f) => f.path)) + + console.log( + `✅ Completed batch ${i + 1}/${batches.length}: ${batchSuccesses} succeeded, ${batchFailures.length} failed` + ) + } + + migrationStats.processedBuckets++ + console.log(`✅ Completed bucket '${bucketName}' migration`) + } + + console.log('\n📊 Migration Summary:') + console.log( + `Buckets: ${migrationStats.processedBuckets}/${migrationStats.totalBuckets} processed, ${migrationStats.skippedBuckets} skipped` + ) + console.log( + `Files: ${migrationStats.successfulFiles} succeeded, ${migrationStats.failedFiles} failed (${migrationStats.totalFiles} total)` + ) + + if (migrationStats.failedFiles > 0) { + console.log('\n⚠️ Failed files:') + migrationStats.failedFilesList.forEach((path) => console.log(` - ${path}`)) + return migrationStats + } + + return migrationStats + } + + migrateBuckets() + .then((stats) => { + if (stats.failedFiles > 0) { + console.log(`\n⚠️ Migration completed with ${stats.failedFiles} failed files.`) + process.exit(1) + } else { + console.log('\n🎉 Migration completed successfully!') + process.exit(0) + } + }) + .catch((err) => { + console.error('❌ Fatal error during migration:', err.message) + process.exit(1) + }) + ``` + + + + + + + Get the [secret keys](/dashboard/project/_/settings/api-keys) or [service_role keys](/dashboard/project/_/settings/api-keys/legacy) for both your new and old projects, then substitute them into the script. + From the [Data API settings](/dashboard/project/_/integrations/data_api/overview), copy your project URL and add it to the script as well. + + + ```js name='index.js' + //rest of code + ... + + // add relevant details for old project + const OLD_PROJECT_URL = 'https://xxx.supabase.co' + const OLD_PROJECT_SERVICE_KEY = 'old-project-service-key-xxx' + + // add relevant details for new project + const NEW_PROJECT_URL = 'https://yyy.supabase.co' + const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy' + + ... + //rest of code + ``` + + + + + + + + + + + ```bash + node index.js + ``` + + + ```bash + bun index.js + ``` + + + + + + + +## Resources + +- [Connecting with PSQL](/docs/guides/database/psql) diff --git a/apps/docs/content/guides/realtime/reports.mdx b/apps/docs/content/guides/realtime/reports.mdx index 914fbde53b7..dc6f57d317e 100644 --- a/apps/docs/content/guides/realtime/reports.mdx +++ b/apps/docs/content/guides/realtime/reports.mdx @@ -60,7 +60,7 @@ height={625} | Action | Description | More information | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | Configure connection limit | Adjust the "Max concurrent connections" setting to increase or decrease the connection limit for your project | [Realtime Settings Guide](/docs/guides/realtime/settings) | -| Upgrade plan | Increase available client connections. Connection limits vary by plan: Free (200), Pro (500), Pro no spend cap (10,000), Team (10,000), Enterprise (10,000+) | [Pricing and Plans](/docs/guides/platform/pricing) | +| Upgrade plan | Increase available client connections. Connection limits vary by plan: Free (200), Pro (500), Pro no spend cap (10,000), Team (10,000), Enterprise (10,000+) | [Pricing and Plans](/pricing) | | Review quotas | Understand connection limits and other Realtime quotas for your plan | [Realtime Quotas Reference](/docs/guides/realtime/quotas) | | Understand connection quota | Learn how the concurrent connections quota works and how to configure it for your plan | [Concurrent Peak Connections Quota Troubleshooting](/docs/troubleshooting/realtime-concurrent-peak-connections-quota-jdDqcp) | | Fix silent disconnections | Fix connection issues in background applications using heartbeat callbacks and Web Workers | [Handling Silent Disconnections in Background Apps](/docs/troubleshooting/realtime-handling-silent-disconnections-in-backgrounded-applications-592794) | diff --git a/apps/docs/content/guides/self-hosting/custom-email-templates.mdx b/apps/docs/content/guides/self-hosting/custom-email-templates.mdx new file mode 100644 index 00000000000..9135f12b86e --- /dev/null +++ b/apps/docs/content/guides/self-hosting/custom-email-templates.mdx @@ -0,0 +1,175 @@ +--- +title: 'Custom Email Templates' +description: 'Configure custom email templates with self-hosted Supabase instance' +subtitle: 'Configure custom email templates with self-hosted Supabase instance' +--- + +When running a self-hosted Supabase instance, you can fully customize emails sent by Supabase Auth. + +## Overview + +Supabase Auth does not read email templates from mounted Docker volumes. Instead, it expects each template to be available at a URL that returns a valid HTML template. + +This URL: + +- Does not need to be public +- Must be reachable from `auth` service +- Must return a valid Golang HTML template + +To provide templates to Supabase Auth, you need a service that serves static HTML files. This can be any server of your choice. You can even use `kong` service which is included with the default Supabase docker configuration. The only requirement is that the `auth` service must be able to reach it via a HTTP GET request. + +This guide uses [Caddy](https://github.com/caddyserver/caddy) for serving templates. + + + +If Supabase Auth cannot fetch the template or if the fetched template is invalid, it falls back to the default template. + + + +## Authentication email templates + +Authentication email templates can be configured using the following environment variables: + +- `GOTRUE_MAILER_TEMPLATES_`: Provide a custom template URL. Falls back to the default template if not set. +- `GOTRUE_MAILER_SUBJECTS_`: Customize the email subject. Falls back to the default subject if not set. + +| Auth flow | Sent | +| ------------------ | -------------------------------------------------------------------- | +| `CONFIRMATION` | When a user signs up and needs to verify their email address | +| `RECOVERY` | When a user requests a password reset | +| `MAGIC_LINK` | When a user requests a magic link for password-less authentication | +| `INVITE` | When a user is invited to join your application via email invitation | +| `EMAIL_CHANGE` | When a user requests to change their email address | +| `REAUTHENTICATION` | When a user needs to re-authenticate for sensitive operations | + +For example: + +```sh +GOTRUE_MAILER_TEMPLATES_MAGIC_LINK='' +GOTRUE_MAILER_SUBJECTS_MAGIC_LINK='' +``` + +### Example + +Below is an example configuration for setting up a custom invite template. + +### Step 1: Create a templates directory + +Create a `templates` directory inside the existing `volumes` directory and add your email templates to it. + +Your directory structure should look like this: + +``` +volumes/ + templates/ + invite.html +``` + +### Step 2: Update `docker-compose.yml` + +Update the `auth` service to depend on `templates-server`, and pass the email template environment variables. Then add a `templates-server` service to serve the templates from `./volumes/templates`. + +```yml +services: + auth: + depends_on: + db: + condition: service_healthy + templates-server: # 👈 new dependency + condition: service_started + environment: + GOTRUE_MAILER_TEMPLATES_INVITE: 'http://templates-server/invite.html' + GOTRUE_MAILER_SUBJECTS_INVITE: 'You have been invited' + + templates-server: + image: caddy:2-alpine + command: ['caddy', 'file-server', '-r', '/templates', '--listen', ':80'] + volumes: + - ./volumes/templates:/templates +``` + +#### What this configuration does + +- Adds a `templates-server`service that runs alongside the Supabase services in the same docker network. +- Serves your custom email template files from the `./volumes/templates` directory. +- Keeps the templates-server private to the Docker network (no published ports), so it is not accessible from outside. +- Allows the `auth` service to fetch templates via `http://templates-server/