diff --git a/.claude/skills/telemetry-standards/SKILL.md b/.claude/skills/telemetry-standards/SKILL.md new file mode 100644 index 00000000000..0ed2f7f85bc --- /dev/null +++ b/.claude/skills/telemetry-standards/SKILL.md @@ -0,0 +1,170 @@ +--- +name: telemetry-standards +description: PostHog event tracking standards for Supabase Studio. Use when reviewing + PRs for telemetry compliance or implementing new event tracking. Covers event naming, + property conventions, approved patterns, and implementation guide. +--- + +# Telemetry Standards for Supabase Studio + +Standards for PostHog event tracking in `apps/studio/`. Apply these when +reviewing PRs that touch tracking or when implementing new tracking. + +## Event Naming + +**Format:** `[object]_[verb]` in snake_case + +**Approved verbs only:** +opened, clicked, submitted, created, removed, updated, retrieved, intended, evaluated, added, +enabled, disabled, copied, exposed, failed, converted + +**Flag these:** +- Unapproved verbs (saved, viewed, seen, pressed, etc.) +- Wrong order: `click_product_card` → should be `product_card_clicked` +- Wrong casing: `productCardClicked` → should be `product_card_clicked` + +**Good examples:** +- `product_card_clicked` +- `backup_button_clicked` +- `sql_query_submitted` + +**Common mistakes with corrections:** +- `database_saved` → `save_button_clicked` or `database_updated` (unapproved verb) +- `click_backup_button` → `backup_button_clicked` (wrong order) +- `dashboardViewed` → don't track passive views on page load +- `component_rendered` → don't track — no user interaction + +## Property Standards + +**Casing:** camelCase preferred for new events. The codebase has existing snake_case properties (e.g., `schema_name`, `table_name`) — when adding properties to an existing event, match its established convention. + +**Names must be self-explanatory:** +- `{ productType: 'database', planTier: 'pro' }` +- `{ assistantType: 'sql', suggestionType: 'optimization' }` + +**Flag these:** +- Generic names: `label`, `value`, `name`, `data` +- PascalCase properties +- Inconsistent names across similar events (e.g., `assistantType` in one event, `aiType` in a related event) +- Mixing camelCase and snake_case within the same event + +## What NOT to Track + +- Passive views/renders on page load (`dashboard_viewed`, `sidebar_appeared`, `page_loaded`) +- Component appearances without user interaction +- Generic "viewed" or "seen" events — already captured by pageview events + +**DO track:** user clicks, form submissions, explicit opens/closes, user-initiated actions. + +**Exception:** `_exposed` events for A/B experiment exposure tracking are valid even though they fire on render. + +**Never track PII** (emails, names, IPs, etc.) in event properties. + +## Required Pattern + +Import `useTrack` from `lib/telemetry/track` (within `apps/studio/`). Never use `useSendEventMutation` (deprecated). + +```typescript +import { useTrack } from 'lib/telemetry/track' + +const MyComponent = () => { + const track = useTrack() + + const handleClick = () => { + track('product_card_clicked', { + productType: 'database', + planTier: 'pro', + source: 'dashboard', + }) + } + + return +} +``` + +## Event Definitions + +All events must be defined as TypeScript interfaces in `packages/common/telemetry-constants.ts`: + +```typescript +/** + * [Event description] + * + * @group Events + * @source [what triggers this event] + */ +export interface MyFeatureClickedEvent { + action: 'my_feature_clicked' + properties: { + /** Description of property */ + featureType: string + } + groups: TelemetryGroups +} +``` + +Add the new interface to the `TelemetryEvent` union type so `useTrack` picks it up. +`@group Events` and `@source` must be accurate. + +## Review Rules + +When reviewing a PR, flag these as **required changes:** + +1. **Naming violations** — event not following `[object]_[verb]` snake_case, or using an unapproved verb +2. **Property violations** — not camelCase, generic names, or inconsistent with similar events +3. **Deprecated hook** — any usage of `useSendEventMutation` instead of `useTrack` +4. **Unnecessary view tracking** — events that fire on page load without user interaction +5. **Inaccurate docs** — `@page`/`@source` descriptions that don't match the actual implementation + +When a PR adds user-facing interactions (buttons, forms, toggles, modals) **without** tracking, suggest: +- "This adds a user interaction that may benefit from tracking." +- Propose the event name following `[object]_[verb]` convention +- Propose the `useTrack()` call with suggested properties + +When checking property consistency, search `packages/common/telemetry-constants.ts` for similar events and verify property names match. + +## Well-Formed Event Examples + +From the actual codebase: + +```typescript +// User copies a connection string +track('connection_string_copied', { + connectionType: 'psql', + connectionMethod: 'transaction_pooler', + connectionTab: 'Connection String', +}) + +// User enables a feature preview +track('feature_preview_enabled', { + feature: 'realtime_inspector', +}) + +// User clicks a banner CTA +track('index_advisor_banner_dismiss_button_clicked') + +// Experiment exposure (fires on render — valid exception) +track('home_new_experiment_exposed', { + variant: 'treatment', +}) +``` + +## Implementing New Tracking + +To add tracking for a user action: + +1. **Name the event** — `[object]_[verb]` using approved verbs only +2. **Choose properties** — camelCase preferred for new events; check `packages/common/telemetry-constants.ts` for similar events and match their property names and casing +3. **Add interface to telemetry-constants.ts** — with `@group Events` and `@source` JSDoc, add to the `TelemetryEvent` union type +4. **Add to component** — `import { useTrack } from 'lib/telemetry/track'`, call `track('event_name', { properties })` + +### Verification checklist + +- [ ] Event name follows `[object]_[verb]` with approved verb +- [ ] Event name is snake_case +- [ ] Properties are camelCase and self-explanatory +- [ ] Event defined in telemetry-constants.ts with accurate `@page`/`@source` +- [ ] Using `useTrack` hook (not `useSendEventMutation`) +- [ ] Not tracking passive views/appearances +- [ ] No PII in event properties (emails, names, IPs, etc.) +- [ ] Property names consistent with similar events diff --git a/.github/workflows/studio-e2e-test.yml b/.github/workflows/studio-e2e-test.yml index e7f0cbb39b7..c2a31de86b0 100644 --- a/.github/workflows/studio-e2e-test.yml +++ b/.github/workflows/studio-e2e-test.yml @@ -15,8 +15,16 @@ permissions: jobs: test: + name: 'E2E tests' timeout-minutes: 60 runs-on: blacksmith-4vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2] + shardTotal: [2] + outputs: + tests_ran: ${{ steps.filter.outputs.studio == 'true' }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -55,13 +63,65 @@ jobs: if: steps.filter.outputs.studio == 'true' run: pnpm -C e2e/studio exec playwright install chromium --with-deps --only-shell + - name: Set up NextJS/Turbo cache + if: steps.filter.outputs.studio == 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + # See here for caching with `yarn`, `bun` or other package managers https://github.com/actions/cache/blob/main/examples.md or you can leverage caching with actions/setup-node https://github.com/actions/setup-node + path: | + .turbo/cache + apps/studio/.next/build + apps/studio/.next/cache + # Generate a new cache whenever packages or source files change. + key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/studio/**/*.js', 'apps/studio/**/*.jsx', 'apps/studio/**/*.ts', 'apps/studio/**/*.tsx') }} + # If source files changed but packages didn't, rebuild from a prior cache. + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}- + - name: 🚀 Run Playwright tests against Vercel Preview if: steps.filter.outputs.studio == 'true' id: playwright - run: pnpm e2e + run: pnpm e2e --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + + - name: Upload blob report to GitHub Actions Artifacts + if: always() && steps.filter.outputs.studio == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: blob-report-${{ matrix.shardIndex }} + path: e2e/studio/blob-report + retention-days: 7 + + - name: Fail job if tests failed + if: steps.filter.outputs.studio == 'true' && steps.playwright.outcome != 'success' + run: | + echo "E2E tests failed" >&2 + exit 1 + + merge-reports: + name: 'E2E reports' + # Merge reports after playwright-tests, even if some shards have failed + if: ${{ !cancelled() && needs.test.outputs.tests_ran == 'true' }} + needs: [test] + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - name: Use Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: '.nvmrc' + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v 5.0.0 + with: + path: e2e/studio/blob-report + pattern: blob-report-* + merge-multiple: true + + - name: Merge Playwright reports + run: npx playwright merge-reports --config=e2e/studio/playwright.merge.config.ts -- e2e/studio/blob-report - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - if: always() && steps.filter.outputs.studio == 'true' with: name: playwright-artifacts path: | @@ -70,14 +130,21 @@ jobs: retention-days: 7 - name: Comment Playwright test results on PR + if: always() && github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork uses: daun/playwright-report-comment@be9e270edd5ad86038604d3caa84a819a6ff6fed # v3.10.0 - if: always() && steps.filter.outputs.studio == 'true' && github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork with: report-file: e2e/studio/test-results/test-results.json comment-title: '🎭 Playwright Test Results' - - name: Fail job if tests failed - if: steps.filter.outputs.studio == 'true' && (steps.playwright.outcome != 'success' || steps.summarize.outputs.flaky_count > 0) - run: | - echo "E2E tests failed" >&2 - exit 1 + merge-results: + name: 'E2E results' + runs-on: ubuntu-latest + needs: [test] + if: ${{ !cancelled() && needs.test.outputs.tests_ran == 'true' }} + steps: + - name: All tests ok + if: ${{ !(contains(needs.*.result, 'failure')) }} + run: exit 0 + - name: Some tests failed + if: ${{ contains(needs.*.result, 'failure') }} + run: exit 1 diff --git a/.github/workflows/studio-unit-tests.yml b/.github/workflows/studio-unit-tests.yml index 3c8db2064e9..f7e96718d5e 100644 --- a/.github/workflows/studio-unit-tests.yml +++ b/.github/workflows/studio-unit-tests.yml @@ -79,6 +79,10 @@ jobs: continue-on-error: true runs-on: blacksmith-4vcpu-ubuntu-2404 steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + sparse-checkout: | + apps/studio - name: Download coverage artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: diff --git a/.gitignore b/.gitignore index a2566899919..6f13c135c9b 100644 --- a/.gitignore +++ b/.gitignore @@ -156,4 +156,7 @@ gcloud.json keys.json # Playwright MCP -.playwright-mcp/* \ No newline at end of file +.playwright-mcp/* + +# Application +**/.superset/** diff --git a/.prettierignore b/.prettierignore index e65459c8c02..e0cec2dda80 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,7 @@ .expo .next node_modules -package-lock.json +pnpm-lock.yaml docker* apps/**/out # prettier-plugin-sql-cst only supports sqlite syntax diff --git a/apps/design-system/app/(app)/docs/[[...slug]]/page.tsx b/apps/design-system/app/(app)/docs/[[...slug]]/page.tsx index f4e4c6694d9..f3cdccc24d5 100644 --- a/apps/design-system/app/(app)/docs/[[...slug]]/page.tsx +++ b/apps/design-system/app/(app)/docs/[[...slug]]/page.tsx @@ -1,5 +1,5 @@ import { Mdx } from '@/components/mdx-components' -import { DocsPager } from '@/components/pager' +import { DocsPager, getBreadcrumbSegments } from '@/components/pager' import { SourcePanel } from '@/components/source-panel' import { DashboardTableOfContents } from '@/components/toc' import { siteConfig } from '@/config/site' @@ -12,6 +12,7 @@ import '@/styles/mdx.css' import { allDocs } from 'contentlayer/generated' import { ChevronRight } from 'lucide-react' import type { Metadata } from 'next' +import Link from 'next/link' import { notFound } from 'next/navigation' import Balancer from 'react-wrap-balancer' import { ScrollArea, Separator } from 'ui' @@ -83,15 +84,39 @@ export default async function DocPage(props: DocPageProps) { } const toc = await getTableOfContents(doc.body.raw) + const breadcrumbSegments = getBreadcrumbSegments(doc) return (
-
-
Docs
- -
{doc.title}
-
+

{doc.title}

