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/ai-tests.yml b/.github/workflows/ai-tests.yml index 6e27f7e86a1..8596d3eacd2 100644 --- a/.github/workflows/ai-tests.yml +++ b/.github/workflows/ai-tests.yml @@ -36,6 +36,7 @@ jobs: with: sparse-checkout: | packages + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm with: diff --git a/.github/workflows/authorize-vercel-deploys.yml b/.github/workflows/authorize-vercel-deploys.yml index 6a72b23d7a6..967dff10d78 100644 --- a/.github/workflows/authorize-vercel-deploys.yml +++ b/.github/workflows/authorize-vercel-deploys.yml @@ -29,6 +29,7 @@ jobs: # fetch only the root files and scripts folder sparse-checkout: | scripts + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm with: diff --git a/.github/workflows/autofix_linters.yml b/.github/workflows/autofix_linters.yml index 2ae0159afa5..f86ce2e573f 100644 --- a/.github/workflows/autofix_linters.yml +++ b/.github/workflows/autofix_linters.yml @@ -36,6 +36,7 @@ jobs: sparse-checkout: | packages apps + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/.github/workflows/dashboard-pr-reminder.yml b/.github/workflows/dashboard-pr-reminder.yml index 76d464d0f4e..d90bd6a0dcc 100644 --- a/.github/workflows/dashboard-pr-reminder.yml +++ b/.github/workflows/dashboard-pr-reminder.yml @@ -21,6 +21,7 @@ jobs: with: sparse-checkout: | scripts + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/.github/workflows/docs-last-changed.yml b/.github/workflows/docs-last-changed.yml index 1ec0774bba1..7760a24f32c 100644 --- a/.github/workflows/docs-last-changed.yml +++ b/.github/workflows/docs-last-changed.yml @@ -28,6 +28,7 @@ jobs: fetch-depth: 0 sparse-checkout: | apps/docs + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/.github/workflows/docs-mgmt-api-update.yml b/.github/workflows/docs-mgmt-api-update.yml index 26eb74756e6..61c2a31bc31 100644 --- a/.github/workflows/docs-mgmt-api-update.yml +++ b/.github/workflows/docs-mgmt-api-update.yml @@ -20,6 +20,7 @@ jobs: ref: master sparse-checkout: | apps/docs + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/.github/workflows/docs-sync-troubleshooting.yml b/.github/workflows/docs-sync-troubleshooting.yml index c3794079eca..ce1720cb0b2 100644 --- a/.github/workflows/docs-sync-troubleshooting.yml +++ b/.github/workflows/docs-sync-troubleshooting.yml @@ -28,6 +28,7 @@ jobs: with: sparse-checkout: | apps/docs + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index 7d7761070ca..437d32ce368 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -31,6 +31,7 @@ jobs: sparse-checkout: | apps/docs packages + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/.github/workflows/docs-tests-smoke.yml b/.github/workflows/docs-tests-smoke.yml index 92c72f25020..0ac43ebcb0c 100644 --- a/.github/workflows/docs-tests-smoke.yml +++ b/.github/workflows/docs-tests-smoke.yml @@ -24,6 +24,7 @@ jobs: sparse-checkout: | apps/docs packages + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/.github/workflows/docs-tests.yml b/.github/workflows/docs-tests.yml index 94d1bd66c0f..35ab7b6ef59 100644 --- a/.github/workflows/docs-tests.yml +++ b/.github/workflows/docs-tests.yml @@ -30,6 +30,7 @@ jobs: examples packages supabase + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/.github/workflows/pg-meta-tests.yml b/.github/workflows/pg-meta-tests.yml index 23cc250212a..2c5740b46f3 100644 --- a/.github/workflows/pg-meta-tests.yml +++ b/.github/workflows/pg-meta-tests.yml @@ -29,6 +29,7 @@ jobs: sparse-checkout: | packages/pg-meta packages/tsconfig + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index ba815b105a9..8f36d889f46 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -20,7 +20,9 @@ jobs: - name: Check out repo uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: - sparse-checkout: apps + sparse-checkout: | + apps + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm with: @@ -46,6 +48,7 @@ jobs: with: sparse-checkout: | i18n + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm with: @@ -71,6 +74,7 @@ jobs: sparse-checkout: | apps/docs/pages apps/docs/content + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm with: diff --git a/.github/workflows/search.yml b/.github/workflows/search.yml index e8f5671d8d5..6ce4cdcbe5e 100644 --- a/.github/workflows/search.yml +++ b/.github/workflows/search.yml @@ -48,6 +48,7 @@ jobs: examples packages supabase + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/.github/workflows/studio-lint-ratchet-decrease.yml b/.github/workflows/studio-lint-ratchet-decrease.yml index 4598a0cf7fb..d29b3721bc2 100644 --- a/.github/workflows/studio-lint-ratchet-decrease.yml +++ b/.github/workflows/studio-lint-ratchet-decrease.yml @@ -20,6 +20,7 @@ jobs: .github apps/studio packages + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/.github/workflows/studio-lint-ratchet.yml b/.github/workflows/studio-lint-ratchet.yml index 7e635ca754c..9a3025584cb 100644 --- a/.github/workflows/studio-lint-ratchet.yml +++ b/.github/workflows/studio-lint-ratchet.yml @@ -26,6 +26,7 @@ jobs: .github apps/studio packages + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm @@ -41,5 +42,5 @@ jobs: - name: Install deps run: pnpm install --frozen-lockfile - - name: Run ratchet script + - name: Run ratchet script run: pnpm --filter studio run lint:ratchet diff --git a/.github/workflows/studio-unit-tests.yml b/.github/workflows/studio-unit-tests.yml index f7e96718d5e..fa95d796757 100644 --- a/.github/workflows/studio-unit-tests.yml +++ b/.github/workflows/studio-unit-tests.yml @@ -36,6 +36,8 @@ jobs: sparse-checkout: | apps/studio packages + patches + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter with: @@ -83,6 +85,7 @@ jobs: with: sparse-checkout: | apps/studio + patches - name: Download coverage artifact uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: diff --git a/.github/workflows/ui-patterns-tests.yml b/.github/workflows/ui-patterns-tests.yml index 2036b9d3f62..4638b4616a1 100644 --- a/.github/workflows/ui-patterns-tests.yml +++ b/.github/workflows/ui-patterns-tests.yml @@ -23,6 +23,7 @@ jobs: with: sparse-checkout: | packages + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 8d78237348c..0e8de49a0f3 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -33,6 +33,7 @@ jobs: with: sparse-checkout: | packages + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 if: steps.filter.outputs.relevant == 'true' diff --git a/.github/workflows/www-tests.yml b/.github/workflows/www-tests.yml index e6b9aecfbb1..74cc5c34fb1 100644 --- a/.github/workflows/www-tests.yml +++ b/.github/workflows/www-tests.yml @@ -30,6 +30,7 @@ jobs: apps/www packages supabase + patches - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 name: Install pnpm 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/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index a4e25e570bf..412a1c26944 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1292,6 +1292,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "sheet-confirm-on-close-demo": { + name: "sheet-confirm-on-close-demo", + type: "components:example", + registryDependencies: ["alert-dialog","button","input","label","separator","sheet"], + component: React.lazy(() => import("@/registry/default/example/sheet-confirm-on-close-demo")), + source: "", + files: ["registry/default/example/sheet-confirm-on-close-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "sheet-demo": { name: "sheet-demo", type: "components:example", 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..ac52d9beb11 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, use the dedicated discard-confirmation pattern (`DiscardChangesConfirmationDialog` + `useConfirmOnClose`) instead of `ConfirmationModal`. Avoid creating custom wrapper components for this flow; wire `modalProps` from `useConfirmOnClose` directly into `DiscardChangesConfirmationDialog`. + ## 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..eca2d40bdce 100644 --- a/apps/design-system/content/docs/ui-patterns/modality.mdx +++ b/apps/design-system/content/docs/ui-patterns/modality.mdx @@ -74,3 +74,53 @@ Sheets are dialogs presented as side panels. Use them for content that is larger [Sheet](../components/sheet) is modal by default, blocking interaction with the underlying page. + +## Best practices + +### Dirty form dismissal + +When a dialog or sheet contains a form, keep all normal dismissal affordances enabled (backdrop click, Escape key, close icon, and footer `Cancel` button). + +Decision flow: + +1. User attempts to close the dialog/sheet. +2. If the form is clean, close immediately. +3. If the form is dirty, show a discard-confirmation dialog. +4. `Keep editing` returns to the form. +5. `Discard changes` closes and resets the form. + +Implementation checklist: + +- Intercept close attempts from `onOpenChange`. +- Route footer `Cancel` through the same close guard. +- Render a separate discard confirmation dialog when dirty. +- Keep `Cancel` non-destructive; use `Discard`/`Discard changes` for one-click destructive exits. +- Guard controlled close attempts only; do not try to block route changes or arbitrary unmounts. + +Studio implementation (preferred in Studio code): + +```tsx +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' + +const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ + checkIsDirty: () => form.formState.isDirty, + onClose, +}) + + + ... + + ... + + +``` + +Generic implementation (outside Studio): + +- If Studio-only helpers are unavailable, recreate the same behavior with `AlertDialog`. +- The demo below shows the same flow and API shape (`confirmOnClose`, `handleOpenChange`, `modalProps`). + + diff --git a/apps/design-system/registry/default/example/sheet-confirm-on-close-demo.tsx b/apps/design-system/registry/default/example/sheet-confirm-on-close-demo.tsx new file mode 100644 index 00000000000..a82d115797a --- /dev/null +++ b/apps/design-system/registry/default/example/sheet-confirm-on-close-demo.tsx @@ -0,0 +1,225 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Input_Shadcn_ as Input, + Label_Shadcn_ as Label, + Separator, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, +} from 'ui' + +interface EndpointValues { + endpointUrl: string + secretHeader: string +} + +interface ConfirmOnCloseModalProps { + visible: boolean + onClose: () => void + onCancel: () => void +} + +const defaultValues: EndpointValues = { + endpointUrl: '', + secretHeader: '', +} + +const useConfirmOnClose = ({ + checkIsDirty, + onClose, +}: { + checkIsDirty: () => boolean + onClose: () => void +}) => { + const [visible, setVisible] = useState(false) + + const confirmOnClose = useCallback(() => { + if (checkIsDirty()) { + setVisible(true) + return + } + + onClose() + }, [checkIsDirty, onClose]) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + confirmOnClose() + } + }, + [confirmOnClose] + ) + + const onConfirm = useCallback(() => { + setVisible(false) + onClose() + }, [onClose]) + + const onCancel = useCallback(() => { + setVisible(false) + }, []) + + const modalProps: ConfirmOnCloseModalProps = useMemo( + () => ({ + visible, + onClose: onConfirm, + onCancel, + }), + [visible, onConfirm, onCancel] + ) + + return { + confirmOnClose, + handleOpenChange, + modalProps, + } +} + +const DiscardChangesAlertDialog = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => { + const isConfirmingRef = useRef(false) + + useEffect(() => { + if (visible) { + isConfirmingRef.current = false + } + }, [visible]) + + const handleConfirm = useCallback(() => { + isConfirmingRef.current = true + onClose() + }, [onClose]) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (open) return + + if (isConfirmingRef.current) { + isConfirmingRef.current = false + return + } + + onCancel() + }, + [onCancel] + ) + + return ( + + + + Discard changes? + + Any unsaved changes to this endpoint will be lost. + + + + Keep editing + + Discard changes + + + + + ) +} + +export default function SheetConfirmOnCloseDemo() { + const [open, setOpen] = useState(false) + const [savedValues, setSavedValues] = useState(defaultValues) + const [draftValues, setDraftValues] = useState(defaultValues) + + const isDirty = useMemo( + () => + draftValues.endpointUrl !== savedValues.endpointUrl || + draftValues.secretHeader !== savedValues.secretHeader, + [draftValues, savedValues] + ) + + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ + checkIsDirty: () => isDirty, + onClose: () => { + setDraftValues(savedValues) + setOpen(false) + }, + }) + + const openSheet = () => { + setDraftValues(savedValues) + setOpen(true) + } + + const saveChanges = () => { + setSavedValues(draftValues) + setOpen(false) + } + + return ( + <> + + + + + + Edit endpoint + + + +
+ + + setDraftValues((current) => ({ + ...current, + endpointUrl: event.target.value, + })) + } + /> +
+
+ + + setDraftValues((current) => ({ + ...current, + secretHeader: event.target.value, + })) + } + /> +
+
+ + + + + +
+
+ + + ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 905e07d95cf..3a236c19096 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -761,6 +761,12 @@ export const examples: Registry = [ registryDependencies: ['separator'], files: ['example/separator-demo.tsx'], }, + { + name: 'sheet-confirm-on-close-demo', + type: 'components:example', + registryDependencies: ['alert-dialog', 'button', 'input', 'label', 'separator', 'sheet'], + files: ['example/sheet-confirm-on-close-demo.tsx'], + }, { name: 'sheet-demo', type: 'components:example', 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 4cea890356d..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 { @@ -2849,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/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/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index 74146dcdc8d..a196650d77b 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -428,6 +428,20 @@ By default all files are stored locally on the server. You can connect Storage t See the [Configure S3 Storage](/docs/guides/self-hosting/self-hosted-s3) guide for detailed setup instructions. +#### Configuring HTTPS + +By default, Supabase is accessible over HTTP. For production deployments, especially when using OAuth providers, you need HTTPS with a valid TLS certificate. The recommended approach is to place a reverse proxy (such as Caddy or Nginx) in front of Kong. + +See the [Configure HTTPS](/docs/guides/self-hosting/self-hosted-proxy-https) guide for setup instructions. + +#### Configuring social login (OAuth) providers + +See the [Configure Social Login (OAuth) Providers](/docs/guides/self-hosting/self-hosted-oauth) guide for setup instructions. + +#### Configuring phone login, SMS, and MFA + +See the [Configure Phone Login & MFA](/docs/guides/self-hosting/self-hosted-phone-mfa) guide for SMS provider setup, OTP settings, and multi-factor authentication configuration. + #### Configuring Supabase AI Assistant Configuring the Supabase AI Assistant is optional. By adding **your own** `OPENAI_API_KEY` to `.env` you can enable AI services, which help with writing SQL queries, statements, and policies. diff --git a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx index f8707dc48ac..02eaefaa097 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-functions.mdx @@ -125,14 +125,14 @@ const customVar = Deno.env.get('MY_CUSTOM_VAR') The functions service is pre-configured with the following environment variables: -| 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 | +| 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/error-connection-refused-when-trying-to-connect-to-supabase-database-hwG0Dr.mdx b/apps/docs/content/troubleshooting/error-connection-refused-when-trying-to-connect-to-supabase-database-hwG0Dr.mdx
index 43284c297b6..fa251d50167 100644
--- a/apps/docs/content/troubleshooting/error-connection-refused-when-trying-to-connect-to-supabase-database-hwG0Dr.mdx
+++ b/apps/docs/content/troubleshooting/error-connection-refused-when-trying-to-connect-to-supabase-database-hwG0Dr.mdx
@@ -16,8 +16,12 @@ message = "connect ECONNREFUSED 1.2.3.4:5432"
 message = "psql: error: connection to server at \"db.xxxxxxxxxxxxxxxxxxxx.supabase.co\" (1.2.3.4), port 5432 failed: Connection refused Is the server running on that host and accepting TCP/IP connections?"
 ---
 
-If you're not able to connect to the Supabase database and see the error `connect ECONNREFUSED 1.2.3.4:5432` or `psql: error: connection to server at "db.xxxxxxxxxxxxxxxxxxxx.supabase.co" (1.2.3.4), port 5432 failed: Connection refused
-Is the server running on that host and accepting TCP/IP connections?`, this could be because there are banned IPs on your project caused by Fail2ban as it kicks in when attempting 2 wrong passwords in a row.
+If you're not able to connect to the Supabase database and see one of the errors below, this could be because there are banned IPs on your project caused by Fail2ban as it kicks in when attempting 2 wrong passwords in a row.
+
+- `connect ECONNREFUSED 1.2.3.4:5432`
+- `psql: error: connection to server at "db.xxxxxxxxxxxxxxxxxxxx.supabase.co" (1.2.3.4), port 5432 failed: Connection refused
+Is the server running on that host and accepting TCP/IP connections?`
+- `Circuit breaker open: Unable to establish connection to upstream database`
 
 These bans will clear after 30mins but you can unban the IPs using the Supabase CLI https://supabase.com/docs/guides/cli following the commands below.
 
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/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..d62be0e15e0 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
@@ -34,6 +37,7 @@ Brendan Stephens
 Brent Newson
 Carel de Waal
 Cameron Blackwood
+Cameron Michie
 Cemal Kılıç
 Chandana Anumula
 Charis Lam
@@ -82,6 +86,7 @@ Francesco Sansalvadore
 Garrett Crowell
 Gerardo Estaba
 Gildas Garcia
+Giuseppe Mandato
 Greg Kress
 Greg P
 Greg Richardson
@@ -108,7 +113,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 0dd5b3fe2f1..3137e6c6f54 100644
--- a/apps/studio/.github/eslint-rule-baselines.json
+++ b/apps/studio/.github/eslint-rule-baselines.json
@@ -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,
@@ -948,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,
@@ -1110,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 c63dbd112e9..2b6eb4d64c9 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -82,8 +82,9 @@ export const useIsColumnLevelPrivilegesEnabled = () => { export const useUnifiedLogsPreview = () => { const { flags, onUpdateFlag } = useFeaturePreviewContext() + const unifiedLogsEnabled = useFlag('unifiedLogs') - const isEnabled = flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS] + const isEnabled = unifiedLogsEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS] const enable = () => onUpdateFlag(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, true) const disable = () => onUpdateFlag(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, false) @@ -93,8 +94,7 @@ export const useUnifiedLogsPreview = () => { export const useIsBranching2Enabled = () => { const { flags } = useFeaturePreviewContext() - const gitlessBranchingEnabled = useFlag('gitlessBranching') - return gitlessBranchingEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_BRANCHING_2_0] + return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_BRANCHING_2_0] } export const useIsPgDeltaDiffEnabled = () => { @@ -105,8 +105,7 @@ export const useIsPgDeltaDiffEnabled = () => { export const useIsAdvisorRulesEnabled = () => { const { flags } = useFeaturePreviewContext() - const advisorRulesEnabled = useFlag('advisorRules') - return advisorRulesEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_ADVISOR_RULES] + return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_ADVISOR_RULES] } export const useIsQueueOperationsEnabled = () => { @@ -114,6 +113,12 @@ export const useIsQueueOperationsEnabled = () => { return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS] } +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 2c4c5f7a2ba..ca9f986f09f 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -24,6 +24,7 @@ import { APISidePanelPreview } from './APISidePanelPreview' import { Branching2Preview } from './Branching2Preview' import { CLSPreview } from './CLSPreview' import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext' +import { PlatformWebhooksPreview } from './PlatformWebhooksPreview' import { PgDeltaDiffPreview } from './PgDeltaDiffPreview' import { QueueOperationsPreview } from './QueueOperationsPreview' import { TableFilterBarPreview } from './TableFilterBarPreview' @@ -42,6 +43,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: { [LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR]: , + [LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: , } export const FeaturePreviewModal = () => { @@ -63,15 +65,17 @@ export const FeaturePreviewModal = () => { ) const { flags, onUpdateFlag } = featurePreviewContext - const selectedFeature = - featurePreviews.find((preview) => preview.key === selectedFeatureKey) ?? featurePreviews[0] - const isSelectedFeatureEnabled = flags[selectedFeatureKey] + const allFeaturePreviews = ( + IS_PLATFORM ? featurePreviews : featurePreviews.filter((x) => !x.isPlatformOnly) + ).filter((x) => x.enabled) - const allFeaturePreviews = IS_PLATFORM - ? featurePreviews - : featurePreviews.filter((x) => !x.isPlatformOnly) + const selectedFeature = + allFeaturePreviews.find((preview) => preview.key === selectedFeatureKey) ?? + allFeaturePreviews[0] + const isSelectedFeatureEnabled = flags[selectedFeature?.key] const toggleFeature = () => { + if (!selectedFeature) return onUpdateFlag(selectedFeature.key, !isSelectedFeatureEnabled) sendEvent({ action: isSelectedFeatureEnabled ? 'feature_preview_disabled' : 'feature_preview_enabled', @@ -98,7 +102,7 @@ export const FeaturePreviewModal = () => { - {featurePreviews.length > 0 ? ( + {allFeaturePreviews.length > 0 ? (
@@ -111,7 +115,7 @@ export const FeaturePreviewModal = () => { onClick={() => selectFeaturePreview(feature.key)} className={cn( 'flex items-center justify-between p-4 border-b cursor-pointer bg transition', - selectedFeature.key === feature.key ? 'bg-surface-300' : 'bg-surface-100' + selectedFeature?.key === feature.key ? 'bg-surface-300' : 'bg-surface-100' )} >
@@ -150,7 +154,7 @@ export const FeaturePreviewModal = () => {
- {FEATURE_PREVIEW_KEY_TO_CONTENT[selectedFeature.key]} + {FEATURE_PREVIEW_KEY_TO_CONTENT[selectedFeature?.key ?? '']}
) : ( 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 ce54a339df1..df3f9535d6c 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -14,11 +14,10 @@ type FeaturePreview = { } export const useFeaturePreviews = (): FeaturePreview[] => { - const gitlessBranchingEnabled = useFlag('gitlessBranching') - const advisorRulesEnabled = useFlag('advisorRules') const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs') const tableEditorNewFilterBar = useFlag('tableEditorNewFilterBar') const pgDeltaDiffEnabled = useFlag('pgdeltaDiff') + const platformWebhooksEnabled = useFlag('platformWebhooks') return [ { @@ -34,7 +33,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_BRANCHING_2_0, name: 'Branching via dashboard', discussionsUrl: 'https://github.com/orgs/supabase/discussions/branching-2-0', - enabled: gitlessBranchingEnabled, + enabled: true, isNew: false, isPlatformOnly: true, isDefaultOptIn: false, @@ -43,7 +42,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_ADVISOR_RULES, name: 'Disable Advisor rules', discussionsUrl: undefined, - enabled: advisorRulesEnabled, + enabled: true, isNew: false, isPlatformOnly: true, isDefaultOptIn: false, @@ -58,6 +57,15 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isDefaultOptIn: true, enabled: pgDeltaDiffEnabled, }, + { + 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/AuthProvidersForm.types.ts b/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.types.ts index af4bcc95c19..32667230f54 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.types.ts +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.types.ts @@ -1,3 +1,5 @@ +import type { FeatureKey } from 'data/entitlements/entitlements-query' + export interface Enum { label: string value: string @@ -22,7 +24,7 @@ export interface Provider { descriptionOptional?: string units?: string isSecret?: boolean - isPaid?: boolean + entitlementKey?: FeatureKey link?: string } } diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index 594b16b7d37..160a08f3cd2 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -7,6 +7,7 @@ import { ResourceItem } from 'components/ui/Resource/ResourceItem' import type { components } from 'data/api' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useHasEntitlementAccess } from 'hooks/misc/useCheckEntitlements' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH } from 'lib/constants' import { Check } from 'lucide-react' @@ -67,7 +68,7 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) ) } - const isFreePlan = organization?.plan.id === 'free' + const hasEntitlementAccess = useHasEntitlementAccess() const INITIAL_VALUES = (() => { const initialValues: { [x: string]: string | boolean } = {} @@ -201,18 +202,17 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) ) } + const { entitlementKey } = provider.properties[x] + const hasAccess = + entitlementKey == null || hasEntitlementAccess(entitlementKey) const properties = { ...provider.properties[x], - description: - provider.properties[x].isPaid && isFreePlan - ? `${description} Only available on [Pro plan](/org/${organization.slug}/billing?panel=subscriptionPlan) and above.` - : description, + description: hasAccess + ? description + : `${description} Only available on [Pro plan](/org/${organization?.slug}/billing?panel=subscriptionPlan) and above.`, } - const isDisabledDueToPlan = properties.isPaid && isFreePlan const shouldDisable = - properties.type === 'boolean' - ? isDisabledDueToPlan && !values[x] - : isDisabledDueToPlan + properties.type === 'boolean' ? !hasAccess && !values[x] : !hasAccess return ( isDirty, + onClose, + }) const statements = useMemo(() => { let permissionChanges: string[] = [] @@ -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 = ({
)} - )} )} @@ -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/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/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/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index b28b5eb7221..77727b3a454 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -13,7 +13,7 @@ import { useDatabaseFunctionCreateMutation } from 'data/database-functions/datab import { DatabaseFunction } from 'data/database-functions/database-functions-query' import { useDatabaseFunctionUpdateMutation } from 'data/database-functions/database-functions-update-mutation' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import type { FormSchema } from 'types' import { @@ -41,7 +41,7 @@ import { Toggle, cn, } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { convertArgumentTypes, convertConfigParams } from '../Functions.utils' import { CreateFunctionHeader } from './CreateFunctionHeader' @@ -87,7 +87,7 @@ export const CreateFunction = ({ }) const language = form.watch('language') - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: () => form.formState.isDirty, onClose, }) @@ -158,7 +158,7 @@ export const CreateFunction = ({ const { data: protectedSchemas } = useProtectedSchemas() return ( - + - + ) } -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) - interface FormFieldConfigParamsProps { readonly?: boolean } diff --git a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx index 9c078d71c88..11646d724e7 100644 --- a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx @@ -8,16 +8,16 @@ import { useEffect, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Form_Shadcn_, SidePanel } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormSchema, WebhookFormValues } from './EditHookPanel.constants' import { FormContents } from './FormContents' +import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { useDatabaseTriggerCreateMutation } from '@/data/database-triggers/database-trigger-create-mutation' import { useDatabaseTriggerUpdateMutation } from '@/data/database-triggers/database-trigger-update-transaction-mutation' import { useDatabaseHooksQuery } from '@/data/database-triggers/database-triggers-query' import { tableEditorQueryOptions } from '@/data/table-editor/table-editor-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from '@/hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' import { uuidv4 } from '@/lib/helpers' export type HTTPArgument = { id: string; name: string; value: string } @@ -288,7 +288,7 @@ export const EditHookPanel = () => { // This is intentionally kept outside of the useConfirmOnClose hook to force RHF to update the isDirty state. const isDirty = form.formState.isDirty - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, modalProps } = useConfirmOnClose({ checkIsDirty: () => isDirty, onClose: () => onClose(), }) @@ -340,22 +340,7 @@ export const EditHookPanel = () => { - + ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) 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/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx index 68e51584c9b..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' @@ -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 unifiedReplication = useFlag('unifiedReplication') 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 @@ -57,7 +57,7 @@ export const DestinationTypeSelection = () => { '[&>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 @@ -108,7 +108,7 @@ export const DestinationTypeSelection = () => {

Analytics Bucket

- {unifiedReplication && Alpha} + Alpha

Send data to Apache Iceberg tables in your Supabase Storage for flexible analytics 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.

- )} +
+ {data.description && ( + + + + + {data.description} + + )} + + {!placeholder && ( + + )} +
{data.columns.map((column) => ( diff --git a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts index 0dc70a1f953..c8af180fed6 100644 --- a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts +++ b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts @@ -2,10 +2,12 @@ import dagre from '@dagrejs/dagre' import type { PostgresSchema, PostgresTable } from '@supabase/postgres-meta' import { uniqBy } from 'lodash' import { Edge, Node, Position } from 'reactflow' + import 'reactflow/dist/style.css' import { LOCAL_STORAGE_KEYS } from 'common' import { tryParseJson } from 'lib/helpers' + import { TABLE_NODE_ROW_HEIGHT, TABLE_NODE_WIDTH, TableNodeData } from './SchemaTableNode' const NODE_SEP = 25 @@ -41,6 +43,7 @@ export async function getGraphDataFromTables( ref, id: table.id, name: table.name, + description: table.comment ?? '', schema: table.schema, isForeign: false, columns, @@ -78,6 +81,7 @@ export async function getGraphDataFromTables( ref: ref!, schema: rel.target_table_schema, name: targetId, + description: '', isForeign: true, columns: [], } diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx index c14145482fd..28fe906b51a 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx @@ -1,18 +1,17 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { Terminal } from 'lucide-react' -import { useEffect, useState } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' -import { toast } from 'sonner' -import * as z from 'zod' - import { PostgresTrigger } from '@supabase/postgres-meta' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import FormBoxEmpty from 'components/ui/FormBoxEmpty' import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/database-trigger-create-mutation' import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-mutation' import { useTablesQuery } from 'data/tables/tables-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' +import { Terminal } from 'lucide-react' +import { useEffect, useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' import { Button, Checkbox_Shadcn_, @@ -33,8 +32,9 @@ import { SheetHeader, SheetTitle, } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import * as z from 'zod' + import ChooseFunctionForm from './ChooseFunctionForm' import { TRIGGER_ENABLED_MODES, @@ -135,7 +135,7 @@ export const TriggerSheet = ({ }) const { function_name, function_schema } = form.watch() - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: () => form.formState.isDirty, onClose, }) @@ -189,7 +189,7 @@ export const TriggerSheet = ({ return ( <> - + @@ -481,7 +481,7 @@ export const TriggerSheet = ({ - + @@ -489,25 +489,10 @@ export const TriggerSheet = ({ visible={showFunctionSelector} setVisible={setShowFunctionSelector} onChange={(fn) => { - form.setValue('function_name', fn.name) - form.setValue('function_schema', fn.schema) + form.setValue('function_name', fn.name, { shouldDirty: true }) + form.setValue('function_schema', fn.schema, { shouldDirty: true }) }} /> ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx index 5b7f4a1779b..8863864d593 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx @@ -1,34 +1,30 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { useEffect, useState, type ReactNode } from 'react' -import { SubmitHandler, useForm, type UseFormReturn } from 'react-hook-form' -import { toast } from 'sonner' -import z from 'zod' - import { useParams } from 'common' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { useSecretsCreateMutation } from 'data/secrets/secrets-create-mutation' import { ProjectSecret } from 'data/secrets/secrets-query' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' -import { Eye, EyeOff, X } from 'lucide-react' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' +import { Eye, EyeOff } from 'lucide-react' +import { useEffect, useState } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' import { useLatest } from 'react-use' +import { toast } from 'sonner' import { Button, - cn, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input, Input_Shadcn_, - Separator, Sheet, - SheetClose, SheetContent, SheetFooter, SheetHeader, SheetSection, SheetTitle, } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import z from 'zod' const FORM_ID = 'edit-secret-sidepanel' @@ -46,21 +42,17 @@ interface EditSecretSheetProps { } export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetProps) { + const { ref: projectRef } = useParams() const secretName = useLatest(secret?.name) + const [showSecretValue, setShowSecretValue] = useState(false) + const form = useForm({ resolver: zodResolver(FormSchema), }) - useEffect(() => { - if (visible) { - form.reset({ - name: secretName.current ?? '', - value: '', - }) - } - }, [form, secretName, visible]) - const isValid = form.formState.isValid - const { ref: projectRef } = useParams() + const isValid = form.formState.isValid + const isDirty = form.formState.isDirty + const { mutate: updateSecret, isPending: isUpdating } = useSecretsCreateMutation({ onSuccess: (_, variables) => { toast.success(`Successfully updated secret "${variables.secrets[0].name}"`) @@ -74,21 +66,83 @@ export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetPro }) } - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ - checkIsDirty: () => form.formState.isDirty, + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ + checkIsDirty: () => isDirty, onClose, }) + useEffect(() => { + if (visible) { + form.reset({ name: secretName.current ?? '', value: '' }) + } + }, [form, secretName, visible]) + return ( - - -
- - + + + + Edit secret + + + + +
+ ( + + + + + + )} + /> + ( + + + +
- + +
) } - -const Header = (): ReactNode => { - return ( - - - - Close - - Edit secret - - ) -} - -type FormBodyProps = { - form: UseFormReturn - onSubmit: SubmitHandler -} - -const FormBody = ({ form, onSubmit }: FormBodyProps): ReactNode => { - return ( - -
- - - - - - - - - -
- ) -} - -type NameFieldProps = { - form: UseFormReturn -} - -const NameField = ({ form }: NameFieldProps): ReactNode => { - return ( - ( - - - - - - )} - /> - ) -} - -type SecretFieldProps = { - form: UseFormReturn -} - -const SecretField = ({ form }: SecretFieldProps): ReactNode => { - const [showSecretValue, setShowSecretValue] = useState(false) - - return ( - ( - - - - - - - + {cronType === 'edge_function' && ( + <> + + + + + + + )} + {cronType === 'sql_function' && } + {cronType === 'sql_snippet' && } + + + + + + + + + + + {pgNetExtension && ( { const edgeFunctionSlug = edgeFunction?.split('/functions/v1/').pop() const isValidEdgeFunction = edgeFunctions.some((x) => x.slug === edgeFunctionSlug) - const [isDirty, setIsDirty] = useState(false) - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ - checkIsDirty: () => isDirty, - onClose: () => { - setIsDirty(false) - setIsEditSheetOpen(false) - }, - }) - const pageTitle = childLabel || childId || 'Cron Job' const pageSubtitle = job ? ( @@ -192,40 +179,13 @@ export const CronJobPage = () => { - - - {job && ( - setIsEditSheetOpen(false)} - onCloseWithConfirmation={confirmOnClose} - /> - )} - - - + {job && ( + setIsEditSheetOpen(false)} + /> + )} ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 807d37b8a8e..b3547a4d70e 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -5,7 +5,6 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers' import { createNavigationHandler } from 'lib/navigation' import { isGreaterThanOrEqual } from 'lib/semver' @@ -14,8 +13,7 @@ import { useRouter } from 'next/router' import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' import { MouseEvent, UIEvent, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' -import { LoadingLine, Sheet, SheetContent } from 'ui' -import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' +import { LoadingLine } from 'ui' import { formatCronJobColumns } from './CronJobs.utils' import { CronJobRunDetailsOverflowNotice } from './CronJobsTab.CleanupNotice' @@ -34,7 +32,6 @@ export const CronjobsTab = () => { const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault('')) - const [isDirty, setIsDirty] = useState(false) const [search, setSearch] = useState(searchQuery) const handleSearchSubmit = () => { @@ -142,13 +139,6 @@ export const CronjobsTab = () => { setCreateCronJobSheetShown(false) cleanPointerEventsNoneOnBody(500) } - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ - checkIsDirty: () => isDirty, - onClose: () => { - setIsDirty(false) - onClose() - }, - }) useEffect(() => { if (grid.isSuccess && !!cronJobIdForEditing && !cronJobForEditing) { @@ -192,18 +182,11 @@ export const CronjobsTab = () => { - - - - - - + ) } @@ -228,19 +211,3 @@ const CronJobsFooter = ({ count }: CronJobsFooterProps) => ( )} ) - -// Confirmation modal for unsaved changes -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx index e28a7e7375b..b9d362895f7 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx @@ -1,4 +1,3 @@ -import { useFlag } from 'common' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useSchemasQuery } from 'data/database/schemas-query' import { useFDWsQuery } from 'data/fdw/fdws-query' @@ -19,7 +18,6 @@ import { export const useInstalledIntegrations = () => { 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, diff --git a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx index 95bc4ddc696..b01387918a1 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx @@ -8,7 +8,7 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex import { useDatabaseQueueCreateMutation } from 'data/database-queues/database-queues-create-mutation' import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { useRouter } from 'next/router' import { useEffect } from 'react' import { @@ -31,7 +31,7 @@ import { SheetTitle, } from 'ui' import { Admonition } from 'ui-patterns' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { QUEUE_TYPES } from './Queues.constants' import { QueryNameSchema } from './Queues.utils' @@ -102,7 +102,7 @@ export const CreateQueueSheet = ({ visible, onClose }: CreateQueueSheetProps) => const checkIsDirty = () => form.formState.isDirty - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty, onClose, }) @@ -144,7 +144,7 @@ export const CreateQueueSheet = ({ visible, onClose }: CreateQueueSheetProps) => const queueType = form.watch('values.type') return ( - +
@@ -322,23 +322,8 @@ export const CreateQueueSheet = ({ visible, onClose }: CreateQueueSheetProps) =>
- +
) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx index 42cf788d560..85cbb6afd22 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx @@ -11,8 +11,9 @@ import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation' import { FDW } from 'data/fdw/fdws-query' import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { Button, Form, Input, SheetFooter, SheetHeader, SheetTitle } from 'ui' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import InputField from './InputField' import { WrapperMeta } from './Wrappers.types' @@ -108,7 +109,7 @@ export const EditWrapperSheet = ({ const checkIsDirty = useCallback(() => hasChangesRef.current, []) - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, modalProps } = useConfirmOnClose({ checkIsDirty, onClose, }) @@ -397,7 +398,7 @@ export const EditWrapperSheet = ({

Are you sure you want to continue?

- + ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx index ecc85736351..6560414d392 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx @@ -5,10 +5,11 @@ import { parseAsBoolean, useQueryState } from 'nuqs' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { Alert_Shadcn_, AlertDescription_Shadcn_, @@ -19,7 +20,6 @@ import { SheetContent, WarningIcon, } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' import { CreateIcebergWrapperSheet } from './CreateIcebergWrapperSheet' import { CreateWrapperSheet } from './CreateWrapperSheet' @@ -45,7 +45,7 @@ export const WrapperOverviewTab = () => { }) const [isDirty, setIsDirty] = useState(false) - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: () => isDirty, onClose: () => { setCreateWrapperShown(false) @@ -141,7 +141,7 @@ export const WrapperOverviewTab = () => { - + { /> - + ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx index 50eacdefe4c..2db4fa85d34 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx @@ -4,10 +4,10 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useFDWsQuery } from 'data/fdw/fdws-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' import { HTMLProps, ReactNode, useCallback, useState } from 'react' import { Sheet, SheetContent } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { CreateWrapperSheet } from './CreateWrapperSheet' import { WRAPPERS } from './Wrappers.constants' @@ -38,7 +38,7 @@ export const WrappersTab = () => { : [] const [isDirty, setIsDirty] = useState(false) - const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ + const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({ checkIsDirty: useCallback(() => isDirty, [isDirty]), onClose: useCallback(() => { setCreateWrapperShown(false) @@ -50,7 +50,7 @@ export const WrappersTab = () => { ({ ...props }: { children: ReactNode } & HTMLProps) => (
{props.children} - + {wrapperMeta && ( {
), - [createWrapperShown, wrapperMeta, confirmOnClose] + [createWrapperShown, handleOpenChange, wrapperMeta, confirmOnClose] ) if (!wrapperMeta) { @@ -102,22 +102,7 @@ export const WrappersTab = () => { return ( - + ) } - -const CloseConfirmationModal = ({ visible, onClose, onCancel }: ConfirmOnCloseModalProps) => ( - -

- There are unsaved changes. Are you sure you want to close the panel? Your changes will be - lost. -

-
-) 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..c8235010cc1 100644 --- a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx @@ -1,11 +1,10 @@ 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' import { CHART_INTERVALS } from 'components/ui/Logs/logs.utils' import dayjs from 'dayjs' -import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { RefreshCw } from 'lucide-react' @@ -25,14 +24,8 @@ export const ObservabilityOverview = () => { const router = useRouter() const { ref: projectRef } = useParams() const { data: organization } = useSelectedOrganizationQuery() - 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 +70,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 +78,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 +86,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 +94,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 +102,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) @@ -169,8 +154,6 @@ export const ObservabilityOverview = () => { setInterval(interval as ChartIntervalKey)} - planId={plan?.id} - planName={plan?.name} organizationSlug={organization?.slug} dropdownAlign="end" tooltipSide="left" diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts index b34098174b3..de562f4824a 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts @@ -721,13 +721,6 @@ export const TAX_IDS: TaxId[] = [ placeholder: 'MK1234567890123', countryIso2: 'MK', }, - { - name: 'nz_gst', - type: 'nz_gst', - country: 'New Zealand', - placeholder: '123456789', - countryIso2: 'NZ', - }, { name: 'NO VAT', type: 'no_vat', 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/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 f6a454b40ed..117c62339fd 100644 --- a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx +++ b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx @@ -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 51af92d27de..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" + > + +