{doc.description && ( diff --git a/apps/design-system/components/pager.tsx b/apps/design-system/components/pager.tsx index b5c6eb39a0d..b7b1620b8ea 100644 --- a/apps/design-system/components/pager.tsx +++ b/apps/design-system/components/pager.tsx @@ -71,3 +71,27 @@ export function flatten(links: NavItemWithChildren[]): NavItem[] { }, []) .filter((link) => !link?.disabled) } + +export interface BreadcrumbSegment { + title: string + href?: string +} + +export function getBreadcrumbSegments(doc: Doc): BreadcrumbSegment[] { + const segments: BreadcrumbSegment[] = [{ title: 'Docs', href: '/docs' }] + + for (const section of docsConfig.sidebarNav) { + const flatItems = flatten(section.items ?? []) + const inSection = flatItems.some((item) => item.href === doc.slug) + if (!inSection || !section.title) continue + + const sectionLanding = flatItems.find((item) => item.priority) ?? flatItems[0] + const isOnLanding = sectionLanding?.href === doc.slug + if (sectionLanding?.href && !isOnLanding) { + segments.push({ title: section.title, href: sectionLanding.href }) + } + break + } + + return segments +} diff --git a/apps/design-system/content/docs/components/alert-dialog.mdx b/apps/design-system/content/docs/components/alert-dialog.mdx index 0968428ea14..7c35e586b3c 100644 --- a/apps/design-system/content/docs/components/alert-dialog.mdx +++ b/apps/design-system/content/docs/components/alert-dialog.mdx @@ -99,6 +99,7 @@ This enforced decision helps prevent accidental dismissal of critical warnings o - **Keep content concise:** AlertDialogDescription renders as a single paragraph and must not contain block-level elements such as lists, multiple paragraphs, or complex layouts. - **Use for critical decisions only:** Reserve Alert Dialog for destructive or irreversible actions, or for warnings that require explicit acknowledgement. +- **Use for dirty-form discard confirmation:** A short discard-confirmation step after a dirty form dismissal attempt (backdrop, Escape, or `Cancel`) is a valid Alert Dialog pattern. In Studio, prefer `DiscardChangesConfirmationDialog` for this flow. - **Always provide a cancel action:** Include AlertDialogCancel so users can safely back out, in addition to supporting the Escape key. - **Avoid rich content:** If the dialog requires detailed explanations, callouts, or form inputs, use [Confirmation Modal](../fragments/confirmation-modal) or [Dialog](../components/dialog) instead. diff --git a/apps/design-system/content/docs/components/dialog.mdx b/apps/design-system/content/docs/components/dialog.mdx index 108d9852e05..3dffe213230 100644 --- a/apps/design-system/content/docs/components/dialog.mdx +++ b/apps/design-system/content/docs/components/dialog.mdx @@ -58,6 +58,7 @@ import { - **Use for non-critical interactions:** Dialog is appropriate when dismissal has no serious consequences. - **Design for cancellation**: Assume users may close the dialog without completing the action. +- **Guard dirty forms on dismissal:** For form dialogs, keep `Dialog` dismissible but intercept close attempts when the form is dirty and show a discard-confirmation dialog (for example, Studio's `DiscardChangesConfirmationDialog` pattern via `useConfirmOnClose`). - **Keep focus contained**: Dialog content should remain scoped to a single task or flow. - **Avoid destructive confirmations**: If the dialog’s primary purpose is to confirm a risky action, use a confirmation-focused pattern instead. - **Compose freely**: Dialog is intentionally unopinionated. Build custom layouts, forms, or step-based flows as needed. diff --git a/apps/design-system/content/docs/fragments/confirmation-modal.mdx b/apps/design-system/content/docs/fragments/confirmation-modal.mdx index dfe727fa171..e613c83444f 100644 --- a/apps/design-system/content/docs/fragments/confirmation-modal.mdx +++ b/apps/design-system/content/docs/fragments/confirmation-modal.mdx @@ -10,6 +10,8 @@ Use Confirmation Modal when the user needs extra context to make a decision, suc If the confirmation can be expressed as a single short paragraph, use [Alert Dialog](../components/alert-dialog). If the action is highly destructive and requires explicit typed intent, use [Text Confirm Dialog](../fragments/text-confirm-dialog). See [Modality](../ui-patterns/modality) for broader guidance on choosing the appropriate pattern. +For dirty-form dismissal in dialogs/sheets, prefer the dedicated discard-confirmation pattern (`DiscardChangesConfirmationDialog` + `useConfirmOnClose`) rather than creating new local `CloseConfirmationModal` wrappers. The ad-hoc `CloseConfirmationModal` wrapper pattern is deprecated for new implementations. + ## Usage @@ -54,6 +56,7 @@ export default function ConfirmationModalDemo() { ## Guidelines - **Use for moderate complexity:** Suitable when the confirmation requires more than a single sentence but does not need typed intent. +- **Do not use for standard dirty-form dismissal:** Prefer `DiscardChangesConfirmationDialog` for unsaved-changes prompts so copy, behavior, and wiring stay consistent across dialogs/sheets. - **Avoid critical destruction:** Do not use for irreversible or high-risk actions that could benefit from stronger safeguards. - **Keep content focused:** Include only the context needed to make the decision. If the dialog becomes a full flow, use a custom [Dialog](../components/dialog) instead. - **Provide clear actions:** Ensure confirm and cancel labels clearly describe the outcome of each choice. diff --git a/apps/design-system/content/docs/ui-patterns/modality.mdx b/apps/design-system/content/docs/ui-patterns/modality.mdx index 3f593a0eb8e..83683b10874 100644 --- a/apps/design-system/content/docs/ui-patterns/modality.mdx +++ b/apps/design-system/content/docs/ui-patterns/modality.mdx @@ -18,6 +18,18 @@ We have two main ways of handling modality: As a general rule: use dialogs for short, focused tasks and use sheets for longer forms or more detailed views. +### Dirty form dismissal pattern + +When a dialog or sheet contains a form, users should generally be allowed to attempt dismissal via all normal affordances (backdrop click, Escape key, close icon, and footer `Cancel` button). + +If the form is clean, close immediately. If the form has unsaved changes, show a discard-confirmation dialog instead of closing immediately. + +This pattern is implemented in Studio with `useConfirmOnClose` plus `DiscardChangesConfirmationDialog`. + +- **Prompt on footer `Cancel` too:** If a button is labeled `Cancel`, users expect it to stop the current action, not silently discard edits. Prompting keeps behavior consistent with backdrop/Escape dismissal and prevents accidental loss. +- **Use explicit labels for no-prompt discard:** If you intentionally want a one-click destructive exit, label the action `Discard` (or `Discard changes`) rather than `Cancel`. +- **Guard close attempts, not unmounts:** This pattern should intercept controlled modal/sheet close attempts (`onOpenChange`, close buttons, footer actions). It should not attempt to block route changes or arbitrary component unmounts. + ## Dialogs Dialogs are centered overlays used for short, focused tasks. All dialogs should follow these best practices: @@ -41,6 +53,22 @@ There are quite a few dialog components, each suited to a different task or cont +#### Discard changes confirmation dialog (pattern) + +For dirty form dismissal, use a short discard-confirmation dialog after a close attempt instead of disabling dismissal entirely. + +- The primary form remains in a `Dialog` or `Sheet` (dismissible). +- Closing is intercepted only when the form is dirty. +- The follow-up confirmation is an `AlertDialog` pattern (`DiscardChangesConfirmationDialog` in Studio). + +Typical flow: + +1. User attempts to close the dialog/sheet (backdrop, Escape, close icon, or `Cancel`) +2. If the form is clean, close immediately +3. If the form is dirty, show discard confirmation +4. `Keep editing` returns to the form +5. `Discard changes` closes and resets the form + #### Text Confirm Dialog [Text Confirm Dialog](../fragments/text-confirm-dialog) adds a deliberate speed bump for highly destructive actions by requiring the user to type an exact confirmation string before proceeding. The confirm action remains disabled until the input matches. diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index 55ab4a6d45e..19731c8f0d6 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -30,6 +30,8 @@ public/sitemap.xml # llms.txt (generated) public/llms.txt public/llms/ +# Generated guide markdown files +public/docs/ # Copied examples folder /examples/ diff --git a/apps/docs/DEVELOPERS.md b/apps/docs/DEVELOPERS.md index 2d9f7cb3684..4413ab84577 100644 --- a/apps/docs/DEVELOPERS.md +++ b/apps/docs/DEVELOPERS.md @@ -22,6 +22,19 @@ For a complete run-down on how all of our tools work together, see the main DEVE 4. Visit http://localhost:3001/docs in your browser - don't forget to append the `/docs` to the end 5. Your local site should look exactly like [https://supabase.com/docs](https://supabase.com/docs) +## AI friendly documentation + +This project generates Markdown files for each page under `/docs/guides/..` path. + +To test locally, within the `apps/docs` directory: + +1. Run `pnpm build:guides-markdown` +2. Run `pnpm dev` + +This creates Markdown files for all routes under the `public/docs/guides` directory, ignored by Git. + +For production this setup runs as a `prebuild` task to allow Vercel to bundle these files with middleware and functions. + ## Contributing For repo organization and style guide, see the [contributing guide](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md). diff --git a/apps/docs/app/api/guides-md/[...slug]/route.ts b/apps/docs/app/api/guides-md/[...slug]/route.ts new file mode 100644 index 00000000000..f09a7854f25 --- /dev/null +++ b/apps/docs/app/api/guides-md/[...slug]/route.ts @@ -0,0 +1,22 @@ +import { promises as fs } from 'fs' +import path from 'path' +import { NextResponse } from 'next/server' + +export async function GET(_request: Request, { params }: { params: Promise<{ slug: string[] }> }) { + const { slug } = await params + const baseDir = path.join(process.cwd(), 'public/docs/guides') + const filePath = path.join(baseDir, `${slug.join('/')}.md`) + + if (!filePath.startsWith(baseDir + path.sep) && filePath !== baseDir) { + return new NextResponse('Not found', { status: 404 }) + } + + try { + const content = await fs.readFile(filePath, 'utf-8') + return new NextResponse(content, { + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + }) + } catch { + return new NextResponse('Not found', { status: 404 }) + } +} diff --git a/apps/docs/app/guides/local-development/cli/config/page.tsx b/apps/docs/app/guides/local-development/cli/config/page.tsx index 9ce99b1da78..341d800b6f8 100644 --- a/apps/docs/app/guides/local-development/cli/config/page.tsx +++ b/apps/docs/app/guides/local-development/cli/config/page.tsx @@ -1,7 +1,7 @@ import ReactMarkdown from 'react-markdown' import { CodeBlock } from 'ui' import { Heading } from 'ui/src/components/CustomHTMLElements' -import { type TOCHeader } from '~/components/GuidesTableOfContents' +import { type TOCHeader } from '~/components/GuidesSidebar' import { genGuideMeta } from '~/features/docs/GuidesMdx.utils' import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template' import type { Parameter } from '~/lib/refGenerator/refTypes' diff --git a/apps/docs/components/GuidesSidebar.tsx b/apps/docs/components/GuidesSidebar.tsx new file mode 100644 index 00000000000..3a7737ece39 --- /dev/null +++ b/apps/docs/components/GuidesSidebar.tsx @@ -0,0 +1,130 @@ +'use client' + +import { Check, Copy, ExternalLink } from 'lucide-react' +import { usePathname } from 'next/navigation' +import { useState } from 'react' +import { isFeatureEnabled } from 'common' +import { cn } from 'ui' +import { ExpandableVideo } from 'ui-patterns/ExpandableVideo' +import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns/Toc' +import { Feedback } from '~/components/Feedback' +import { useTocAnchors } from '../features/docs/GuidesMdx.state' +import { Chatgpt } from 'icons' +import { Claude } from 'icons' + +interface TOCHeader { + id?: string + text: string + link: string + level: number +} + +function AiTools({ className }: { className?: string }) { + const [copied, setCopied] = useState(false) + const path = usePathname() + + async function copyMarkdown() { + const mdUrl = `/docs/${path}.md` + + try { + const res = await fetch(mdUrl) + const text = await res.text() + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error('Failed to copy markdown', error) + } + } + + return ( +
+

+ AI Tools +

+
+ + + + Ask ChatGPT + + + + Ask Claude + +
+
+ ) +} + +const GuidesSidebar = ({ + className, + video, + hideToc, +}: { + className?: string + video?: string + hideToc?: boolean +}) => { + const pathname = usePathname() + const { toc } = useTocAnchors() + const showFeedback = isFeatureEnabled('feedback:docs') + const tocVideoPreview = `https://img.youtube.com/vi/${video}/0.jpg` + + return ( +
+
+ {video && ( +
+ +
+ )} + {showFeedback && ( +
+ +
+ )} +
+ +
+ {!hideToc && toc.length !== 0 && ( + +

+ On this page +

+ + + +
+ )} +
+
+ ) +} + +export default GuidesSidebar +export { GuidesSidebar } +export type { TOCHeader } diff --git a/apps/docs/components/GuidesTableOfContents.tsx b/apps/docs/components/GuidesTableOfContents.tsx deleted file mode 100644 index ebb1fd4158f..00000000000 --- a/apps/docs/components/GuidesTableOfContents.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client' - -import { usePathname } from 'next/navigation' -import { isFeatureEnabled } from 'common' -import { cn } from 'ui' -import { ExpandableVideo } from 'ui-patterns/ExpandableVideo' -import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns/Toc' -import { Feedback } from '~/components/Feedback' -import { useTocAnchors } from '../features/docs/GuidesMdx.state' - -interface TOCHeader { - id?: string - text: string - link: string - level: number -} - -const GuidesTableOfContents = ({ className, video }: { className?: string; video?: string }) => { - const pathname = usePathname() - const { toc } = useTocAnchors() - - const showFeedback = isFeatureEnabled('feedback:docs') - - const tocVideoPreview = `https://img.youtube.com/vi/${video}/0.jpg` - - return ( -
-
- {video && ( -
- -
- )} - {showFeedback && ( -
- -
- )} - {toc.length !== 0 && ( - -

- On this page -

- - - -
- )} -
-
- ) -} - -export default GuidesTableOfContents -export type { TOCHeader } diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 6fca567215c..0ca56c33284 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1,8 +1,9 @@ -import type { ComponentProps } from 'react' // End of third-party imports import { isFeatureEnabled } from 'common/enabled-features' +import type { ComponentProps } from 'react' import type { IconPanel } from 'ui-patterns/IconPanel' + import type { GlobalMenuItems, NavMenuConstant, NavMenuSection } from '../Navigation.types' const { @@ -1049,6 +1050,10 @@ export const database: NavMenuConstant = { name: 'Partitioning your tables', url: '/guides/database/partitions' as `/${string}`, }, + { + name: 'Migrating to pg_partman', + url: '/guides/database/migrating-to-pg-partman' as `/${string}`, + }, { name: 'Managing connections', url: '/guides/database/connection-management' as `/${string}`, @@ -1249,6 +1254,10 @@ export const database: NavMenuConstant = { name: 'pg_net: Async Networking', url: '/guides/database/extensions/pg_net' as `/${string}`, }, + { + name: 'pg_partman: Partition management', + url: '/guides/database/extensions/pg_partman' as `/${string}`, + }, { name: 'pg_plan_filter: Restrict Total Cost', url: '/guides/database/extensions/pg_plan_filter' as `/${string}`, @@ -2841,10 +2850,19 @@ export const self_hosting: NavMenuConstant = { name: 'How-to Guides', items: [ { name: 'Self-Hosted Functions', url: '/guides/self-hosting/self-hosted-functions' }, - { name: 'Restore from Platform', url: '/guides/self-hosting/restore-from-platform' }, + { + name: 'Add Reverse Proxy with HTTPS', + url: '/guides/self-hosting/self-hosted-proxy-https', + }, + { + name: 'Restore Project from Platform', + url: '/guides/self-hosting/restore-from-platform', + }, { 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: 'Enabling MCP server', url: '/guides/self-hosting/enable-mcp' }, + { 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/guides/api/rest/auto-generated-docs.mdx b/apps/docs/content/guides/api/rest/auto-generated-docs.mdx index 7357392b588..dce84addd9c 100644 --- a/apps/docs/content/guides/api/rest/auto-generated-docs.mdx +++ b/apps/docs/content/guides/api/rest/auto-generated-docs.mdx @@ -6,13 +6,15 @@ description: 'Supabase provides documentation that updates automatically.' Supabase generates documentation in the [Dashboard](/dashboard) which updates as you make database changes. -1. Go to the [API](/dashboard/project/_/api) page in the Dashboard. -2. Select any table under **Tables and Views** in the sidebar. -3. Switch between the JavaScript and the cURL docs using the tabs. +1. Go to the [Project Settings](/dashboard/project/_/settings/general) page in the Dashboard. +2. Select Data API -> Docs +3. Select any table under **Tables and Views** in the sidebar. +4. Switch between the JavaScript and the cURL docs using the tabs. +5. You may also select the SUPABASE_KEY to use. diff --git a/apps/docs/content/guides/auth/oauth-server/getting-started.mdx b/apps/docs/content/guides/auth/oauth-server/getting-started.mdx index b65a671d198..0efdc4068e2 100644 --- a/apps/docs/content/guides/auth/oauth-server/getting-started.mdx +++ b/apps/docs/content/guides/auth/oauth-server/getting-started.mdx @@ -495,11 +495,11 @@ Store the client secret securely. It will only be shown once. If you lose it, yo When a client exchanges an authorization code or refreshes a token, it must authenticate with the token endpoint. The `token_endpoint_auth_method` controls how this authentication happens: -| Method | Description | Used by | -| --- | --- | --- | -| `none` | No client authentication. Only `client_id` is sent in the request body. | Public clients (required) | -| `client_secret_basic` | Client credentials sent via HTTP Basic auth (`Authorization: Basic `). **This is the default for confidential clients.** | Confidential clients | -| `client_secret_post` | Client credentials sent in the request body (`client_id` and `client_secret` as form parameters). | Confidential clients | +| Method | Description | Used by | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| `none` | No client authentication. Only `client_id` is sent in the request body. | Public clients (required) | +| `client_secret_basic` | Client credentials sent via HTTP Basic auth (`Authorization: Basic `). **This is the default for confidential clients.** | Confidential clients | +| `client_secret_post` | Client credentials sent in the request body (`client_id` and `client_secret` as form parameters). | Confidential clients | **Defaults:** Public clients default to `none`. Confidential clients default to `client_secret_basic` (per [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591#section-2)). diff --git a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx index b02a37ad8fd..3ba089193d4 100644 --- a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx +++ b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx @@ -463,8 +463,8 @@ export async function loader({ request }: LoaderFunctionArgs) { return parseCookieHeader(request.headers.get('Cookie') ?? '') }, setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value }) => - headers.append('Set-Cookie', serializeCookieHeader(name, value)) + cookiesToSet.forEach(({ name, value, options }) => + headers.append('Set-Cookie', serializeCookieHeader(name, value, options)) ) }, }, @@ -497,8 +497,8 @@ export async function action({ request }: ActionFunctionArgs) { return parseCookieHeader(request.headers.get('Cookie') ?? '') }, setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value }) => - headers.append('Set-Cookie', serializeCookieHeader(name, value)) + cookiesToSet.forEach(({ name, value, options }) => + headers.append('Set-Cookie', serializeCookieHeader(name, value, options)) ) }, }, diff --git a/apps/docs/content/guides/auth/sessions.mdx b/apps/docs/content/guides/auth/sessions.mdx index 1b79d9f8d66..c7eac35d2bf 100644 --- a/apps/docs/content/guides/auth/sessions.mdx +++ b/apps/docs/content/guides/auth/sessions.mdx @@ -69,7 +69,7 @@ Otherwise sessions are progressively deleted from the database 24 hours after th ### What are recommended values for access token (JWT) expiration? -Most applications should use the default expiration time of 1 hour. This can be customized in your project's [Auth settings](/dashboard/project/_/settings/jwt) in the Advanced Settings section. +Most applications should use the default expiration time of 1 hour. This can be customized in your [project's settings](/dashboard/project/_/settings/jwt/legacy) in the JWT Keys > Legacy JWT Secret section. Setting a value over 1 hour is generally discouraged for security reasons, but it may make sense in certain situations. diff --git a/apps/docs/content/guides/database/extensions/pg_partman.mdx b/apps/docs/content/guides/database/extensions/pg_partman.mdx new file mode 100644 index 00000000000..de2db96c9c0 --- /dev/null +++ b/apps/docs/content/guides/database/extensions/pg_partman.mdx @@ -0,0 +1,95 @@ +--- +id: 'pg_partman' +title: 'pg_partman: partition management' +description: 'Automated partition management' +--- + +[`pg_partman`](https://github.com/pgpartman/pg_partman) is a Postgres extension that automates the creation and maintenance of partitions for tables using Postgres native partitioning. + +## Enable the extension + +To enable `pg_partman`, create a dedicated schema for it and enable the extension there. + +{/* prettier-ignore */} +```sql +create schema if not exists partman; +create extension if not exists pg_partman with schema partman; +``` + +## Create a partitioned table + +`pg_partman` requires your parent table to already be declared as a partitioned table. + +{/* prettier-ignore */} +```sql +create table public.messages ( + id bigint generated by default as identity, + sent_at timestamptz not null, + sender_id uuid, + recipient_id uuid, + body text, + primary key (sent_at, id) +) +partition by range (sent_at); +``` + +## Set up partitioning + +You configure the parent table using `partman.create_parent()`. The function takes an `ACCESS EXCLUSIVE` lock briefly while it creates the initial partitions. + +### Time-based partitions + +{/* prettier-ignore */} +```sql +select partman.create_parent( + p_parent_table := 'public.messages', + p_control := 'sent_at', + p_type := 'range', + p_interval := '7 days', + p_premake := 7, + p_start_partition := '2025-01-01 00:00:00' +); +``` + +### Integer-based partitions + +{/* prettier-ignore */} +```sql +create table public.events ( + id bigint generated by default as identity, + inserted_at timestamptz not null default now(), + payload jsonb, + primary key (id) +) +partition by range (id); + +select partman.create_parent( + p_parent_table := 'public.events', + p_control := 'id', + p_type := 'range', + p_interval := '100000' +); +``` + +## Running maintenance + +It’s important to call `pg_partman` maintenance regularly so future partitions are pre-created and retention policies are applied. + +{/* prettier-ignore */} +```sql +call partman.run_maintenance_proc(); +``` + +To automate this, schedule it using `pg_cron`. + +{/* prettier-ignore */} +```sql +create extension if not exists pg_cron; + +select + cron.schedule('@hourly', $$call partman.run_maintenance_proc()$$); +``` + +## Resources + +- Official [pg_partman documentation](https://github.com/pgpartman/pg_partman/blob/development/doc/pg_partman.md) diff --git a/apps/docs/content/guides/database/extensions/timescaledb.mdx b/apps/docs/content/guides/database/extensions/timescaledb.mdx index 8ca28081578..a3aa0303646 100644 --- a/apps/docs/content/guides/database/extensions/timescaledb.mdx +++ b/apps/docs/content/guides/database/extensions/timescaledb.mdx @@ -8,6 +8,10 @@ description: 'Scalable time-series data storage and analysis' The `timescaledb` extension is deprecated in projects using Postgres 17. It continues to be supported in projects using Postgres 15, but will need to dropped before those projects are upgraded to Postgres 17. See the [Upgrading to Postgres 17 notes](/docs/guides/platform/upgrading#upgrading-to-postgres-17) for more information. +If you are using hypertables, follow the [migration guide](/docs/guides/database/migrating-to-pg-partman) to convert to native partitioning managed by `pg_partman`. + +For additional support, contact our Success team by creating a support ticket in the Supabase Dashboard. + [`timescaledb`](https://docs.timescale.com/timescaledb/latest/) is a Postgres extension designed for improved handling of time-series data. It provides a scalable, high-performance solution for storing and querying time-series data on top of a standard Postgres database. diff --git a/apps/docs/content/guides/database/migrating-to-pg-partman.mdx b/apps/docs/content/guides/database/migrating-to-pg-partman.mdx new file mode 100644 index 00000000000..55243113dcd --- /dev/null +++ b/apps/docs/content/guides/database/migrating-to-pg-partman.mdx @@ -0,0 +1,113 @@ +--- +id: 'migrating-from-timescaledb-to-pg-partman' +title: 'Migrate from TimescaleDB to pg_partman' +description: 'Convert TimescaleDB hypertables to Postgres native partitions managed by pg_partman.' +--- + +Starting from Postgres 17, Supabase projects do not have the `timescaledb` extension available. If your project relies on TimescaleDB hypertables, you will need to migrate to standard Postgres tables before upgrading. + +This guide shows one approach to migrate a hypertable to a native Postgres partitioned table and optionally configure `pg_partman` to automate ongoing partition maintenance. +The approach outlined in this guide can also be used for traditional partitioned tables. + +## Before you begin + +- Test the migration path in a staging environment (for example by creating a copy of your production project or using branching). +- Review your application for TimescaleDB-specific SQL usage (for example `time_bucket()`, compression policies). Those features are not provided by `pg_partman`. + +## Migration overview + +1. Create a new partitioned table. +2. Copy data from the hypertable to the new table. +3. Swap over and drop the hypertable. +4. Configure `pg_partman` (optional) and schedule maintenance. + +## Example: Migrate `messages` from hypertable to native partitions + +This example assumes a `messages` hypertable partitioned by `sent_at`. + +### 1. Rename the existing hypertable + +This keeps the original data in place while you create a new partitioned table with the original name. + +{/* prettier-ignore */} +```sql +alter table public.messages rename to ht_messages; +``` + +### 2. Create a new partitioned table + +When using native partitioning, the partitioning column must be included in any unique index (including the primary key). + +{/* prettier-ignore */} +```sql +create table public.messages ( + like public.ht_messages including all, + primary key (sent_at, id) +) +partition by range (sent_at); +``` + +### 3. Copy data into the new table + +For large tables, consider copying in batches (for example by time range) during a maintenance window. + +{/* prettier-ignore */} +```sql +insert into public.messages +select * +from public.ht_messages; +``` + +### 4. Drop the old hypertable (and TimescaleDB) + +Only drop the extension once you’ve migrated all hypertables and no other objects depend on it. + +{/* prettier-ignore */} +```sql +drop table public.ht_messages; + +drop extension if exists timescaledb; +``` + +### 5. Configure `pg_partman` (optional) + +Enable `pg_partman` and register your table so partitions are created ahead of time. + +{/* prettier-ignore */} +```sql +create schema if not exists partman; +create extension if not exists pg_partman with schema partman; + +select partman.create_parent( + p_parent_table := 'public.messages', + p_control := 'sent_at', + p_type := 'range', + p_interval := '7 days', + p_premake := 7, + p_start_partition := '2025-01-01 00:00:00' +); +``` + +## Keep partitions up to date + +`pg_partman` requires running maintenance to pre-make partitions and apply retention policies. + +{/* prettier-ignore */} +```sql +call partman.run_maintenance_proc(); +``` + +To automate this, schedule it with `pg_cron`. + +{/* prettier-ignore */} +```sql +create extension if not exists pg_cron; + +select cron.schedule('@daily', $$call partman.run_maintenance_proc()$$); +``` + +## Additional resources + +- [Partitioning your tables](/docs/guides/database/partitions). +- [`pg_partman` documentation](/docs/guides/database/extensions/pg_partman) +- [`pg_partman` migration guides](https://github.com/pgpartman/pg_partman/blob/development/doc/migrate_to_partman.md) diff --git a/apps/docs/content/guides/getting-started/tutorials/with-refine.mdx b/apps/docs/content/guides/getting-started/tutorials/with-refine.mdx index 2f7b929dd28..553d0f02105 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-refine.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-refine.mdx @@ -15,91 +15,60 @@ If you get stuck while working through this guide, refer to the [full example on ## About Refine -[Refine](https://github.com/refinedev/refine) is a React-based framework used to rapidly build data-heavy applications like admin panels, dashboards, storefronts and any type of CRUD apps. It separates app concerns into individual layers, each backed by a React context and respective provider object. For example, the auth layer represents a context served by a specific set of [`authProvider`](https://refine.dev/docs/tutorial/understanding-authprovider/index/) methods that carry out authentication and authorization actions such as logging in, logging out, getting roles data, etc. Similarly, the data layer offers another level of abstraction that is equipped with [`dataProvider`](https://refine.dev/docs/tutorial/understanding-dataprovider/index/) methods to handle CRUD operations at appropriate backend API endpoints. +[Refine](https://github.com/refinedev/refine) is a React-based framework used to rapidly build data-heavy applications like admin panels, dashboards, storefronts and any type of CRUD apps. It separates app concerns into individual layers, each backed by a React context and respective provider object. For example, the auth layer represents a context served by a specific set of [`authProvider`](https://refine.dev/docs/tutorial/understanding-authprovider/index/) methods that carry out authentication and authorization actions such as logging in, logging out, getting roles data, etc. Similarly, the data layer offers another level of abstraction equipped with [`dataProvider`](https://refine.dev/docs/tutorial/understanding-dataprovider/index/) methods to handle CRUD operations at appropriate backend API endpoints. -Refine provides hassle-free integration with Supabase backend with its supplementary [`@refinedev/supabase`](https://github.com/refinedev/refine/tree/main/packages/supabase) package. It generates `authProvider` and `dataProvider` methods at project initialization, so we don't need to expend much effort to define them ourselves. We just need to choose Supabase as our backend service while creating the app with `create refine-app`. - - - -It is possible to customize the `authProvider` for Supabase and as we'll see below, it can be tweaked from `src/authProvider.ts` file. In contrast, the Supabase `dataProvider` is part of `node_modules` and therefore is not subject to modification. - - +Refine provides hassle-free integration with a Supabase backend with its supplementary [`@refinedev/supabase`](https://github.com/refinedev/refine/tree/main/packages/supabase) package. It generates `authProvider` and `dataProvider` methods at project initialization, so you don't need to spend much effort defining them yourself, choose Supabase as the backend service while creating the app with `create refine-app`. <$Partial path="project_setup.mdx" variables={{ "framework": "refine", "tab": "frameworks" }} /> ## Building the app -Let's start building the Refine app from scratch. +Start building the Refine app from scratch. ### Initialize a Refine app -We can use [create refine-app](https://refine.dev/docs/tutorial/getting-started/headless/create-project/#launch-the-refine-cli-setup) command to initialize +Use [create refine-app](https://refine.dev/docs/tutorial/getting-started/headless/create-project/#launch-the-refine-cli-setup) command to initialize an app. Run the following in the terminal: ```bash npm create refine-app@latest -- --preset refine-supabase ``` -In the above command, we are using the `refine-supabase` preset which chooses the Supabase supplementary package for our app. We are not using any UI framework, so we'll have a headless UI with plain React and CSS styling. +The command above uses the `refine-supabase` preset which chooses the Supabase supplementary package for the app. There's no UI framework, so the app has a headless UI with plain React and CSS styling. The `refine-supabase` preset installs the `@refinedev/supabase` package which out-of-the-box includes the Supabase dependency: [supabase-js](https://github.com/supabase/supabase-js). -We also need to install `@refinedev/react-hook-form` and `react-hook-form` packages that allow us to use [React Hook Form](https://react-hook-form.com) inside Refine apps. Run: +Install the `@refinedev/react-hook-form` and `react-hook-form` packages that to use [React Hook Form](https://react-hook-form.com) inside Refine apps. Run: ```bash npm install @refinedev/react-hook-form react-hook-form ``` -With the app initialized and packages installed, at this point before we begin discussing Refine concepts, let's try running the app: - -```bash -cd app-name -npm run dev -``` - -We should have a running instance of the app with a Welcome page at `http://localhost:5173`. - -Let's move ahead to understand the generated code now. - ### Refine `supabaseClient` -The `create refine-app` generated a Supabase client for us in the `src/utility/supabaseClient.ts` file. It has two constants: `SUPABASE_URL` and `SUPABASE_KEY`. We want to replace them as `supabaseUrl` and `supabasePublishableKey` respectively and assign them our own Supabase server's values. +The `create refine-app` generated a Supabase client in the `src/utility/supabaseClient.ts` file. It has two constants: `SUPABASE_URL` and `SUPABASE_KEY`. Replace them as `supabaseUrl` and `supabasePublishableKey` respectively and assign them your Supabase server's values. -We'll update it with environment variables managed by Vite: +Update it with environment variables managed by Vite: -<$CodeTabs> +<$CodeSample +path="/user-management/refine-user-management/src/utility/supabaseClient.ts" +lines={[[1, -1]]} +meta="name=src/utility/supabaseClient.ts" +/> -```ts name=src/utility/supabaseClient.ts -import { createClient } from '@refinedev/supabase' - -const supabaseUrl = import.meta.env.VITE_SUPABASE_URL -const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY - -export const supabaseClient = createClient(supabaseUrl, supabasePublishableKey, { - db: { - schema: 'public', - }, - auth: { - persistSession: true, - }, -}) -``` - - - -And then, we want to save the environment variables in a `.env.local` file. All you need are the API URL and the key that you copied [earlier](#get-api-details). +Save the environment variables in a `.env.local` file. All you need are the API URL and the key that you copied [earlier](#get-api-details). ```bash .env.local VITE_SUPABASE_URL=YOUR_SUPABASE_URL VITE_SUPABASE_PUBLISHABLE_KEY=YOUR_SUPABASE_PUBLISHABLE_KEY ``` -The `supabaseClient` will be used in fetch calls to Supabase endpoints from our app. As we'll see below, the client is instrumental in implementing authentication using Refine's auth provider methods and CRUD actions with appropriate data provider methods. +The `supabaseClient` fetches calls to Supabase endpoints from the app. The client is instrumental in implementing authentication using Refine's auth provider methods and CRUD actions with appropriate data provider methods. One optional step is to update the CSS file `src/App.css` to make the app look nice. You can find the full contents of this file [here](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/refine-user-management/src/App.css). -In order for us to add login and user profile pages in this App, we have to tweak the `` component inside `App.tsx`. +In order to add login and user profile pages in this App, tweak the `` component inside `App.tsx`. ### The `` component @@ -151,13 +120,13 @@ export default App -We'd like to focus on the [``](https://refine.dev/docs/api-reference/core/components/refine-config/) component, which comes with several props passed to it. Notice the `dataProvider` prop. It uses a `dataProvider()` function with `supabaseClient` passed as argument to generate the data provider object. The `authProvider` object also uses `supabaseClient` in implementing its methods. You can look it up in `src/authProvider.ts` file. +Focus on the [``](https://refine.dev/docs/api-reference/core/components/refine-config/) component, which comes with props passed to it. Notice the `dataProvider` prop. It uses a `dataProvider()` function with `supabaseClient` passed as argument to generate the data provider object. The `authProvider` object also uses `supabaseClient` in implementing its methods. You can look it up in `src/authProvider.ts` file. ## Customize `authProvider` -If you examine the `authProvider` object you can notice that it has a `login` method that implements a OAuth and Email / Password strategy for authentication. We'll, however, remove them and use Magic Links to allow users sign in with their email without using passwords. +If you examine the `authProvider` object you can notice that it has a `login` method that implements an OAuth and Email / Password strategy for authentication. This tutorial instead removes them and use Magic Links to allow users sign in with their email without using passwords. -We want to use `supabaseClient` auth's `signInWithOtp` method inside `authProvider.login` method: +Use `supabaseClient` auth's `signInWithOtp` method inside `authProvider.login` method: ```ts name=src/authProvider.ts login: async ({ email }) => { @@ -182,320 +151,63 @@ login: async ({ email }) => { }, ``` -We also want to remove `register`, `updatePassword`, `forgotPassword` and `getPermissions` properties, which are optional type members and also not necessary for our app. The final `authProvider` object looks like this: +Remove `register`, `updatePassword`, `forgotPassword` and `getPermissions` properties, which are optional type members and also not necessary for the app. The final `authProvider` object looks like this: -<$CodeTabs> - -```ts name=src/authProvider.ts -import { AuthProvider } from '@refinedev/core' - -import { supabaseClient } from './utility' - -const authProvider: AuthProvider = { - login: async ({ email }) => { - try { - const { error } = await supabaseClient.auth.signInWithOtp({ email }) - - if (!error) { - alert('Check your email for the login link!') - return { - success: true, - } - } - - throw error - } catch (e: any) { - alert(e.message) - return { - success: false, - e, - } - } - }, - logout: async () => { - const { error } = await supabaseClient.auth.signOut() - - if (error) { - return { - success: false, - error, - } - } - - return { - success: true, - redirectTo: '/', - } - }, - onError: async (error) => { - console.error(error) - return { error } - }, - check: async () => { - try { - const { data } = await supabaseClient.auth.getSession() - const { session } = data - - if (!session) { - return { - authenticated: false, - error: { - message: 'Check failed', - name: 'Session not found', - }, - logout: true, - redirectTo: '/login', - } - } - } catch (error: any) { - return { - authenticated: false, - error: error || { - message: 'Check failed', - name: 'Not authenticated', - }, - logout: true, - redirectTo: '/login', - } - } - - return { - authenticated: true, - } - }, - getIdentity: async () => { - const { data } = await supabaseClient.auth.getUser() - - if (data?.user) { - return { - ...data.user, - name: data.user.email, - } - } - - return null - }, -} - -export default authProvider -``` - - +<$CodeSample +path="/user-management/refine-user-management/src/authProvider.ts" +lines={[[1, -1]]} +meta="name=src/authProvider.ts" +/> ### Set up a login component -We have chosen to use the headless Refine core package that comes with no supported UI framework. So, let's set up a plain React component to manage logins and sign ups. +As the app uses the headless Refine core package that comes with no supported UI framework set up a plain React component to manage logins and sign ups. Create and edit `src/components/auth.tsx`: -<$CodeTabs> +<$CodeSample +path="/user-management/refine-user-management/src/components/auth.tsx" +lines={[[1, -1]]} +meta="name=src/components/auth.tsx" +/> -```ts name=src/components/auth.tsx -import { useState } from 'react' -import { useLogin } from '@refinedev/core' - -export default function Auth() { - const [email, setEmail] = useState('') - const { isPending, mutate: login } = useLogin() - - const handleLogin = async (event: { preventDefault: () => void }) => { - event.preventDefault() - login({ email }) - } - - return ( -
-
-

Supabase + Refine

-

Sign in via magic link with your email below

-
-
- setEmail(e.target.value)} - /> -
-
- -
-
-
-
- ) -} -``` - - - -Notice we are using the [`useLogin()`](https://refine.dev/docs/api-reference/core/hooks/authentication/useLogin/) Refine auth hook to grab the `mutate: login` method to use inside `handleLogin()` function and `isLoading` state for our form submission. The `useLogin()` hook conveniently offers us access to `authProvider.login` method for authenticating the user with OTP. +The [`useLogin()`](https://refine.dev/docs/api-reference/core/hooks/authentication/useLogin/) Refine auth hook to grab the `mutate: login` method to use inside `handleLogin()` function and `isLoading` state for the form submission. The `useLogin()` hook conveniently offers access to `authProvider.login` method for authenticating the user with OTP. ### Account page -After a user is signed in we can allow them to edit their profile details and manage their account. +After a user is signed in, allow them to edit their profile details and manage their account. -Let's create a new component for that in `src/components/account.tsx`. +Create a new component for that in `src/components/account.tsx`. -<$CodeTabs> +<$CodeSample +path="/user-management/refine-user-management/src/components/account.tsx" +lines={[[1, 3], [8, 43], [69, -1]]} +meta="name=src/components/account.tsx" +/> -```tsx name=src/components/account.tsx -import { BaseKey, useGetIdentity, useLogout } from '@refinedev/core' -import { useForm } from '@refinedev/react-hook-form' - -interface IUserIdentity { - id?: BaseKey - username: string - name: string -} - -export interface IProfile { - id?: string - username?: string - website?: string - avatar_url?: string -} - -export default function Account() { - const { data: userIdentity } = useGetIdentity() - - const { mutate: logOut } = useLogout() - - const { - refineCore: { formLoading, query, onFinish }, - register, - control, - handleSubmit, - } = useForm({ - refineCoreProps: { - resource: 'profiles', - action: 'edit', - id: userIdentity?.id, - redirect: false, - onMutationError: (data) => alert(data?.message), - }, - }) - - return ( -
-
-
- - -
-
- - -
-
- - -
- -
- -
- -
- -
-
-
- ) -} -``` - - - -Notice above that, we are using three Refine hooks, namely the [`useGetIdentity()`](https://refine.dev/docs/api-reference/core/hooks/authentication/useGetIdentity/), [`useLogOut()`](https://refine.dev/docs/api-reference/core/hooks/authentication/useLogout/) and [`useForm()`](https://refine.dev/docs/packages/documentation/react-hook-form/useForm/) hooks. +This uses three Refine hooks, namely the [`useGetIdentity()`](https://refine.dev/docs/api-reference/core/hooks/authentication/useGetIdentity/), [`useLogOut()`](https://refine.dev/docs/api-reference/core/hooks/authentication/useLogout/) and [`useForm()`](https://refine.dev/docs/packages/documentation/react-hook-form/useForm/) hooks. `useGetIdentity()` is a auth hook that gets the identity of the authenticated user. It grabs the current user by invoking the `authProvider.getIdentity` method under the hood. `useLogOut()` is also an auth hook. It calls the `authProvider.logout` method to end the session. -`useForm()`, in contrast, is a data hook that exposes a series of useful objects that serve the edit form. For example, we are grabbing the `onFinish` function to submit the form with the `handleSubmit` event handler. We are also using `formLoading` property to present state changes of the submitted form. +`useForm()`, in contrast, is a data hook that exposes a series of useful objects that serve the edit form. For example, grabbing the `onFinish` function to submit the form with the `handleSubmit` event handler. It also uses `formLoading` property to present state changes of the submitted form. -The `useForm()` hook is a higher-level hook built on top of Refine's `useForm()` core hook. It fully supports form state management, field validation and submission using React Hook Form. Behind the scenes, it invokes the `dataProvider.getOne` method to get the user profile data from our Supabase `/profiles` endpoint and also invokes `dataProvider.update` method when `onFinish()` is called. +The `useForm()` hook is a higher-level hook built on top of Refine's `useForm()` core hook. It fully supports form state management, field validation and submission using React Hook Form. Behind the scenes, it invokes the `dataProvider.getOne` method to get the user profile data from the Supabase `/profiles` endpoint and also invokes `dataProvider.update` method when `onFinish()` is called. ### Launch! -Now that we have all the components in place, let's define the routes for the pages in which they should be rendered. +Now that you have all the components in place, define the routes for the pages in which they should be rendered. Add the routes for `/login` with the `` component and the routes for `index` path with the `` component. So, the final `App.tsx`: -<$CodeTabs> +<$CodeSample +path="/user-management/refine-user-management/src/App.tsx" +lines={[[1, -1]]} +meta="name=src/App.tsx" +/> -```tsx name=src/App.tsx -import { Authenticated, Refine } from '@refinedev/core' -import { RefineKbar, RefineKbarProvider } from '@refinedev/kbar' -import routerProvider, { - CatchAllNavigate, - DocumentTitleHandler, - UnsavedChangesNotifier, -} from '@refinedev/react-router' -import { dataProvider, liveProvider } from '@refinedev/supabase' -import { BrowserRouter, Outlet, Route, Routes } from 'react-router' - -import './App.css' -import authProvider from './authProvider' -import { supabaseClient } from './utility' -import Account from './components/account' -import Auth from './components/auth' - -function App() { - return ( - - - - - } - > - - - } - > - } /> - - } />}> - } /> - - - - - - - - - ) -} - -export default App -``` - - - -Let's test the App by running the server again: +Test the App by running the server again: ```bash npm run dev @@ -511,153 +223,24 @@ Every Supabase project is configured with [Storage](/docs/guides/storage) for ma ### Create an upload widget -Let's create an avatar for the user so that they can upload a profile photo. We can start by creating a new component: +Create an avatar for the user so that they can upload a profile photo. Add a new component: Create and edit `src/components/avatar.tsx`: -<$CodeTabs> - -```tsx name=src/components/avatar.tsx -import { useEffect, useState } from 'react' -import { supabaseClient } from '../utility/supabaseClient' - -type TAvatarProps = { - url?: string - size: number - onUpload: (filePath: string) => void -} - -export default function Avatar({ url, size, onUpload }: TAvatarProps) { - const [avatarUrl, setAvatarUrl] = useState('') - const [uploading, setUploading] = useState(false) - - useEffect(() => { - if (url) downloadImage(url) - }, [url]) - - async function downloadImage(path: string) { - try { - const { data, error } = await supabaseClient.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) - } - } - - async function uploadAvatar(event: React.ChangeEvent) { - try { - setUploading(true) - - if (!event.target.files || event.target.files.length === 0) { - throw new Error('You must select an image to upload.') - } - - const file = event.target.files[0] - const fileExt = file.name.split('.').pop() - const fileName = `${Math.random()}.${fileExt}` - const filePath = `${fileName}` - - const { error: uploadError } = await supabaseClient.storage - .from('avatars') - .upload(filePath, file) - - if (uploadError) { - throw uploadError - } - onUpload(filePath) - } catch (error: any) { - alert(error.message) - } finally { - setUploading(false) - } - } - - return ( -
- {avatarUrl ? ( - Avatar - ) : ( -
- )} -
- - -
-
- ) -} -``` - - +<$CodeSample +path="/user-management/refine-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 at `src/components/account.tsx`: +And then add the widget to the Account page at `src/components/account.tsx`: -<$CodeTabs> - -```tsx name=src/components/account.tsx -// Import the new components -import { Controller } from 'react-hook-form' -import Avatar from './avatar' - -// ... - -return ( -
-
- { - return ( - { - onFinish({ - ...query?.data?.data, - avatar_url: filePath, - onMutationError: (data: { message: string }) => alert(data?.message), - }) - field.onChange({ - target: { - value: filePath, - }, - }) - }} - /> - ) - }} - /> - {/* ... */} - -
-) -``` - - +<$CodeSample +path="/user-management/refine-user-management/src/components/account.tsx" +lines={[[1, -1]]} +meta="name=src/components/account.tsx" +/> At this stage, you have a fully functional application! diff --git a/apps/docs/content/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes.mdx b/apps/docs/content/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes.mdx index ef13b111f2c..2e749fc4da2 100644 --- a/apps/docs/content/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes.mdx +++ b/apps/docs/content/guides/integrations/build-a-supabase-oauth-integration/oauth-scopes.mdx @@ -39,3 +39,5 @@ You can update scopes of your OAuth app at any time, but existing OAuth app user | `Rest` | `Write` | Update a project's PostgREST configuration | | `Secrets` | `Read` | Retrieve a project's API keys
Retrieve a project's secrets
Retrieve a project's pgsodium config | | `Secrets` | `Write` | Create or update a project's secrets
Update a project's pgsodium configuration | +| `Storage` | `Read` | Retrieve a project's storage buckets. | +| | diff --git a/apps/docs/content/guides/platform/backups.mdx b/apps/docs/content/guides/platform/backups.mdx index 55b43ec5041..a08c9585ebf 100644 --- a/apps/docs/content/guides/platform/backups.mdx +++ b/apps/docs/content/guides/platform/backups.mdx @@ -1,75 +1,39 @@ --- title: 'Database Backups' -description: 'Learn about the available backup methods for your Supabase project.' +description: 'Learn about backups for your Supabase project.' --- -Database backups are an integral part of any disaster recovery plan. Disasters come in many shapes and sizes. It could be as simple as accidentally deleting a table column, the database crashing, or even a natural calamity wiping out the underlying hardware a database is running on. The risks and impact brought by these scenarios can never be fully eliminated, but only minimized or even mitigated. Having database backups is a form of insurance policy. They are essentially snapshots of the database at various points in time. When disaster strikes, database backups allow the project to be brought back to any of these points in time, therefore averting the crisis. +We automatically back up all Free, Pro, Team, and Enterprise Plan projects on a daily basis. You can find backups in the [**Database** > **Backups**](/dashboard/project/_/database/backups/scheduled) section of the Dashboard. - +Pro Plan projects can access the last 7 days of daily backups. Team Plan projects can access the last 14 days of daily backups, while Enterprise Plan projects can access up to 30 days of daily backups. If you need more frequent backups, consider enabling [Point-in-Time Recovery](#point-in-time-recovery). We recommend that free tier plan projects regularly export their data using the [Supabase CLI `db dump` command](/docs/reference/cli/supabase-db-dump) and maintain off-site backups. -The Supabase team regularly monitors the status of backups. In case of any issues, you can [contact support](/dashboard/support/new). Also you can check out our [status page](https://status.supabase.com/) at any time. + + +When you delete a project, we permanently remove all associated data, including any backups stored in S3. This action is irreversible, so consider it carefully before proceeding. + + + + + +For security purposes, daily backups do not store passwords for custom roles, and you will not find them in downloadable files. If you restore from a daily backup and use custom roles, you will need to reset their passwords after the restoration completes. -Once a project is deleted all associated data will be permanently removed, including any backups stored in S3. This action is irreversible and should be carefully considered before proceeding. +Database backups do not include objects you store via the Storage API, as the database only includes metadata about these objects. Restoring an old backup does not restore objects you deleted after that backup. -## Types of backups +## Backup and restore process -Database backups can be categorized into two types: **logical** and **physical**. You can learn more about them [here](/blog/postgresql-physical-logical-backups). +You can access daily backups in the [**Database** > **Backups**](/dashboard/project/_/database/backups/scheduled) section of the Dashboard and restore a project to any of the backups. - +You can restore your project to any of the backups. To generate a logical backup yourself, use the [Supabase CLI `db dump` command](/docs/reference/cli/supabase-db-dump). -To enable physical backups, you have three options: +## Managing backups programmatically -- Enable [Point-in-Time Recovery (PITR)](#point-in-time-recovery) -- [Increase your database size](/docs/guides/platform/database-size) to greater than 15GB -- [Create a read replica](/docs/guides/platform/read-replicas) - -Once a project satisfies at least one of the requirements for physical backups then logical backups are no longer made. However, your project may revert back to logical backups if you remove add-ons. - - - -You can confirm your project's backup type by navigating to [**Database Backups > Scheduled backups**](/dashboard/project/_/database/backups/scheduled) and if you can download a backup then it is logical, otherwise it is physical. - -However, if your project has the Point-in-Time Recovery (PITR) add-on then the backups are physical and you can view them in [Database Backups > Point in time](/dashboard/project/_/database/backups/pitr). - -## Frequency of backups - -When deciding how often a database should be backed up, the key business metric Recovery Point Objective (RPO) should be considered. RPO is the threshold for how much data, measured in time, a business could lose when disaster strikes. This amount is fully dependent on a business and its underlying requirements. A low RPO would mean that database backups would have to be taken at an increased cadence throughout the day. Each Supabase project has access to two forms of backups, Daily Backups and Point-in-Time Recovery (PITR). The agreed upon RPO would be a deciding factor in choosing which solution best fits a project. - - - -If you enable PITR, Daily Backups will no longer be taken. PITR provides a finer granularity than Daily Backups, so it's unnecessary to run both. - - - - - -Database backups do not include objects stored via the Storage API, as the database only includes metadata about these objects. Restoring an old backup does not restore objects that have been deleted since then. - - - -## Daily backups - -All Pro, Team and Enterprise Plan Supabase projects are backed up automatically on a daily basis. In terms of Recovery Point Objective (RPO), Daily Backups would be suitable for projects willing to lose up to 24 hours worth of data if disaster hits at the most inopportune time. If a lower RPO is required, enabling Point-in-Time Recovery should be considered. - - - -For security purposes, passwords for custom roles are not stored in daily backups, and will not be found in downloadable files. As such, if you are restoring from a daily backup and are using custom roles, you will need to set their passwords once more following a completed restoration. - - - -### Backup process [#daily-backups-process] - -The Postgres utility [pg_dumpall](https://www.postgresql.org/docs/current/app-pg-dumpall.html) is used to perform daily backups. An SQL file is generated, zipped up, and sent to our storage servers for safe keeping. - -You can access daily backups in the [Scheduled backups](/dashboard/project/_/database/backups/scheduled) settings in the Dashboard. Pro Plan projects can access the last 7 days' worth of daily backups. Team Plan projects can access the last 14 days' worth of daily backups, while Enterprise Plan projects can access up to 30 days' worth of daily backups. Users can restore their project to any one of the backups. If you wish to generate a logical backup on your own, you can do so through the [Supabase CLI](/docs/reference/cli/supabase-db-dump). - -You can also manage backups programmatically using the Management API: +You can also manage backups programmatically [using the Management API](/docs/reference/api/v1-list-all-backups): ```bash # Get your access token from https://supabase.com/dashboard/account/tokens @@ -80,7 +44,7 @@ export PROJECT_REF="your-project-ref" curl -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ "https://api.supabase.com/v1/projects/$PROJECT_REF/database/backups" -# Restore from a PITR (not logical) backup (replace ISO timestamp with desired restore point) +# Restore from a PITR backup (replace Unix timestamp with desired restore point) curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/database/backups/restore-pitr" \ -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ @@ -89,126 +53,87 @@ curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/database/backups }' ``` -#### Backup process for large databases +### Restoration process -Databases larger than 15GB[^1], if they're on a recent build[^2] of the Supabase platform, get automatically transitioned[^3] to use daily physical backups. Physical backups are a more performant backup mechanism that lowers the overhead and impact on the database being backed up, and also avoids holding locks on objects in your database for a long period of time. While restores are unaffected, the backups created using this method cannot be downloaded from the Backups section of the dashboard. +When selecting a backup to restore to, choose the closest available backup made before your desired restore point. You can always choose earlier backups, but consider how many days of data you might lose. -This class of physical backups only allows for recovery to a fixed time each day, similar to daily backups. You can upgrade to [PITR](#point-in-time-recovery) for access to more granular recovery options. +The Dashboard prompts you for confirmation before proceeding with the restoration. The project is inaccessible during this process, so plan for downtime beforehand. Downtime depends on the size of the database—the larger it is, the longer the downtime will be. -Once a database is transitioned to using physical backups, it continues to use physical backups, even if the database size falls back below the threshold for the transition. +After you confirm, we trigger the process to restore the desired backup data to your project. The dashboard will display a notification once the restoration completes. -[^1]: The threshold for transitioning will be slowly lowered over time. Eventually, all projects will be transitioned to using physical backups. -[^2]: Projects created or upgraded after the 14th of July 2022 are eligible. -[^3]: The transition to physical backups is handled transparently and does not require any user intervention. It involves a single restart of the database to pick up new configuration that can only be loaded at start; the expected downtime for the restart is a few seconds. - -### Restoration process [#daily-backups-restoration-process] - -When selecting a backup to restore to, select the closest available one made before the desired point in time to restore to. Earlier backups can always be chosen too but do consider the number of days' worth of data that could be lost. - -The Dashboard will then prompt for a confirmation before proceeding with the restoration. The project will be inaccessible following this. As such, do ensure to allot downtime beforehand. This is dependent on the size of the database. The larger it is, the longer the downtime will be. Once the confirmation has been given, the underlying SQL of the chosen backup is then run against the project. The Postgres utility [psql](https://www.postgresql.org/docs/current/app-psql.html) is used to facilitate the restoration. The Dashboard will display a notification once the restoration completes. - -If your project is using subscriptions or replication slots, you will need to drop them prior to the restoration, and re-create them afterwards. The slot used by Realtime is exempted from this, and will be handled automatically. +If your project uses subscriptions or replication slots, you need to drop them before the restoration and re-create them afterwards. We exempt the slot used by Realtime from this requirement and handle it automatically. {/* screenshot of the Dashboard of the project completing restoration */} ## Point-in-Time recovery -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. Even with daily backups, a day's worth of data could still be lost. With PITR, backups could be performed up to the point of disaster. +Point-in-Time Recovery (PITR) allows you to back up a project at shorter intervals, giving you the option to restore to any chosen point with up to seconds of granularity. Even with daily backups, you could still lose a day's worth of data. With PITR, you can back up to the point of disaster. Pro, Team and Enterprise Plan projects can enable PITR as an add-on. -Projects interested in PITR will also need to use at least a Small compute add-on, in order to ensure smooth functioning. +Projects that want to use PITR must also use at least a Small compute add-on to ensure smooth functioning. + +
+ + + As [covered in this blog post](/blog/postgresql-physical-logical-backups), a combination of physical backups and [Write Ahead Log (WAL)](https://www.postgresql.org/docs/current/wal-intro.html) file archiving makes PITR possible. Physical backups provide a snapshot of the underlying directory of the database, while WAL files contain records of every change the database processes. + + We use [WAL-G](https://github.com/wal-g/wal-g), an open source archival and restoration tool, to handle both aspects of PITR. Daily, we take a snapshot of the database and send it to our storage servers. Throughout the day, as database transactions occur, we generate and upload WAL files. + + By default, we back up WAL files at two-minute intervals. If these files exceed a certain file size threshold, we back them up immediately. During periods of high transaction volume, WAL file backups therefore become more frequent. Conversely, when the database has no activity, we do not make WAL file backups. Overall, in the worst case scenario, PITR achieves a Recovery Point Objective (RPO) of two minutes. + + + +
+
+ -If you enable PITR, Daily Backups will no longer be taken. PITR provides a finer granularity than Daily Backups, so it's unnecessary to run both. +If you enable PITR, we will no longer take Daily Backups. PITR provides finer granularity than Daily Backups, so running both is unnecessary. -When you disable PITR, all new backups will still be taken as physical backups only. Physical backups can still be used for restoration, but they are not available for direct download. If you need to download a backup after PITR is disabled, you’ll need to take a manual [logical backup using the Supabase CLI or pg_dump](/docs/guides/platform/migrating-within-supabase/backup-restore#backup-database-using-the-cli). - - - -If PITR has been disabled, logical backups remain available until they pass the backup retention period for your plan. After that window passes, only physical backups will be shown. - - - -### Backup process [#pitr-backup-process] - -As discussed [here](/blog/postgresql-physical-logical-backups), PITR is made possible by a combination of taking physical backups of a project, as well as archiving [Write Ahead Log (WAL)](https://www.postgresql.org/docs/current/wal-intro.html) files. Physical backups provide a snapshot of the underlying directory of the database, while WAL files contain records of every change made in the database. - -Supabase uses [WAL-G](https://github.com/wal-g/wal-g), an open source archival and restoration tool, to handle both aspects of PITR. On a daily basis, a snapshot of the database is taken and sent to our storage servers. Throughout the day, as database transactions occur, WAL files are generated and uploaded. - -By default, WAL files are backed up at two minute intervals. If these files cross a certain file size threshold, they are backed up immediately. As such, during periods of high amount of transactions, WAL file backups become more frequent. Conversely, when there is no activity in the database, WAL file backups are not made. Overall, this would mean that at the worst case scenario or disaster, the PITR achieves a Recovery Point Objective (RPO) of two minutes. +### Backup process ![PITR dashboard](/docs/img/backups-pitr-dashboard.png) -You can access PITR in the [Point in Time](/dashboard/project/_/database/backups/pitr) settings in the Dashboard. The recovery period of a project is indicated by the earliest and latest points of recoveries displayed in your preferred timezone. If need be, the maximum amount of this recovery period can be modified accordingly. +You can access PITR in the [Point in Time](/dashboard/project/_/database/backups/pitr) settings in the Dashboard. The recovery period of a project is shown by the earliest and latest recovery points displayed in your preferred timezone. You can change the maximum recovery period if needed. -Note that the latest restore point of the project could be significantly far from the current time. This occurs when there has not been any recent activity in the database, and therefore no WAL file backups have been made recently. This is perfectly fine as the state of the database at the latest point of recovery would still be indicative of the state of the database at the current time given that no transactions have been made in between. +The latest restore point of the project could be significantly behind the current time. This occurs when the database has had no recent activity, and therefore we have not made any recent WAL file backups. However, the state of the database at the latest recovery point still reflects the current state of the database, given that no transactions have occurred in between. -### Restoration process [#pitr-restoration-process] +### Restoration process ![PITR: Calendar view](/docs/img/backups-pitr-calendar-view.png) -A date and time picker will be provided upon pressing the `Start a restore` button. The process will only proceed if the selected date and time fall within the earliest and latest points of recoveries. +A date and time picker appears when you click the **Start a restore** button. The process only proceeds if the selected date and time fall within the earliest and latest recovery points. ![PITR: Confirmation modal](/docs/img/backups-pitr-confirmation-modal.png) -After locking in the desired point in time to recover to, The Dashboard will then prompt for a review and confirmation before proceeding with the restoration. The project will be inaccessible following this. As such, do ensure to allot for downtime beforehand. This is dependent on the size of the database. The larger it is, the longer the downtime will be. Once the confirmation has been given, the latest physical backup available is downloaded to the project and the database is partially restored. WAL files generated after this physical backup up to the specified point-in-time are then downloaded. The underlying records of transactions in these files are replayed against the database to complete the restoration. The Dashboard will display a notification once the restoration completes. +After selecting your desired recovery point, the Dashboard prompts you to review and confirm before proceeding with the restoration. The project is inaccessible during this process, so plan for downtime beforehand. Downtime depends on the size of the database—the larger it is, the longer the downtime will be. After you confirm, we download the latest available physical backup to the project and partially restore the database. We then download the WAL files generated after this physical backup up to your specified point in time. We replay the underlying transaction records in these files against the database to complete the restoration. The Dashboard will display a notification once the restoration completes. <$Show if="billing:all"> <$Partial path="billing/pricing/pricing_pitr.mdx" /> +### Downloading backups after disabling PITR + +When you disable PITR, we still take all new backups as physical backups only. You can still use physical backups for restoration, but they are not available for direct download. If you need to download a backup after disabling PITR, you need to take a manual [legacy logical backup using the Supabase CLI or pg_dump](/docs/guides/platform/migrating-within-supabase/backup-restore#backup-database-using-the-cli). + ## Restore to a new project See the [Duplicate Project docs](/docs/guides/platform/clone-project). - -## Troubleshooting - -### Logical backups - -#### `search_path` issues - -During the `pg_restore` process, the `search_path` is set to an empty string for predictability, and security. Using unqualified references to functions or relations can cause restorations using logical backups to fail, as the database will not be able to locate the function or relation being referenced. This can happen even if the database functions without issues during normal operations, as the `search_path` is usually set to include several schemas during normal operations. Therefore, you should always use schema-qualified names within your SQL code. - -You can refer to [an example PR](https://github.com/supabase/supabase/pull/28393/files) on how to update SQL code to use schema-qualified names. - -#### Invalid check constraints - -Postgres requires that [check constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-CHECK-CONSTRAINTS) be: - -1. immutable -1. not reference table data other than the new or updated row being checked - -Violating these requirements can result in numerous failure scenarios, including during logical restorations. - -Common examples of check constraints that can result in such failures are: - -- validating against the current time, e.g. that the row being inserted references a future event -- validating the contents of a row against the contents of another table - -#### Views that reference themselves - -Views that directly or indirectly reference themselves will cause logical restores to fail due to cyclic dependency errors. These views are also invalid and unusable in Postgres, and any query against them will result in a runtime error. - -**Example:** - -``` --- Direct self-reference -CREATE VIEW my_view AS - SELECT * FROM my_view; - --- Indirect circular reference -CREATE VIEW v1 AS SELECT * FROM v2; -CREATE VIEW v2 AS SELECT * FROM v1; -``` - --- Drop the offending view from your database, or delete them from the logical backup to make it restorable. - -Postgres documentation [views](https://www.postgresql.org/docs/current/sql-createview.html) diff --git a/apps/docs/content/guides/platform/manage-your-usage/log-drains.mdx b/apps/docs/content/guides/platform/manage-your-usage/log-drains.mdx index dac00f80c61..fef7ebb55fc 100644 --- a/apps/docs/content/guides/platform/manage-your-usage/log-drains.mdx +++ b/apps/docs/content/guides/platform/manage-your-usage/log-drains.mdx @@ -36,7 +36,7 @@ Usage is shown as "Log Drain Hours" on your invoice. ### Pricing -Log Drains are available as a project Add-On for all Team and Enterprise users. Each Log Drain costs per hour ( per month). +Log Drains are available as a project Add-On for all Pro, Team and Enterprise users. Each Log Drain costs per hour ( per month). ## Log Drain Events diff --git a/apps/docs/content/guides/realtime/authorization.mdx b/apps/docs/content/guides/realtime/authorization.mdx index 4bdb5850eab..26ad20d965d 100644 --- a/apps/docs/content/guides/realtime/authorization.mdx +++ b/apps/docs/content/guides/realtime/authorization.mdx @@ -133,7 +133,7 @@ exists ( rooms_users where user_id = (select auth.uid()) - and topic = (select realtime.topic()) + and room_topic = (select realtime.topic()) and realtime.messages.extension in ('broadcast') ) ); @@ -266,7 +266,7 @@ with check ( rooms_users where user_id = (select auth.uid()) - and topic = (select realtime.topic()) + and room_topic = (select realtime.topic()) and realtime.messages.extension in ('broadcast') ) ); @@ -293,7 +293,7 @@ using ( rooms_users where user_id = (select auth.uid()) - and topic = (select realtime.topic()) + and room_topic = (select realtime.topic()) and realtime.messages.extension in ('presence') ) ); @@ -316,7 +316,7 @@ with check ( rooms_users where user_id = (select auth.uid()) - and name = (select realtime.topic()) + and room_topic = (select realtime.topic()) and realtime.messages.extension in ('presence') ) ); @@ -343,7 +343,7 @@ using ( rooms_users where user_id = (select auth.uid()) - and topic = (select realtime.topic()) + and room_topic = (select realtime.topic()) and realtime.messages.extension in ('broadcast', 'presence') ) ); @@ -366,7 +366,7 @@ with check ( rooms_users where user_id = (select auth.uid()) - and name = (select realtime.topic()) + and room_topic = (select realtime.topic()) and realtime.messages.extension in ('broadcast', 'presence') ) ); diff --git a/apps/docs/content/guides/realtime/broadcast.mdx b/apps/docs/content/guides/realtime/broadcast.mdx index 7fd48580d3e..9d740fcbd06 100644 --- a/apps/docs/content/guides/realtime/broadcast.mdx +++ b/apps/docs/content/guides/realtime/broadcast.mdx @@ -10,22 +10,22 @@ You can use Realtime Broadcast to send low-latency messages between users. Messa The way Broadcast works changes based on the channel you are using: -- From REST API will receive an HTTP request which then will be sent via WebSocket to connected clients -- From Client libraries we have an established WebSocket connection and we use that to send a message to the server which then will be sent via WebSocket to connected clients -- From Database we add a new entry to `realtime.messages` where we have logical replication set to listen for changes which then will be sent via WebSocket to connected clients +- **REST API**: Receives an HTTP request and then sends a message via WebSocket to connected clients +- **Client libraries**: Sends a message via WebSocket to the server, and then the server sends a message via WebSocket to connected clients +- **Database**: Adds a new entry to `realtime.messages` where a logical replication is set to listen for changes, and then sends a message via WebSocket to connected clients -The public flag (the last argument in `realtime.send(payload, event, topic, is_private))` only affects who can subscribe to the topic not who can read messages from the database. +The public flag (the last argument in `realtime.send(payload, event, topic, is_private)`) only affects who can subscribe to the topic not who can read messages from the database. -- Public (false) → Anyone can subscribe to that topic without authentication -- Private (true) → Only authenticated clients can subscribe to that topic +- Public (`false`) → Anyone can subscribe to that topic without authentication +- Private (`true`) → Only authenticated clients can subscribe to that topic -However, regardless of whether it's public or private, the Realtime service connects to your database as the authenticated Supabase Admin role. +Regardless if it's public or private, the Realtime service connects to your database as the authenticated Supabase Admin role. -For Authorization we insert a message and try to read it, and rollback the transaction to verify that the RLS policies set by the user are being respected by the user joining the channel, but this message isn't sent to the user. You can read more about it in the [Authorization docs](/docs/guides/realtime/authorization). +For Authorization, we insert a message and try to read it, and rollback the transaction to verify that the Row Level Security (RLS) policies set by the user are being respected by the user joining the channel, but this message isn't sent to the user. You can read more about it in [Authorization](/docs/guides/realtime/authorization). ## Subscribe to messages @@ -132,9 +132,9 @@ In most cases, you can get the correct key from [the Project's **Connect** dialo -### Receiving Broadcast messages +### Receive Broadcast messages -You can provide a callback for the `broadcast` channel to receive messages. This example will receive any `broadcast` messages that are sent to `test-channel`: +You can receive Broadcast messages by providing a callback to the channel. -The realtime.send function in the database includes a flag that determines whether the broadcast is private or public, and client channels also have the same configuration. For broadcasts to work correctly, these settings must match a public broadcast will only reach public channels, and a private broadcast will only reach private ones. +The `realtime.send()` function in the database includes a flag that determines whether the broadcast is private or public, and client channels also have the same configuration. For broadcasts to work correctly, these settings must match. A public broadcast only reaches public channels and a private broadcast only reaches private channels. -By default, all database broadcasts are private, meaning clients must authenticate to receive them. If the database sends a public message but the client subscribes to a private channel, the message won't be delivered since private channels only accept signed, authenticated messages. +By default, all database broadcasts are private, meaning clients must authenticate to receive them. If the database sends a public message but the client subscribes to a private channel, the message is not delivered because private channels only accept signed, authenticated messages.
-It's a common use case to broadcast messages when a record is created, updated, or deleted. We provide a helper function specific to this use case, `realtime.broadcast_changes()`. For more details, check out the [Subscribing to Database Changes](/docs/guides/realtime/subscribing-to-database-changes) guide. +You can use the `realtime.broadcast_changes()` helper function to broadcast messages when a record is created, updated, or deleted. For more details, read [Subscribing to Database Changes](/docs/guides/realtime/subscribing-to-database-changes). ### Broadcast using the REST API @@ -685,7 +685,7 @@ You can pass configuration options while initializing the Supabase Client. > - You can confirm that the Realtime servers have received your message by setting Broadcast's `ack` config to `true`. + You can confirm that the Realtime servers have received your message by setting Broadcast's `ack` setting to `true`. {/* prettier-ignore */} ```js @@ -881,20 +881,20 @@ You can also send a Broadcast message by making an HTTP request to Realtime serv ### How it works -Broadcast Changes allows you to trigger messages from your database. To achieve it Realtime is directly reading your WAL (Write Append Log) file using a publication against the `realtime.messages` table so whenever a new insert happens a message is sent to connected users. +Broadcast Changes allows you to trigger messages from your database. To achieve it, Realtime directly reads your Write-Ahead Log (WAL) file using a publication against the `realtime.messages` table. Whenever a new insert occurs, a message is sent to connected users. -It uses partitioned tables per day which allows the deletion your previous messages in a performant way by dropping the physical tables of this partitioned table. Tables older than 3 days old are deleted. +It uses partitioned tables per day, which allows performant deletion of your previous messages by dropping the physical tables of this partitioned table. Tables older than 3 days are deleted. -Broadcasting from the database works like a client-side broadcast, using WebSockets to send JSON packages. [Realtime Authorization](/docs/guides/realtime/authorization) is required and enabled by default to protect your data. +Broadcasting from the database works like a client-side broadcast, using WebSockets to send JSON payloads. [Realtime Authorization](/docs/guides/realtime/authorization) is required and enabled by default to protect your data. -The database broadcast feature provides two functions to help you send messages: +Broadcast Changes provides two functions to help you send messages: -- `realtime.send` will insert a message into realtime.messages without a specific format. -- `realtime.broadcast_changes` will insert a message with the required fields to emit database changes to clients. This helps you set up triggers on your tables to emit changes. +- `realtime.send()` inserts a message into `realtime.messages` without a specific format. +- `realtime.broadcast_changes()` inserts a message with the required fields to emit database changes to clients. This helps you set up triggers on your tables to emit changes. ### Broadcasting a message from your database -The `realtime.send` function provides the most flexibility by allowing you to broadcast messages from your database without a specific format. This allows you to use database broadcast for messages that aren't necessarily tied to the shape of a Postgres row change. +The `realtime.send()` function provides the most flexibility by allowing you to broadcast messages from your database without a specific format. This allows you to use database broadcast for messages that aren't necessarily tied to the shape of a Postgres row change. ```sql SELECT realtime.send ( @@ -909,7 +909,7 @@ SELECT realtime.send ( #### Setup realtime authorization -Realtime Authorization is required and enabled by default. To allow your users to listen to messages from topics, create an RLS (Row Level Security) policy: +Realtime Authorization is required and enabled by default. To allow your users to listen to messages from topics, create an RLS policy: ```sql CREATE POLICY "authenticated can receive broadcasts" @@ -920,13 +920,13 @@ USING ( true ); ``` -See the [Realtime Authorization](/docs/guides/realtime/authorization) docs to learn how to set up more specific policies. +Read [Realtime Authorization](/docs/guides/realtime/authorization) to learn how to set up more specific policies. #### Set up trigger function -First, set up a trigger function that uses `realtime.broadcast_changes` to insert an event whenever it is triggered. The event is set up to include data on the schema, table, operation, and field changes that triggered it. +First, set up a trigger function that uses the `realtime.broadcast_changes()` function to insert an event whenever it is triggered. The event is set up to include data on the schema, table, operation, and field changes that triggered it. -For this example use case, we want to have a topic with the name `topic:` to which we're going to broadcast events. +For this example, you're going broadcast events to a topic named `topic:`. ```sql CREATE OR REPLACE FUNCTION public.your_table_changes() @@ -948,7 +948,7 @@ END; $$ LANGUAGE plpgsql; ``` -Of note are the Postgres native trigger special variables used: +The Postgres native trigger special variables used are: - `TG_OP` - the operation that triggered the function - `TG_TABLE_NAME` - the table that caused the trigger @@ -1000,7 +1000,7 @@ Broadcast Replay enables **private** channels to access messages that were sent You can configure replay with the following options: -- **`since`** (Required): The epoch timestamp in milliseconds (e.g., `1697472000000`), specifying the earliest point from which messages should be retrieved. +- **`since`** (Required): The epoch timestamp in milliseconds (for example, `1697472000000`), specifying the earliest point from which messages should be retrieved. - **`limit`** (Optional): The number of messages to return. This must be a positive integer, with a maximum value of 25. :8000` | Base URL for accessing Supabase from the Internet | -| `JWT_SECRET` | Your secret key | Legacy symmetric encryption key used to sign and verify JWTs | -| `SUPABASE_ANON_KEY` | Your anon key | Client-side API key with limited permissions (`anon` role). | -| `SUPABASE_SERVICE_ROLE_KEY` | Your service role key | Server-side API key with full database access (`service_role` role) | -| `SUPABASE_DB_URL` | Postgres connection string | Can be used for direct database access | +| Variable | Value | Purpose | +| --------------------------- | --------------------------- | ------------------------------------------------------------------- | +| `SUPABASE_URL` | `http://kong:8000` | Internal API gateway URL | +| `SUPABASE_PUBLIC_URL` | `http://:8000` | Base URL for accessing Supabase from the Internet | +| `JWT_SECRET` | Your secret key | Legacy symmetric encryption key used to sign and verify JWTs | +| `SUPABASE_ANON_KEY` | Your anon key | Client-side API key with limited permissions (`anon` role). | +| `SUPABASE_SERVICE_ROLE_KEY` | Your service role key | Server-side API key with full database access (`service_role` role) | +| `SUPABASE_DB_URL` | Postgres connection string | Can be used for direct database access | Here's an example function that queries a table using `@supabase/supabase-js`: diff --git a/apps/docs/content/guides/self-hosting/self-hosted-oauth.mdx b/apps/docs/content/guides/self-hosting/self-hosted-oauth.mdx new file mode 100644 index 00000000000..4297d06127c --- /dev/null +++ b/apps/docs/content/guides/self-hosting/self-hosted-oauth.mdx @@ -0,0 +1,475 @@ +--- +title: 'Configure Social Login (OAuth) Providers' +description: 'Set up social login (OAuth/OIDC) providers for self-hosted Supabase with Docker.' +subtitle: 'Set up social login (OAuth/OIDC) providers for self-hosted Supabase with Docker.' +--- + +This guide covers the **server-side configuration** required to enable social login providers on a self-hosted Supabase instance running with Docker Compose. This applies to all OAuth and OIDC-based providers, including third-party identity providers like Keycloak. + +## Before you begin + +You need: + +- A working self-hosted Supabase installation. See [Self-Hosting with Docker](/docs/guides/self-hosting/docker). +- `API_EXTERNAL_URL` set to the publicly reachable URL of your Supabase instance (e.g., `https://`). + + + +HTTPS is strongly recommended in production. Most OAuth providers reject `http://` callback URLs (except `localhost`). + + + +Your **OAuth callback URL** is built from `API_EXTERNAL_URL`. For example, if `API_EXTERNAL_URL` is `https://`, the callback URL will become: + +``` +https:///auth/v1/callback +``` + +You will have to register this URL with each OAuth provider. + +## OAuth request flow + +When a user signs in with an OAuth provider, the following flow occurs: + +1. Your app calls `supabase.auth.signInWithOAuth()` and the browser redirects to the Auth service +2. API gateway (Kong) routes the request to the Auth container (`/auth/v1/authorize`) +3. Auth redirects the user to the OAuth provider (e.g., Google) for consent +4. The provider redirects back to `https:///auth/v1/callback` +5. Auth exchanges the authorization code for tokens and redirects the user to your `SITE_URL` or an allowed redirect URL + +## Auth environment variables + +The Auth service (GoTrue) uses the prefix `GOTRUE_EXTERNAL_` followed by a provider name for all OAuth configuration. For example, when using Google: + +- `GOTRUE_EXTERNAL_GOOGLE_ENABLED` +- `GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID` +- `GOTRUE_EXTERNAL_GOOGLE_SECRET` +- `GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI` + +## Step-by-step configuration + +The default `.env.example` and `docker-compose.yml` include commented-out placeholders for Google, GitHub, and Azure. + +### Step 1: Register your app with the provider + +1. Go to the OAuth provider's developer console and create an application. +2. Set the **authorized redirect URL**, e.g., `https:///auth/v1/callback` +3. Copy the **client ID** and **client secret** into your `.env` file. + +### Step 2: Configure variables in the `.env` file + +Uncomment the lines for your provider in `.env` and add your client ID and secret, e.g., for Google: + +``` +GOOGLE_ENABLED=true +GOOGLE_CLIENT_ID=your-client-id +GOOGLE_SECRET=your-client-secret +``` + +### Step 3: Enable the matching lines in `docker-compose.yml` + +Uncomment the corresponding `GOTRUE_EXTERNAL_` lines in the `auth` service's `environment`: + +```yaml +auth: + environment: + # ... existing variables ... + GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED} + GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET} + GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback +``` + + + +For providers **not** pre-configured in the files (see the [full provider list](#other-supported-providers) below), add the lines manually following the same pattern: variables in `.env`, passthrough with `GOTRUE_EXTERNAL_PROVIDER_` in `docker-compose.yml`. + + + +### Step 4: Restart the auth service + +```sh +docker compose up -d --force-recreate --no-deps auth +``` + +### Step 5: Verify the configuration + +Check that the provider is enabled: + +```sh +curl -H 'apikey: your-anon-key' https:///auth/v1/settings +``` + +The response should include your provider under `external`: + +``` +{ + "external": { + "google": true + } +} +``` + +## Provider-specific setup + + + + + +**Google Cloud Console setup:** + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create or select a project +3. Select **Solutions** > **All products** in the navigation menu on the left +4. Go to **APIs & services** > **OAuth consent screen** and click **Get started** +5. Follow the configuration steps and add an **External** app +6. Go to **APIs & Services** > **Credentials** +7. Click **Create Credentials** > **OAuth client ID** +8. Set application type to **Web application** +9. Under **Authorized redirect URIs**, add: `https:///auth/v1/callback` +10. Click **Create** and copy the client ID and client secret + +**`.env`:** + +``` +GOOGLE_ENABLED=true +GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com +GOOGLE_SECRET=your-google-client-secret +``` + +**`docker-compose.yml`:** + +```yaml +auth: + environment: + # ... existing variables ... + GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED} + GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOTRUE_EXTERNAL_GOOGLE_SECRET: ${GOOGLE_SECRET} + GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback +``` + + + + + +**GitHub setup:** + +1. Go to [GitHub Developer Settings](https://github.com/settings/developers) +2. Click **New OAuth app** +3. Fill in **Homepage URL**, e.g., `https://` +4. Set **Authorization callback URL** to: `https:///auth/v1/callback` +5. Click **Register application** +6. Copy the client ID, generate and copy a client secret + +**`.env`:** + +``` +GITHUB_ENABLED=true +GITHUB_CLIENT_ID=your-github-client-id +GITHUB_SECRET=your-github-client-secret +``` + +**`docker-compose.yml`:** + +```yaml +auth: + environment: + # ... existing variables ... + GOTRUE_EXTERNAL_GITHUB_ENABLED: ${GITHUB_ENABLED} + GOTRUE_EXTERNAL_GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} + GOTRUE_EXTERNAL_GITHUB_SECRET: ${GITHUB_SECRET} + GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback +``` + + + + + +**Azure Portal setup:** + +1. Go to [Azure Portal](https://portal.azure.com/) +2. Go to **All services** > **Identity** > **App registrations** via the navigation menu on the left +3. Click **New registration** +4. Add application **Name** +5. Under **Redirect URI**, select **Web** and enter: `https:///auth/v1/callback` +6. Click **Register** +7. Copy the **Application (client) ID** +8. Click on **Client credentials** > **Add a certificate or secret** +9. Click on **New client secret** and add a client secret +10. Copy the secret value (not "secret ID") + +**`.env`:** + +``` +AZURE_ENABLED=true +AZURE_CLIENT_ID=your-azure-application-client-id +AZURE_SECRET=your-azure-client-secret +## Optional: restrict to a specific tenant (defaults to 'common') +# AZURE_URL=https://login.microsoftonline.com/your-tenant-id +``` + +**`docker-compose.yml`:** + +```yaml +auth: + environment: + # ... existing variables ... + GOTRUE_EXTERNAL_AZURE_ENABLED: ${AZURE_ENABLED} + GOTRUE_EXTERNAL_AZURE_CLIENT_ID: ${AZURE_CLIENT_ID} + GOTRUE_EXTERNAL_AZURE_SECRET: ${AZURE_SECRET} + GOTRUE_EXTERNAL_AZURE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + ## Optional: uncomment for tenant-specific Azure login + # GOTRUE_EXTERNAL_AZURE_URL: ${AZURE_URL} +``` + + + + + +**Apple Developer setup:** + +1. Refer to [Apple Developer documentation](https://developer.apple.com/documentation/signinwithapple/configuring-your-environment-for-sign-in-with-apple) to learn how to enable App ID and create a Services ID +2. Create a private key for sign in with Apple +3. Generate a client secret JWT from your private key. See [Apple Developer documentation](https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret) for details. + +**`.env`:** + +``` +APPLE_ENABLED=true +APPLE_CLIENT_ID=com.example.your-services-id +APPLE_SECRET=your-generated-jwt-client-secret +``` + +**`docker-compose.yml`:** + +```yaml +auth: + environment: + # ... existing variables ... + GOTRUE_EXTERNAL_APPLE_ENABLED: ${APPLE_ENABLED} + GOTRUE_EXTERNAL_APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} + GOTRUE_EXTERNAL_APPLE_SECRET: ${APPLE_SECRET} + GOTRUE_EXTERNAL_APPLE_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback +``` + + + +Apple uses `response_mode=form_post` for its OAuth flow. The Auth service handles this automatically - no additional configuration is needed. + + + + + + + +**Keycloak setup:** + +1. Open your Keycloak admin console +2. Select (or create) the realm you want to use +3. Go to **Clients** > **Create client** +4. Set **Client type** to **OpenID Connect** +5. Set **Client ID** (e.g., `supabase`) +6. On the next screen, enable **Client authentication** +7. Under **Valid redirect URIs**, add: `https:///auth/v1/callback` +8. Save, then go to the **Credentials** tab and copy the **Client secret** + +**`.env` variables:** + +``` +KEYCLOAK_ENABLED=true +KEYCLOAK_CLIENT_ID=supabase +KEYCLOAK_SECRET=your-keycloak-client-secret +## Required: your Keycloak realm URL +KEYCLOAK_URL=https://keycloak.example.com/realms/myrealm +``` + +**`docker-compose.yml` passthrough:** + +```yaml +auth: + environment: + # ... existing variables ... + GOTRUE_EXTERNAL_KEYCLOAK_ENABLED: ${KEYCLOAK_ENABLED} + GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} + GOTRUE_EXTERNAL_KEYCLOAK_SECRET: ${KEYCLOAK_SECRET} + GOTRUE_EXTERNAL_KEYCLOAK_REDIRECT_URI: ${API_EXTERNAL_URL}/auth/v1/callback + GOTRUE_EXTERNAL_KEYCLOAK_URL: ${KEYCLOAK_URL} +``` + + + +`KEYCLOAK_URL` is **required**. It must be the full realm URL (e.g., `https://keycloak.example.com/realms/myrealm`). The Auth service uses this to discover the OIDC endpoints (`.well-known/openid-configuration`). Without it, Keycloak login will not work. + + + + + + + +## Other supported providers + +Supabase Auth supports the following OAuth providers: + +| Provider | Env prefix | Additional variables | Docs | +| ----------------- | ---------------- | ------------------------------- | --------------------------------------------------------------------- | +| Apple | `APPLE_` | - | [Login with Apple](/docs/guides/auth/social-login/auth-apple) | +| Azure (Microsoft) | `AZURE_` | `URL` (tenant URL) | [Login with Azure](/docs/guides/auth/social-login/auth-azure) | +| Bitbucket | `BITBUCKET_` | - | [Login with Bitbucket](/docs/guides/auth/social-login/auth-bitbucket) | +| Discord | `DISCORD_` | - | [Login with Discord](/docs/guides/auth/social-login/auth-discord) | +| Facebook | `FACEBOOK_` | - | [Login with Facebook](/docs/guides/auth/social-login/auth-facebook) | +| Figma | `FIGMA_` | - | [Login with Figma](/docs/guides/auth/social-login/auth-figma) | +| GitHub | `GITHUB_` | `URL` (for GitHub Enterprise) | [Login with GitHub](/docs/guides/auth/social-login/auth-github) | +| GitLab | `GITLAB_` | `URL` (for self-hosted GitLab) | [Login with GitLab](/docs/guides/auth/social-login/auth-gitlab) | +| Google | `GOOGLE_` | - | [Login with Google](/docs/guides/auth/social-login/auth-google) | +| Kakao | `KAKAO_` | - | [Login with Kakao](/docs/guides/auth/social-login/auth-kakao) | +| Keycloak (OIDC) | `KEYCLOAK_` | `URL` (realm URL, **required**) | [Login with Keycloak](/docs/guides/auth/social-login/auth-keycloak) | +| LinkedIn (OIDC) | `LINKEDIN_OIDC_` | - | [Login with LinkedIn](/docs/guides/auth/social-login/auth-linkedin) | +| Notion | `NOTION_` | - | [Login with Notion](/docs/guides/auth/social-login/auth-notion) | +| Slack (OIDC) | `SLACK_OIDC_` | - | [Login with Slack](/docs/guides/auth/social-login/auth-slack) | +| Snapchat | `SNAPCHAT_` | - | - | +| Spotify | `SPOTIFY_` | - | [Login with Spotify](/docs/guides/auth/social-login/auth-spotify) | +| Twitch | `TWITCH_` | - | [Login with Twitch](/docs/guides/auth/social-login/auth-twitch) | +| Twitter | `TWITTER_` | - | [Login with Twitter](/docs/guides/auth/social-login/auth-twitter) | +| WorkOS | `WORKOS_` | - | [Login with WorkOS](/docs/guides/auth/social-login/auth-workos) | +| Zoom | `ZOOM_` | - | [Login with Zoom](/docs/guides/auth/social-login/auth-zoom) | + +For each provider, you need at minimum `ENABLED`, `CLIENT_ID`, `SECRET`, and `REDIRECT_URI` in `.env` and `docker-compose.yml`. + + + +**LinkedIn (OIDC)** and **Slack (OIDC)** use multi-word env prefixes. The full Docker Compose variables are `GOTRUE_EXTERNAL_LINKEDIN_OIDC_CLIENT_ID` and `GOTRUE_EXTERNAL_SLACK_OIDC_CLIENT_ID` respectively - not `LINKEDIN_CLIENT_ID` or `SLACK_CLIENT_ID`. + + + +## Test the login flow + +You can test OAuth with the following minimal HTML page: + +- Save the code below to `index.html` +- Start `python -m http.server 3000` in the same directory +- Make sure `SITE_URL` is set to `http://localhost:3000` in your self-hosted Supabase `.env` configuration +- Open your browser and go to `http://localhost:3000` + +```html + + + +

Supabase OAuth Test

+ +

+
+    
+    
+  
+
+```
+
+For detailed client-side integration, see [Social Login](/docs/guides/auth/social-login).
+
+## Troubleshooting
+
+### "Provider not enabled" or provider shows `false` in `/auth/v1/settings`
+
+- Check that `GOTRUE_EXTERNAL_*_ENABLED` is set to `true` in `docker-compose.yml`
+- Verify the `.env` variable is not empty, e.g., check with `docker compose exec auth env | grep GOOGLE`
+
+### Variables added to `.env` but provider still not working
+
+Configuration variables from `.env` are **not** automatically available inside the container unless there's a matching passthrough definition in `docker-compose.yml`. Check, e.g., for:
+
+```
+GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOOGLE_ENABLED}
+```
+
+Run `docker compose exec auth env | grep GOTRUE_EXTERNAL` to verify the variables are reaching the container.
+
+### `SITE_URL` or redirect URL errors after login
+
+After a successful OAuth login, the Auth service redirects to `SITE_URL` or a URL from `ADDITIONAL_REDIRECT_URLS`. Ensure:
+
+- `SITE_URL` in `.env` is set to your application's URL
+- If your app uses a different redirect URL, add it to `ADDITIONAL_REDIRECT_URLS` (comma-separated)
+
+{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
+
+### Nonce check failure on mobile (Google Sign In)
+
+When using Google Sign In on mobile with ID tokens, nonce verification may fail because mobile SDKs don't always support the nonce flow that the Auth service expects.
+
+
+
+`GOTRUE_EXTERNAL_SKIP_NONCE_CHECK` disables nonce validation on ID tokens, which weakens replay-attack protection. Treat it as a **short-lived troubleshooting workaround**, not a permanent fix:
+
+- Enable it only in the environment where you're debugging the issue.
+- Revert it as soon as the issue is resolved.
+- Prefer fixing the client-side nonce handling or switching to an OAuth flow (authorization code with PKCE) that avoids ID-token nonce issues entirely.
+
+
+
+To enable it, uncomment the following line in `docker-compose.yml`:
+
+```
+GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: true
+```
+
+### Auth service fails to start
+
+Check the auth container logs:
+
+```sh
+docker compose logs auth
+```
+
+Common causes:
+
+- Missing required environment variable (e.g., `CLIENT_ID` or `SECRET` is empty)
+- Invalid `API_EXTERNAL_URL` (must be a valid URL with protocol)
+
+## Environment variable reference
+
+All OAuth-related environment variables for the `auth` service in `docker-compose.yml`:
+
+| Variable                         | Description                                                              | Required |
+| -------------------------------- | ------------------------------------------------------------------------ | -------- |
+| `GOTRUE_EXTERNAL_*_ENABLED`      | Enable the provider (`true`/`false`)                                     | Yes      |
+| `GOTRUE_EXTERNAL_*_CLIENT_ID`    | OAuth client ID from the provider                                        | Yes      |
+| `GOTRUE_EXTERNAL_*_SECRET`       | OAuth client secret from the provider                                    | Yes      |
+| `GOTRUE_EXTERNAL_*_REDIRECT_URI` | Callback URL: `${API_EXTERNAL_URL}/auth/v1/callback`                     | Yes      |
+| `GOTRUE_SITE_URL`                | Default redirect URL after authentication (set via `SITE_URL` in `.env`) | Yes      |
+
+## Additional resources
+
+- [Redirect URLs](/docs/guides/auth/redirect-urls)
+- [Auth server on GitHub](https://github.com/supabase/auth) (check README and `example.env`)
diff --git a/apps/docs/content/guides/self-hosting/self-hosted-phone-mfa.mdx b/apps/docs/content/guides/self-hosting/self-hosted-phone-mfa.mdx
new file mode 100644
index 00000000000..30d427e471f
--- /dev/null
+++ b/apps/docs/content/guides/self-hosting/self-hosted-phone-mfa.mdx
@@ -0,0 +1,225 @@
+---
+title: 'Configure Phone Login & MFA'
+description: 'Set up phone login SMS providers, OTP settings, and multi-factor authentication for self-hosted Supabase with Docker.'
+subtitle: 'Set up phone login SMS providers, OTP settings, and multi-factor authentication for self-hosted Supabase with Docker.'
+---
+
+This guide covers the **server-side configuration** for phone login and multi-factor authentication (MFA) on a self-hosted Supabase instance running with Docker Compose.
+
+For client-side implementation, see [Phone Login](/docs/guides/auth/phone-login) and [Multi-Factor Authentication](/docs/guides/auth/auth-mfa).
+
+## Before you begin
+
+You need:
+
+- A working self-hosted Supabase installation. See [Self-Hosting with Docker](/docs/guides/self-hosting/docker).
+- An account with an SMS provider (e.g., Twilio)
+
+Phone auth is **enabled by default** in the Docker setup (`ENABLE_PHONE_SIGNUP=true` in `.env`). However, without an SMS provider configured, the Auth service has no way to deliver OTP codes.
+
+## SMS provider configuration
+
+The default `.env.example` and `docker-compose.yml` include commented-out SMS provider placeholders. The example below uses Twilio - you'll need a Twilio account with an account SID, auth token, and message service SID. See [Twilio's documentation](https://www.twilio.com/docs/messaging) for how to obtain these credentials.
+
+To enable SMS delivery:
+
+### Step 1: Uncomment and configure the settings in `.env`
+
+```
+SMS_PROVIDER=twilio
+SMS_OTP_EXP=60
+SMS_OTP_LENGTH=6
+SMS_MAX_FREQUENCY=60s
+SMS_TEMPLATE=Your code is {{ .Code }}
+
+## Twilio credentials
+SMS_TWILIO_ACCOUNT_SID=your-account-sid
+SMS_TWILIO_AUTH_TOKEN=your-auth-token
+SMS_TWILIO_MESSAGE_SERVICE_SID=your-message-service-sid
+```
+
+### Step 2: Uncomment the matching lines in `docker-compose.yml`
+
+Uncomment the `GOTRUE_SMS_*` lines in the `auth` service's `environment` block:
+
+```yaml
+auth:
+  environment:
+    # ... existing variables ...
+    GOTRUE_SMS_PROVIDER: ${SMS_PROVIDER}
+    GOTRUE_SMS_OTP_EXP: ${SMS_OTP_EXP}
+    GOTRUE_SMS_OTP_LENGTH: ${SMS_OTP_LENGTH}
+    GOTRUE_SMS_MAX_FREQUENCY: ${SMS_MAX_FREQUENCY}
+    GOTRUE_SMS_TEMPLATE: ${SMS_TEMPLATE}
+    GOTRUE_SMS_TWILIO_ACCOUNT_SID: ${SMS_TWILIO_ACCOUNT_SID}
+    GOTRUE_SMS_TWILIO_AUTH_TOKEN: ${SMS_TWILIO_AUTH_TOKEN}
+    GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: ${SMS_TWILIO_MESSAGE_SERVICE_SID}
+```
+
+### Step 3: Restart the auth service
+
+```sh
+docker compose up -d --force-recreate --no-deps auth
+```
+
+### Step 4: Verify
+
+```sh
+docker compose exec auth env | grep GOTRUE_SMS
+```
+
+Confirm your provider and credentials appear in the output.
+
+
+
+For providers other than Twilio, add the provider-specific `GOTRUE_SMS_*` lines manually to `docker-compose.yml`.
+
+
+
+## OTP settings
+
+### Expiration
+
+
+
+The default OTP expiration is **60 seconds**. This is often too short for production use, consider increasing it.
+
+
+
+Set `SMS_OTP_EXP` in `.env` (value is in seconds):
+
+```
+# Set expiration to 5 minutes
+SMS_OTP_EXP=300
+```
+
+And ensure `GOTRUE_SMS_OTP_EXP: ${SMS_OTP_EXP}` is uncommented in `docker-compose.yml`.
+
+### Length
+
+The default OTP length is 6 digits. You can set it to any value between 6 and 10:
+
+```
+SMS_OTP_LENGTH=8
+```
+
+### Rate limiting
+
+`SMS_MAX_FREQUENCY` controls the minimum interval between SMS sends to the same phone number. The default is 60 seconds:
+
+```
+## Allow one SMS every 30 seconds
+SMS_MAX_FREQUENCY=30s
+```
+
+## Test OTPs for development
+
+To avoid sending real SMS during development, use `SMS_TEST_OTP` to map phone numbers to fixed OTP codes:
+
+```
+SMS_TEST_OTP=16505551234:123456,16505555678:654321
+```
+
+And uncomment `GOTRUE_SMS_TEST_OTP: ${SMS_TEST_OTP}` in `docker-compose.yml`.
+
+When a test phone number requests an OTP, the Auth service skips SMS delivery and accepts only the mapped code. Other phone numbers continue to use the real SMS provider.
+
+
+
+Remove test OTPs before deploying to production. You can also set an expiration with `SMS_TEST_OTP_VALID_UNTIL` (ISO 8601 datetime, e.g., `2026-12-31T23:59:59Z`) so they stop working automatically.
+
+
+
+## Multi-factor authentication (MFA)
+
+The Auth service supports three MFA factor types. Configure them by uncommenting variables in `.env` and the matching `GOTRUE_MFA_*` lines in `docker-compose.yml`.
+
+### App authenticator (TOTP)
+
+TOTP is **enabled by default** - users can enroll with apps like Google Authenticator or Authy without any additional configuration.
+
+To disable TOTP:
+
+```
+MFA_TOTP_ENROLL_ENABLED=false
+MFA_TOTP_VERIFY_ENABLED=false
+```
+
+### Phone MFA
+
+Phone MFA is **disabled by default** (opt-in). It uses the same SMS provider configuration as phone login.
+
+To enable:
+
+```
+MFA_PHONE_ENROLL_ENABLED=true
+MFA_PHONE_VERIFY_ENABLED=true
+```
+
+### Maximum enrolled factors
+
+By default, a user can enroll up to 10 MFA factors. To change this:
+
+```
+MFA_MAX_ENROLLED_FACTORS=5
+```
+
+## Troubleshooting
+
+### OTP expires too quickly
+
+The default `SMS_OTP_EXP` is 60 seconds. Increase it in `.env`:
+
+```
+SMS_OTP_EXP=300
+```
+
+Ensure `GOTRUE_SMS_OTP_EXP: ${SMS_OTP_EXP}` is uncommented in `docker-compose.yml`, then restart:
+
+```sh
+docker compose up -d --force-recreate --no-deps auth
+```
+
+### SMS not being delivered
+
+Check the auth container logs for errors:
+
+```sh
+docker compose logs auth --tail 50
+```
+
+Verify provider credentials reach the container:
+
+```sh
+docker compose exec auth env | grep GOTRUE_SMS
+```
+
+Common causes:
+
+- Provider credentials are in `.env` but the matching `GOTRUE_SMS_*` line is still commented out in `docker-compose.yml`
+- Provider credentials are wrong
+- Phone number format is wrong (use E.164 format: `+1234567890`)
+
+### Variables are configured in `.env` but not working
+
+Configuration variables from `.env` are **not** automatically available inside the container unless there's a matching passthrough definition in `docker-compose.yml`. Check, e.g., for:
+
+```sh
+docker compose exec auth env | grep -E 'GOTRUE_SMS|GOTRUE_MFA'
+```
+
+After changing the configuration environment variables, recreate the Auth service container:
+
+```sh
+docker compose up -d --force-recreate --no-deps auth
+```
+
+### Rate limit errors
+
+If users see "rate limit exceeded" errors, check `SMS_MAX_FREQUENCY` (minimum interval between sends) and the global rate limit `GOTRUE_RATE_LIMIT_SMS_SENT` (default: 30 per hour).
+
+### Additional resources
+
+- [Multi-Factor Authentication (Phone)](/docs/guides/auth/auth-mfa/phone)
+- [Multi-Factor Authentication (TOTP)](/docs/guides/auth/auth-mfa/totp)
+- [Auth server on GitHub](https://github.com/supabase/auth) (check README and `example.env`)
diff --git a/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx b/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx
new file mode 100644
index 00000000000..25723468ba8
--- /dev/null
+++ b/apps/docs/content/guides/self-hosting/self-hosted-proxy-https.mdx
@@ -0,0 +1,228 @@
+---
+title: 'Configure Reverse Proxy and HTTPS'
+description: 'Set up a reverse proxy with HTTPS for self-hosted Supabase.'
+subtitle: 'Set up a reverse proxy with HTTPS for self-hosted Supabase.'
+---
+
+HTTPS is required for production self-hosted Supabase deployments. This guide covers two production approaches using a reverse proxy in front of self-hosted Supabase API gateway, plus a self-signed certificate option for development environment.
+
+## Before you begin
+
+You need:
+
+- A working self-hosted Supabase installation. See [Self-Hosting with Docker](/docs/guides/self-hosting/docker).
+- A domain name with DNS pointing to your server's public IP address (to obtain Let's Encrypt certificate).
+- Ports 80 and 443 open.
+
+## Set up HTTPS
+
+Below are two options for adding a reverse proxy with automatic HTTPS in front of your self-hosted Supabase: **Caddy** (simpler, zero-config TLS) and **Nginx + Let's Encrypt** (more control over proxy settings). Both sit in front of Kong and terminate TLS, so internal traffic stays on HTTP.
+
+
+
+If you already run [HAProxy](https://www.haproxy.com/), [Traefik](https://traefik.io/), [Nginx Proxy Manager](https://nginxproxymanager.com/), or another reverse proxy for your infrastructure, you can use it instead of Caddy or Nginx above. The key requirements are:
+
+- Proxy to Kong on port `8000` (or `:8000` if the proxy runs outside the Docker network)
+- Enable WebSocket support (required for Realtime)
+- Proxy traffic to Storage directly to the container, bypassing Kong
+- Add `X-Forwarded` headers to all requests
+- Comment out Kong's host port bindings in `docker-compose.yml` if the proxy runs in the same Docker network
+- Update `SUPABASE_PUBLIC_URL`, `API_EXTERNAL_URL`, and `SITE_URL` in `.env` to your HTTPS URL
+
+
+
+### Step 1: Remove public port bindings for API gateway
+
+Comment out Kong's host port mappings in `docker-compose.yml` so that it's not exposed to the Internet:
+
+```yaml
+kong:
+  # ...
+  ports:
+    # - ${KONG_HTTP_PORT}:8000/tcp
+    # - ${KONG_HTTPS_PORT}:8443/tcp
+```
+
+Kong remains accessible to other containers on the internal Docker network.
+
+### Step 2: Update environment variables
+
+Update the URL configuration in your `.env` file to use your HTTPS domain:
+
+```
+SUPABASE_PUBLIC_URL=https://
+API_EXTERNAL_URL=https://
+SITE_URL=https://
+```
+
+Change the following to your domain name and a **valid** email address:
+
+```
+PROXY_DOMAIN=your-domain.example.com
+CERTBOT_EMAIL=admin@your-domain.example.com
+```
+
+### Step 3: Start the reverse proxy
+
+Pick one of the options below and use the corresponding Docker Compose overlay.
+
+ 
+
+[Caddy](https://caddyserver.com/) automatically provisions and renews Let's Encrypt TLS certificates with zero configuration. It also handles HTTP-to-HTTPS redirects, WebSocket upgrades, and HTTP/2 and HTTP/3 out of the box.
+
+Start Caddy by using the pre-configured `docker-compose.caddy.yml` overlay:
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
+```
+
+Caddy configuration is in `volumes/proxy/caddy/Caddyfile`.
+
+
+
+
+This option uses a 3rd party Nginx Docker image ([`jonasal/nginx-certbot`](https://github.com/JonasAlfredsson/docker-nginx-certbot)), which includes Certbot for automatic Let's Encrypt certificate issuance and renewal in a single container.
+
+Start Nginx by using the pre-configured `docker-compose.nginx.yml` overlay:
+
+```sh
+docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
+```
+
+Nginx configuration template is in `volumes/proxy/nginx/supabase-nginx.conf.tpl`. On container startup, `${NGINX_SERVER_NAME}` is substituted using the environment variable from the `.env` file. The [`jonasal/nginx-certbot`](https://github.com/JonasAlfredsson/docker-nginx-certbot) image reads the resolved `server_name` to determine which domain to request a Let's Encrypt certificate for.
+
+HTTP-to-HTTPS redirects are handled automatically by the `jonasal/nginx-certbot` image.
+
+
+
+
+### Step 4: Verify HTTPS connection
+
+```sh
+curl -I https:///auth/v1/
+```
+
+You should receive a `401` response confirming you could connect to Auth.
+
+## Self-signed certificates (development only)
+
+
+
+Self-signed certificates trigger browser warnings and are rejected by most OAuth providers. Use this approach only in development environment or internal networks.
+
+
+
+For development or internal networks where you cannot use Let's Encrypt, you can configure Kong to serve HTTPS directly using self-signed certificates.
+
+### Step 1: Generate a self-signed certificate
+
+Change `` in the example below, and create certificates with `openssl`:
+
+```sh
+openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
+  -keyout volumes/api/server.key \
+  -out volumes/api/server.crt \
+  -subj "/CN=" && \
+  chmod 640 volumes/api/server.key && \
+  chgrp 65533 volumes/api/server.key
+```
+
+{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
+
+### Step 2: Configure Kong for SSL
+
+Comment out Kong's HTTP port mapping in `docker-compose.yml`:
+
+```yaml
+kong:
+  # ...
+  ports:
+    # - ${KONG_HTTP_PORT}:8000/tcp
+```
+
+Uncomment the certificate volume mounts and SSL environment variables in `docker-compose.yml`:
+
+```yaml
+kong:
+  # ... existing configuration ...
+  volumes:
+    - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z
+    - ./volumes/api/server.crt:/home/kong/server.crt:ro
+    - ./volumes/api/server.key:/home/kong/server.key:ro
+  environment:
+    # ... existing environment variables ...
+    KONG_SSL_CERT: /home/kong/server.crt
+    KONG_SSL_CERT_KEY: /home/kong/server.key
+```
+
+### Step 3: Update configuration variables in `.env`
+
+Edit your `.env` file to use HTTPS with the Kong HTTPS port:
+
+```
+SUPABASE_PUBLIC_URL=https://:8443
+API_EXTERNAL_URL=https://:8443
+SITE_URL=https://:8443
+```
+
+### Step 4: Restart and verify
+
+```sh
+docker compose down && docker compose up -d
+```
+
+```sh
+curl -I -k https://:8443/auth/v1/
+```
+
+The `-k` flag tells curl to accept the self-signed certificate.
+
+## Troubleshooting
+
+### Certificate not issued
+
+If Caddy or Certbot fails to obtain a certificate:
+
+- Verify that ports 80 and 443 are open on your firewall
+- Verify that your domain's DNS A record points to your server's public IP
+- Check proxy logs via `docker logs supabase-caddy` or `docker logs supabase-nginx`
+- Let's Encrypt has [rate limits](https://letsencrypt.org/docs/rate-limits/) - if you hit them, wait before retrying
+
+### WebSocket connection failed
+
+If Realtime subscriptions fail to connect:
+
+- **Caddy** handles WebSocket upgrades automatically - check that Kong is healthy
+- **Nginx** requires explicit `Upgrade` and `Connection` headers on the `/realtime/v1/` location. Verify your `nginx.conf` includes these headers as shown above
+
+### OAuth callback URL mismatch
+
+If OAuth redirects fail with a callback URL error:
+
+- Verify `API_EXTERNAL_URL` in `.env` is set to your HTTPS URL
+- Verify the callback URL registered with your OAuth provider matches `API_EXTERNAL_URL` followed by `/auth/v1/callback`
+- After changing `API_EXTERNAL_URL`, restart all services with `docker compose down && docker compose up -d`
+
+### Mixed content warnings
+
+If the browser console shows mixed content errors:
+
+- Verify `SUPABASE_PUBLIC_URL` is set to your HTTPS URL
+- Verify `SITE_URL` is also set to HTTPS
+- Clear your browser cache after making changes
+
+### ERR_CERT_AUTHORITY_INVALID
+
+This is expected when using self-signed certificates. For production, use Caddy or Nginx with Let's Encrypt. If you need to use self-signed certificates, add the certificate to your system's trust store or use a browser flag to bypass the warning.
+
+## Additional resources
+
+- [Caddy documentation](https://caddyserver.com/docs/)
+- [Nginx documentation](https://nginx.org/en/docs/) (on nginx.org)
+- [docker-nginx-certbot on GitHub](https://github.com/JonasAlfredsson/docker-nginx-certbot)
diff --git a/apps/docs/content/guides/telemetry/log-drains.mdx b/apps/docs/content/guides/telemetry/log-drains.mdx
index 9bf926f643d..9b5b8adfdd0 100644
--- a/apps/docs/content/guides/telemetry/log-drains.mdx
+++ b/apps/docs/content/guides/telemetry/log-drains.mdx
@@ -4,7 +4,7 @@ title: 'Log Drains'
 description: 'Getting started with Supabase Log Drains'
 ---
 
-Log drains will send all logs of the Supabase stack to one or more desired destinations. It is only available for customers on Team and Enterprise Plans. Log drains is available in the dashboard under [Project Settings > Log Drains](/dashboard/project/_/settings/log-drains).
+Log drains send all logs of the Supabase stack to one or more desired destinations. It is only available for customers on Pro, Team and Enterprise Plans. Log drains are available in the dashboard under [Project Settings > Log Drains](/dashboard/project/_/settings/log-drains).
 
 You can read about the initial announcement [here](/blog/log-drains) and vote for your preferred drains in [this discussion](https://github.com/orgs/supabase/discussions/28324?sort=top).
 
diff --git a/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx b/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx
index ecbb16f8bea..203e64a42dd 100644
--- a/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx
+++ b/apps/docs/content/troubleshooting/all-about-supabase-egress-a_Sg_e.mdx
@@ -9,7 +9,7 @@ database_id = "ba593989-12b5-464f-8ede-a525e1ca2ffb"
 
 **What is Egress?**
 
-Egress (also known as bandwidth) is any amount of network packets/bytes being streamed back to a connected client. Means, the data that is leaving the Supabase platform. Egress in Supabase includes any calls through PostgREST, to Storage, Realtime, Auth, Edge Functions, Database and Supavisor.
+Egress (also known as bandwidth) is any amount of network packets/bytes being streamed to a connected client from your project. Means, the data that is leaving the Supabase platform. Egress in Supabase includes any calls through PostgREST, to Storage, Realtime, Auth, Edge Functions, Database and Supavisor.
 
 You can read about Unified egress, included quota, and how to check the egress usage here: https://supabase.com/docs/guides/platform/manage-your-usage/egress. Additionally, the [project reports](/dashboard/project/_/observability) have a few egress related stats. You can create a custom report to look into daily egress.
 
diff --git a/apps/docs/content/troubleshooting/supabase-storage-inefficient-folder-operations-and-hierarchical-rls-challenges-b05a4d.mdx b/apps/docs/content/troubleshooting/supabase-storage-inefficient-folder-operations-and-hierarchical-rls-challenges-b05a4d.mdx
index c4fdf405942..d666ff718b3 100644
--- a/apps/docs/content/troubleshooting/supabase-storage-inefficient-folder-operations-and-hierarchical-rls-challenges-b05a4d.mdx
+++ b/apps/docs/content/troubleshooting/supabase-storage-inefficient-folder-operations-and-hierarchical-rls-challenges-b05a4d.mdx
@@ -20,3 +20,45 @@ To overcome these limitations and implement robust folder management with hierar
 - **Implement RLS policies on `storage.objects`.** These policies must `JOIN` with your custom metadata table to enforce hierarchical access permissions based on your defined folder structure.
 - **Handle batch folder operations via your metadata table.** For operations like moving or renaming folders, update the relevant entries in your custom metadata table. Note that actual file paths in Storage are not directly altered by these operations.
 - **Optimize RLS policies for performance.** `JOIN`s in RLS policies can lead to performance degradation, especially with large datasets. Ensure proper indexing on your custom metadata table and consider using `SECURITY DEFINER` functions to optimize policy execution.
+
+## Alternative approach: Using the S3 protocol for bulk operations
+
+Supabase Storage also supports an S3-compatible API. This allows you to use tools like the AWS CLI to perform bulk file operations such as downloading, moving, or reorganizing objects more efficiently.
+
+Install the AWS CLI by following the [AWS CLI installation guide](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html).
+
+Create S3 credentials in Supabase using the [Supabase S3 authentication guide](/docs/guides/storage/s3/authentication?queryGroups=language&language=credentials).
+
+Configure an AWS CLI profile using the credentials you generated in Supabase. The profile name can be anything, but it must match the value used in the following commands.
+
+```shell
+aws configure --profile supabase-s3
+```
+
+Download files from a bucket or prefix:
+
+```shell
+aws s3 cp s3://bucket-name/folder-name ./download-target
+--profile supabase-s3
+--endpoint-url https://.supabase.co/storage/v1/s3
+--recursive
+--region 
+```
+
+- Replace `bucket-name` with your bucket name.
+- Replace `folder-name` with the prefix you want to download, or omit it to download the entire bucket.
+- Replace `` with your Supabase project reference.
+- Replace `` with your project's region (for example `eu-central-1`).
+- `./download-target` is the local directory where files will be saved.
+
+Move or rename files using the `mv` command. Because folders in Supabase Storage are implemented as prefixes, renaming a folder is effectively moving objects from one prefix to another.
+
+```shell
+aws s3 mv s3://bucket-name-one/folder-name-one s3://bucket-name-two/folder-name-two
+--profile supabase-s3
+--endpoint-url https://.supabase.co/storage/v1/s3
+--recursive
+--region 
+```
+
+This method is useful for large-scale downloads, migrations, or reorganizing files within a bucket.
diff --git a/apps/docs/features/docs/GuidesMdx.template.tsx b/apps/docs/features/docs/GuidesMdx.template.tsx
index 76fbe6d397c..97758032965 100644
--- a/apps/docs/features/docs/GuidesMdx.template.tsx
+++ b/apps/docs/features/docs/GuidesMdx.template.tsx
@@ -5,7 +5,7 @@ import ReactMarkdown from 'react-markdown'
 import { cn } from 'ui'
 
 import Breadcrumbs from '~/components/Breadcrumbs'
-import GuidesTableOfContents from '~/components/GuidesTableOfContents'
+import GuidesSidebar from '~/components/GuidesSidebar'
 import { TocAnchorsProvider } from '~/features/docs/GuidesMdx.client'
 import { MDXRemoteBase } from '~/features/docs/MdxBase'
 import type { WithRequired } from '~/features/helpers.types'
@@ -71,7 +71,7 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT
             'relative',
             'transition-all ease-out',
             'duration-100',
-            hideToc ? 'col-span-12' : 'col-span-12 md:col-span-9'
+            'col-span-12 md:col-span-9'
           )}
         >
           
@@ -114,24 +114,23 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT
             
           
         
- {!hideToc && ( - - )} +
) diff --git a/apps/docs/features/docs/MdxBase.shared.tsx b/apps/docs/features/docs/MdxBase.shared.tsx index 7c82b3a5d56..3502b92392d 100644 --- a/apps/docs/features/docs/MdxBase.shared.tsx +++ b/apps/docs/features/docs/MdxBase.shared.tsx @@ -96,6 +96,13 @@ const components = { ), pre: CodeBlock, + /** + * Force inline code tags to go sync, this prevents Heading anchor resolution fail due to + * our CodeBlock component being async. We need to find a better solution for more future + * proof MDX rendering. Definitely improving the anchors utility in the ui/Heading component + * plus having a more resilient highlighting strategy. + */ + code: (props: any) => {props.children}, Price, } diff --git a/apps/docs/features/ui/guide/Guide.tsx b/apps/docs/features/ui/guide/Guide.tsx index e0179dd7ab3..9fa9b8c31c3 100644 --- a/apps/docs/features/ui/guide/Guide.tsx +++ b/apps/docs/features/ui/guide/Guide.tsx @@ -3,7 +3,7 @@ import { createContext, useContext, type ReactNode } from 'react' import { cn } from 'ui' -import GuidesTableOfContents from '~/components/GuidesTableOfContents' +import GuidesTableOfContents from '~/components/GuidesSidebar' import { TocAnchorsProvider } from '~/features/docs/GuidesMdx.client' import { type GuideFrontmatter } from '~/lib/docs' diff --git a/apps/docs/internals/generate-guides-markdown.ts b/apps/docs/internals/generate-guides-markdown.ts new file mode 100644 index 00000000000..c07cb27bcb2 --- /dev/null +++ b/apps/docs/internals/generate-guides-markdown.ts @@ -0,0 +1,165 @@ +import fs from 'fs' +import path from 'path' +import { globby } from 'globby' +import matter from 'gray-matter' + +const PARTIALS_DIR = path.join(process.cwd(), 'content', '_partials') + +/** + * Reads <$Partial path="..." /> tags and replaces them with the file contents. + * Recurses to handle nested partials. + */ +async function inlinePartials(content: string): Promise { + const partialRegex = /<\$Partial\s+path="([^"]+)"[^/]*\/>/g + const matches = [...content.matchAll(partialRegex)] + for (const [fullMatch, partialPath] of matches) { + try { + const raw = await fs.promises.readFile(path.join(PARTIALS_DIR, partialPath), 'utf8') + const { content: partialBody } = matter(raw) + const inlined = await inlinePartials(partialBody) + content = content.replace(fullMatch, inlined) + } catch { + content = content.replace(fullMatch, '') + } + } + return content +} + +/** Remove the minimum common leading whitespace from all non-empty lines. */ +function dedentBlock(text: string): string { + const lines = text.split('\n') + const nonEmpty = lines.filter((l) => /\S/.test(l)) + if (!nonEmpty.length) return text + const minIndent = Math.min(...nonEmpty.map((l) => (l.match(/^([ \t]*)/) ?? ['', ''])[1].length)) + if (!minIndent) return text + return lines.map((l) => l.slice(minIndent)).join('\n') +} + +/** + * Converts StepHikeCompact components to markdown ordered lists. + * Each step becomes a numbered item: the title (from Details) is bolded on the item + * line, and the full step body (Details + Code) is dedented and appended below. + * Remaining JSX tags inside the body are later stripped by stripJsxTags. + */ +function convertStepHike(content: string): string { + return content.replace(/([\s\S]*?)<\/StepHikeCompact>/g, (_, body) => { + const items: string[] = [] + const stepRe = /]*>([\s\S]*?)<\/StepHikeCompact\.Step>/g + let stepNum = 1 + let m: RegExpExecArray | null + while ((m = stepRe.exec(body)) !== null) { + const stepBody = m[1] + const titleMatch = stepBody.match(/]+title="([^"]*)"/) + const title = titleMatch ? titleMatch[1] : '' + // Dedent the entire step body so nested JSX indentation is removed. + // Remaining component tags (Details, Code, Admonition…) are stripped later. + const inner = dedentBlock(stepBody).trim() + const item = title ? `${stepNum}. **${title}**\n\n${inner}` : `${stepNum}. ${inner}` + items.push(item) + stepNum++ + } + return items.join('\n\n') + }) +} + +/** + * Strips JSX component tags (capitalized names, dot-notation, or $-prefixed) + * while keeping their inner content. Also strips wrapper div and a elements. + * Removes MDX JSX comment blocks. Strips unnecessary leading indentation from + * non-code-block lines. + */ +function stripJsxTags(content: string): string { + // Remove MDX/JSX comments {/* ... */} + content = content.replace(/\{\/\*[\s\S]*?\*\/\}/g, '') + + // Remove self-closing JSX components: or <$Directive ... /> + content = content.replace(/<[\$A-Z][\w.]*(?:\s[^>]*)?\s*\/>/gs, '') + + // Remove opening JSX component tags (possibly multi-line): + content = content.replace(/<[\$A-Z][\w.]*(?:\s[^>]*)?\s*>/gs, '') + + // Remove closing JSX component tags: + content = content.replace(/<\/[\$A-Z][\w.]*>/g, '') + + // Remove wrapper div and a elements used structurally in MDX (carry JSX props + // like className which are not valid HTML; inner content such as img is preserved) + content = content.replace(/]*)?\s*>/g, '') + content = content.replace(/<\/div>/g, '') + content = content.replace(/]*)?\s*>/g, '') + content = content.replace(/<\/a>/g, '') + + // Split on fenced code blocks to handle prose and code separately. + // For prose (even segments): strip leading whitespace (removes JSX nesting indent). + // For code blocks (odd segments): dedent the body to remove JSX nesting indent while + // preserving relative code structure, then normalize the closing fence. + const segments = content.split(/(```[\s\S]*?```)/g) + content = segments + .map((seg, i) => { + if (i % 2 === 0) return seg.replace(/^[ \t]+/gm, '') + return seg.replace( + /^(```[^\n]*\n)([\s\S]*?)(\n[ \t]*```)$/, + (_, open, body) => open + dedentBlock(body) + '\n```' + ) + }) + .join('') + + // Collapse lines that are only whitespace to empty lines, then deduplicate blank lines + content = content.replace(/^[^\S\n]+$/gm, '') + content = content.replace(/\n{3,}/g, '\n\n').trim() + + return content +} + +async function generate() { + const files = await globby(['content/guides/**/!(_)*.mdx']) + let warnings = 0 + + await Promise.all( + files.map(async (filePath) => { + const outPath = filePath + .replace(/^content\/guides\//, 'public/docs/guides/') + .replace(/\.mdx$/, '.md') + + let output: string + try { + const raw = await fs.promises.readFile(filePath, 'utf8') + const { content: rawContent, data } = matter(raw) + + const withPartials = await inlinePartials(rawContent) + const withSteps = convertStepHike(withPartials) + const processed = stripJsxTags(withSteps) + + const header = [ + data.title ? `# ${data.title}` : '', + data.subtitle || data.description ? `\n${data.subtitle ?? data.description}` : '', + ] + .filter(Boolean) + .join('\n') + + output = header ? `${header}\n\n${processed}` : processed + } catch (err) { + warnings++ + console.warn( + `[warn] Failed to process ${filePath}: ${err instanceof Error ? err.message : err}` + ) + // Fall back to raw file content so the route still serves something + try { + output = await fs.promises.readFile(filePath, 'utf8') + } catch { + output = `` + } + } + + // content/guides/ai/vector-columns.mdx → public/docs/guides/ai/vector-columns.md + // Placing under public/docs/ ensures the file is served at /docs/guides/... + // matching the exact URL of the rendered page. + await fs.promises.mkdir(path.dirname(outPath), { recursive: true }) + await fs.promises.writeFile(outPath, output) + }) + ) + + const summary = warnings ? ` (${warnings} with warnings)` : '' + console.log(`Generated ${files.length} markdown files under public/docs/guides/${summary}`) +} + +generate() diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index ea55aafa52e..27868037dee 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -6,8 +6,19 @@ import { BASE_PATH } from '~/lib/constants' const REFERENCE_PATH = `${BASE_PATH ?? ''}/reference` +const GUIDES_PATH = `${BASE_PATH ?? ''}/guides` + export function middleware(request: NextRequest) { const url = new URL(request.url) + + // Serve pre-generated .md files before the [[...slug]] page route can intercept them + if (url.pathname.startsWith(GUIDES_PATH + '/') && url.pathname.endsWith('.md')) { + const slug = url.pathname.slice(GUIDES_PATH.length + 1, -'.md'.length) + const rewriteUrl = new URL(url) + rewriteUrl.pathname = `${BASE_PATH ?? ''}/api/guides-md/${slug}` + return NextResponse.rewrite(rewriteUrl) + } + if (!url.pathname.startsWith(REFERENCE_PATH)) { return NextResponse.next() } @@ -56,5 +67,5 @@ export function middleware(request: NextRequest) { } export const config = { - matcher: '/reference/:path*', + matcher: ['/reference/:path*', '/guides/:path*'], } diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index d1419a3e104..53e8066039e 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -60,6 +60,7 @@ const nextConfig = { ], outputFileTracingIncludes: { '/api/crawlers': ['./features/docs/generated/**/*', './docs/ref/**/*'], + '/api/guides-md/**/*': ['./public/docs/guides/**/*'], '/guides/**/*': ['./content/guides/**/*', './content/troubleshooting/**/*', './examples/**/*'], '/reference/**/*': ['./features/docs/generated/**/*', './docs/ref/**/*'], }, diff --git a/apps/docs/package.json b/apps/docs/package.json index 98acb87d8d3..1f562c8413e 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -7,6 +7,7 @@ "build": "next build", "build:analyze": "ANALYZE=true next build", "build:llms": "tsx --conditions=react-server ./scripts/llms.ts", + "build:guides-markdown": "tsx ./internals/generate-guides-markdown.ts", "build:sitemap": "tsx ./internals/generate-sitemap.ts", "clean": "rimraf .next .turbo node_modules features/docs/generated examples __generated__", "codegen:examples": "shx cp -r ../../examples ./examples", @@ -25,7 +26,7 @@ "lint": "eslint .", "lint:mdx": "supa-mdx-lint content --config ../../supa-mdx-lint.config.toml", "postbuild": "pnpm run build:sitemap && pnpm run build:llms && ./../../scripts/upload-static-assets.sh", - "prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples", + "prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples && pnpm run build:guides-markdown", "predev": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples", "preembeddings": "pnpm run codegen:references", "preinstall": "npx only-allow pnpm", diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index c8a6dbae323..f6ebc3f70d8 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -2,14 +2,17 @@ TEAM Supabase is 100% remote. +Aaron Brooks Aaron Byrne Ada Wong +Adam Mohammed Adam Mokan AJ Matias Akash Manimaran Alaister Young Alan De Los Santos Aleksi Immonen +Alex Hall Alexander Korotkov Ali Waseem Allan S @@ -82,6 +85,7 @@ Francesco Sansalvadore Garrett Crowell Gerardo Estaba Gildas Garcia +Giuseppe Mandato Greg Kress Greg P Greg Richardson @@ -108,7 +112,10 @@ Jess Shears Jim Brodeur Jim Chanco Jr Joakim Ahrlin +Joaquim Moreno Joel Low +Joel Martin +Johan Bergström John Pena John Schaeffer Jon M diff --git a/apps/docs/public/img/mcp-clients/claude-dark-icon.svg b/apps/docs/public/img/mcp-clients/claude-dark-icon.svg deleted file mode 100644 index b68b27324e2..00000000000 --- a/apps/docs/public/img/mcp-clients/claude-dark-icon.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - diff --git a/apps/docs/public/img/mcp-clients/claude-icon.svg b/apps/docs/public/img/mcp-clients/claude-icon.svg index 135a8b9f33f..7b9c0252ed7 100644 --- a/apps/docs/public/img/mcp-clients/claude-icon.svg +++ b/apps/docs/public/img/mcp-clients/claude-icon.svg @@ -1,16 +1,3 @@ - - - - - - - - - - - - - \ No newline at end of file + + + diff --git a/apps/docs/public/img/mcp-clients/cursor-dark-icon.svg b/apps/docs/public/img/mcp-clients/cursor-dark-icon.svg deleted file mode 100644 index 48f85eb4d5a..00000000000 --- a/apps/docs/public/img/mcp-clients/cursor-dark-icon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/docs/public/img/mcp-clients/factory-dark-icon.svg b/apps/docs/public/img/mcp-clients/factory-icon-dark.svg similarity index 100% rename from apps/docs/public/img/mcp-clients/factory-dark-icon.svg rename to apps/docs/public/img/mcp-clients/factory-icon-dark.svg diff --git a/apps/docs/public/img/mcp-clients/gemini-cli-dark-icon.svg b/apps/docs/public/img/mcp-clients/gemini-cli-dark-icon.svg deleted file mode 100644 index 7e457a00a77..00000000000 --- a/apps/docs/public/img/mcp-clients/gemini-cli-dark-icon.svg +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/docs/public/img/mcp-clients/goose-dark-icon.svg b/apps/docs/public/img/mcp-clients/goose-icon-dark.svg similarity index 100% rename from apps/docs/public/img/mcp-clients/goose-dark-icon.svg rename to apps/docs/public/img/mcp-clients/goose-icon-dark.svg diff --git a/apps/docs/public/img/mcp-clients/kiro-dark-icon.svg b/apps/docs/public/img/mcp-clients/kiro-dark-icon.svg deleted file mode 100644 index ef4abd2d85d..00000000000 --- a/apps/docs/public/img/mcp-clients/kiro-dark-icon.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/apps/docs/public/img/mcp-clients/openai-dark-icon.svg b/apps/docs/public/img/mcp-clients/openai-icon-dark.svg similarity index 100% rename from apps/docs/public/img/mcp-clients/openai-dark-icon.svg rename to apps/docs/public/img/mcp-clients/openai-icon-dark.svg diff --git a/apps/docs/public/img/mcp-clients/openai-icon.svg b/apps/docs/public/img/mcp-clients/openai-icon.svg index 2838e13817b..316440a338d 100644 --- a/apps/docs/public/img/mcp-clients/openai-icon.svg +++ b/apps/docs/public/img/mcp-clients/openai-icon.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/docs/public/img/mcp-clients/opencode-dark-icon.svg b/apps/docs/public/img/mcp-clients/opencode-icon-dark.svg similarity index 100% rename from apps/docs/public/img/mcp-clients/opencode-dark-icon.svg rename to apps/docs/public/img/mcp-clients/opencode-icon-dark.svg diff --git a/apps/docs/public/img/mcp-clients/vscode-dark-icon.svg b/apps/docs/public/img/mcp-clients/vscode-dark-icon.svg deleted file mode 100644 index c453e633f34..00000000000 --- a/apps/docs/public/img/mcp-clients/vscode-dark-icon.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/docs/public/img/mcp-clients/windsurf-dark-icon.svg b/apps/docs/public/img/mcp-clients/windsurf-icon-dark.svg similarity index 100% rename from apps/docs/public/img/mcp-clients/windsurf-dark-icon.svg rename to apps/docs/public/img/mcp-clients/windsurf-icon-dark.svg diff --git a/apps/docs/public/img/troubleshooting/reports-page-egress.png b/apps/docs/public/img/troubleshooting/reports-page-egress.png new file mode 100644 index 00000000000..8801fc6fd8e Binary files /dev/null and b/apps/docs/public/img/troubleshooting/reports-page-egress.png differ diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index 5903ab1be23..3137e6c6f54 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -3,9 +3,9 @@ "react-hooks/exhaustive-deps": 208, "import/no-anonymous-default-export": 57, "@tanstack/query/exhaustive-deps": 13, - "@typescript-eslint/no-explicit-any": 1214, + "@typescript-eslint/no-explicit-any": 1207, "no-restricted-imports": 59, - "no-restricted-exports": 297, + "no-restricted-exports": 296, "react/no-unstable-nested-components": 69 }, "ruleFiles": { @@ -87,7 +87,6 @@ "components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx": 1, "components/interfaces/Settings/Database/SSLConfiguration.tsx": 1, "components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx": 1, - "components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx": 1, "components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx": 1, "components/interfaces/Settings/Logs/LogTable.tsx": 2, "components/interfaces/Settings/Logs/LogsPreviewer.tsx": 2, @@ -636,7 +635,6 @@ "data/materialized-views/materialized-views-query.ts": 1, "data/network-restrictions/network-restrictions-query.ts": 3, "data/notifications/keys.ts": 1, - "data/notifications/notifications-v2-query.ts": 2, "data/oauth-secrets/client-secret-create-mutation.ts": 2, "data/open-api/api-spec-query.ts": 8, "data/organization-members/organization-roles-query.ts": 1, @@ -683,7 +681,7 @@ "hooks/ui/useClickedOutside.ts": 2, "hooks/ui/useFlag.ts": 1, "hooks/useStaticEffectEvent.ts": 1, - "instrumentation-client.ts": 2, + "instrumentation-client.ts": 3, "lib/ai/generate-assistant-response.ts": 2, "lib/ai/model.ts": 2, "lib/ai/model.utils.ts": 4, @@ -721,13 +719,11 @@ "pages/project/[ref]/settings/log-drains.tsx": 1, "pages/project/[ref]/storage/files/buckets/[bucketId].tsx": 1, "state/ai-assistant-state.tsx": 4, - "state/editor-panel-state.tsx": 4, "state/role-impersonation-state.tsx": 1, "state/sql-editor-v2.ts": 5, - "state/storage-explorer.tsx": 16, + "state/storage-explorer.tsx": 15, "state/table-editor-table.tsx": 9, "state/table-editor.tsx": 5, - "state/tabs.tsx": 1, "tests/lib/custom-render.tsx": 1, "tests/setup/polyfills.ts": 3, "tests/vitestSetup.ts": 1, @@ -951,7 +947,6 @@ "components/interfaces/Settings/General/CustomDomainConfig/DNSRecord.tsx": 1, "components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx": 1, "components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx": 1, - "components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx": 1, "components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DropAllReplicasConfirmationModal.tsx": 1, "components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx": 1, "components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx": 1, @@ -975,7 +970,6 @@ "components/interfaces/Settings/Logs/RecentQueriesItem.tsx": 1, "components/interfaces/Settings/Logs/UpgradePrompt.tsx": 1, "components/interfaces/SignIn/PasswordConditionsHelper.tsx": 1, - "components/interfaces/SignIn/ResetPasswordForm.tsx": 1, "components/interfaces/Storage/StorageBucketsError.tsx": 1, "components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx": 1, "components/interfaces/Storage/StoragePolicies/StoragePoliciesEditor.tsx": 1, @@ -1114,7 +1108,6 @@ "components/interfaces/Organization/Usage/UsageBarChart.tsx": 1, "components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx": 1, "components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx": 1, - "components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx": 1, "components/interfaces/Settings/Logs/LogSelection.tsx": 1, "components/interfaces/Settings/Logs/LogTable.tsx": 3, "components/interfaces/Settings/Logs/LogsFormatters.tsx": 2, diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index be97e1cefa5..04990fd8d49 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -77,7 +77,6 @@ export const SupabaseGrid = ({ } = useTableRowsQuery( { projectRef: project?.ref, - connectionString: project?.connectionString, tableId, sorts, filters, diff --git a/apps/studio/components/grid/components/editor/TimeEditor.tsx b/apps/studio/components/grid/components/editor/TimeEditor.tsx index ba9a34fdcab..ca89fb895d8 100644 --- a/apps/studio/components/grid/components/editor/TimeEditor.tsx +++ b/apps/studio/components/grid/components/editor/TimeEditor.tsx @@ -1,7 +1,7 @@ -import * as React from 'react' -import type { RenderEditCellProps } from 'react-data-grid' import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' +import * as React from 'react' +import type { RenderEditCellProps } from 'react-data-grid' dayjs.extend(customParseFormat) @@ -29,15 +29,18 @@ function BaseEditor({ onClose, }: TimeEditorProps) { const value = row[column.key as keyof TRow] as unknown as string - const timeValue = value ? dayjs(value, format).format(INPUT_TIME_FORMAT) : value function onChange(event: React.ChangeEvent) { const _value = event.target.value + if (_value == '') { onRowChange({ ...row, [column.key]: null }) } else { - const _timeValue = dayjs(_value, INPUT_TIME_FORMAT).format(format) - onRowChange({ ...row, [column.key]: _timeValue }) + const dayJsValue = dayjs(_value, format) + if (dayJsValue.isValid()) { + const _timeValue = dayJsValue.format(format) + onRowChange({ ...row, [column.key]: _timeValue }) + } } } @@ -45,7 +48,7 @@ function BaseEditor({ onClose(true)} type="time" diff --git a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx index 50d3b92f8b9..4a4b2914742 100644 --- a/apps/studio/components/grid/components/footer/pagination/Pagination.tsx +++ b/apps/studio/components/grid/components/footer/pagination/Pagination.tsx @@ -9,7 +9,7 @@ import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query' import { useTableRowsQuery } from 'data/table-rows/table-rows-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { RoleImpersonationState } from 'lib/role-impersonation' -import { ArrowLeft, ArrowRight, HelpCircle, Loader2 } from 'lucide-react' +import { AlertCircle, ArrowLeft, ArrowRight, HelpCircle, Loader2 } from 'lucide-react' import { useEffect, useState } from 'react' import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' @@ -90,7 +90,6 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = } = useTableRowsCountQuery( { projectRef: project?.ref, - connectionString: project?.connectionString, tableId: snap.table.id, filters, enforceExactCount: snap.enforceExactCount, @@ -102,6 +101,8 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = } ) const count = data?.count ?? 0 + const hasCountData = count >= 0 + const isEstimateCount = data?.is_estimate ?? false const countString = data?.is_estimate ? formatEstimatedCount(count) : count.toLocaleString() const maxPages = Math.ceil(count / tableEditorSnap.rowsPerPage) const totalPages = count > 0 ? maxPages : 1 @@ -113,7 +114,6 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = const { data: rowsData, isPending: isLoadingRows } = useTableRowsQuery( { projectRef: project?.ref, - connectionString: project?.connectionString, tableId: id, sorts, filters, @@ -144,12 +144,10 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = } const onNextPage = () => { - if (page < maxPages) { - if (snap.selectedRows.size >= 1) { - setIsConfirmNextModalOpen(true) - } else { - goToNextPage() - } + if (snap.selectedRows.size >= 1) { + setIsConfirmNextModalOpen(true) + } else { + goToNextPage() } } @@ -176,6 +174,7 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = const onRowsPerPageChange = (value: string | number) => { const rowsPerPage = Number(value) tableEditorSnap.setRowsPerPage(isNaN(rowsPerPage) ? 100 : rowsPerPage) + snap.setPage(1) } // keep input value in-sync with actual page @@ -183,20 +182,6 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = setValue(String(page)) }, [page]) - useEffect(() => { - if (!isForeignTableSelected && page && page > totalPages) { - snap.setPage(totalPages) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isForeignTableSelected, page, totalPages]) - - useEffect(() => { - if (id !== undefined) { - snap.setEnforceExactCount(rowsCountEstimate !== null && rowsCountEstimate <= THRESHOLD_COUNT) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]) - useEffect(() => { // If the count query encountered a timeout error with exact count // turn off the exact count to rely on approximate @@ -206,6 +191,7 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = // eslint-disable-next-line react-hooks/exhaustive-deps }, [isError, snap.enforceExactCount, error?.code]) + // [Joshen] One to revisit if we can consolidate this and the main return statement if (isForeignTableSelected) { return (
@@ -251,102 +237,107 @@ export const Pagination = ({ enableForeignRowsQuery = true }: PaginationProps) = return (
- {isLoading && ( -
- -

Loading records count...

-
- )} +
+
+ + {isLoading ? ( +
- - {!isForeignTableSelected && ( -
-

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

- - {data.is_estimate && ( - - -
+ + Failed to retrieve count: {error?.message} + + ) : !isForeignTableSelected ? ( +
+ {hasCountData && ( +

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

)} - - )} - {isError && ( -

- Error fetching records count. Please refresh the page. -

- )} + {data.is_estimate && ( + + +
+ ) : null} ', label: 'Not equal', group: 'comparison' }, + { value: 'is', label: 'Is', group: 'setNull' }, ] const BOOLEAN_OPERATORS: OperatorDefinition[] = [ { value: '=', label: 'Equals', group: 'comparison' }, { value: '<>', label: 'Not equal', group: 'comparison' }, + { value: 'is', label: 'Is', group: 'setNull' }, ] export function columnToFilterProperty(column: SupaColumn): FilterProperty { diff --git a/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx index bbb8df4e78b..ee26db7e751 100644 --- a/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx +++ b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx @@ -10,16 +10,16 @@ import { isEqual } from 'lodash' import { ChevronDown, List } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { - type RoleImpersonationState, useRoleImpersonationStateSnapshot, + type RoleImpersonationState, } from 'state/role-impersonation-state' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { Button, + Popover_Shadcn_, PopoverContent_Shadcn_, PopoverSeparator_Shadcn_, PopoverTrigger_Shadcn_, - Popover_Shadcn_, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -72,7 +72,6 @@ export const SortPopoverPrimitive = ({ const { data: countData } = useTableRowsCountQuery( { projectRef: project?.ref, - connectionString: project?.connectionString, tableId: snap.table.id, filters, enforceExactCount: snap.enforceExactCount, diff --git a/apps/studio/components/interfaces/App/CommandMenu/ContextSearchCommands.tsx b/apps/studio/components/interfaces/App/CommandMenu/ContextSearchCommands.tsx index 9cd507530de..8e61ba4731a 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ContextSearchCommands.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ContextSearchCommands.tsx @@ -1,25 +1,26 @@ 'use client' -import { useMemo } from 'react' -import { Database } from 'lucide-react' +import { IS_PLATFORM } from 'common' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Auth, EdgeFunctions, Storage } from 'icons' +import { Database } from 'lucide-react' +import { useMemo } from 'react' import type { ICommand } from 'ui-patterns/CommandMenu' import { CommandHeader, CommandInput, CommandWrapper, PageType, + useQuery, useRegisterCommands, useRegisterPage, useSetPage, - useQuery, } from 'ui-patterns/CommandMenu' + import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { orderCommandSectionsByPriority } from './ordering' import { ContextSearchResults } from './ContextSearchResults' -import { useFlag, IS_PLATFORM } from 'common' -import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { orderCommandSectionsByPriority } from './ordering' import type { SearchContextValue } from './SearchContext.types' interface SearchContextOption { @@ -81,7 +82,6 @@ function ContextSearchPage({ } export function useContextSearchCommands() { - const enableSearchEntitiesCommandMenu = useFlag('enableSearchEntitiesCommandMenu') const { data: project } = useSelectedProjectQuery() const setPage = useSetPage() @@ -147,6 +147,6 @@ export function useContextSearchCommands() { useRegisterCommands(COMMAND_MENU_SECTIONS.QUERY, contextCommands, { orderSection: orderCommandSectionsByPriority, sectionMeta: { priority: 3 }, - enabled: !IS_PLATFORM || (enableSearchEntitiesCommandMenu && !!project), + enabled: !IS_PLATFORM || !!project, }) } diff --git a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.tsx b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.tsx index cce80055c86..4885b327494 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.tsx @@ -1,6 +1,6 @@ 'use client' -import { IS_PLATFORM, useFlag } from 'common' +import { IS_PLATFORM } from 'common' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { Clock5, @@ -25,13 +25,13 @@ import { } from 'lucide-react' import dynamic from 'next/dynamic' import { useMemo } from 'react' +import type { CommandOptions, ICommand } from 'ui-patterns/CommandMenu' import { PageType, useRegisterCommands, useRegisterPage, useSetCommandMenuOpen, } from 'ui-patterns/CommandMenu' -import type { CommandOptions, ICommand } from 'ui-patterns/CommandMenu' import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils' import { @@ -51,14 +51,12 @@ const Graphql = dynamic(() => import('icons').then((mod) => mod.Graphql)) const CREATE_STUDIO_ENTITY = 'Create Studio Entity' export function useCreateCommands(options?: CommandOptions) { - const enableCreateCommands = useFlag('enablecreatecommands') const setIsOpen = useSetCommandMenuOpen() const { ref, setPage, openSidebar, snap, - authenticationOauth21, authEnabled, edgeFunctionsEnabled, storageEnabled, @@ -184,7 +182,7 @@ export function useCreateCommands(options?: CommandOptions) { }, ] : []), - ...(IS_PLATFORM && authenticationOauth21 + ...(IS_PLATFORM ? [ { id: 'create-oauth-app', @@ -207,7 +205,6 @@ export function useCreateCommands(options?: CommandOptions) { passwordVerificationHook, passwordVerificationHookEnabled, beforeUserCreatedHook, - authenticationOauth21, ] ) @@ -453,7 +450,7 @@ export function useCreateCommands(options?: CommandOptions) { }, { deps: [sections], - enabled: enableCreateCommands, + enabled: true, } ) @@ -471,7 +468,7 @@ export function useCreateCommands(options?: CommandOptions) { ...options, orderSection: (sections) => sections, sectionMeta: { priority: 3 }, - enabled: enableCreateCommands, + enabled: true, } ) } diff --git a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx index 58d54a6af07..c3493239a28 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx @@ -1,4 +1,4 @@ -import { useFlag, useParams } from 'common' +import { useParams } from 'common' import type { Hook } from 'components/interfaces/Auth/Hooks/hooks.constants' import { HOOKS_DEFINITIONS } from 'components/interfaces/Auth/Hooks/hooks.constants' import { extractMethod, isValidHook } from 'components/interfaces/Auth/Hooks/hooks.utils' @@ -74,7 +74,7 @@ export function getIntegrationCommandName(integration: IntegrationDefinition): s case 'cron': return 'Create Cron Job' case 'webhooks': - return 'Create Webhook' + return 'Create Database Webhook' case 'queues': return 'Create Queue' default: @@ -89,9 +89,6 @@ export function useCreateCommandsConfig() { const { openSidebar } = useSidebarManagerSnapshot() const snap = useAiAssistantStateSnapshot() - // Auth - const authenticationOauth21 = useFlag('EnableOAuth21') - const { projectAuthAll: authEnabled, projectEdgeFunctionAll: edgeFunctionsEnabled, @@ -182,7 +179,6 @@ export function useCreateCommandsConfig() { setPage, openSidebar, snap, - authenticationOauth21, authEnabled, edgeFunctionsEnabled, storageEnabled, diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index ee4a43ef84a..d18f61dcfbf 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -120,6 +120,12 @@ export const useIsJitDbAccessEnabled = () => { return jitDbAccessEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS] } +export const useIsPlatformWebhooksEnabled = () => { + const { flags } = useFeaturePreviewContext() + const platformWebhooksEnabled = useFlag('platformWebhooks') + return platformWebhooksEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS] +} + export const useIsTableFilterBarEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR] diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 361b5d16bbe..2e5788fa439 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -25,11 +25,13 @@ import { Branching2Preview } from './Branching2Preview' import { CLSPreview } from './CLSPreview' import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext' import { JitDbAccessPreview } from './JitDbAccessPreview' +import { PlatformWebhooksPreview } from './PlatformWebhooksPreview' import { PgDeltaDiffPreview } from './PgDeltaDiffPreview' import { QueueOperationsPreview } from './QueueOperationsPreview' import { TableFilterBarPreview } from './TableFilterBarPreview' import { UnifiedLogsPreview } from './UnifiedLogsPreview' import { useFeaturePreviews } from './useFeaturePreviews' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' const FEATURE_PREVIEW_KEY_TO_CONTENT: { [key: string]: ReactNode @@ -43,6 +45,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: { [LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]: , + [LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: , } export const FeaturePreviewModal = () => { @@ -58,6 +61,11 @@ export const FeaturePreviewModal = () => { const featurePreviewContext = useFeaturePreviewContext() const { mutate: sendEvent } = useSendEventMutation() + const [isDismissedTableFilterBar, setIsDismissedTableFilterBar] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.TABLE_EDITOR_NEW_FILTER_BANNER_DISMISSED(ref ?? ''), + false + ) + const { flags, onUpdateFlag } = featurePreviewContext const selectedFeature = featurePreviews.find((preview) => preview.key === selectedFeatureKey) ?? featurePreviews[0] @@ -74,6 +82,13 @@ export const FeaturePreviewModal = () => { properties: { feature: selectedFeature.key }, groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) + + if ( + selectedFeature.key === LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR && + !isDismissedTableFilterBar + ) { + setIsDismissedTableFilterBar(true) + } } return ( diff --git a/apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx new file mode 100644 index 00000000000..a0824e80246 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx @@ -0,0 +1,26 @@ +import { useParams } from 'common' + +import { InlineLink } from '@/components/ui/InlineLink' + +export const PlatformWebhooksPreview = () => { + const { slug = '_', ref = '_' } = useParams() + + return ( +
+

+ Configure webhook endpoints and review deliveries from both project and organization + settings pages. +

+
    +
  • + Project scope:{' '} + Project Webhooks +
  • +
  • + Organization scope:{' '} + Organization Webhooks +
  • +
+
+ ) +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index c836053ce04..ea5083c1df6 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -20,6 +20,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { const tableEditorNewFilterBar = useFlag('tableEditorNewFilterBar') const pgDeltaDiffEnabled = useFlag('pgdeltaDiff') const jitDbAccessEnabled = useFlag('jitDbAccess') + const platformWebhooksEnabled = useFlag('platformWebhooks') return [ { @@ -68,6 +69,15 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isDefaultOptIn: false, enabled: jitDbAccessEnabled, }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS, + name: 'Platform webhooks', + discussionsUrl: undefined, + isNew: true, + isPlatformOnly: true, + isDefaultOptIn: false, + enabled: platformWebhooksEnabled, + }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, name: 'Project API documentation', diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index daf0e8bbe13..594b16b7d37 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -1,11 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Check } from 'lucide-react' -import { useTheme } from 'next-themes' -import { useQueryState } from 'nuqs' -import { useEffect, useState } from 'react' -import ReactMarkdown from 'react-markdown' -import { toast } from 'sonner' - import { useParams } from 'common' import { Markdown } from 'components/interfaces/Markdown' import { ButtonTooltip } from 'components/ui/ButtonTooltip' @@ -13,17 +6,23 @@ import { DocsButton } from 'components/ui/DocsButton' import { ResourceItem } from 'components/ui/Resource/ResourceItem' import type { components } from 'data/api' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH } from 'lib/constants' +import { Check } from 'lucide-react' +import { useTheme } from 'next-themes' +import { useQueryState } from 'nuqs' +import { useEffect, useState } from 'react' +import ReactMarkdown from 'react-markdown' +import { toast } from 'sonner' import { Button, Form, Input, Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from 'ui' import { Admonition } from 'ui-patterns' + import { NO_REQUIRED_CHARACTERS } from '../Auth.constants' import { AuthAlert } from './AuthAlert' import type { Provider } from './AuthProvidersForm.types' import FormField from './FormField' +import { useProjectApiUrl } from '@/data/config/project-endpoint-query' interface ProviderFormProps { config: components['schemas']['GoTrueConfigResponse'] @@ -42,6 +41,8 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) const [open, setOpen] = useState(false) const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation() + const { data: endpoint } = useProjectApiUrl({ projectRef }) + const { can: canUpdateConfig } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'custom_config_gotrue' @@ -67,12 +68,6 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) } const isFreePlan = organization?.plan.id === 'free' - const { data: settings } = useProjectSettingsV2Query({ projectRef }) - const protocol = settings?.app_config?.protocol ?? 'https' - const endpoint = settings?.app_config?.endpoint - const apiUrl = `${protocol}://${endpoint}` - - const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) const INITIAL_VALUES = (() => { const initialValues: { [x: string]: string | boolean } = {} @@ -247,11 +242,7 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) readOnly disabled label="Callback URL (for OAuth)" - value={ - customDomainData?.customDomain?.status === 'active' - ? `https://${customDomainData.customDomain?.hostname}/auth/v1/callback` - : `${apiUrl}/auth/v1/callback` - } + value={`${endpoint}/auth/v1/callback`} descriptionText={ isDirty, + onClose, + }) const statements = useMemo(() => { let permissionChanges: string[] = [] @@ -239,7 +250,7 @@ export const CreateHookSheet = ({ form.reset({ hookType: definition.title, - enabled: authConfig?.[definition.enabledKey] || true, + enabled: isCreating ? true : authConfig?.[definition.enabledKey], selectedType: values.type, httpsValues: { url: (values.type === 'https' && values.url) || '', @@ -270,7 +281,7 @@ export const CreateHookSheet = ({ }, [authConfig, title, visible, definition]) return ( - onClose()}> +
-
-

- The following statements will be executed on the selected function: -

- -
+ + {statements.length > 0 && ( +
+

+ The following statements will be executed on the selected function: +

+ +
+ )} ) : (
@@ -470,7 +484,9 @@ export const CreateHookSheet = ({ className="rounded-l-none text-xs" onClick={() => { const authHookSecret = generateAuthHookSecret() - form.setValue('httpsValues.secret', authHookSecret) + form.setValue('httpsValues.secret', authHookSecret, { + shouldDirty: true, + }) }} > Generate secret @@ -494,7 +510,7 @@ export const CreateHookSheet = ({
)} - )}
@@ -408,7 +408,7 @@ export const PerformanceSettingsForm = () => { )}
@@ -52,7 +56,7 @@ export const CPUWarnings = ({ isFreePlan, upgradeUrl, severity }: CPUWarningsPro diff --git a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx index 000e1b92573..10a3c784423 100644 --- a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx +++ b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx @@ -4,7 +4,7 @@ import { Admonition } from 'ui-patterns' // [Joshen] In the future, conditionals should be from resource exhaustion endpoint as single source of truth interface DiskIOBandwidthWarningsProps { - isFreePlan: boolean + hasAccessToComputeSizes: boolean hasLatest: boolean upgradeUrl: string currentBillingCycleSelected: boolean @@ -13,7 +13,7 @@ interface DiskIOBandwidthWarningsProps { } export const DiskIOBandwidthWarnings = ({ - isFreePlan, + hasAccessToComputeSizes, hasLatest, currentBillingCycleSelected, upgradeUrl, @@ -34,7 +34,7 @@ export const DiskIOBandwidthWarnings = ({

@@ -58,7 +58,7 @@ export const DiskIOBandwidthWarnings = ({

@@ -81,7 +81,7 @@ export const DiskIOBandwidthWarnings = ({

@@ -105,7 +105,7 @@ export const DiskIOBandwidthWarnings = ({

diff --git a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/RAMWarnings.tsx b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/RAMWarnings.tsx index d2099e53b20..c2c37f79ef8 100644 --- a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/RAMWarnings.tsx +++ b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/RAMWarnings.tsx @@ -6,12 +6,16 @@ import { DOCS_URL } from 'lib/constants' import { AlertDescription_Shadcn_, Alert_Shadcn_, Button } from 'ui' interface RAMWarningsProps { - isFreePlan: boolean + hasAccessToComputeSizes: boolean upgradeUrl: string severity?: 'warning' | 'critical' | null } -export const RAMWarnings = ({ isFreePlan, upgradeUrl, severity }: RAMWarningsProps) => { +export const RAMWarnings = ({ + hasAccessToComputeSizes, + upgradeUrl, + severity, +}: RAMWarningsProps) => { if (severity === 'warning') { return ( @@ -28,7 +32,7 @@ export const RAMWarnings = ({ isFreePlan, upgradeUrl, severity }: RAMWarningsPro @@ -52,7 +56,7 @@ export const RAMWarnings = ({ isFreePlan, upgradeUrl, severity }: RAMWarningsPro diff --git a/apps/studio/components/interfaces/Connect/ApiKeysTabContent.tsx b/apps/studio/components/interfaces/Connect/ApiKeysTabContent.tsx index e1a486efbb8..78232db1ca6 100644 --- a/apps/studio/components/interfaces/Connect/ApiKeysTabContent.tsx +++ b/apps/studio/components/interfaces/Connect/ApiKeysTabContent.tsx @@ -1,12 +1,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { AlertCircle, ExternalLink, Loader2 } from 'lucide-react' import Link from 'next/link' import type { ReactNode } from 'react' - -import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' + import type { projectKeys } from './Connect.types' function KeyRow({ label, value }: { label: ReactNode; value: string }) { @@ -63,7 +63,6 @@ export function ApiKeysTabContent({ projectKeys }: { projectKeys: projectKeys }) value={projectKeys.anonKey ?? ''} /> - {/* Footer */}

For secret keys, see API settings.

diff --git a/apps/studio/components/interfaces/Connect/Connect.tsx b/apps/studio/components/interfaces/Connect/Connect.tsx index e2e91db0874..9ccd9c8d47d 100644 --- a/apps/studio/components/interfaces/Connect/Connect.tsx +++ b/apps/studio/components/interfaces/Connect/Connect.tsx @@ -5,7 +5,6 @@ import { DatabaseConnectionString } from 'components/interfaces/Connect/Database import { McpTabContent } from 'components/interfaces/Connect/McpTabContent' import Panel from 'components/ui/Panel' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { BASE_PATH } from 'lib/constants' @@ -35,6 +34,7 @@ import { CONNECTION_TYPES, ConnectionType, FRAMEWORKS, MOBILES, ORMS } from './C import { getContentFilePath, inferConnectTabFromParentKey } from './Connect.utils' import { ConnectDropdown } from './ConnectDropdown' import { ConnectTabContent } from './ConnectTabContent' +import { useProjectApiUrl } from '@/data/config/project-endpoint-query' export const Connect = () => { const router = useRouter() @@ -97,7 +97,8 @@ export const Connect = () => { ?.children.find((child) => child.key === selectedChild)?.children[0]?.key || '' ) - const { data: settings } = useProjectSettingsV2Query({ projectRef }, { enabled: open }) + const { data: resolvedEndpoint } = useProjectApiUrl({ projectRef }) + const { can: canReadAPIKeys } = useAsyncCheckPermissions( PermissionAction.READ, 'service_api_keys' @@ -216,22 +217,12 @@ export const Connect = () => { : { anonKey: null, publishableKey: null } const projectKeys = useMemo(() => { - const protocol = settings?.app_config?.protocol ?? 'https' - const endpoint = settings?.app_config?.endpoint ?? '' - const apiHost = canReadAPIKeys ? `${protocol}://${endpoint ?? '-'}` : '' - return { - apiUrl: apiHost ?? null, + apiUrl: resolvedEndpoint ?? null, anonKey: anonKey?.api_key ?? null, publishableKey: publishableKey?.api_key ?? null, } - }, [ - settings?.app_config?.protocol, - settings?.app_config?.endpoint, - canReadAPIKeys, - anonKey?.api_key, - publishableKey?.api_key, - ]) + }, [resolvedEndpoint, anonKey?.api_key, publishableKey?.api_key]) const filePath = getContentFilePath({ connectionObject, diff --git a/apps/studio/components/interfaces/Connect/ConnectTabContent.tsx b/apps/studio/components/interfaces/Connect/ConnectTabContent.tsx index 954ad513a77..b760a5d613f 100644 --- a/apps/studio/components/interfaces/Connect/ConnectTabContent.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectTabContent.tsx @@ -6,7 +6,7 @@ import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query import { usePgbouncerConfigQuery } from 'data/database/pgbouncer-config-query' import { useSupavisorConfigurationQuery } from 'data/database/supavisor-configuration-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements' import { pluckObjectFields } from 'lib/helpers' import { useTrack } from 'lib/telemetry/track' import { cn, CopyCallbackContext } from 'ui' @@ -34,8 +34,7 @@ export const ConnectTabContent = forwardRef { const { ref: projectRef } = useParams() const track = useTrack() - const { data: selectedOrg } = useSelectedOrganizationQuery() - const allowPgBouncerSelection = useMemo(() => selectedOrg?.plan.id !== 'free', [selectedOrg]) + const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler') const handleCopy = () => { const trackingProperties: { diff --git a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx index 8907cca134b..9000583ef7f 100644 --- a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx +++ b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx @@ -8,12 +8,13 @@ import { useSupavisorConfigurationQuery } from 'data/database/supavisor-configur import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { DOCS_URL, IS_PLATFORM } from 'lib/constants' import { pluckObjectFields } from 'lib/helpers' import { BookOpen, ChevronDown, ExternalLink } from 'lucide-react' import { parseAsString, useQueryState } from 'nuqs' -import { HTMLAttributes, ReactNode, useEffect, useMemo, useState } from 'react' +import { HTMLAttributes, ReactNode, useEffect, useState } from 'react' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { Badge, @@ -67,6 +68,12 @@ export const DatabaseConnectionString = () => { const { ref: projectRef } = useParams() const { data: org } = useSelectedOrganizationQuery() const state = useDatabaseSelectorStateSnapshot() + const { + hasAccess: hasDedicatedPooler, + isLoading: isLoadingEntitlement, + isSuccess: isSuccessEntitlement, + } = useCheckEntitlements('dedicated_pooler') + const sharedPoolerPreferred = !hasDedicatedPooler // URL state management const [queryType, setQueryType] = useQueryState('type', parseAsString.withDefault('uri')) @@ -76,10 +83,6 @@ export const DatabaseConnectionString = () => { const [selectedTab, setSelectedTab] = useState('uri') const [selectedMethod, setSelectedMethod] = useState('direct') - const sharedPoolerPreferred = useMemo(() => { - return org?.plan?.id === 'free' - }, [org]) - // Sync URL state with component state on mount and when URL changes useEffect(() => { const validTypes = DATABASE_CONNECTION_TYPES.map((t) => t.id) @@ -177,9 +180,9 @@ export const DatabaseConnectionString = () => { : isSuccessSupavisorConfig const error = poolerError || readReplicasError - const isLoading = isLoadingPoolerConfig || isLoadingReadReplicas + const isLoading = isLoadingPoolerConfig || isLoadingReadReplicas || isLoadingEntitlement const isError = isErrorPoolerConfig || isErrorReadReplicas - const isSuccess = isSuccessPoolerConfig && isSuccessReadReplicas + const isSuccess = isSuccessPoolerConfig && isSuccessReadReplicas && isSuccessEntitlement const sharedPoolerConfig = supavisorConfig?.find((x) => x.identifier === state.selectedDatabaseId) const poolingConfiguration = sharedPoolerPreferred ? sharedPoolerConfig : pgbouncerConfig diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx index 60d3d1f5e94..02f605fc838 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectSheet.tsx @@ -1,7 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' import { useMemo } from 'react' @@ -11,9 +10,12 @@ import type { ProjectKeys } from './Connect.types' import { ConnectConfigSection, ModeSelector } from './ConnectConfigSection' import { ConnectStepsSection } from './ConnectStepsSection' import { useConnectState } from './useConnectState' +import { useProjectApiUrl } from '@/data/config/project-endpoint-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' export const ConnectSheet = () => { + const { ref: projectRef } = useParams() + const { projectConnectionShowAppFrameworks: showAppFrameworks, projectConnectionShowMobileFrameworks: showMobileFrameworks, @@ -40,9 +42,8 @@ export const ConnectSheet = () => { const { state, activeFields, resolvedSteps, schema, getFieldOptions, setMode, updateField } = useConnectState() - // Project keys for step components - const { ref: projectRef } = useParams() - const { data: settings } = useProjectSettingsV2Query({ projectRef }, { enabled: showConnect }) + const { data: endpoint = '' } = useProjectApiUrl({ projectRef }, { enabled: showConnect }) + const { can: canReadAPIKeys } = useAsyncCheckPermissions( PermissionAction.READ, 'service_api_keys' @@ -53,22 +54,12 @@ export const ConnectSheet = () => { : { anonKey: null, publishableKey: null } const projectKeys: ProjectKeys = useMemo(() => { - const protocol = settings?.app_config?.protocol ?? 'https' - const endpoint = settings?.app_config?.endpoint ?? '' - const apiHost = canReadAPIKeys ? `${protocol}://${endpoint ?? '-'}` : '' - return { - apiUrl: apiHost ?? null, + apiUrl: endpoint, anonKey: anonKey?.api_key ?? null, publishableKey: publishableKey?.api_key ?? null, } - }, [ - settings?.app_config?.protocol, - settings?.app_config?.endpoint, - canReadAPIKeys, - anonKey?.api_key, - publishableKey?.api_key, - ]) + }, [endpoint, anonKey?.api_key, publishableKey?.api_key]) const availableModeIds = useMemo(() => { const modes: string[] = [] diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx index 06241ac97d6..74493dd8d4e 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx @@ -4,7 +4,7 @@ import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query import { usePgbouncerConfigQuery } from 'data/database/pgbouncer-config-query' import { useSupavisorConfigurationQuery } from 'data/database/supavisor-configuration-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements' import { pluckObjectFields } from 'lib/helpers' import dynamic from 'next/dynamic' import { useMemo, useRef } from 'react' @@ -52,8 +52,7 @@ function resolveContentPath(template: string, state: ConnectState): string { */ function useConnectionStringPooler(): ConnectionStringPooler { const { ref: projectRef } = useParams() - const { data: selectedOrg } = useSelectedOrganizationQuery() - const allowPgBouncerSelection = useMemo(() => selectedOrg?.plan.id !== 'free', [selectedOrg]) + const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler') const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: pgbouncerConfig } = usePgbouncerConfigQuery({ projectRef }) diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx index 07265a6e2b5..b99a4c2e367 100644 --- a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/BackupsList.tsx @@ -1,6 +1,5 @@ import Panel from 'components/ui/Panel' import { useCloneBackupsQuery } from 'data/projects/clone-query' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Badge, Button } from 'ui' import { TimestampInfo } from 'ui-patterns' @@ -9,17 +8,14 @@ import { BackupsEmpty } from '../BackupsEmpty' interface BackupsListProps { onSelectRestore: (id: number) => void disabled?: boolean + hasAccess?: boolean } -export const BackupsList = ({ onSelectRestore, disabled }: BackupsListProps) => { +export const BackupsList = ({ onSelectRestore, disabled, hasAccess }: BackupsListProps) => { const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() - - const isFreePlan = organization?.plan?.id === 'free' - const { data: cloneBackups } = useCloneBackupsQuery( { projectRef: project?.ref }, - { enabled: !isFreePlan } + { enabled: hasAccess } ) return ( diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx index a57a29ef738..b93a387c382 100644 --- a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx @@ -7,7 +7,6 @@ import { z } from 'zod' import { PasswordStrengthBar } from 'components/ui/PasswordStrengthBar' import { useProjectCloneMutation } from 'data/projects/clone-mutation' import { useCloneBackupsQuery } from 'data/projects/clone-query' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { passwordStrength, PasswordStrengthScore } from 'lib/password-strength' import { generateStrongPassword } from 'lib/project' @@ -37,6 +36,7 @@ interface CreateNewProjectDialogProps { onOpenChange: (value: boolean) => void onCloneSuccess: () => void additionalMonthlySpend: NewProjectPrice + hasAccess?: boolean } export const CreateNewProjectDialog = ({ @@ -46,10 +46,9 @@ export const CreateNewProjectDialog = ({ onOpenChange, onCloneSuccess, additionalMonthlySpend, + hasAccess, }: CreateNewProjectDialogProps) => { const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() - const [passwordStrengthScore, setPasswordStrengthScore] = useState(0) const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('') @@ -66,11 +65,9 @@ export const CreateNewProjectDialog = ({ }, }) - const isFreePlan = organization?.plan?.id === 'free' - const { data: cloneBackups } = useCloneBackupsQuery( { projectRef: project?.ref }, - { enabled: !isFreePlan } + { enabled: hasAccess } ) const hasPITREnabled = cloneBackups?.pitr_enabled diff --git a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx index b69e049afc5..8b5203f133e 100644 --- a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx @@ -72,7 +72,7 @@ const Migrations = () => { Try refreshing your browser, but if the issue persists for more than a few minutes, please reach out to us via support.

-

Error: {(error as any)?.message ?? 'Unknown'}

+

Error: {error?.message ?? 'Unknown'}

} > diff --git a/apps/studio/components/interfaces/Database/Replication/ComingSoon.tsx b/apps/studio/components/interfaces/Database/Replication/ComingSoon.tsx deleted file mode 100644 index 0816a5cd1fe..00000000000 --- a/apps/studio/components/interfaces/Database/Replication/ComingSoon.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { ArrowRight, ArrowUpRight, Circle, Database, Plus } from 'lucide-react' -import { useTheme } from 'next-themes' -import Link from 'next/link' -import { useMemo } from 'react' -import ReactFlow, { Background, Handle, Position, ReactFlowProvider } from 'reactflow' - -import 'reactflow/dist/style.css' - -import { useParams } from 'common' -import { BASE_PATH } from 'lib/constants' -import { Button, Card, CardContent } from 'ui' - -import { NODE_WIDTH } from '../../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' - -const STATIC_NODES = [ - { - id: '1', - type: 'primary', - data: { - label: 'Primary Database', - region: 'East US (Ohio)', - provider: 'AWS', - regionIcon: 'us-east-1', - }, - position: { x: 825, y: 0 }, - }, - { - id: '2', - type: 'replica', - data: { - label: 'Iceberg', - details: '3 tables', - regionIcon: 'us-west-1', - }, - position: { x: 875, y: 110 }, - }, - { - id: '3', - type: 'replica', - data: { - label: 'BigQuery', - details: '5 tables', - regionIcon: 'us-west-1', - }, - position: { x: 875, y: 200 }, - }, - { - id: '4', - type: 'blank', - position: { x: 875, y: 290 }, - data: {}, - }, - { - id: '5', - type: 'cta', - position: { x: 125, y: 20 }, - data: {}, - }, -] - -const STATIC_EDGES = [ - { id: 'e1-2', source: '1', target: '2', type: 'smoothstep', animated: true }, - { id: 'e1-3', source: '1', target: '3', type: 'smoothstep', animated: true }, - { id: 'e1-4', source: '1', target: '4', type: 'smoothstep', animated: true }, -] - -export const ReplicationComingSoon = () => { - return ( - - - - ) -} - -const ReplicationStaticMockup = () => { - const { ref: projectRef = '_' } = useParams() - const nodes = useMemo(() => STATIC_NODES, []) - const edges = useMemo(() => STATIC_EDGES, []) - - const { resolvedTheme } = useTheme() - - const backgroundPatternColor = - resolvedTheme === 'dark' && projectRef !== '_' - ? 'rgba(255, 255, 255, 0.3)' - : 'rgba(0, 0, 0, 0.4)' - - const nodeTypes = useMemo( - () => ({ - primary: PrimaryNode, - replica: ReplicaNode, - blank: BlankNode, - cta: () => CTANode({ projectRef }), - }), - [projectRef] - ) - - return ( -
- - - -
- ) -} - -const PrimaryNode = ({ - data, -}: { - data: { label: string; region: string; provider: string; regionIcon: string } -}) => { - return ( -
-
-
-
- -
-
-

{data.label}

-

- {data.region} -

-

- {data.provider} -

-
-
-
- region icon - -
-
- -
- ) -} -const ReplicaNode = ({ - data, -}: { - data: { label: string; details: string; regionIcon: string } -}) => { - return ( -
-
-
-
-

{data.label}

-

- {data.details} -

-
-
-
- region icon - -
-
- - -
- ) -} - -const BlankNode = () => { - return ( -
-
-
-
- - Add new -
-
- -
-
- ) -} - -const CTANode = ({ projectRef }: { projectRef: string }) => { - return ( - - -

Stream database changes to external destinations

-

- Automatically replicate your data to external data warehouses and analytics platforms in - real-time. No manual exports, no lag. -

-

- We are currently in private alpha and slowly - onboarding new customers to ensure stable data pipelines. Request access below to join the - waitlist. Read replicas are available now. -

-
- - -
-
-
- ) -} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx index 3971a14d673..475b47d8246 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AdvancedSettings.tsx @@ -1,6 +1,5 @@ import type { ChangeEvent } from 'react' import type { UseFormReturn } from 'react-hook-form' - import { Accordion_Shadcn_, AccordionContent_Shadcn_, @@ -11,8 +10,13 @@ import { FormField_Shadcn_, Input_Shadcn_, PrePostTab, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + import { DestinationType } from '../DestinationPanel.types' import { type DestinationPanelSchemaType } from './DestinationForm.schema' @@ -53,11 +57,11 @@ export const AdvancedSettings = ({ description={ <>

- Maximum time the pipeline waits to collect additional changes before - flushing a batch. + Maximum time pipeline waits to collect additional changes before flushing a + batch.

- Lower values reduce replication latency; higher values improve batching + Lower values reduce replication latency, higher values improve batching efficiency.

@@ -122,7 +126,7 @@ export const AdvancedSettings = ({ Number of parallel connections each table copy can use during initial sync.

- Higher values can copy large tables faster, but consume more database + More connections speed up large table copies, but use more database connections.

@@ -143,47 +147,118 @@ export const AdvancedSettings = ({ )} /> - {/* BigQuery-specific: Max staleness */} + ( + + + + + {field.value ?? 'error'} + + + +

Error

+

+ Blocks startup for manual recovery +

+
+ +

Recreate

+

+ Rebuilds the slot and restarts replication from scratch +

+
+
+
+
+
+ )} + /> + {type === 'BigQuery' && ( - ( - - Maximum staleness - BigQuery only -
- } - layout="horizontal" - description={ - <> -

- Maximum allowed age for BigQuery cached metadata before reading base - tables. -

-

- Lower values improve freshness; higher values can reduce query cost and - latency. -

- - } - > - - - - - - - )} - /> + <> + ( + + Connection pool size + BigQuery only +
+ } + layout="horizontal" + description={ + <> +

Size of the BigQuery Storage Write API connection pool.

+

+ More connections allow more parallel writes, but consume more resources. +

+ + } + > + + + + + + + )} + /> + + ( + + Maximum staleness + BigQuery only + + } + layout="horizontal" + description={ + <> +

+ Maximum allowed age for BigQuery cached metadata before reading base + tables. +

+

+ Lower values improve freshness, higher values can reduce query cost and + latency. +

+ + } + > + + + + + +
+ )} + /> + )} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.ts index a8949f27729..d2ff933585d 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.schema.ts @@ -17,10 +17,12 @@ export const DestinationPanelFormSchema = z.object({ .int() .min(1, 'Max copy connections per table should be greater than 0') .optional(), + invalidatedSlotBehavior: z.enum(['error', 'recreate']).optional(), // BigQuery fields projectId: z.string().optional(), datasetId: z.string().optional(), serviceAccountKey: z.string().optional(), + connectionPoolSize: z.number().int().min(1).optional(), maxStalenessMins: z.number().nonnegative().optional(), // Analytics Bucket fields, only warehouse name and namespace are visible + editable fields warehouseName: z.string().optional(), diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts index 77a95a7cc4d..e9801c0f202 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.ts @@ -1,8 +1,20 @@ +import { UseMutateAsyncFunction } from '@tanstack/react-query' +import { snakeCase } from 'lodash' import z from 'zod' import { DestinationType } from '../DestinationPanel.types' import { CREATE_NEW_KEY, CREATE_NEW_NAMESPACE } from './DestinationForm.constants' import { DestinationPanelFormSchema } from './DestinationForm.schema' +import { + BigQueryDestinationConfig, + DestinationConfig, + IcebergDestinationConfig, +} from '@/data/replication/create-destination-pipeline-mutation' +import { + type CreateS3AccessKeyCredentialVariables, + type S3AccessKeyCreateData, +} from '@/data/storage/s3-access-key-create-mutation' +import { ResponseError } from '@/types' // Helper function to build destination config for validation export const buildDestinationConfigForValidation = ({ @@ -22,7 +34,8 @@ export const buildDestinationConfigForValidation = ({ projectId: data.projectId ?? '', datasetId: data.datasetId ?? '', serviceAccountKey: data.serviceAccountKey ?? '', - ...(data.maxStalenessMins !== undefined ? { maxStalenessMins: data.maxStalenessMins } : {}), + connectionPoolSize: data.connectionPoolSize, + maxStalenessMins: data.maxStalenessMins, }, } } else if (selectedType === 'Analytics Bucket') { @@ -54,3 +67,67 @@ export const buildDestinationConfigForValidation = ({ throw new Error('Invalid destination type') } } + +export const buildDestinationConfig = async ({ + projectRef, + selectedType, + data, + warehouseName, + createS3AccessKey, + resolveNamespace, +}: { + projectRef?: string + selectedType: DestinationType + data: z.infer + warehouseName?: string + createS3AccessKey: UseMutateAsyncFunction< + S3AccessKeyCreateData, + ResponseError, + CreateS3AccessKeyCredentialVariables, + unknown + > + resolveNamespace: ( + data: z.infer + ) => Promise +}) => { + if (!projectRef) throw new Error('Project ref is required') + + let destinationConfig: DestinationConfig | undefined = undefined + + if (selectedType === 'BigQuery') { + const bigQueryConfig: BigQueryDestinationConfig = { + projectId: data.projectId ?? '', + datasetId: data.datasetId ?? '', + serviceAccountKey: data.serviceAccountKey ?? '', + connectionPoolSize: data.connectionPoolSize, + maxStalenessMins: data.maxStalenessMins, + } + destinationConfig = { bigQuery: bigQueryConfig } + } else if (selectedType === 'Analytics Bucket') { + let s3Keys = { accessKey: data.s3AccessKeyId, secretKey: data.s3SecretAccessKey } + + if (data.s3AccessKeyId === CREATE_NEW_KEY) { + const newKeys = await createS3AccessKey({ + projectRef, + description: `Autogenerated key for replication to ${snakeCase(warehouseName)}`, + }) + s3Keys = { accessKey: newKeys.access_key, secretKey: newKeys.secret_key } + } + + // Resolve namespace (create if needed) + const finalNamespace = await resolveNamespace(data) + + const icebergConfig: IcebergDestinationConfig = { + projectRef: projectRef, + warehouseName: data.warehouseName ?? '', + namespace: finalNamespace, + catalogToken: data.catalogToken ?? '', + s3AccessKeyId: s3Keys.accessKey ?? '', + s3SecretAccessKey: s3Keys.secretKey ?? '', + s3Region: data.s3Region ?? '', + } + destinationConfig = { iceberg: icebergConfig } + } + + return destinationConfig +} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx index 8d99386278e..3e0c87901ec 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx @@ -1,14 +1,11 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useFlag, useParams } from 'common' +import { useParams } from 'common' import { CreateAnalyticsBucketSheet } from 'components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { BatchConfig, - BigQueryDestinationConfig, - DestinationConfig, - IcebergDestinationConfig, useCreateDestinationPipelineMutation, } from 'data/replication/create-destination-pipeline-mutation' import { useReplicationDestinationByIdQuery } from 'data/replication/destination-by-id-query' @@ -27,7 +24,6 @@ import { useIcebergNamespaceCreateMutation } from 'data/storage/iceberg-namespac import { useS3AccessKeyCreateMutation } from 'data/storage/s3-access-key-create-mutation' import { AnimatePresence, motion } from 'framer-motion' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { snakeCase } from 'lodash' import { Loader2 } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import { useForm } from 'react-hook-form' @@ -39,11 +35,18 @@ import { import { Button, DialogSectionSeparator, Form_Shadcn_, SheetFooter, SheetSection } from 'ui' import * as z from 'zod' +import { + useIsETLBigQueryPrivateAlpha, + useIsETLIcebergPrivateAlpha, +} from '../../useIsETLPrivateAlpha' import { DestinationType } from '../DestinationPanel.types' import { AdvancedSettings } from './AdvancedSettings' -import { CREATE_NEW_KEY, CREATE_NEW_NAMESPACE } from './DestinationForm.constants' +import { CREATE_NEW_NAMESPACE } from './DestinationForm.constants' import { DestinationPanelFormSchema as FormSchema } from './DestinationForm.schema' -import { buildDestinationConfigForValidation } from './DestinationForm.utils' +import { + buildDestinationConfig, + buildDestinationConfigForValidation, +} from './DestinationForm.utils' import { DestinationNameInput } from './DestinationNameInput' import { AnalyticsBucketFields, BigQueryFields } from './DestinationPanelFields' import { NewPublicationPanel } from './NewPublicationPanel' @@ -76,8 +79,8 @@ export const DestinationForm = ({ const { ref: projectRef } = useParams() const { setRequestStatus } = usePipelineRequestStatus() - const etlEnableBigQuery = useFlag('etlEnableBigQuery') - const etlEnableIceberg = useFlag('etlEnableIceberg') + const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha() + const etlEnableIceberg = useIsETLIcebergPrivateAlpha() const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') const [isFormInteracting, setIsFormInteracting] = useState(false) @@ -176,10 +179,16 @@ export const DestinationForm = ({ maxFillMs: pipelineData?.config?.batch?.max_fill_ms ?? undefined, maxTableSyncWorkers: pipelineData?.config?.max_table_sync_workers ?? undefined, maxCopyConnectionsPerTable: pipelineData?.config?.max_copy_connections_per_table ?? undefined, + invalidatedSlotBehavior: + (pipelineData?.config as { invalidated_slot_behavior?: 'error' | 'recreate' } | undefined) + ?.invalidated_slot_behavior ?? undefined, // BigQuery fields projectId: isBigQueryConfig ? config.big_query.project_id : '', datasetId: isBigQueryConfig ? config.big_query.dataset_id : '', serviceAccountKey: isBigQueryConfig ? config.big_query.service_account_key : '', + connectionPoolSize: + (config as { big_query?: { connection_pool_size?: number } } | undefined)?.big_query + ?.connection_pool_size ?? undefined, maxStalenessMins: isBigQueryConfig ? config.big_query.max_staleness_mins : undefined, // Default: null // Analytics Bucket fields warehouseName: isIcebergConfig ? config.iceberg.supabase.warehouse_name : '', @@ -305,6 +314,7 @@ export const DestinationForm = ({ maxFillMs: data.maxFillMs, maxTableSyncWorkers: data.maxTableSyncWorkers, maxCopyConnectionsPerTable: data.maxCopyConnectionsPerTable, + invalidatedSlotBehavior: data.invalidatedSlotBehavior, }), ]) @@ -349,10 +359,6 @@ export const DestinationForm = ({ return !hasCriticalFailures } - // [Joshen] I reckon this function can be refactored to be a bit more modular, it's currently pretty - // complicated with 4 different types of flows -> edit bigquery/analytics, and create bigquery/analytics - // At first glance we could try grouping as edit / create bigquery, edit / create analytics - // since the destination config seems rather similar between edit and create for the same type const submitPipeline = async (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') if (!sourceId) return console.error('Source id is required') @@ -361,69 +367,39 @@ export const DestinationForm = ({ } try { + const destinationConfig = await buildDestinationConfig({ + projectRef, + selectedType, + warehouseName, + data, + createS3AccessKey, + resolveNamespace, + }) + + if (!destinationConfig) throw new Error('Destination configuration is missing') + + const batchConfig: BatchConfig | undefined = + data.maxFillMs !== undefined ? { maxFillMs: data.maxFillMs } : undefined + const hasBatchFields = batchConfig !== undefined + + const pipelineConfig = { + publicationName: data.publicationName, + maxTableSyncWorkers: data.maxTableSyncWorkers, + maxCopyConnectionsPerTable: data.maxCopyConnectionsPerTable, + invalidatedSlotBehavior: data.invalidatedSlotBehavior, + ...(hasBatchFields ? { batch: batchConfig } : {}), + } + if (editMode && existingDestination) { if (!existingDestination.pipelineId) return console.error('Pipeline id is required') - let destinationConfig: DestinationConfig | undefined = undefined - - if (selectedType === 'BigQuery') { - const bigQueryConfig: BigQueryDestinationConfig = { - projectId: data.projectId ?? '', - datasetId: data.datasetId ?? '', - serviceAccountKey: data.serviceAccountKey ?? '', - } - if (!!data.maxStalenessMins) { - bigQueryConfig.maxStalenessMins = data.maxStalenessMins - } - destinationConfig = { bigQuery: bigQueryConfig } - } else if (selectedType === 'Analytics Bucket') { - let s3Keys = { accessKey: data.s3AccessKeyId, secretKey: data.s3SecretAccessKey } - - if (data.s3AccessKeyId === CREATE_NEW_KEY) { - const newKeys = await createS3AccessKey({ - projectRef, - description: `Autogenerated key for replication to ${snakeCase(warehouseName)}`, - }) - s3Keys = { accessKey: newKeys.access_key, secretKey: newKeys.secret_key } - } - - // Resolve namespace (create if needed) - const finalNamespace = await resolveNamespace(data) - - const icebergConfig: IcebergDestinationConfig = { - projectRef: projectRef, - warehouseName: data.warehouseName ?? '', - namespace: finalNamespace, - catalogToken: data.catalogToken ?? '', - s3AccessKeyId: s3Keys.accessKey ?? '', - s3SecretAccessKey: s3Keys.secretKey ?? '', - s3Region: data.s3Region ?? '', - } - destinationConfig = { iceberg: icebergConfig } - } - - const batchConfig: BatchConfig | undefined = - data.maxFillMs !== undefined - ? { - ...(data.maxFillMs !== undefined ? { maxFillMs: data.maxFillMs } : {}), - } - : undefined - const hasBatchFields = batchConfig !== undefined - - if (!destinationConfig) throw new Error('Destination configuration is missing') - await updateDestinationPipeline({ destinationId: existingDestination.destinationId, pipelineId: existingDestination.pipelineId, projectRef, destinationName: data.name, destinationConfig, - pipelineConfig: { - publicationName: data.publicationName, - maxTableSyncWorkers: data.maxTableSyncWorkers, - maxCopyConnectionsPerTable: data.maxCopyConnectionsPerTable, - ...(hasBatchFields ? { batch: batchConfig } : {}), - }, + pipelineConfig, sourceId, }) // Set request status only right before starting, then fire and close @@ -448,64 +424,12 @@ export const DestinationForm = ({ } onClose() } else { - let destinationConfig: DestinationConfig | undefined = undefined - - if (selectedType === 'BigQuery') { - const bigQueryConfig: BigQueryDestinationConfig = { - projectId: data.projectId ?? '', - datasetId: data.datasetId ?? '', - serviceAccountKey: data.serviceAccountKey ?? '', - } - if (!!data.maxStalenessMins) { - bigQueryConfig.maxStalenessMins = data.maxStalenessMins - } - destinationConfig = { bigQuery: bigQueryConfig } - } else if (selectedType === 'Analytics Bucket') { - let s3Keys = { accessKey: data.s3AccessKeyId, secretKey: data.s3SecretAccessKey } - - if (data.s3AccessKeyId === CREATE_NEW_KEY) { - const newKeys = await createS3AccessKey({ - projectRef, - description: `Autogenerated key for replication to ${snakeCase(warehouseName)}`, - }) - s3Keys = { accessKey: newKeys.access_key, secretKey: newKeys.secret_key } - } - - // Resolve namespace (create if needed) - const finalNamespace = await resolveNamespace(data) - - const icebergConfig: IcebergDestinationConfig = { - projectRef: projectRef, - warehouseName: data.warehouseName ?? '', - namespace: finalNamespace, - catalogToken: data.catalogToken ?? '', - s3AccessKeyId: s3Keys.accessKey ?? '', - s3SecretAccessKey: s3Keys.secretKey ?? '', - s3Region: data.s3Region ?? '', - } - destinationConfig = { iceberg: icebergConfig } - } - const batchConfig: BatchConfig | undefined = - data.maxFillMs !== undefined - ? { - ...(data.maxFillMs !== undefined ? { maxFillMs: data.maxFillMs } : {}), - } - : undefined - const hasBatchFields = batchConfig !== undefined - - if (!destinationConfig) throw new Error('Destination configuration is missing') - const { pipeline_id: pipelineId } = await createDestinationPipeline({ projectRef, destinationName: data.name, destinationConfig, + pipelineConfig, sourceId, - pipelineConfig: { - publicationName: data.publicationName, - maxTableSyncWorkers: data.maxTableSyncWorkers, - maxCopyConnectionsPerTable: data.maxCopyConnectionsPerTable, - ...(hasBatchFields ? { batch: batchConfig } : {}), - }, }) // Set request status only right before starting, then fire and close setRequestStatus(pipelineId, PipelineStatusRequestStatus.StartRequested, undefined) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx index 8c2d69d6437..475513d75e5 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx @@ -1,4 +1,3 @@ -import { useFlag } from 'common' import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements' import { ArrowUpRight } from 'lucide-react' import Link from 'next/link' @@ -7,6 +6,7 @@ import { useEffect } from 'react' import { toast } from 'sonner' import { Button, + cn, DialogSectionSeparator, Sheet, SheetContent, @@ -14,7 +14,6 @@ import { SheetHeader, SheetSection, SheetTitle, - cn, } from 'ui' import { EnableReplicationCallout } from '../EnableReplicationCallout' @@ -34,7 +33,6 @@ interface DestinationPanelProps { export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPanelProps) => { const enablePgReplicate = useIsETLPrivateAlpha() - const unifiedReplication = useFlag('unifiedReplication') const { hasAccess: hasETLReplicationAccess } = useCheckEntitlements('replication.etl') const [urlDestinationType, setDestinationType] = useQueryState( @@ -97,11 +95,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane return ( <> - +
{editMode ? 'Edit destination' : 'Create a new destination'} @@ -118,7 +112,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane {destinationType === 'Read Replica' ? ( onSuccessCreateReadReplica?.()} /> - ) : unifiedReplication && !enablePgReplicate ? ( + ) : !enablePgReplicate ? (
@@ -147,7 +141,7 @@ export const DestinationPanel = ({ onSuccessCreateReadReplica }: DestinationPane
- ) : unifiedReplication && replicationNotEnabled ? ( + ) : replicationNotEnabled ? ( { - const enablePgReplicate = useIsETLPrivateAlpha() - const unifiedReplication = useFlag('unifiedReplication') - const etlEnableBigQuery = useFlag('etlEnableBigQuery') - const etlEnableIceberg = useFlag('etlEnableIceberg') + const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha() + const etlEnableIceberg = useIsETLIcebergPrivateAlpha() + const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas']) - const numberOfTypes = [unifiedReplication, etlEnableBigQuery, etlEnableIceberg].filter( + const numberOfTypes = [infrastructureReadReplicas, etlEnableBigQuery, etlEnableIceberg].filter( Boolean ).length @@ -52,13 +51,13 @@ export const DestinationTypeSelection = () => { value={destinationType} onValueChange={(value) => setDestinationType(value as DestinationType)} className={cn( - 'grid [&>button>div]:py-4', - numberOfTypes === 3 ? 'grid-cols-3' : numberOfTypes === 2 ? 'grid-cols-2' : 'grid-cols-1', + 'grid [&>button>div]:py-4 grid-cols-3', + numberOfTypes === 3 && !editMode ? 'grid-cols-3' : 'grid-cols-2', '[&>button:first-of-type]:rounded-none [&>button:last-of-type]:rounded-none', '[&>button:first-of-type]:!rounded-l-lg [&>button:last-of-type]:!rounded-r-lg' )} > - {((!editMode && unifiedReplication) || + {((!editMode && infrastructureReadReplicas) || (editMode && destinationType === 'Read Replica')) && ( {

BigQuery

- {unifiedReplication && Alpha} + Alpha

Send data to Google Cloud's data warehouse for analytics and business intelligence @@ -109,7 +108,7 @@ export const DestinationTypeSelection = () => {

Analytics Bucket

- {unifiedReplication && Alpha} + Alpha

Send data to Apache Iceberg tables in your Supabase Storage for flexible analytics @@ -121,7 +120,7 @@ export const DestinationTypeSelection = () => { )} - {destinationType !== 'Read Replica' && enablePgReplicate && ( + {destinationType !== 'Read Replica' && (

Replication is in alpha. Expect rapid changes and possible breaking updates.{' '} diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index 76b467a77de..0c9c8343e1a 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -1,13 +1,11 @@ import { useQueryClient } from '@tanstack/react-query' -import { useFlag, useParams } from 'common' +import { useParams } from 'common' import { AlertError } from 'components/ui/AlertError' import { DocsButton } from 'components/ui/DocsButton' import { useReplicationDestinationsQuery } from 'data/replication/destinations-query' import { replicationKeys } from 'data/replication/keys' import { fetchReplicationPipelineVersion } from 'data/replication/pipeline-version-query' import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query' -import { useReplicationSourcesQuery } from 'data/replication/sources-query' -import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements' import { DOCS_URL } from 'lib/constants' import { Plus, Search, X } from 'lucide-react' import { parseAsStringEnum, useQueryState } from 'nuqs' @@ -16,13 +14,13 @@ import { Button, Card, CardContent, + cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, - cn, } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' @@ -31,18 +29,27 @@ import { REPLICA_STATUS } from '../../Settings/Infrastructure/InfrastructureConf import { DestinationPanel } from './DestinationPanel/DestinationPanel' import { DestinationType } from './DestinationPanel/DestinationPanel.types' import { DestinationRow } from './DestinationRow' -import { EnableReplicationCallout } from './EnableReplicationCallout' import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' import { ReadReplicaRow } from './ReadReplicas/ReadReplicaRow' +import { useIsETLBigQueryPrivateAlpha, useIsETLIcebergPrivateAlpha } from './useIsETLPrivateAlpha' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' export const Destinations = () => { const queryClient = useQueryClient() const { ref: projectRef } = useParams() - const { hasAccess: hasETLReplicationAccess, isLoading: isLoadingEntitlement } = - useCheckEntitlements('replication.etl') - const unifiedReplication = useFlag('unifiedReplication') + const etlEnableBigQuery = useIsETLBigQueryPrivateAlpha() + const etlEnableIceberg = useIsETLIcebergPrivateAlpha() + const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas']) + + const newDestinationDefaultType = infrastructureReadReplicas + ? 'Read Replica' + : etlEnableBigQuery + ? 'BigQuery' + : etlEnableIceberg + ? 'Analytics Bucket' + : null const prefetchedRef = useRef(false) const [filterString, setFilterString] = useState('') @@ -74,18 +81,6 @@ export const Destinations = () => { ? readReplicas : readReplicas.filter((replica) => replica.identifier.includes(filterString.toLowerCase())) - const { - data: sourcesData, - error: sourcesError, - isPending: isSourcesLoading, - isError: isSourcesError, - isSuccess: isSourcesSuccess, - } = useReplicationSourcesQuery({ - projectRef, - }) - const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id - const replicationNotEnabled = isSourcesSuccess && !sourceId - const { data: destinationsData, error: destinationsError, @@ -104,19 +99,12 @@ export const Destinations = () => { destination.name.toLowerCase().includes(filterString.toLowerCase()) ) - const { - data: pipelinesData, - error: pipelinesError, - isPending: isPipelinesLoading, - isError: isPipelinesError, - isSuccess: isPipelinesSuccess, - } = useReplicationPipelinesQuery({ + const { data: pipelinesData, isSuccess: isPipelinesSuccess } = useReplicationPipelinesQuery({ projectRef, }) - const isLoading = - isSourcesLoading || isDestinationsLoading || isDatabasesLoading || isLoadingEntitlement - const hasErrorsFetchingData = isSourcesError || isDestinationsError || isDatabasesError + const isLoading = isDestinationsLoading || isDatabasesLoading + const hasErrorsFetchingData = isDestinationsError || isDatabasesError useEffect(() => { if ( @@ -184,15 +172,14 @@ export const Destinations = () => { />

- {(unifiedReplication || !!sourceId) && ( - - )} +
@@ -201,16 +188,14 @@ export const Destinations = () => {
{hasErrorsFetchingData && ( )} {isLoading ? ( - ) : !unifiedReplication && replicationNotEnabled ? ( - - ) : (unifiedReplication && hasReplicas) || hasDestinations ? ( + ) : hasReplicas || hasDestinations ? ( @@ -231,16 +216,15 @@ export const Destinations = () => { - {unifiedReplication && - filteredReplicas.map((replica) => { - return ( - setStatusRefetchInterval(5000)} - /> - ) - })} + {filteredReplicas.map((replica) => { + return ( + setStatusRefetchInterval(5000)} + /> + ) + })} {filteredDestinations.map((destination) => ( @@ -249,7 +233,7 @@ export const Destinations = () => { {!isLoading && filteredDestinations.length === 0 && filteredReplicas.length === 0 && - ((unifiedReplication && hasReplicas) || hasDestinations) && ( + (hasReplicas || hasDestinations) && (

No results found

@@ -273,15 +257,10 @@ export const Destinations = () => { 'flex flex-col px-16 rounded-lg justify-center items-center py-8 mt-4' )} > -

- {unifiedReplication - ? 'Replication keeps your data in sync across systems' - : 'Create your first destination'} -

+

Replication keeps your data in sync across systems

- {unifiedReplication - ? 'Deploy read replicas for lower latency and better resource management, or capture database changes to external platforms for real-time data pipelines.' - : 'Destinations are external platforms where your database changes are automatically sent. Connect to various data warehouses and analytics platforms to enable real-time data pipelines.'} + Deploy read replicas for lower latency and better resource management, or capture + database changes to external platforms for real-time data pipelines.

diff --git a/apps/studio/components/interfaces/HomeNew/TopSection.tsx b/apps/studio/components/interfaces/HomeNew/TopSection.tsx index 70c3cb009b1..27f1c511934 100644 --- a/apps/studio/components/interfaces/HomeNew/TopSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/TopSection.tsx @@ -11,7 +11,7 @@ import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedPr import { DOCS_URL, PROJECT_STATUS } from 'lib/constants' import Link from 'next/link' import { ReactFlowProvider } from 'reactflow' -import { Badge, Tooltip, TooltipContent, TooltipTrigger, cn } from 'ui' +import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { InstanceConfiguration } from '../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration' diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DataApi.utils.test.ts b/apps/studio/components/interfaces/Integrations/DataApi/DataApi.utils.test.ts index 838bc0f7233..f39388b27dc 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/DataApi.utils.test.ts +++ b/apps/studio/components/interfaces/Integrations/DataApi/DataApi.utils.test.ts @@ -1,28 +1,10 @@ import { describe, expect, it } from 'vitest' -import { buildEntityMaps, getApiEndpoint, getProjectApiEndpoint } from './DataApi.utils' -import type { ProjectSettings } from '@/data/config/project-settings-v2-query' -import type { - CustomDomainResponse, - CustomDomainsData, -} from '@/data/custom-domains/custom-domains-query' +import { buildEntityMaps, getApiEndpoint } from './DataApi.utils' import type { ProjectJsonSchemaPaths } from '@/data/docs/project-json-schema-query' import type { LoadBalancer } from '@/data/read-replicas/load-balancers-query' import type { Database } from '@/data/read-replicas/replicas-query' -const makeCustomDomainData = (hostname: string): CustomDomainsData => ({ - customDomain: { - id: '', - ssl: {} as CustomDomainResponse['ssl'], - hostname, - status: 'active', - created_at: '', - custom_metadata: null, - custom_origin_server: '', - }, - status: '5_services_reconfigured', -}) - const makeDatabase = ( identifier: string, restUrl: string @@ -30,71 +12,13 @@ const makeDatabase = ( const makeLoadBalancer = (endpoint: string): Pick => ({ endpoint }) -describe('getProjectApiEndpoint', () => { - it('returns custom domain URL when custom domain is active', () => { - expect( - getProjectApiEndpoint({ - settings: undefined, - customDomainData: makeCustomDomainData('api.example.com'), - }) - ).toBe('https://api.example.com') - }) - - it('returns settings-based URL when no custom domain', () => { - expect( - getProjectApiEndpoint({ - settings: { - app_config: { protocol: 'https', endpoint: 'abc.supabase.co' }, - } as ProjectSettings, - customDomainData: undefined, - }) - ).toBe('https://abc.supabase.co') - }) - - it('respects protocol from settings', () => { - expect( - getProjectApiEndpoint({ - settings: { - app_config: { protocol: 'http', endpoint: 'localhost:54321' }, - } as ProjectSettings, - customDomainData: undefined, - }) - ).toBe('http://localhost:54321') - }) - - it('returns placeholder when settings are undefined', () => { - expect( - getProjectApiEndpoint({ - settings: undefined, - customDomainData: undefined, - }) - ).toBe('https://-') - }) - - it('ignores inactive custom domain', () => { - const inactiveCustomDomain: CustomDomainsData = { - customDomain: null, - status: '0_no_hostname_configured', - } - - expect( - getProjectApiEndpoint({ - settings: { - app_config: { protocol: 'https', endpoint: 'abc.supabase.co' }, - } as ProjectSettings, - customDomainData: inactiveCustomDomain, - }) - ).toBe('https://abc.supabase.co') - }) -}) - describe('getApiEndpoint', () => { it('returns custom domain URL when custom domain is active and primary database is selected', () => { expect( getApiEndpoint({ selectedDatabaseId: 'project-ref', projectRef: 'project-ref', - customDomainData: makeCustomDomainData('api.example.com'), + resolvedEndpoint: 'https://api.example.com', loadBalancers: undefined, selectedDatabase: makeDatabase( 'project-ref', @@ -109,7 +33,7 @@ describe('getApiEndpoint', () => { getApiEndpoint({ selectedDatabaseId: 'replica-1', projectRef: 'project-ref', - customDomainData: makeCustomDomainData('api.example.com'), + resolvedEndpoint: 'https://api.example.com', loadBalancers: undefined, selectedDatabase: makeDatabase( 'replica-1', @@ -124,7 +48,7 @@ describe('getApiEndpoint', () => { getApiEndpoint({ selectedDatabaseId: 'load-balancer', projectRef: 'project-ref', - customDomainData: undefined, + resolvedEndpoint: 'https://project-ref.supabase.co', loadBalancers: [makeLoadBalancer('https://lb.supabase.co') as LoadBalancer], selectedDatabase: undefined, }) @@ -136,46 +60,26 @@ describe('getApiEndpoint', () => { getApiEndpoint({ selectedDatabaseId: 'load-balancer', projectRef: 'project-ref', - customDomainData: undefined, + resolvedEndpoint: 'https://project-ref.supabase.co', loadBalancers: undefined, selectedDatabase: undefined, }) ).toBe('') }) - it('returns database restUrl for a normal database selection', () => { + it('returns database restUrl for a replica database selection', () => { expect( getApiEndpoint({ - selectedDatabaseId: 'project-ref', + selectedDatabaseId: 'replica-2', projectRef: 'project-ref', - customDomainData: undefined, + resolvedEndpoint: 'https://project-ref.supabase.co', loadBalancers: undefined, selectedDatabase: makeDatabase( - 'project-ref', - 'https://project-ref.supabase.co/rest/v1' + 'replica-2', + 'https://replica-2.supabase.co/rest/v1' ) as Database, }) - ).toBe('https://project-ref.supabase.co/rest/v1') - }) - - it('ignores custom domain when it is not active', () => { - const inactiveCustomDomain: CustomDomainsData = { - customDomain: null, - status: '0_no_hostname_configured', - } - - expect( - getApiEndpoint({ - selectedDatabaseId: 'project-ref', - projectRef: 'project-ref', - customDomainData: inactiveCustomDomain, - loadBalancers: undefined, - selectedDatabase: makeDatabase( - 'project-ref', - 'https://project-ref.supabase.co/rest/v1' - ) as Database, - }) - ).toBe('https://project-ref.supabase.co/rest/v1') + ).toBe('https://replica-2.supabase.co/rest/v1') }) }) diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DataApi.utils.ts b/apps/studio/components/interfaces/Integrations/DataApi/DataApi.utils.ts index 61990329ea5..da0fd5d6be1 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/DataApi.utils.ts +++ b/apps/studio/components/interfaces/Integrations/DataApi/DataApi.utils.ts @@ -1,29 +1,8 @@ -import type { ProjectSettings } from '@/data/config/project-settings-v2-query' -import type { CustomDomainsData } from '@/data/custom-domains/custom-domains-query' import type { ProjectJsonSchemaPaths } from '@/data/docs/project-json-schema-query' import type { LoadBalancer } from '@/data/read-replicas/load-balancers-query' import type { Database } from '@/data/read-replicas/replicas-query' import { snakeToCamel } from '@/lib/helpers' -/** - * Resolves the primary project API endpoint, respecting custom domains. - */ -export function getProjectApiEndpoint({ - settings, - customDomainData, -}: { - settings: ProjectSettings | undefined - customDomainData: CustomDomainsData | undefined -}): string { - if (customDomainData?.customDomain?.status === 'active') { - return `https://${customDomainData.customDomain.hostname}` - } - - const protocol = settings?.app_config?.protocol ?? 'https' - const endpoint = settings?.app_config?.endpoint - return `${protocol}://${endpoint ?? '-'}` -} - /** * Resolves the API endpoint URL based on the selected database, custom domain * status, and load balancer configuration. @@ -31,21 +10,20 @@ export function getProjectApiEndpoint({ export function getApiEndpoint({ selectedDatabaseId, projectRef, - customDomainData, + resolvedEndpoint, loadBalancers, selectedDatabase, }: { selectedDatabaseId: string | undefined projectRef: string | undefined - customDomainData: CustomDomainsData | undefined + resolvedEndpoint: string | undefined loadBalancers: Array | undefined selectedDatabase: Database | undefined }): string { - const isCustomDomainActive = customDomainData?.customDomain?.status === 'active' const loadBalancerSelected = selectedDatabaseId === 'load-balancer' - if (isCustomDomainActive && selectedDatabaseId === projectRef) { - return `https://${customDomainData.customDomain.hostname}` + if (selectedDatabaseId === projectRef && !!resolvedEndpoint) { + return resolvedEndpoint } if (loadBalancerSelected) { diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DocView.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DocView.tsx index 7316245f00e..8950db2a349 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/DocView.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/DocView.tsx @@ -4,14 +4,10 @@ import type { ShowApiKey } from '../../Docs/Docs.types' import { GeneralContent } from '@/components/interfaces/Docs/GeneralContent' import { ResourceContent } from '@/components/interfaces/Docs/ResourceContent' import { RpcContent } from '@/components/interfaces/Docs/RpcContent' -import { - buildEntityMaps, - getProjectApiEndpoint, -} from '@/components/interfaces/Integrations/DataApi/DataApi.utils' +import { buildEntityMaps } from '@/components/interfaces/Integrations/DataApi/DataApi.utils' import { DocViewError } from '@/components/interfaces/Integrations/DataApi/DocViewError' import { DocViewLoading } from '@/components/interfaces/Integrations/DataApi/DocViewLoading' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' -import { useCustomDomainsQuery } from '@/data/custom-domains/custom-domains-query' import { useProjectJsonSchemaQuery } from '@/data/docs/project-json-schema-query' interface DocViewProps { @@ -29,9 +25,6 @@ export const DocView = ({ selectedLang, selectedApiKey }: DocViewProps) => { isPending: isLoading, refetch, } = useProjectJsonSchemaQuery({ projectRef }) - const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) - - const endpoint = getProjectApiEndpoint({ settings, customDomainData }) const { paths } = jsonSchema || {} const PAGE_KEY = resource || rpc || page || 'index' @@ -51,7 +44,6 @@ export const DocView = ({ selectedLang, selectedApiKey }: DocViewProps) => {
{resource ? ( - {alert &&
{alert}
} + + {!!alert &&
{alert}
} + + + {dependsOnExtension && (

Required extensions

@@ -66,16 +70,12 @@ export const IntegrationOverviewTab = ({ return (
  • -
    - - {requiredExtension} - -
    + {requiredExtension}
    {extension ? ( @@ -100,6 +100,7 @@ export const IntegrationOverviewTab = ({
    )} + {!!actions && (
    { const { data: project } = useSelectedProjectQuery() const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers']) - const stripeSyncEnabled = useFlag('enableStripeSyncEngineIntegration') const allIntegrations = useMemo(() => { return INTEGRATIONS.filter((integration) => { @@ -29,15 +27,12 @@ export const useInstalledIntegrations = () => { ) { return false } - if (!stripeSyncEnabled && integration.id === 'stripe_sync_engine') { - return false - } if (!IS_PLATFORM && integration.id === 'data_api') { return false } return true }) - }, [integrationsWrappers, stripeSyncEnabled]) + }, [integrationsWrappers]) const { data, @@ -86,8 +81,8 @@ export const useInstalledIntegrations = () => { } if (integration.id === 'stripe_sync_engine') { const stripeSchema = findStripeSchema(schemas) - const status = parseStripeSchemaStatus(stripeSchema) - return checkIsInstalled(status) + const parsedSchema = parseStripeSchema(stripeSchema) + return checkIsInstalled(parsedSchema.status) } if (integration.type === 'wrapper') { return wrappers.find((w) => wrapperMetaComparator(integration.meta, w)) diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/InstallationError.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/InstallationError.tsx new file mode 100644 index 00000000000..2c8e63d7b5c --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/InstallationError.tsx @@ -0,0 +1,60 @@ +import { useParams } from 'common' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns' + +import { ContactSupportButton } from '@/components/ui/AlertError' + +export const InstallationError = ({ + error, + handleUninstall, + handleOpenInstallSheet, +}: { + error: 'install' | 'uninstall' + handleUninstall: () => void + handleOpenInstallSheet: () => void +}) => { + const { ref } = useParams() + + if (error === 'uninstall') { + return ( + + + +
    + } + /> + ) + } + + if (error === 'install') { + return ( + + + +
  • + } + /> + ) + } + + return null +} diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/InstallationOverview.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/InstallationOverview.tsx index fa12685ee66..d1a3343376f 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/InstallationOverview.tsx +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/InstallationOverview.tsx @@ -4,13 +4,12 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useStripeSyncInstallMutation } from 'data/database-integrations/stripe/stripe-sync-install-mutation' import { useStripeSyncUninstallMutation } from 'data/database-integrations/stripe/stripe-sync-uninstall-mutation' import { useSchemasQuery } from 'data/database/schemas-query' -import { formatRelative } from 'date-fns' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useTrack } from 'lib/telemetry/track' -import { AlertCircle, BadgeCheck, Check, ExternalLink, RefreshCwIcon } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import Link from 'next/link' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { @@ -32,6 +31,8 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' import { IntegrationOverviewTab } from '../../Integration/IntegrationOverviewTab' +import { InstallationError } from './InstallationError' +import { StatusDisplay } from './StatusDisplay' import { canInstall as checkCanInstall, hasInstallError, @@ -39,13 +40,13 @@ import { isInstallDone, isInstalled, isInstalling, - isSyncRunning, isUninstallDone, isUninstalling, } from './stripe-sync-status' import { StripeSyncChangesCard } from './StripeSyncChangesCard' import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus' import { InlineLink } from '@/components/ui/InlineLink' +import { useSSLEnforcementQuery } from '@/data/ssl-enforcement/ssl-enforcement-query' const installFormSchema = z.object({ stripeSecretKey: z.string().min(1, 'Stripe API key is required'), @@ -69,8 +70,9 @@ export const StripeSyncInstallationPage = () => { mode: 'onSubmit', }) - // Use the unified status hook - const { installationStatus, syncState } = useStripeSyncStatus({ + const { + parsedSchema: { status: installationStatus }, + } = useStripeSyncStatus({ projectRef: project?.ref, connectionString: project?.connectionString, }) @@ -81,13 +83,11 @@ export const StripeSyncInstallationPage = () => { '*' ) - const isSyncing = isSyncRunning(syncState) - const installed = isInstalled(installationStatus) const installError = hasInstallError(installationStatus) const uninstallError = hasUninstallError(installationStatus) const installInProgress = isInstalling(installationStatus) - const uninsallInProgress = isUninstalling(installationStatus) + const uninstallInProgress = isUninstalling(installationStatus) const installDone = isInstallDone(installationStatus) const uninstallDone = isUninstallDone(installationStatus) @@ -116,15 +116,25 @@ export const StripeSyncInstallationPage = () => { // Combine schema status with mutation/initiated states for UI const installing = installInProgress || isInstallRequested || isInstallInitiated - const uninstalling = uninsallInProgress || isUninstallRequested || isUninstallInitiated + const uninstalling = uninstallInProgress || isUninstallRequested || isUninstallInitiated const canInstall = checkCanInstall(installationStatus) && !installed && !installing + const hasError = (uninstallError || installError) && !uninstalling && !installing + // Poll for schema changes during transitions useSchemasQuery( { projectRef: project?.ref, connectionString: project?.connectionString }, { refetchInterval: installing || uninstalling ? 5000 : false } ) + const { data: sslEnforcementConfiguration, isSuccess: isSuccessSslEnforcement } = + useSSLEnforcementQuery({ + projectRef: project?.ref, + }) + const isSSLEnforced = + sslEnforcementConfiguration?.appliedSuccessfully && + sslEnforcementConfiguration?.currentConfig.database + const handleUninstall = useCallback(() => { if (!project?.ref) return @@ -148,152 +158,6 @@ export const StripeSyncInstallationPage = () => { } } - const tableEditorUrl = `/project/${project?.ref}/editor?schema=stripe` - - const alert = useMemo(() => { - if (uninstallError) { - return ( - -
    - There was an error during the uninstallation of the Stripe Sync Engine. Please try - again. If the problem persists, contact support. -
    -
    - -
    -
    - ) - } - - if (installError) { - return ( - -
    - There was an error during the installation of the Stripe Sync Engine. Please try - reinstalling the integration. If the problem persists, contact support. -
    -
    - - -
    -
    - ) - } - - if (syncState && installed && !uninstalling) { - return ( - -
    - {isSyncing ? ( - <> -
    - -
    Sync in progress...
    -
    -
    - Started {formatRelative(new Date(syncState.started_at!), new Date())} -
    - - ) : ( - <> -
    - -
    All up to date
    - -
    -
    - Last synced {formatRelative(new Date(syncState.closed_at!), new Date())} -
    - - )} -
    -
    - ) - } - - return null - }, [ - uninstallError, - installError, - syncState, - isSyncing, - installed, - isUninstallRequested, - tableEditorUrl, - uninstalling, - handleOpenInstallSheet, - handleUninstall, - ]) - - const statusDisplay = useMemo(() => { - if (uninstallError) { - return ( - - - Uninstallation error - - ) - } - if (uninstalling) { - return ( - - - Uninstalling... - - ) - } - if (installError) { - return ( - - - Installation error - - ) - } - if (installing) { - return ( - - - Installing... - - ) - } - if (isSyncing && installed) { - return ( - - - Sync in progress... - - ) - } - if (installed) { - return ( - - Installed - - ) - } - return ( - Not installed - ) - }, [uninstallError, uninstalling, installError, installing, isSyncing, installed]) - // Track install failures useEffect(() => { if (!installError) { @@ -324,39 +188,61 @@ export const StripeSyncInstallationPage = () => { }, [isUninstallInitiated, uninstallDone]) return ( - <> - - -
    - setShouldShowInstallSheet(true)} - disabled={!canInstall || !canManageSecrets} - tooltip={{ - content: { - text: !canInstall - ? 'Your database already uses a schema named "stripe"' - : !canManageSecrets - ? 'You need additional permissions to install the Stripe Sync Engine.' - : undefined, - }, - }} - > - Install integration - -
    - - ) : installed && !uninstalling ? ( + + ) : null + } + status={ + + } + actions={ + !installed && !uninstalling && !uninstallError ? ( + <> + +
    + setShouldShowInstallSheet(true)} + disabled={!canInstall || !canManageSecrets} + loading={installing} + tooltip={{ + content: { + text: !canInstall + ? 'Your database already uses a schema named "stripe"' + : !canManageSecrets + ? 'You need additional permissions to install the Stripe Sync Engine.' + : undefined, + }, + }} + > + {installError ? 'Retry installation' : 'Install integration'} + + {installError && ( + + )} +
    + + ) : installed || uninstalling || uninstallError ? ( + <> +
    setShowUninstallModal(true)} disabled={!canManageSecrets} + loading={uninstalling} tooltip={{ content: { text: !canManageSecrets @@ -368,30 +254,34 @@ export const StripeSyncInstallationPage = () => { Uninstall integration
    - ) : null - } - > - - - -
    { - if (!project?.ref) return - installStripeSync({ projectRef: project.ref, stripeSecretKey }) - })} - className="overflow-auto flex-grow px-0 flex flex-col" - > - - Install Stripe Sync Engine - - - + + ) : null + } + > + + + + { + if (!project?.ref) return + installStripeSync({ projectRef: project.ref, stripeSecretKey }) + })} + className="overflow-auto flex-grow px-0 flex flex-col" + > + + Install Stripe Sync Engine + + + + {isSuccessSslEnforcement && isSSLEnforced && (
    This integration currently requires{' '} - + SSL Enforcement {' '} to be disabled during initial setup. @@ -401,9 +291,11 @@ export const StripeSyncInstallationPage = () => { all webhook and sync operations use HTTPS/SSL.

    + )} -

    Configuration

    +

    Configuration

    +
    {
    +
    - {installRequestError && ( - - )} - + {installRequestError && ( + + )} + - - - - - - - - + + + + + + + + - setShowUninstallModal(false)} - onConfirm={handleUninstall} - > -

    - Are you sure you want to uninstall the Stripe Sync Engine? This will: -

    -
      -
    • - Remove the stripe schema and all tables -
    • -
    • Delete all synced Stripe data
    • -
    • Remove the associated Edge Functions
    • -
    • Remove the scheduled sync jobs
    • -
    -

    - This action cannot be undone. -

    -
    - - + setShowUninstallModal(false)} + onConfirm={handleUninstall} + > +

    + Are you sure you want to uninstall the Stripe Sync Engine? This will: +

    +
      +
    • + Remove the stripe schema and all tables +
    • +
    • Delete all synced Stripe data
    • +
    • Remove the associated Edge Functions
    • +
    • Remove the scheduled sync jobs
    • +
    +

    + This action cannot be undone. +

    +
    + ) } diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StatusDisplay.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StatusDisplay.tsx new file mode 100644 index 00000000000..da6e6ee44c7 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StatusDisplay.tsx @@ -0,0 +1,72 @@ +import { AlertCircle, Check, RefreshCwIcon } from 'lucide-react' + +import { + hasInstallError, + hasUninstallError, + isInstalled, + isInstalling, + isUninstalling, + StripeInstallationStatus, +} from './stripe-sync-status' + +export const StatusDisplay = ({ + status, + isInstallRequested, + isUninstallRequested, +}: { + status: StripeInstallationStatus + isInstallRequested: boolean + isUninstallRequested: boolean +}) => { + const installed = isInstalled(status) + const installError = hasInstallError(status) + const uninstallError = hasUninstallError(status) + const installInProgress = isInstalling(status) + const uninstallInProgress = isUninstalling(status) + + const installing = installInProgress || isInstallRequested + const uninstalling = uninstallInProgress || isUninstallRequested + + if (uninstallError) { + return ( + + + Uninstallation error + + ) + } + if (uninstalling) { + return ( + + + Uninstalling... + + ) + } + if (installError) { + return ( + + + Installation error + + ) + } + if (installing) { + return ( + + + Installing... + + ) + } + if (installed) { + return ( + + Installed + + ) + } + return ( + Not installed + ) +} diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncChangesCard.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncChangesCard.tsx index 0271b45a9c7..98db11118c3 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncChangesCard.tsx +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncChangesCard.tsx @@ -2,43 +2,119 @@ import { EdgeFunctions } from 'icons' import { Layers, Table } from 'lucide-react' import { Card, CardContent, cn } from 'ui' +import { + hasInstallError, + hasUninstallError, + isInstallDone, + isInstalled, + isInstalling, + isUninstallDone, + isUninstalling, + StripeInstallationStatus, +} from './stripe-sync-status' + type StripeSyncChangesCardProps = { + installationStatus: StripeInstallationStatus className?: string } const ListItemClassName = 'flex items-center gap-x-3 py-2 px-3 border-b' -export const StripeSyncChangesCard = ({ className }: StripeSyncChangesCardProps) => { +export const StripeSyncChangesCard = ({ + installationStatus, + className, +}: StripeSyncChangesCardProps) => { + const installed = isInstalled(installationStatus) + const installError = hasInstallError(installationStatus) + const uninstallError = hasUninstallError(installationStatus) + const installInProgress = isInstalling(installationStatus) + const uninstallInProgress = isUninstalling(installationStatus) + const installDone = isInstallDone(installationStatus) + const uninstallDone = isUninstallDone(installationStatus) + + const title = + uninstallDone || installError + ? 'This integration will modify your Supabase project:' + : installInProgress || uninstallInProgress + ? 'This integration is modifying your Supabase project:' + : installDone || installed || uninstallError + ? 'This integration has modified your Supabase project:' + : '' + + const dbLine = + uninstallDone || installError + ? 'Creates a new database schema named ' + : installInProgress + ? 'Creating a new database schema named ' + : installDone || installed || uninstallError + ? 'Created a new database schema named ' + : uninstallInProgress + ? 'Dropping database schema named ' + : '' + + const tableAndViewLine = + uninstallDone || installError + ? 'Creates tables and views in the ' + : installInProgress + ? 'Creating tables and views in the ' + : installDone || installed || uninstallError + ? 'Created tables and views in the ' + : uninstallInProgress + ? 'Dropping tables and views in the ' + : '' + + const edgeFunctionsLine = + uninstallDone || installError + ? 'Deploys Edge Functions to handle incoming webhooks from Stripe' + : installInProgress + ? 'Deploying Edge Functions to handle incoming webhooks from Stripe' + : installDone || installed || uninstallError + ? 'Deployed Edge Functions to handle incoming webhooks from Stripe' + : uninstallInProgress + ? 'Undeploying Edge Functions to handle incoming webhooks from Stripe' + : '' + + const scheduleLine = + uninstallDone || installError + ? 'Schedules automatic Stripe data syncs using Supabase Queues' + : installInProgress + ? 'Scheduling automatic Stripe data syncs using Supabase Queues' + : installDone || installed || uninstallError + ? 'Scheduled automatic Stripe data syncs using Supabase Queues' + : uninstallInProgress + ? 'Unscheduling automatic Stripe data syncs using Supabase Queues' + : '' + return (
    -

    This integration will modify your Supabase project:

    +

    {title}

    • -
    +
    - Creates a new database schema named stripe + {dbLine} stripe
  • -
  • +
    - Creates tables and views in the stripe{' '} - schema for synced Stripe data + {tableAndViewLine} stripe schema for + synced Stripe data
  • - Deploys Edge Functions to handle incoming webhooks from Stripe + {edgeFunctionsLine}
  • - - Schedules automatic Stripe data syncs using Supabase Queues + + {scheduleLine}
  • diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx index 3075db7778f..311e61e292c 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/StripeSyncSettingsPage.tsx @@ -1,9 +1,10 @@ +import { formatRelative } from 'date-fns' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Table2 } from 'lucide-react' +import { BadgeCheck, RefreshCwIcon, Table2 } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { useEffect } from 'react' import { Button, Card, CardContent } from 'ui' +import { Admonition } from 'ui-patterns' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, @@ -14,28 +15,73 @@ import { PageSectionTitle, } from 'ui-patterns/PageSection' -import { isInstalled } from './stripe-sync-status' +import { isInstalled, isSyncRunning, isUninstalling } from './stripe-sync-status' import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus' export const StripeSyncSettingsPage = () => { const router = useRouter() const { data: project } = useSelectedProjectQuery() - const { installationStatus, isLoading: isLoadingInstallationStatus } = useStripeSyncStatus({ + const { + parsedSchema: { status: installationStatus }, + syncState, + isLoading: isLoadingInstallationStatus, + } = useStripeSyncStatus({ projectRef: project?.ref, connectionString: project?.connectionString, }) const installed = isInstalled(installationStatus) - - // Redirect to overview page when integration is not installed - useEffect(() => { - if (isLoadingInstallationStatus || installed || !project?.ref) return - - router.push(`/project/${project.ref}/integrations/stripe_sync_engine/overview`) - }, [isLoadingInstallationStatus, installed, project?.ref, router]) + const isSyncing = isSyncRunning(syncState) + const uninstalling = isUninstalling(installationStatus) + const tableEditorUrl = `/project/${project?.ref}/editor?schema=stripe` return ( - + + {syncState && installed && !uninstalling && ( + + + + Sync Status + + + + +
    + {isSyncing ? ( + <> +
    + +
    Sync in progress...
    +
    +
    + Started{' '} + {syncState.started_at + ? formatRelative(new Date(syncState.started_at), new Date()) + : 'recently'} +
    + + ) : ( + <> +
    + +
    All up to date
    + +
    +
    + Last synced{' '} + {syncState.closed_at + ? formatRelative(new Date(syncState.closed_at), new Date()) + : 'recently'} +
    + + )} +
    +
    +
    +
    + )} diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status.ts b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status.ts index 190f76ad643..d57ce7100b9 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status.ts +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status.ts @@ -21,12 +21,26 @@ export type StripeInstallationStatus = | 'uninstalled' | 'uninstall_error' +/** + * Parsed Stripe schema status including version and error message + */ +export interface ParsedStripeSchema { + /** The installation status */ + status: StripeInstallationStatus + + /** The version from the schema comment (e.g., 'v1.2.3') */ + version?: string + + /** Error message if status is install_error or uninstall_error */ + errorMessage?: string +} + /** * Complete Stripe Sync status including schema, installation state, and sync state */ export interface StripeSyncStatusResult { - /** The installation status */ - installationStatus: StripeInstallationStatus + /** The parsed schema with status, version and error */ + parsedSchema: ParsedStripeSchema /** Current sync run state (only available when installationStatus is installed) */ syncState: StripeSyncState | undefined @@ -51,40 +65,72 @@ function isStripeSyncSchema(schema: Schema | undefined): boolean { return !!schema?.comment?.startsWith(STRIPE_SCHEMA_COMMENT_PREFIX) } -function hasStatusSuffix(schema: Schema | undefined, suffix: string): boolean { - return isStripeSyncSchema(schema) && !!schema?.comment?.endsWith(suffix) -} - /** * Parse the installation status from a stripe schema. * + * Schema comment format: {PREFIX} {version} {SUFFIX}[ - {error message}] + * Example: "stripe_sync v1.2.3 installation:error - Could not apply migration" + * * @param stripeSchema - The stripe schema from the database, if it exists - * @returns The installation status + * @returns The parsed status including version and optional error message */ -export function parseStripeSchemaStatus( - stripeSchema: Schema | undefined -): StripeInstallationStatus { - if (hasStatusSuffix(stripeSchema, UNINSTALLATION_ERROR_SUFFIX)) { - return 'uninstall_error' +export function parseStripeSchema(stripeSchema: Schema | undefined): ParsedStripeSchema { + if (!isStripeSyncSchema(stripeSchema)) { + return { status: 'uninstalled' } } - if (hasStatusSuffix(stripeSchema, INSTALLATION_ERROR_SUFFIX)) { - return 'install_error' + const comment = stripeSchema!.comment! + + // Remove prefix and leading space + const afterPrefix = comment.slice(STRIPE_SCHEMA_COMMENT_PREFIX.length).trimStart() + + // Split at first space to get version and trailing segment + const firstSpaceIndex = afterPrefix.indexOf(' ') + if (firstSpaceIndex === -1) { + return { status: 'uninstalled' } } - if (hasStatusSuffix(stripeSchema, UNINSTALLATION_STARTED_SUFFIX)) { - return 'uninstalling' + const version = afterPrefix.slice(0, firstSpaceIndex) + const trailing = afterPrefix.slice(firstSpaceIndex + 1) + + // Helper to extract error message if present after ' - ' separator + const extractError = (afterSuffix: string): string | undefined => { + if (afterSuffix.startsWith(' - ')) { + return afterSuffix.slice(3) // Remove ' - ' (3 characters) + } + return afterSuffix } - if (hasStatusSuffix(stripeSchema, INSTALLATION_STARTED_SUFFIX)) { - return 'installing' + // Check status in priority order + if (trailing.startsWith(UNINSTALLATION_ERROR_SUFFIX)) { + return { + status: 'uninstall_error', + version, + errorMessage: extractError(trailing.slice(UNINSTALLATION_ERROR_SUFFIX.length)), + } } - if (hasStatusSuffix(stripeSchema, INSTALLATION_INSTALLED_SUFFIX)) { - return 'installed' + if (trailing.startsWith(INSTALLATION_ERROR_SUFFIX)) { + return { + status: 'install_error', + version, + errorMessage: extractError(trailing.slice(INSTALLATION_ERROR_SUFFIX.length)), + } } - return 'uninstalled' + if (trailing.startsWith(UNINSTALLATION_STARTED_SUFFIX)) { + return { status: 'uninstalling', version } + } + + if (trailing.startsWith(INSTALLATION_STARTED_SUFFIX)) { + return { status: 'installing', version } + } + + if (trailing.startsWith(INSTALLATION_INSTALLED_SUFFIX)) { + return { status: 'installed', version } + } + + return { status: 'uninstalled' } } export function isInstalled(status: StripeInstallationStatus): boolean { diff --git a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus.ts b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus.ts index a28f8405984..4332391c25c 100644 --- a/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus.ts +++ b/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus.ts @@ -6,7 +6,7 @@ import { findStripeSchema, isInProgress, isInstalled, - parseStripeSchemaStatus, + parseStripeSchema, type StripeSyncStatusResult, } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status' @@ -30,10 +30,10 @@ export function useStripeSyncStatus({ // Find and parse stripe schema status const stripeSchema = findStripeSchema(schemas) - const installationStatus = parseStripeSchemaStatus(stripeSchema) + const parsedSchema = parseStripeSchema(stripeSchema) - const installed = isInstalled(installationStatus) - const inProgress = isInProgress(installationStatus) + const installed = isInstalled(parsedSchema.status) + const inProgress = isInProgress(parsedSchema.status) // Poll schemas during install/uninstall operations useEffect(() => { @@ -58,7 +58,7 @@ export function useStripeSyncStatus({ ) return { - installationStatus, + parsedSchema, syncState: installed ? syncState : undefined, isLoading: isSchemasLoading, } diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx index d10fe19b30b..dc881ea3bbb 100644 --- a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx @@ -1,10 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { AnimatePresence } from 'framer-motion' -import { AlertCircle, RotateCw, Timer } from 'lucide-react' -import { useMemo, useState } from 'react' -import { toast } from 'sonner' - -import { useFlag, useParams } from 'common' +import { useParams } from 'common' import { TextConfirmModal } from 'components/ui/TextConfirmModalWrapper' import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query' import { useJWTSigningKeyDeleteMutation } from 'data/jwt-signing-keys/jwt-signing-key-delete-mutation' @@ -12,8 +7,12 @@ import { useJWTSigningKeyUpdateMutation } from 'data/jwt-signing-keys/jwt-signin import { JWTSigningKey, useJWTSigningKeysQuery } from 'data/jwt-signing-keys/jwt-signing-keys-query' import { useLegacyJWTSigningKeyCreateMutation } from 'data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation' import { useLegacyJWTSigningKeyQuery } from 'data/jwt-signing-keys/legacy-jwt-signing-key-query' +import { AnimatePresence } from 'framer-motion' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { AlertCircle, RotateCw, Timer } from 'lucide-react' +import { useMemo, useState } from 'react' +import { toast } from 'sonner' import { AlertDialog, AlertDialogCancel, @@ -39,7 +38,7 @@ import { TableRow, } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { SigningKeysComingSoonBanner } from '../signing-keys-coming-soon' + import { StartUsingJwtSigningKeysBanner } from '../start-using-keys-banner' import { ActionPanel } from './action-panel' import { CreateKeyDialog } from './create-key-dialog' @@ -53,8 +52,6 @@ export const JWTSecretKeysTable = () => { const { ref: projectRef } = useParams() const { data: project, isPending: isProjectLoading } = useSelectedProjectQuery() - const newJwtSecrets = useFlag('newJwtSecrets') - const [selectedKey, setSelectedKey] = useState() const [selectedKeyToUpdate, setSelectedKeyToUpdate] = useState() const [shownDialog, setShownDialog] = useState() @@ -182,10 +179,6 @@ export const JWTSecretKeysTable = () => { return } - if (!newJwtSecrets) { - return - } - return ( <>
    diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx index 47272a107c7..56a5d66fad1 100644 --- a/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx @@ -4,6 +4,20 @@ import { JwtSecretUpdateProgress, JwtSecretUpdateStatus, } from '@supabase/shared-types/out/events' +import { useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { FormActions } from 'components/ui/Forms/FormActions' +import Panel from 'components/ui/Panel' +import { TextConfirmModal } from 'components/ui/TextConfirmModalWrapper' +import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query' +import { useAuthConfigQuery } from 'data/auth/auth-config-query' +import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' +import { useJwtSecretUpdateMutation } from 'data/config/jwt-secret-update-mutation' +import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' +import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { useLegacyJWTSigningKeyQuery } from 'data/jwt-signing-keys/legacy-jwt-signing-key-query' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { uuidv4 } from 'lib/helpers' import { AlertCircle, ChevronDown, @@ -23,21 +37,6 @@ import { import Link from 'next/link' import { Dispatch, SetStateAction, useState } from 'react' import { toast } from 'sonner' - -import { useFlag, useParams } from 'common' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { FormActions } from 'components/ui/Forms/FormActions' -import Panel from 'components/ui/Panel' -import { TextConfirmModal } from 'components/ui/TextConfirmModalWrapper' -import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query' -import { useAuthConfigQuery } from 'data/auth/auth-config-query' -import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useJwtSecretUpdateMutation } from 'data/config/jwt-secret-update-mutation' -import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' -import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' -import { useLegacyJWTSigningKeyQuery } from 'data/jwt-signing-keys/legacy-jwt-signing-key-query' -import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { uuidv4 } from 'lib/helpers' import { Button, DropdownMenu, @@ -52,6 +51,7 @@ import { } from 'ui' import { Admonition } from 'ui-patterns/admonition' import { number, object } from 'yup' + import { JWT_SECRET_UPDATE_ERROR_MESSAGES, JWT_SECRET_UPDATE_PROGRESS_MESSAGES, @@ -66,8 +66,6 @@ const schema = object({ const JWTSettings = () => { const { ref: projectRef } = useParams() - const newJwtSecrets = useFlag('newJwtSecrets') - const [customToken, setCustomToken] = useState('') const [showCustomTokenInput, setShowCustomTokenInput] = useState(false) const [isCreatingKey, setIsCreatingKey] = useState(false) @@ -276,7 +274,7 @@ const JWTSettings = () => { disabled={!canUpdateConfig || isLoadingAuthConfig} /> - {newJwtSecrets && !legacyKey && ( + {!legacyKey && ( <> {isUpdatingJwtSecret && (
    @@ -388,89 +386,6 @@ const JWTSettings = () => {
    )} - - {!newJwtSecrets && ( -
    -
    - {isUpdatingJwtSecret ? ( -
    - -

    - Updating JWT secret: {jwtSecretUpdateProgressMessage} -

    -
    - ) : ( -
    -
    -
    -

    Generate a new JWT secret

    -

    - A random secret will be created, or you can create your own. -

    -
    -
    - {isUpdatingJwtSecret ? ( - - ) : !canGenerateNewJWTSecret ? ( - } - tooltip={{ - content: { - side: 'bottom', - text: 'You need additional permissions to generate a new JWT secret', - }, - }} - > - Generate a new secret - - ) : ( - - - - - - setIsGeneratingKey(true)} - > - -

    Generate a random secret

    -
    - - setIsCreatingKey(true)} - > - -

    Create my own secret

    -
    -
    -
    - )} -
    -
    -
    - )} -
    - {isJwtSecretUpdateFailed ? ( - - Please try again. If the failures persist, please contact Supabase - support with the following details:
    - Change tracking ID: {data?.changeTrackingId}
    - Error message: {jwtSecretUpdateErrorMessage} -
    - ) : null} -
    - )} )} diff --git a/apps/studio/components/interfaces/JwtSecrets/signing-keys-coming-soon.tsx b/apps/studio/components/interfaces/JwtSecrets/signing-keys-coming-soon.tsx deleted file mode 100644 index dcfdb7999ff..00000000000 --- a/apps/studio/components/interfaces/JwtSecrets/signing-keys-coming-soon.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Github } from 'lucide-react' -import { Button } from 'ui' - -import { FeatureBanner } from 'components/ui/FeatureBanner' - -export const SigningKeysComingSoonBanner = () => { - return ( - -
    -

    JWT signing keys are coming soon

    -

    - We're rolling out JWT signing keys to better support your application needs. -

    -
    - -
    -
    -
    - ) -} diff --git a/apps/studio/components/interfaces/LocalDropdown.tsx b/apps/studio/components/interfaces/LocalDropdown.tsx index c4950b00cde..a335bb2b807 100644 --- a/apps/studio/components/interfaces/LocalDropdown.tsx +++ b/apps/studio/components/interfaces/LocalDropdown.tsx @@ -36,14 +36,17 @@ export const LocalDropdown = () => { toggleFeaturePreviewModal(true)} onSelect={() => toggleFeaturePreviewModal(true)} > Feature previews - setCommandMenuOpen(true)}> + setCommandMenuOpen(true)} + > Command menu @@ -57,7 +60,11 @@ export const LocalDropdown = () => { }} > {singleThemes.map((theme: Theme) => ( - + {theme.name} ))} diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainsEmpty.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainsEmpty.tsx index c9f6ab3af98..8765914043b 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrainsEmpty.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrainsEmpty.tsx @@ -3,6 +3,7 @@ import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { DOCS_URL } from 'lib/constants' import Link from 'next/link' import { Button, Card, cn } from 'ui' + import { AnimatedLogos } from './AnimatedLogos' import { VoteLink } from './VoteLink' @@ -12,7 +13,7 @@ export const LogDrainsEmpty = () => { step: 1, title: 'Pricing', description: - 'Log Drains are available as a project Add-On for all Team and Enterprise users. Each Log Drain costs $60 per month.', + 'Log Drains are available as a project Add-On for all Pro, Team, and Enterprise users. Each Log Drain costs $60 per month.', label: 'See our pricing', link: `${DOCS_URL}/guides/platform/manage-your-usage/log-drains`, }, @@ -34,10 +35,11 @@ export const LogDrainsEmpty = () => {

    Capture your logs, your way

    - Upgrade to a Team or Enterprise Plan to send your logs to your preferred platform + Upgrade to a Pro, Team or Enterprise Plan to send your logs to your preferred platform

    + {/* This should only be shown to free tier users so upgrade to Pro makes sense */} diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx index a41b5d6e045..998916d8924 100644 --- a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from '@tanstack/react-query' -import { useFlag, useParams } from 'common' +import { useParams } from 'common' import ReportHeader from 'components/interfaces/Reports/ReportHeader' import ReportPadding from 'components/interfaces/Reports/ReportPadding' import { ChartIntervalDropdown } from 'components/ui/Logs/ChartIntervalDropdown' @@ -28,11 +28,6 @@ export const ObservabilityOverview = () => { const { plan } = useCurrentOrgPlan() const queryClient = useQueryClient() - const authReportEnabled = useFlag('authreportv2') - const edgeFnReportEnabled = useFlag('edgefunctionreport') - const realtimeReportEnabled = useFlag('realtimeReport') - const storageReportEnabled = useFlag('storagereport') - const postgrestReportEnabled = useFlag('postgrestreport') const { projectStorageAll: storageSupported } = useIsFeatureEnabled(['project_storage:all']) const DEFAULT_INTERVAL: ChartIntervalKey = '1day' @@ -77,7 +72,7 @@ export const ObservabilityOverview = () => { reportUrl: `/project/${projectRef}/observability/auth`, logsUrl: `/project/${projectRef}/logs/auth-logs`, enabled: true, - hasReport: authReportEnabled, + hasReport: true, }, { key: 'functions' as const, @@ -85,7 +80,7 @@ export const ObservabilityOverview = () => { reportUrl: `/project/${projectRef}/observability/edge-functions`, logsUrl: `/project/${projectRef}/logs/edge-functions-logs`, enabled: true, - hasReport: edgeFnReportEnabled, + hasReport: true, }, { key: 'realtime' as const, @@ -93,7 +88,7 @@ export const ObservabilityOverview = () => { reportUrl: `/project/${projectRef}/observability/realtime`, logsUrl: `/project/${projectRef}/logs/realtime-logs`, enabled: true, - hasReport: realtimeReportEnabled, + hasReport: true, }, { key: 'storage' as const, @@ -101,7 +96,7 @@ export const ObservabilityOverview = () => { reportUrl: `/project/${projectRef}/observability/storage`, logsUrl: `/project/${projectRef}/logs/storage-logs`, enabled: storageSupported, - hasReport: storageReportEnabled, + hasReport: true, }, { key: 'postgrest' as const, @@ -109,18 +104,10 @@ export const ObservabilityOverview = () => { reportUrl: `/project/${projectRef}/observability/postgrest`, logsUrl: `/project/${projectRef}/logs/postgrest-logs`, enabled: true, - hasReport: postgrestReportEnabled, + hasReport: true, }, ], - [ - projectRef, - authReportEnabled, - edgeFnReportEnabled, - realtimeReportEnabled, - storageReportEnabled, - storageSupported, - postgrestReportEnabled, - ] + [projectRef, storageSupported] ) const enabledServices = serviceBase.filter((s) => s.enabled) diff --git a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx index f2887ca1f4c..ff36e39e6b9 100644 --- a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx +++ b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx @@ -163,7 +163,7 @@ export const AuditLogs = () => { if (isLogsNotAvailableBasedOnPlan) { return ( - + { return ( <> - +
    {showFilters && ( diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx index 332e9c7cc8d..f7c23432868 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -1,9 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ExternalLink } from 'lucide-react' -import { useTheme } from 'next-themes' -import Image from 'next/image' -import Link from 'next/link' - import { useFlag, useParams } from 'common' import { ScaffoldSection, @@ -19,10 +14,15 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH, DOCS_URL } from 'lib/constants' import { MANAGED_BY } from 'lib/constants/infrastructure' +import { ExternalLink } from 'lucide-react' +import { useTheme } from 'next-themes' +import Image from 'next/image' +import Link from 'next/link' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' -import { Alert, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { Alert, Alert_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' + +import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' import SpendCapSidePanel from './SpendCapSidePanel' export interface CostControlProps {} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx index 4956a78098a..a02b3300730 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip.tsx @@ -7,29 +7,28 @@ export interface ProjectUpdateDisabledTooltipProps { tooltip?: string } -const ProjectUpdateDisabledTooltip = ({ +export const ProjectUpdateDisabledTooltip = ({ projectUpdateDisabled, projectNotActive = false, children, tooltip, }: PropsWithChildren) => { - const showTooltip = projectUpdateDisabled || projectNotActive + const tooltipMessage = + tooltip || + (projectUpdateDisabled + ? 'Subscription changes are currently disabled. Our engineers are working on a fix.' + : projectNotActive + ? 'Unable to update subscription as project is currently not active' + : undefined) return ( {children} - {showTooltip && ( - - {projectUpdateDisabled - ? tooltip || - 'Subscription changes are currently disabled. Our engineers are working on a fix.' - : projectNotActive - ? 'Unable to update subscription as project is currently not active' - : ''} + {tooltipMessage !== undefined && ( + + {tooltipMessage} )} ) } - -export default ProjectUpdateDisabledTooltip diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx index 198c988faa2..a9decb09b3d 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/DowngradeModal.tsx @@ -1,7 +1,6 @@ -import { MinusCircle, PauseCircle } from 'lucide-react' - import { getComputeSize, OrgProject } from 'data/projects/org-projects-infinite-query' import type { OrgSubscription, ProjectAddon } from 'data/subscriptions/types' +import { MinusCircle, PauseCircle } from 'lucide-react' import { useMemo } from 'react' import { plans as subscriptionsPlans } from 'shared-data/plans' import { Modal } from 'ui' diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx index 2ab3378a058..fec46b51718 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx @@ -1,13 +1,13 @@ -import { useState } from 'react' -import { toast } from 'sonner' - import { useFlag, useParams } from 'common' import { CANCELLATION_REASONS } from 'components/interfaces/Billing/Billing.constants' import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send' import { getComputeSize, OrgProject } from 'data/projects/org-projects-infinite-query' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' +import { useState } from 'react' +import { toast } from 'sonner' import { Alert, Button, cn, Input, Modal } from 'ui' -import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' + +import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' export interface ExitSurveyModalProps { visible: boolean @@ -104,7 +104,7 @@ export const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalP

    - Share with us why you're downgrading your plan. + What made you decide to downgrade your plan?

    diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index 3c6b01b2faa..b8252ec5b81 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -1,9 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { isArray } from 'lodash' -import { Check, ExternalLink } from 'lucide-react' -import { useRouter } from 'next/router' -import { useEffect, useMemo, useRef, useState } from 'react' - import { useParams } from 'common' import { StudioPricingSidePanelOpenedEvent } from 'common/telemetry-constants' import { getPlanChangeType } from 'components/interfaces/Billing/Subscription/Subscription.utils' @@ -22,11 +17,16 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { MANAGED_BY } from 'lib/constants/infrastructure' import { formatCurrency } from 'lib/helpers' +import { isArray } from 'lodash' +import { Check, ExternalLink } from 'lucide-react' +import { useRouter } from 'next/router' +import { useEffect, useMemo, useRef, useState } from 'react' import { plans as subscriptionsPlans } from 'shared-data/plans' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' import { Organization } from 'types/base' -import { Button, SidePanel, cn } from 'ui' +import { Button, cn, SidePanel } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import DowngradeModal from './DowngradeModal' import { EnterpriseCard } from './EnterpriseCard' import { ExitSurveyModal } from './ExitSurveyModal' @@ -187,7 +187,8 @@ export const PlanUpdateSidePanel = () => { const source = Array.isArray(router.query.source) ? router.query.source[0] : router.query.source - const shouldHighlight = source === 'log-drains-empty-state' && plan.id === 'tier_team' + // TODO this panel should allow direct configuration of the highlighting rather than indirectly via the source param + const shouldHighlight = source === 'log-drains-empty-state' && plan.id === 'tier_pro' if (plan.id === 'tier_enterprise') { return diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index 8494343da25..f3f6b37074c 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -1,6 +1,4 @@ import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants' -import Link from 'next/link' - import { useFlag, useParams } from 'common' import { SupportLink } from 'components/interfaces/Support/SupportLink' import { @@ -12,11 +10,13 @@ import AlertError from 'components/ui/AlertError' import NoPermission from 'components/ui/NoPermission' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import Link from 'next/link' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' import { Alert, Button } from 'ui' import { Admonition } from 'ui-patterns' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' + +import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' import { Restriction } from '../Restriction' import { PlanUpdateSidePanel } from './PlanUpdateSidePanel' diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx index 54194192c44..dd76e2e0d4d 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx @@ -1,10 +1,12 @@ import { NoProjectsOnPaidOrgInfo } from 'components/interfaces/Billing/NoProjectsOnPaidOrgInfo' -import { - ScaffoldContainer, - ScaffoldSection, - ScaffoldSectionTitle, -} from 'components/layouts/Scaffold' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { + PageSection, + PageSectionContent, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' import { OrganizationDeletePanel } from './OrganizationDeletePanel' import { DataPrivacyForm } from './DataPrivacyForm' @@ -14,20 +16,43 @@ export const GeneralSettings = () => { const organizationDeletionEnabled = useIsFeatureEnabled('organizations:delete') return ( - + <> - - Organization Details - - + + + + Organization details + + + + + + - - Data Privacy - - + + + + Data privacy + + + + + + - {organizationDeletionEnabled && } - + {organizationDeletionEnabled && ( + + + + Danger zone + + + + + + + )} + ) } diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx index d484b76993b..a9ddf0d076b 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx @@ -1,4 +1,3 @@ -import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' import PartnerManagedResource from 'components/ui/PartnerManagedResource' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { MANAGED_BY } from 'lib/constants/infrastructure' @@ -8,28 +7,23 @@ import { DeleteOrganizationButton } from './DeleteOrganizationButton' export const OrganizationDeletePanel = () => { const { data: selectedOrganization } = useSelectedOrganizationQuery() - return ( - - Danger Zone - {selectedOrganization?.managed_by !== 'vercel-marketplace' ? ( - - - - ) : ( - - )} - + return selectedOrganization?.managed_by !== 'vercel-marketplace' ? ( + + + + ) : ( + ) } diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx index f09b319cd5d..52d6e954610 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizedAppRow.tsx @@ -1,9 +1,8 @@ import { Trash } from 'lucide-react' -import Table from 'components/to-be-cleaned/Table' import CopyButton from 'components/ui/CopyButton' import type { AuthorizedApp } from 'data/oauth/authorized-apps-query' -import { Button } from 'ui' +import { Button, TableCell, TableRow } from 'ui' import { TimestampInfo } from 'ui-patterns' export interface AuthorizedAppRowProps { @@ -13,35 +12,39 @@ export interface AuthorizedAppRowProps { export const AuthorizedAppRow = ({ app, onSelectRevoke }: AuthorizedAppRowProps) => { return ( - - + +
    {!!app.icon ? '' : `${app.name[0]}`}
    -
    - {app.name} - {app.created_by} - + + +

    + {app.name} +

    +
    + {app.created_by} +
    -

    +

    {app.app_id}

    -
    - + + - - + +
    , - Name, - Client ID, - Created at, - , - ]} - body={ + +
    + + + + Avatar + + + Name + + + Client ID + + + {hasPublishedApps ? ( + + CREATED + + ) : ( + 'CREATED' + )} + + + Actions + + + + + {hasPublishedApps ? ( sortedPublishedApps?.map((app) => ( { }} onSelectDelete={() => setSelectedAppToDelete(app)} /> - )) ?? [] - } - /> - )} - + )) + ) : ( + + +

    No results found

    +

    + You do not have any published applications yet +

    +
    +
    + )} +
    +
    +
    )} -
    + + -
    -

    Authorized Apps

    -

    - Applications that have access to your organization's settings and projects -

    + + + + Authorized apps + + Applications that have access to your organization's settings and projects + + + + + {isLoadingAuthorizedApps || isLoadingPermissions ? ( +
    + + + +
    + ) : !canReadOAuthApps ? ( + + ) : null} -
    - {isLoadingAuthorizedApps || isLoadingPermissions ? ( -
    - - - -
    - ) : !canReadOAuthApps ? ( - - ) : null} + {isErrorAuthorizedApps && } - {isErrorAuthorizedApps && } - - {isSuccessAuthorizedApps && ( - <> - {(authorizedApps.length ?? 0) === 0 ? ( -
    -

    - You do not have any authorized applications yet -

    -
    - ) : ( - , - Name, - Created by, - App ID, - Authorized at, - , - ]} - body={ - sortedAuthorizedApps?.map((app) => ( - setSelectedAppToRevoke(app)} - /> - )) ?? [] - } - /> - )} - - )} - - - - + {isSuccessAuthorizedApps && ( + +
    + + + + Avatar + + + Name + + + Author + + + App ID + + + {hasAuthorizedApps ? ( + + AUTHORIZED + + ) : ( + 'AUTHORIZED' + )} + + + Actions + + + + + {hasAuthorizedApps ? ( + sortedAuthorizedApps?.map((app) => ( + setSelectedAppToRevoke(app)} + /> + )) + ) : ( + + +

    No results found

    +

    + You do not have any authorized applications yet +

    +
    +
    + )} +
    +
    + + )} + + + {organization.plan.name} Plan {numProjects > 0 && ( <> - • + · {numProjects} project{numProjects > 1 ? 's' : ''} diff --git a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx index 753af528fcc..117c62339fd 100644 --- a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx +++ b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx @@ -153,7 +153,7 @@ export const SSOConfig = () => { }, [ssoConfig, form]) return ( - + {isLoadingEntitlement || (hasAccessToSso && isLoadingSSOConfig) ? ( @@ -168,7 +168,7 @@ export const SSOConfig = () => { plan="Team" source="organizationSso" primaryText="Organization Single Sign-on (SSO) is available from Team plan and above" - secondaryText="SSO as a login option provides additional acccount security for your team by enforcing the use of an identity provider when logging into Supabase. Upgrade to Team or above to set up SSO for your organization." + secondaryText="SSO as a login option provides additional account security for your team by enforcing the use of an identity provider when logging into Supabase. Upgrade to Team or above to set up SSO for your organization." featureProposition="enable Single Sign-on (SSO)" /> ) : isSuccess || isSSOProviderNotFound ? ( diff --git a/apps/studio/components/interfaces/Organization/SecuritySettings.tsx b/apps/studio/components/interfaces/Organization/SecuritySettings.tsx index 50162239359..2777bfa9a7d 100644 --- a/apps/studio/components/interfaces/Organization/SecuritySettings.tsx +++ b/apps/studio/components/interfaces/Organization/SecuritySettings.tsx @@ -15,8 +15,8 @@ import { useOrganizationMembersQuery } from 'data/organizations/organization-mem import { useOrganizationMfaToggleMutation } from 'data/organizations/organization-mfa-mutation' import { useOrganizationMfaQuery } from 'data/organizations/organization-mfa-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useProfile } from 'lib/profile' import { Button, @@ -41,7 +41,6 @@ const schema = z.object({ export const SecuritySettings = () => { const { slug } = useParams() const { profile } = useProfile() - const { data: selectedOrganization } = useSelectedOrganizationQuery() const { data: members } = useOrganizationMembersQuery({ slug }) const { can: canReadMfaConfig, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( @@ -54,7 +53,8 @@ export const SecuritySettings = () => { ) const { mutate: sendEvent } = useSendEventMutation() - const isPaidPlan = selectedOrganization?.plan.id !== 'free' + const { hasAccess: hasAccessToEnforceMfa, isLoading: isLoadingEntitlement } = + useCheckEntitlements('security.enforce_mfa') const { data: mfaConfig, @@ -62,7 +62,7 @@ export const SecuritySettings = () => { isPending: isLoadingMfa, isError: isErrorMfa, isSuccess: isSuccessMfa, - } = useOrganizationMfaQuery({ slug }, { enabled: isPaidPlan && canReadMfaConfig }) + } = useOrganizationMfaQuery({ slug }, { enabled: hasAccessToEnforceMfa && canReadMfaConfig }) const { mutate: toggleMfa, isPending: isUpdatingMfa } = useOrganizationMfaToggleMutation({ onError: (error) => { @@ -100,14 +100,14 @@ export const SecuritySettings = () => { members?.find((member) => member.primary_email == profile?.primary_email)?.mfa_enabled || false const onSubmit = (values: { enforceMfa: boolean }) => { - if (!slug || !isPaidPlan) return + if (!slug || !hasAccessToEnforceMfa) return toggleMfa({ slug, setEnforced: values.enforceMfa }) } return ( - + - {!isPaidPlan ? ( + {!hasAccessToEnforceMfa && !isLoadingEntitlement ? ( { /> ) : ( <> - {isLoadingMfa || isLoadingPermissions ? ( + {isLoadingMfa || isLoadingPermissions || isLoadingEntitlement ? ( @@ -126,11 +126,11 @@ export const SecuritySettings = () => { ) : null} - {(isErrorMfa || mfaError) && isPaidPlan && ( + {(isErrorMfa || mfaError) && hasAccessToEnforceMfa && ( )} - {isSuccessMfa && isPaidPlan && ( + {isSuccessMfa && hasAccessToEnforceMfa && (
    @@ -151,7 +151,7 @@ export const SecuritySettings = () => { checked={field.value} onCheckedChange={field.onChange} disabled={ - !isPaidPlan || + !hasAccessToEnforceMfa || !canUpdateMfaConfig || !hasMFAEnabled || isUpdatingMfa @@ -181,7 +181,9 @@ export const SecuritySettings = () => { @@ -190,7 +192,7 @@ export const SecuritySettings = () => { type="primary" htmlType="submit" disabled={ - !isPaidPlan || + !hasAccessToEnforceMfa || !canUpdateMfaConfig || isUpdatingMfa || isLoadingMfa || diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index dbd847a86b0..36f02cacd1a 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -1,12 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import Link from 'next/link' -import { useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' -import { toast } from 'sonner' -import * as z from 'zod' - import { useParams } from 'common' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector' import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' @@ -14,44 +9,44 @@ import { useOrganizationCreateInvitationMutation } from 'data/organization-membe import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' import { useHasAccessToProjectLevelPermissions } from 'data/subscriptions/org-subscription-query' +import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements' import { doPermissionsCheck, useGetPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { DOCS_URL } from 'lib/constants' import { useProfile } from 'lib/profile' +import { UserPlus } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, Button, Dialog, DialogContent, + DialogFooter, DialogHeader, DialogSection, DialogSectionSeparator, DialogTitle, DialogTrigger, ExpandingTextArea, + Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, - DialogFooter, - Form_Shadcn_, + Select_Shadcn_, SelectContent_Shadcn_, SelectGroup_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, - Select_Shadcn_, Switch, } from 'ui' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { useGetRolesManagementPermissions } from './TeamSettings.utils' -import { UserPlus } from 'lucide-react' import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import * as z from 'zod' + +import { useGetRolesManagementPermissions } from './TeamSettings.utils' +import { DocsButton } from '@/components/ui/DocsButton' function parseEmails(value: string): string[] { return value @@ -71,15 +66,20 @@ export const InviteMemberButton = () => { ]) const [isOpen, setIsOpen] = useState(false) - const [isDiscardConfirmOpen, setIsDiscardConfirmOpen] = useState(false) const [projectDropdownOpen, setProjectDropdownOpen] = useState(false) const { data: members } = useOrganizationMembersQuery({ slug }) const { data: allRoles, isSuccess } = useOrganizationRolesV2Query({ slug }) const orgScopedRoles = allRoles?.org_scoped_roles ?? [] - const currentPlan = organization?.plan - const isFreeOrProPlan = currentPlan?.id === 'free' || currentPlan?.id === 'pro' + const defaultValues = { + email: '', + role: orgScopedRoles.find((role) => role.name === 'Developer')?.id.toString() ?? '', + applyToOrg: true, + projectRef: '', + } + + const { hasAccess: hasAccessToSso } = useCheckEntitlements('auth.platform.sso') const hasAccessToProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string) const userMemberData = members?.find((m) => m.gotrue_id === profile?.gotrue_id) @@ -140,7 +140,7 @@ export const InviteMemberButton = () => { mode: 'onSubmit', reValidateMode: 'onChange', resolver: zodResolver(FormSchema), - defaultValues: { email: '', role: '', applyToOrg: true, projectRef: '' }, + defaultValues, }) const { applyToOrg, projectRef, email } = form.watch() @@ -150,8 +150,6 @@ export const InviteMemberButton = () => { const onInviteMember = async (values: z.infer) => { if (!slug) return console.error('Slug is required') if (profile?.id === undefined) return console.error('Profile ID required') - - const developerRole = orgScopedRoles.find((role) => role.name === 'Developer') const emails = parseEmails(values.email).map((e) => e.toLowerCase()) const alreadyInvited: string[] = [] @@ -211,13 +209,7 @@ export const InviteMemberButton = () => { ? 'Successfully sent invitation to new member' : `Successfully sent invitations to ${successCount} new members` ) - setIsOpen(false) - form.reset({ - email: '', - role: developerRole?.id.toString() ?? '', - applyToOrg: true, - projectRef: '', - }) + closeInviteDialog() } if (failedEmails.length > 0) { toast.error( @@ -230,12 +222,12 @@ export const InviteMemberButton = () => { useEffect(() => { if (isSuccess && isOpen) { - const developerRole = orgScopedRoles.find((role) => role.name === 'Developer') - if (developerRole !== undefined) { - form.reset({ - ...form.getValues(), - role: developerRole.id.toString(), - }) + const developerRoleId = orgScopedRoles + .find((role) => role.name === 'Developer') + ?.id.toString() + + if (developerRoleId !== undefined && form.getValues('role') === '') { + form.setValue('role', developerRoleId, { shouldDirty: false }) } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -243,39 +235,21 @@ export const InviteMemberButton = () => { const hasUnsavedChanges = form.formState.isDirty - const handleOpenChange = (open: boolean) => { - if (!open && hasUnsavedChanges) { - setIsDiscardConfirmOpen(true) - } else { - setIsOpen(open) - } - } - - const handleCancel = () => { - if (hasUnsavedChanges) { - setIsDiscardConfirmOpen(true) - } else { - form.reset({ - email: '', - role: '', - applyToOrg: true, - projectRef: '', - }) - setIsOpen(false) - } - } - - const handleDiscardConfirm = () => { - form.reset({ - email: '', - role: '', - applyToOrg: true, - projectRef: '', - }) - setIsDiscardConfirmOpen(false) + const closeInviteDialog = () => { + setProjectDropdownOpen(false) setIsOpen(false) + form.reset(defaultValues) } + const { + confirmOnClose, + handleOpenChange, + modalProps: discardChangesModalProps, + } = useConfirmOnClose({ + checkIsDirty: () => hasUnsavedChanges, + onClose: closeInviteDialog, + }) + return ( @@ -308,23 +282,19 @@ export const InviteMemberButton = () => { type="note" showIcon={false} title="Single Sign-On (SSO) available" - layout={isFreeOrProPlan ? 'vertical' : 'horizontal'} + layout={!hasAccessToSso ? 'vertical' : 'horizontal'} className="rounded-none border-t-0 border-x-0 px-5" description="Enforce login via your company identity provider for added security and access control. Available on Team plan and above." actions={ <> - - {isFreeOrProPlan ? ( + + {!hasAccessToSso && ( - ) : null} + )} } /> @@ -341,10 +311,7 @@ export const InviteMemberButton = () => { render={({ field }) => ( - form.setValue('role', value)} - > + {orgScopedRoles.find((role) => role.id === Number(field.value))?.name ?? 'Unknown'} @@ -382,10 +349,7 @@ export const InviteMemberButton = () => { render={({ field }) => ( - form.setValue('applyToOrg', value)} - /> + )} @@ -441,7 +405,7 @@ export const InviteMemberButton = () => { /> - ) } diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts new file mode 100644 index 00000000000..1a10d55d272 --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts @@ -0,0 +1,124 @@ +import type { PlatformWebhooksMockSeed, WebhookScope } from './PlatformWebhooks.types' + +export const PLATFORM_WEBHOOKS_MOCK_DATA: Record = { + organization: { + eventTypes: [ + 'organization.member_invited', + 'organization.member_removed', + 'organization.updated', + 'project.created', + 'project.updated', + 'project.deleted', + 'project.paused', + 'project.resumed', + ], + endpoints: [ + { + id: 'org-endpoint-1', + name: 'Lovable production', + url: 'https://api.lovable.dev/webhooks/supabase', + description: 'Primary organization webhook endpoint', + enabled: true, + eventTypes: ['project.created', 'project.updated', 'project.deleted'], + customHeaders: [ + { id: 'org-header-1', key: 'X-Lovable-Version', value: 'v2' }, + { id: 'org-header-2', key: 'X-Environment', value: 'production' }, + ], + createdBy: 'user@supabase.io', + createdAt: '2026-02-15T21:30:00.000Z', + }, + { + id: 'org-endpoint-2', + name: 'Slack notifications', + url: 'https://hooks.slack.com/services/T000/B000/ORG', + description: 'Operational alerts for organization-level events', + enabled: false, + eventTypes: ['organization.updated'], + customHeaders: [], + createdBy: 'ops@supabase.io', + createdAt: '2026-02-12T10:14:00.000Z', + }, + ], + deliveries: [ + { + id: 'org-delivery-1', + endpointId: 'org-endpoint-1', + eventType: 'project.created', + status: 'success', + responseCode: 200, + attemptAt: '2026-02-27T08:04:00.000Z', + }, + { + id: 'org-delivery-2', + endpointId: 'org-endpoint-1', + eventType: 'project.updated', + status: 'failure', + responseCode: 500, + attemptAt: '2026-02-27T07:56:00.000Z', + }, + { + id: 'org-delivery-3', + endpointId: 'org-endpoint-1', + eventType: 'project.deleted', + status: 'pending', + attemptAt: '2026-02-27T07:45:00.000Z', + }, + { + id: 'org-delivery-4', + endpointId: 'org-endpoint-2', + eventType: 'organization.updated', + status: 'skipped', + attemptAt: '2026-02-26T14:12:00.000Z', + }, + ], + }, + project: { + eventTypes: [ + 'project.updated', + 'project.paused', + 'project.resumed', + 'project.branch_created', + 'project.branch_deleted', + 'project.resource_exhausted', + ], + endpoints: [ + { + id: 'project-endpoint-1', + name: 'Project analytics', + url: 'https://analytics.example.com/hooks/supabase-project', + description: 'Project-level status updates for analytics pipeline', + enabled: true, + eventTypes: ['project.updated', 'project.resource_exhausted'], + customHeaders: [{ id: 'project-header-1', key: 'X-Project-Type', value: 'platform' }], + createdBy: 'owner@project.dev', + createdAt: '2026-02-18T11:23:00.000Z', + }, + ], + deliveries: [ + { + id: 'project-delivery-1', + endpointId: 'project-endpoint-1', + eventType: 'project.updated', + status: 'success', + responseCode: 200, + attemptAt: '2026-02-27T09:01:00.000Z', + }, + { + id: 'project-delivery-2', + endpointId: 'project-endpoint-1', + eventType: 'project.resource_exhausted', + status: 'failure', + responseCode: 429, + attemptAt: '2026-02-27T08:47:00.000Z', + }, + { + id: 'project-delivery-3', + endpointId: 'project-endpoint-1', + eventType: 'project.paused', + status: 'success', + responseCode: 202, + attemptAt: '2026-02-26T21:10:00.000Z', + }, + ], + }, +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts new file mode 100644 index 00000000000..a73e6461c46 --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts @@ -0,0 +1,267 @@ +import { useEffect, useState } from 'react' + +import { PLATFORM_WEBHOOKS_MOCK_DATA } from './PlatformWebhooks.mock' +import type { + PlatformWebhooksState, + UpsertWebhookEndpointInput, + WebhookDelivery, + WebhookEndpoint, + WebhookScope, +} from './PlatformWebhooks.types' + +interface CreateEndpointOptions { + now?: string + endpointId?: string + createdBy?: string + signingSecret?: string +} + +interface UpdateEndpointOptions { + headerIdFactory?: () => string +} + +const secureRandomHex = (length: number) => { + if (length <= 0) return '' + + const cryptoApi = globalThis.crypto + if (!cryptoApi?.getRandomValues) { + throw new Error('Web Crypto API is not available') + } + + const byteCount = Math.ceil(length / 2) + const bytes = new Uint8Array(byteCount) + cryptoApi.getRandomValues(bytes) + + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')) + .join('') + .slice(0, length) +} + +const randomId = (prefix: string) => `${prefix}-${secureRandomHex(8)}` + +const generateSigningSecret = () => `whsec_${secureRandomHex(16)}` + +const deepClone = (value: T): T => JSON.parse(JSON.stringify(value)) + +const toHeaders = ( + headers: UpsertWebhookEndpointInput['customHeaders'], + options?: UpdateEndpointOptions +) => { + const headerIdFactory = options?.headerIdFactory ?? (() => randomId('header')) + return headers + .map((header) => ({ + id: headerIdFactory(), + key: header.key.trim(), + value: header.value.trim(), + })) + .filter((header) => header.key.length > 0 && header.value.length > 0) +} + +const normalizeSearch = (value: string) => value.trim().toLowerCase() + +export const createInitialPlatformWebhooksState = (scope: WebhookScope): PlatformWebhooksState => { + const seed = PLATFORM_WEBHOOKS_MOCK_DATA[scope] + return { + endpoints: deepClone(seed.endpoints), + deliveries: deepClone(seed.deliveries), + } +} + +const persistedMockStateByScope: Partial> = {} + +const getPersistedMockState = (scope: WebhookScope) => { + const persistedState = persistedMockStateByScope[scope] + if (persistedState) return persistedState + + const initialState = createInitialPlatformWebhooksState(scope) + persistedMockStateByScope[scope] = initialState + return initialState +} + +// Test-only helper to avoid cross-test state leakage in hook tests. +export const resetPlatformWebhooksMockStateForTests = (scope?: WebhookScope) => { + if (scope) { + delete persistedMockStateByScope[scope] + return + } + + for (const key of Object.keys(persistedMockStateByScope) as WebhookScope[]) { + delete persistedMockStateByScope[key] + } +} + +export const createWebhookEndpoint = ( + state: PlatformWebhooksState, + input: UpsertWebhookEndpointInput, + options?: CreateEndpointOptions +): { state: PlatformWebhooksState; endpoint: WebhookEndpoint; signingSecret: string } => { + const endpointId = options?.endpointId ?? randomId('endpoint') + const internalName = input.name.trim().length > 0 ? input.name.trim() : endpointId + const signingSecret = options?.signingSecret ?? generateSigningSecret() + const endpoint: WebhookEndpoint = { + id: endpointId, + name: internalName, + url: input.url.trim(), + description: input.description.trim(), + enabled: input.enabled, + eventTypes: input.eventTypes.length > 0 ? input.eventTypes : ['*'], + customHeaders: toHeaders(input.customHeaders), + createdBy: options?.createdBy ?? 'mock-user@supabase.io', + createdAt: options?.now ?? new Date().toISOString(), + } + + return { + endpoint, + signingSecret, + state: { + ...state, + endpoints: [endpoint, ...state.endpoints], + }, + } +} + +export const updateWebhookEndpoint = ( + state: PlatformWebhooksState, + endpointId: string, + input: UpsertWebhookEndpointInput, + options?: UpdateEndpointOptions +) => { + return { + ...state, + endpoints: state.endpoints.map((endpoint) => + endpoint.id === endpointId + ? { + ...endpoint, + name: input.name.trim().length > 0 ? input.name.trim() : endpoint.name, + url: input.url.trim(), + description: input.description.trim(), + enabled: input.enabled, + eventTypes: input.eventTypes.length > 0 ? input.eventTypes : ['*'], + customHeaders: toHeaders(input.customHeaders, options), + } + : endpoint + ), + } +} + +export const deleteWebhookEndpoint = (state: PlatformWebhooksState, endpointId: string) => { + return { + endpoints: state.endpoints.filter((endpoint) => endpoint.id !== endpointId), + deliveries: state.deliveries.filter((delivery) => delivery.endpointId !== endpointId), + } +} + +export const toggleWebhookEndpoint = ( + state: PlatformWebhooksState, + endpointId: string, + enabled?: boolean +) => { + return { + ...state, + endpoints: state.endpoints.map((endpoint) => + endpoint.id === endpointId + ? { ...endpoint, enabled: enabled === undefined ? !endpoint.enabled : enabled } + : endpoint + ), + } +} + +export const regenerateWebhookEndpointSecret = ( + state: PlatformWebhooksState, + endpointId: string, + secret?: string +) => { + const endpointExists = state.endpoints.some((endpoint) => endpoint.id === endpointId) + if (!endpointExists) return { state, signingSecret: null } + + return { + state: { ...state }, + signingSecret: secret ?? generateSigningSecret(), + } +} + +export const filterWebhookEndpoints = (endpoints: WebhookEndpoint[], search: string) => { + const normalizedSearch = normalizeSearch(search) + if (normalizedSearch.length === 0) return endpoints + + return endpoints.filter((endpoint) => { + const haystack = `${endpoint.name} ${endpoint.url} ${endpoint.description}`.toLowerCase() + return haystack.includes(normalizedSearch) + }) +} + +export const filterWebhookDeliveries = ( + deliveries: WebhookDelivery[], + endpointId: string, + search: string +) => { + const normalizedSearch = normalizeSearch(search) + + return deliveries + .filter((delivery) => delivery.endpointId === endpointId) + .filter((delivery) => { + if (normalizedSearch.length === 0) return true + const haystack = + `${delivery.eventType} ${delivery.status} ${delivery.responseCode ?? ''}`.toLowerCase() + return haystack.includes(normalizedSearch) + }) +} + +export const usePlatformWebhooksMockStore = (scope: WebhookScope) => { + const [state, setState] = useState(() => + deepClone(getPersistedMockState(scope)) + ) + + useEffect(() => { + setState(deepClone(getPersistedMockState(scope))) + }, [scope]) + + const applyStateUpdate = ( + updater: (previous: PlatformWebhooksState) => PlatformWebhooksState + ) => { + setState((previous) => { + const next = updater(previous) + persistedMockStateByScope[scope] = next + return next + }) + } + + return { + ...state, + createEndpoint: (input: UpsertWebhookEndpointInput) => { + const endpointId = randomId('endpoint') + const now = new Date().toISOString() + const signingSecret = generateSigningSecret() + const createdBy = 'mock-user@supabase.io' + let createdSecret = signingSecret + applyStateUpdate((prev) => { + const next = createWebhookEndpoint(prev, input, { + endpointId, + now, + signingSecret, + createdBy, + }) + createdSecret = next.signingSecret + return next.state + }) + return { endpointId, signingSecret: createdSecret } + }, + updateEndpoint: (endpointId: string, input: UpsertWebhookEndpointInput) => { + applyStateUpdate((prev) => updateWebhookEndpoint(prev, endpointId, input)) + }, + deleteEndpoint: (endpointId: string) => { + applyStateUpdate((prev) => deleteWebhookEndpoint(prev, endpointId)) + }, + toggleEndpoint: (endpointId: string, enabled?: boolean) => { + applyStateUpdate((prev) => toggleWebhookEndpoint(prev, endpointId, enabled)) + }, + regenerateSecret: (endpointId: string) => { + const currentState = persistedMockStateByScope[scope] ?? state + const next = regenerateWebhookEndpointSecret(currentState, endpointId) + if (!next.signingSecret) return null + + applyStateUpdate(() => next.state) + return next.signingSecret + }, + } +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts new file mode 100644 index 00000000000..b98c56268db --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts @@ -0,0 +1,50 @@ +export type WebhookScope = 'organization' | 'project' + +export type WebhookDeliveryStatus = 'pending' | 'success' | 'failure' | 'skipped' + +export interface WebhookHeader { + id: string + key: string + value: string +} + +export interface WebhookEndpoint { + id: string + name: string + url: string + description: string + enabled: boolean + eventTypes: string[] + customHeaders: WebhookHeader[] + createdBy: string + createdAt: string +} + +export interface WebhookDelivery { + id: string + endpointId: string + eventType: string + status: WebhookDeliveryStatus + responseCode?: number + attemptAt: string +} + +export interface PlatformWebhooksMockSeed { + eventTypes: string[] + endpoints: WebhookEndpoint[] + deliveries: WebhookDelivery[] +} + +export interface PlatformWebhooksState { + endpoints: WebhookEndpoint[] + deliveries: WebhookDelivery[] +} + +export interface UpsertWebhookEndpointInput { + name: string + url: string + description: string + enabled: boolean + eventTypes: string[] + customHeaders: Array> +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx new file mode 100644 index 00000000000..ab0b57a8c91 --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx @@ -0,0 +1,187 @@ +import { Copy } from 'lucide-react' + +import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils' +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { + AlertDialog, + Badge, + Button, + Card, + CardContent, + Separator, + Sheet, + SheetContent, + SheetHeader, + SheetSection, + SheetTitle, + Tabs_Shadcn_ as Tabs, + TabsContent_Shadcn_ as TabsContent, + TabsList_Shadcn_ as TabsList, + TabsTrigger_Shadcn_ as TabsTrigger, +} from 'ui' +import { TimestampInfo } from 'ui-patterns' +import type { WebhookDelivery } from './PlatformWebhooks.types' +import { formatDeliveryStatus, statusBadgeVariant } from './PlatformWebhooksView.utils' + +interface PlatformWebhooksDeliveryDetailsSheetProps { + deliveryAttempt: number | null + deliveryDetailsTab: 'event' | 'response' + deliveryEventPayload: string + deliveryResponsePayload: string + open: boolean + selectedDelivery: WebhookDelivery | null + onCopy: (value: string, label: string) => void + onOpenChange: (open: boolean) => void + onTabChange: (tab: 'event' | 'response') => void +} + +export const PlatformWebhooksDeliveryDetailsSheet = ({ + deliveryAttempt, + deliveryDetailsTab, + deliveryEventPayload, + deliveryResponsePayload, + open, + selectedDelivery, + onCopy, + onOpenChange, + onTabChange, +}: PlatformWebhooksDeliveryDetailsSheetProps) => { + return ( + + + +
    + Delivery details + {selectedDelivery && ( + + {formatDeliveryStatus(selectedDelivery.status)} + + )} +
    +
    + + + {selectedDelivery && ( + +
    + + +
    +

    Delivery ID

    + {selectedDelivery.id} +
    +
    +

    Attempt

    +

    + {deliveryAttempt ? `#${deliveryAttempt}` : '-'} +

    +
    +
    +

    Timestamp

    + +
    +
    +

    Response code

    + {selectedDelivery.responseCode ? ( + + ) : ( + – + )} +
    +
    +
    + + onTabChange(value as 'event' | 'response')} + > + + + Event + + + Response + + + + +
    +

    Event type

    + {selectedDelivery.eventType} +
    +
    +

    Event ID

    + {selectedDelivery.id} +
    +
    +

    Event timestamp

    + +
    +
    +
    +

    Payload

    + +
    +
    +
    +                        {deliveryEventPayload}
    +                      
    +
    +
    +
    + + +
    +

    Status

    + + {formatDeliveryStatus(selectedDelivery.status)} + +
    +
    +

    Response code

    + {selectedDelivery.responseCode ? ( + + ) : ( + – + )} +
    +
    +
    +

    Response payload

    + +
    +
    +
    +                        {deliveryResponsePayload}
    +                      
    +
    +
    +
    +
    +
    +
    + )} +
    +
    + ) +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx new file mode 100644 index 00000000000..6d84c04bcee --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx @@ -0,0 +1,175 @@ +import { Search } from 'lucide-react' + +import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils' +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { + Badge, + Card, + CardContent, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' +import { TimestampInfo } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' +import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types' +import { statusBadgeVariant } from './PlatformWebhooksView.utils' + +interface DetailItemProps { + label: string + children: React.ReactNode + ddClassName?: string +} + +const DetailItem = ({ label, children, ddClassName = 'text-sm' }: DetailItemProps) => ( +
    +
    {label}
    +
    {children}
    +
    +) + +interface PlatformWebhooksEndpointDetailsProps { + deliverySearch: string + filteredDeliveries: WebhookDelivery[] + selectedEndpoint: WebhookEndpoint + onDeliverySearchChange: (value: string) => void + onOpenDelivery: (deliveryId: string) => void +} + +export const PlatformWebhooksEndpointDetails = ({ + deliverySearch, + filteredDeliveries, + selectedEndpoint, + onDeliverySearchChange, + onOpenDelivery, +}: PlatformWebhooksEndpointDetailsProps) => { + const hasCustomHeaders = selectedEndpoint.customHeaders.length > 0 + + return ( +
    +
    +

    Overview

    + + +
    + + {selectedEndpoint.url} + + + {selectedEndpoint.description || '-'} + + + {(selectedEndpoint.eventTypes.includes('*') + ? ['All events (*)'] + : selectedEndpoint.eventTypes + ).map((eventType) => ( + + {eventType} + + ))} + + + {hasCustomHeaders && ( + +
    + {selectedEndpoint.customHeaders.map((header) => ( +
    + {header.key}: + {header.value} +
    + ))} +
    +
    + )} + + {selectedEndpoint.createdBy} + + + + +
    +
    +
    +
    + +
    +

    Deliveries

    +
    + } + value={deliverySearch} + className="w-full lg:w-52" + onChange={(event) => onDeliverySearchChange(event.target.value)} + /> +

    {filteredDeliveries.length} total

    +
    + + + + + Status + Event type + Response + Attempted + + + + {filteredDeliveries.length > 0 ? ( + filteredDeliveries.map((delivery) => ( + onOpenDelivery(delivery.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onOpenDelivery(delivery.id) + } + }} + tabIndex={0} + > + + {delivery.status} + + + {delivery.eventType} + + + {delivery.responseCode ? ( + + ) : ( + – + )} + + + + + + )) + ) : ( + + No deliveries found + + )} + +
    +
    +
    +
    + ) +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx new file mode 100644 index 00000000000..41015c161ef --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx @@ -0,0 +1,232 @@ +import { ChevronRight, Eye, MoreVertical, Plus, Search, Trash2 } from 'lucide-react' +import { useRouter } from 'next/router' +import { useMemo, useState } from 'react' + +import { createNavigationHandler } from 'lib/navigation' +import { + Badge, + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Table, + TableBody, + TableCell, + TableHead, + TableHeadSort, + TableHeader, + TableRow, +} from 'ui' +import { EmptyStatePresentational, TimestampInfo } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' +import type { WebhookEndpoint } from './PlatformWebhooks.types' + +interface PlatformWebhooksEndpointListProps { + filteredEndpoints: WebhookEndpoint[] + search: string + webhooksHref: string + onCreateEndpoint: () => void + onDeleteEndpoint: (endpointId: string) => void + onSearchChange: (value: string) => void + onViewEndpoint: (endpointId: string) => void +} + +export const PlatformWebhooksEndpointList = ({ + filteredEndpoints, + search, + webhooksHref, + onCreateEndpoint, + onDeleteEndpoint, + onSearchChange, + onViewEndpoint, +}: PlatformWebhooksEndpointListProps) => { + const router = useRouter() + const formatEventCount = (eventTypes: string[]) => { + if (eventTypes.includes('*')) return 'All events' + if (eventTypes.length === 1) return '1 event' + return `${eventTypes.length} events` + } + const [sort, setSort] = useState<'status:asc' | 'status:desc' | 'created:asc' | 'created:desc'>( + 'created:desc' + ) + + const [sortColumn, sortDirection] = sort.split(':') as ['status' | 'created', 'asc' | 'desc'] + + const getAriaSort = (column: 'status' | 'created') => { + if (sortColumn !== column) return 'none' + return sortDirection === 'asc' ? 'ascending' : 'descending' + } + + const handleSortChange = (column: 'status' | 'created') => { + if (sortColumn !== column) { + setSort(`${column}:asc`) + return + } + + setSort(`${column}:${sortDirection === 'asc' ? 'desc' : 'asc'}`) + } + + const sortedEndpoints = useMemo(() => { + const items = [...filteredEndpoints] + + items.sort((a, b) => { + if (sortColumn === 'status') { + const statusA = a.enabled ? 'enabled' : 'disabled' + const statusB = b.enabled ? 'enabled' : 'disabled' + const comparison = statusA.localeCompare(statusB) + + if (comparison !== 0) return sortDirection === 'asc' ? comparison : -comparison + } + + const createdComparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + return sortDirection === 'asc' ? createdComparison : -createdComparison + }) + + return items + }, [filteredEndpoints, sortColumn, sortDirection]) + + return ( +
    +
    +

    Endpoints

    +
    +
    + } + value={search} + className="w-full lg:w-52" + onChange={(event) => onSearchChange(event.target.value)} + /> + +
    + + {filteredEndpoints.length === 0 ? ( + + + + ) : ( + + + + + + + Status + + + URL + Events + + + Created + + + + + + + {sortedEndpoints.map((endpoint) => ( + + + + {endpoint.enabled ? 'Enabled' : 'Disabled'} + + + +

    {endpoint.url}

    + {endpoint.description && ( +

    + {endpoint.description} +

    + )} +
    + + {formatEventCount(endpoint.eventTypes)} + + + +

    + by {endpoint.createdBy} +

    +
    + +
    event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + + +
    +
    +
    + ))} +
    +
    +
    + )} +
    + ) +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx new file mode 100644 index 00000000000..42bd837657f --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx @@ -0,0 +1,581 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { InlineLink } from 'components/ui/InlineLink' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' +import { ChevronDown, Trash2 } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { useFieldArray, useForm } from 'react-hook-form' +import { + Accordion_Shadcn_ as Accordion, + AccordionContent_Shadcn_ as AccordionContent, + AccordionItem_Shadcn_ as AccordionItem, + AccordionTrigger_Shadcn_ as AccordionTrigger, + Button, + Checkbox_Shadcn_ as Checkbox, + cn, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_ as InputField, + Label_Shadcn_ as Label, + Separator, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + Switch, + TextArea_Shadcn_ as Textarea, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import * as z from 'zod' + +import type { + UpsertWebhookEndpointInput, + WebhookEndpoint, + WebhookScope, +} from './PlatformWebhooks.types' + +const endpointFormSchema = z + .object({ + url: z.string().trim().url('Please enter a valid URL'), + description: z.string().trim().max(512, 'Description cannot exceed 512 characters'), + enabled: z.boolean().default(true), + subscribeAll: z.boolean().default(false), + eventTypes: z.array(z.string()).default([]), + customHeaders: z + .array( + z.object({ + key: z.string().trim(), + value: z.string().trim(), + }) + ) + .default([]), + }) + .superRefine((data, ctx) => { + if (!data.subscribeAll && data.eventTypes.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Select at least one event type', + path: ['eventTypes'], + }) + } + }) + +export type EndpointFormValues = z.infer + +const toEventTypes = (values: EndpointFormValues) => + values.subscribeAll ? ['*'] : values.eventTypes + +type EventTypeGroup = { + id: string + label: string + eventTypes: string[] +} + +const buildEventTypeGroups = (scope: WebhookScope, eventTypes: string[]): EventTypeGroup[] => { + if (scope === 'project') { + return [{ id: 'project', label: 'Project events', eventTypes }] + } + + const organizationEvents = eventTypes.filter((eventType) => eventType.startsWith('organization.')) + const projectEvents = eventTypes.filter((eventType) => eventType.startsWith('project.')) + const ungroupedEvents = eventTypes.filter( + (eventType) => !eventType.startsWith('organization.') && !eventType.startsWith('project.') + ) + + return [ + { id: 'organization', label: 'Organization events', eventTypes: organizationEvents }, + { id: 'project', label: 'Project events', eventTypes: projectEvents }, + { id: 'other', label: 'Other events', eventTypes: ungroupedEvents }, + ].filter((group) => group.eventTypes.length > 0) +} + +const toggleEventType = (selectedEventTypes: string[], eventType: string, checked: boolean) => { + if (checked) return [...new Set([...selectedEventTypes, eventType])] + return selectedEventTypes.filter((value) => value !== eventType) +} + +const toggleEventTypeGroup = ( + selectedEventTypes: string[], + groupedEventTypes: string[], + checked: boolean +) => { + if (checked) return [...new Set([...selectedEventTypes, ...groupedEventTypes])] + return selectedEventTypes.filter((value) => !groupedEventTypes.includes(value)) +} + +const toControlId = (prefix: string, value: string) => + `${prefix}-${value.replace(/[^a-zA-Z0-9_-]/g, '-')}` + +export const toEndpointPayload = (values: EndpointFormValues): UpsertWebhookEndpointInput => ({ + name: '', + url: values.url, + description: values.description, + enabled: values.enabled, + eventTypes: toEventTypes(values), + customHeaders: values.customHeaders, +}) + +interface EndpointSheetProps { + visible: boolean + mode: 'create' | 'edit' + scope: WebhookScope + orgSlug?: string + endpoint?: WebhookEndpoint + enabledOverride?: boolean | null + eventTypes: string[] + onClose: () => void + onSubmit: (values: EndpointFormValues) => void +} + +export const PlatformWebhooksEndpointSheet = ({ + visible, + mode, + scope, + orgSlug, + endpoint, + enabledOverride, + eventTypes, + onClose, + onSubmit, +}: EndpointSheetProps) => { + const form = useForm({ + resolver: zodResolver(endpointFormSchema), + defaultValues: { + url: '', + description: '', + enabled: true, + subscribeAll: false, + eventTypes: [], + customHeaders: [], + }, + }) + const isDirty = form.formState.isDirty + const { + confirmOnClose, + handleOpenChange, + modalProps: discardChangesModalProps, + } = useConfirmOnClose({ + checkIsDirty: () => isDirty, + onClose, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'customHeaders', + }) + + const subscribeAll = form.watch('subscribeAll') + const selectedEventTypes = form.watch('eventTypes') + const groupedEventTypes = useMemo( + () => buildEventTypeGroups(scope, eventTypes), + [scope, eventTypes] + ) + const [openEventGroups, setOpenEventGroups] = useState([]) + + useEffect(() => { + if (!visible) return + + if (!endpoint) { + form.reset({ + url: '', + description: '', + enabled: true, + subscribeAll: false, + eventTypes: [], + customHeaders: [], + }) + return + } + + form.reset({ + url: endpoint.url, + description: endpoint.description, + enabled: enabledOverride ?? endpoint.enabled, + subscribeAll: endpoint.eventTypes.includes('*'), + eventTypes: endpoint.eventTypes.includes('*') ? eventTypes : endpoint.eventTypes, + customHeaders: endpoint.customHeaders.map((header) => ({ + key: header.key, + value: header.value, + })), + }) + }, [enabledOverride, endpoint, eventTypes, form, visible]) + + useEffect(() => { + if (!visible) return + setOpenEventGroups(groupedEventTypes.map((group) => group.id)) + }, [groupedEventTypes, visible]) + + useEffect(() => { + if (!visible) return + const allSelected = + eventTypes.length > 0 && + eventTypes.every((eventType) => selectedEventTypes.includes(eventType)) + + if (subscribeAll !== allSelected) { + form.setValue('subscribeAll', allSelected, { + shouldDirty: true, + shouldValidate: true, + }) + } + }, [eventTypes, form, selectedEventTypes, subscribeAll, visible]) + + return ( + + + + {mode === 'create' ? 'Create endpoint' : 'Edit endpoint'} + + + + +
    +
    + ( + + + + + + )} + /> + + ( + + Description (optional) + + } + layout="vertical" + className="gap-1" + > + +