diff --git a/apps/docs/content/guides/database/functions.mdx b/apps/docs/content/guides/database/functions.mdx
index 8fb0158569b..8186c100eb4 100644
--- a/apps/docs/content/guides/database/functions.mdx
+++ b/apps/docs/content/guides/database/functions.mdx
@@ -62,7 +62,7 @@ At it's most basic a function has the following parts:
-When naming your functions, please make the name of the function unique as overloaded functions are not supported.
+When naming your functions, make the name of the function unique as overloaded functions are not supported.
diff --git a/apps/docs/content/guides/database/postgres/setup-replication-external.mdx b/apps/docs/content/guides/database/postgres/setup-replication-external.mdx
index 0e74c9df844..a29c9156df7 100644
--- a/apps/docs/content/guides/database/postgres/setup-replication-external.mdx
+++ b/apps/docs/content/guides/database/postgres/setup-replication-external.mdx
@@ -49,7 +49,7 @@ To copy data from before the slot was created, set `copy_data` to `true`.
-4. Add all the tables that you want replicated to the publication.
+4. Now we'll go back to the Supabase DB and add all the tables that you want replicated to the publication.
```sql
ALTER PUBLICATION example_pub ADD TABLE example_table;
diff --git a/apps/docs/content/guides/functions/auth.mdx b/apps/docs/content/guides/functions/auth.mdx
index 34ec98d4e36..0ade89bb3ac 100644
--- a/apps/docs/content/guides/functions/auth.mdx
+++ b/apps/docs/content/guides/functions/auth.mdx
@@ -19,9 +19,11 @@ Deno.serve(async (req: Request) => {
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
+ // Create client with Auth context of the user that called the function.
+ // This way your row-level-security (RLS) policies are applied.
{
global: {
- headers: { Authorization: req.headers.get('Authorization') },
+ headers: { Authorization: req.headers.get('Authorization')! },
},
}
);
@@ -38,7 +40,7 @@ Importantly, this is done _inside_ the `Deno.serve()` callback argument, so that
## Fetching the user
-After initializing a Supabase client with the Auth context, you can use `getUser()` to fetch the user object, and run queries in the context of the user with [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security) policies enforced.
+By getting the JWT from the `Authorization` header, you can provide the token to `getUser()` to fetch the user object to obtain metadata for the logged in user.
```js
import { createClient } from 'npm:@supabase/supabase-js@2'
@@ -81,9 +83,11 @@ Deno.serve(async (req: Request) => {
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
+ // Create client with Auth context of the user that called the function.
+ // This way your row-level-security (RLS) policies are applied.
{
global: {
- headers: { Authorization: req.headers.get('Authorization') },
+ headers: { Authorization: req.headers.get('Authorization')! },
},
}
);
diff --git a/apps/docs/content/guides/getting-started/mcp.mdx b/apps/docs/content/guides/getting-started/mcp.mdx
index c9f4e45f947..fa878830241 100644
--- a/apps/docs/content/guides/getting-started/mcp.mdx
+++ b/apps/docs/content/guides/getting-started/mcp.mdx
@@ -95,6 +95,10 @@ For more info on using MCP in VS Code, see the [Copilot documentation](https://c
### Claude code
+You can add the Supabase MCP server to Claude Code in two ways:
+
+#### Option 1: Project-scoped server (via .mcp.json file)
+
1. Create a `.mcp.json` file in your project root if it doesn't exist.
1. Add the following configuration:
@@ -104,6 +108,18 @@ For more info on using MCP in VS Code, see the [Copilot documentation](https://c
1. Restart [Claude code](https://claude.ai/code) to apply the new configuration.
+#### Option 2: Locally-scoped server (via CLI command)
+
+You can also add the Supabase MCP server as a locally-scoped server, which will only be available to you in the current project:
+
+1. Run the following command in your terminal:
+
+ ```bash
+ claude mcp add supabase -s local -e SUPABASE_ACCESS_TOKEN=your_token_here npx -y @supabase/mcp-server-supabase@latest
+ ```
+
+Locally-scoped servers take precedence over project-scoped servers with the same name and are stored in your project-specific user settings.
+
### Next steps
Your AI tool is now connected to Supabase using MCP. Try asking your AI assistant to create a new project, create a table, or fetch project config.
diff --git a/apps/docs/content/guides/platform/manage-your-usage/egress.mdx b/apps/docs/content/guides/platform/manage-your-usage/egress.mdx
index 9f979e821ee..71a893036af 100644
--- a/apps/docs/content/guides/platform/manage-your-usage/egress.mdx
+++ b/apps/docs/content/guides/platform/manage-your-usage/egress.mdx
@@ -45,7 +45,7 @@ Data pushed to clients via Supabase Realtime for subscribed events.
### Shared pooler Egress
-Data sent to the client when using the shared connection pooler (Supavisor) to access your database. WHen using the shared connection pooler, we do not count database egress, as this would otherwise count double (Database -> Shared Pooler + Shared Pooler -> Client).
+Data sent to the client when using the shared connection pooler (Supavisor) to access your database. When using the shared connection pooler, we do not count database egress, as this would otherwise count double (Database -> Shared Pooler + Shared Pooler -> Client).
**Example:** You are using our [shared connection pooler](https://supabase.com/docs/guides/database/connecting-to-postgres#shared-pooler) and you query a list of invoices in your backend. The data returned from that query is contributing to Shared Pooler Egress.
diff --git a/apps/docs/features/ui/CodeBlock/CodeBlock.client.tsx b/apps/docs/features/ui/CodeBlock/CodeBlock.client.tsx
index da8a74b65f2..9b3ca95161f 100644
--- a/apps/docs/features/ui/CodeBlock/CodeBlock.client.tsx
+++ b/apps/docs/features/ui/CodeBlock/CodeBlock.client.tsx
@@ -4,7 +4,7 @@ import { Check, Copy } from 'lucide-react'
import { type MouseEvent, useCallback, useEffect, useState } from 'react'
import { type ThemedToken } from 'shiki'
import { type NodeHover } from 'twoslash'
-import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
+import { cn, copyToClipboard, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
export function AnnotatedSpan({
token,
@@ -89,13 +89,10 @@ export function CodeCopyButton({ className, content }: { className?: string; con
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
- try {
- await navigator.clipboard.writeText(content)
+ copyToClipboard(content, () => {
setCopied(true)
setTimeout(() => setCopied(false), 1000)
- } catch (error) {
- console.error('Failed to copy text: ', error)
- }
+ })
}
return (
diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt
index 18b5d9a3207..1dba617c869 100644
--- a/apps/docs/public/humans.txt
+++ b/apps/docs/public/humans.txt
@@ -45,6 +45,7 @@ Greg Richardson
Guilherme Souza
Haydn Maley
Hieu Pham
+Ignacio Dobronich
Illia Basalaiev
Inian P
Ivan Vasilov
diff --git a/apps/docs/scripts/helpers.mdx.ts b/apps/docs/scripts/helpers.mdx.ts
index cbcbfb8f709..c6d9ce672ef 100644
--- a/apps/docs/scripts/helpers.mdx.ts
+++ b/apps/docs/scripts/helpers.mdx.ts
@@ -1,4 +1,4 @@
-import { createHash } from 'crypto'
+import { createHash } from 'node:crypto'
import { ObjectExpression } from 'estree'
import GithubSlugger from 'github-slugger'
import matter from 'gray-matter'
diff --git a/apps/docs/scripts/search/sources/index.ts b/apps/docs/scripts/search/sources/index.ts
index 5f712ded87f..c46c46a7986 100644
--- a/apps/docs/scripts/search/sources/index.ts
+++ b/apps/docs/scripts/search/sources/index.ts
@@ -5,6 +5,7 @@ import {
type GitHubDiscussionSource,
fetchDiscussions,
} from './github-discussion.js'
+import { LintWarningsGuideLoader, type LintWarningsGuideSource } from './lint-warnings-guide.js'
import { MarkdownLoader, type MarkdownSource } from './markdown.js'
import { IntegrationLoader, type IntegrationSource, fetchPartners } from './partner-integrations.js'
import {
@@ -26,6 +27,7 @@ export type SearchSource =
| CliReferenceSource
| GitHubDiscussionSource
| IntegrationSource
+ | LintWarningsGuideSource
export async function fetchGuideSources() {
return (
@@ -122,12 +124,23 @@ export async function fetchCliLibReferenceSource() {
).load()
}
+export async function fetchLintWarningsGuideSources() {
+ return new LintWarningsGuideLoader(
+ 'guide',
+ '/guides/database/database-advisors',
+ 'supabase',
+ 'splinter',
+ 'main',
+ 'docs'
+ ).load()
+}
+
/**
* Fetches all the sources we want to index for search
*/
export async function fetchAllSources() {
const guideSources = fetchGuideSources()
-
+ const lintWarningsGuideSources = fetchLintWarningsGuideSources()
const openApiReferenceSource = fetchOpenApiReferenceSource()
const jsLibReferenceSource = fetchJsLibReferenceSource()
const dartLibReferenceSource = fetchDartLibReferenceSource()
@@ -164,6 +177,7 @@ export async function fetchAllSources() {
const sources: SearchSource[] = (
await Promise.all([
guideSources,
+ lintWarningsGuideSources,
openApiReferenceSource,
jsLibReferenceSource,
dartLibReferenceSource,
diff --git a/apps/docs/scripts/search/sources/lint-warnings-guide.ts b/apps/docs/scripts/search/sources/lint-warnings-guide.ts
new file mode 100644
index 00000000000..61d4f5e4e34
--- /dev/null
+++ b/apps/docs/scripts/search/sources/lint-warnings-guide.ts
@@ -0,0 +1,116 @@
+import { Octokit } from '@octokit/core'
+import { BaseLoader, BaseSource } from './base.js'
+import { createHash } from 'node:crypto'
+
+const getBasename = (path: string) => path.split('/').at(-1)!.replace(/\.md$/, '')
+
+export class LintWarningsGuideLoader extends BaseLoader {
+ type = 'markdown' as const
+
+ constructor(
+ source: string,
+ path: string,
+ public org: string,
+ public repo: string,
+ public branch: string,
+ public docsDir: string
+ ) {
+ super(source, path)
+ }
+
+ async load() {
+ const octokit = new Octokit()
+
+ const response = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', {
+ owner: this.org,
+ repo: this.repo,
+ path: this.docsDir,
+ ref: this.branch,
+ headers: {
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ })
+
+ if (response.status >= 400) {
+ throw Error(`Could not get contents of repo ${this.org}/${this.repo}`)
+ }
+
+ if (!Array.isArray(response.data)) {
+ throw Error(
+ 'Reading a directory, not a file. Should not reach this, solely to appease Typescript.'
+ )
+ }
+
+ const lintsList = response.data.filter(({ path }) => /docs\/\d+.+\.md$/.test(path))
+
+ // Fetch all lint files and combine them into a single guide
+ const lints = await Promise.all(
+ lintsList.map(async ({ path }) => {
+ const fileResponse = await fetch(
+ `https://raw.githubusercontent.com/${this.org}/${this.repo}/${this.branch}/${path}`
+ )
+
+ if (fileResponse.status >= 400) {
+ throw Error(`Could not get contents of file ${this.org}/${this.repo}/${path}`)
+ }
+
+ const content = await fileResponse.text()
+ const basename = getBasename(path)
+
+ return {
+ path: basename,
+ content,
+ originalPath: path,
+ }
+ })
+ )
+
+ // Create a separate source for each lint file
+ return lints.map(
+ (lint) =>
+ new LintWarningsGuideSource(
+ this.source,
+ `${this.path}?queryGroups=lint&lint=${lint.path}`,
+ lint
+ )
+ )
+ }
+}
+
+export class LintWarningsGuideSource extends BaseSource {
+ type = 'markdown' as const
+
+ constructor(
+ source: string,
+ path: string,
+ public lint: {
+ path: string
+ content: string
+ originalPath: string
+ }
+ ) {
+ super(source, path)
+ }
+
+ process() {
+ this.checksum = createHash('sha256').update(this.lint.content).digest('base64')
+
+ this.meta = {
+ title: `Database Advisor: Lint ${this.lint.path}`,
+ }
+
+ this.sections = [
+ {
+ content: this.lint.content,
+ },
+ ]
+
+ return { checksum: this.checksum, meta: this.meta, sections: this.sections }
+ }
+
+ extractIndexedContent(): string {
+ const sections = this.sections ?? []
+ const sectionText = sections.map(({ content }) => content).join('\n\n')
+ return `# ${this.lint.path}\n\n${sectionText}`
+ }
+}
diff --git a/apps/studio/components/grid/components/menu/RowContextMenu.tsx b/apps/studio/components/grid/components/menu/RowContextMenu.tsx
index dfa4d26bede..14a457eceb6 100644
--- a/apps/studio/components/grid/components/menu/RowContextMenu.tsx
+++ b/apps/studio/components/grid/components/menu/RowContextMenu.tsx
@@ -5,8 +5,9 @@ import { Item, ItemParams, Menu } from 'react-contexify'
import type { SupaRow } from 'components/grid/types'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
+import { copyToClipboard } from 'ui'
import { ROW_CONTEXT_MENU_ID } from '.'
-import { copyToClipboard, formatClipboardValue } from '../../utils/common'
+import { formatClipboardValue } from '../../utils/common'
export type RowContextMenuProps = {
rows: SupaRow[]
diff --git a/apps/studio/components/grid/utils/common.ts b/apps/studio/components/grid/utils/common.ts
index 781c9066c3e..b532c45cff3 100644
--- a/apps/studio/components/grid/utils/common.ts
+++ b/apps/studio/components/grid/utils/common.ts
@@ -5,12 +5,3 @@ export function formatClipboardValue(value: any) {
}
return value
}
-
-export const copyToClipboard = (str: string, callback = () => {}) => {
- const focused = window.document.hasFocus()
- if (focused) {
- window.navigator?.clipboard?.writeText(str).then(callback)
- } else {
- console.warn('Unable to copy to clipboard')
- }
-}
diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx
index c86051bba3f..7010e1a8c82 100644
--- a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx
+++ b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx
@@ -3,8 +3,7 @@ import { useMemo } from 'react'
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
-import { copyToClipboard } from 'lib/helpers'
-import { Badge } from 'ui'
+import { Badge, copyToClipboard } from 'ui'
import type { ICommand } from 'ui-patterns/CommandMenu'
import {
PageType,
diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx
index 50a6fb33c3b..dfad51d0667 100644
--- a/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx
+++ b/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx
@@ -2,8 +2,7 @@ import { Link } from 'lucide-react'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
-import { copyToClipboard } from 'lib/helpers'
-import { Badge } from 'ui'
+import { Badge, copyToClipboard } from 'ui'
import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils'
import { orderCommandSectionsByPriority } from './ordering'
diff --git a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx
index ac381001c9f..f87aa4aa0c6 100644
--- a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx
+++ b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx
@@ -69,8 +69,8 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => {
const isValidOrg = organizations.some((org) => org.slug === slug)
if (!isValidOrg) {
- toast.error('This organization does not exist')
- router.push(DEFAULT_HOME)
+ toast.error("We couldn't find that organization")
+ router.push(`${DEFAULT_HOME}?error=org_not_found&org=${slug}`)
return
}
}
diff --git a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx
index 80dc0264277..38bb4226a16 100644
--- a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx
+++ b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx
@@ -1,10 +1,9 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Check, Webhook } from 'lucide-react'
-import { Badge, Input } from 'ui'
+import { Badge, Input, copyToClipboard } from 'ui'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
-import { copyToClipboard } from 'lib/helpers'
import { Hook } from './hooks.constants'
interface HookCardProps {
diff --git a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx
index 7f5be688a1c..ac1fee9298d 100644
--- a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx
+++ b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx
@@ -5,7 +5,6 @@ import { Column, useRowSelection } from 'react-data-grid'
import { User } from 'data/auth/users-infinite-query'
import { BASE_PATH } from 'lib/constants'
-import { copyToClipboard } from 'lib/helpers'
import {
Checkbox_Shadcn_,
cn,
@@ -14,6 +13,7 @@ import {
ContextMenuItem_Shadcn_,
ContextMenuSeparator_Shadcn_,
ContextMenuTrigger_Shadcn_,
+ copyToClipboard,
} from 'ui'
import { PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
import { ColumnConfiguration, USERS_TABLE_COLUMNS } from './Users.constants'
diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx
new file mode 100644
index 00000000000..5b568e36437
--- /dev/null
+++ b/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx
@@ -0,0 +1,39 @@
+import { useStripe } from '@stripe/react-stripe-js'
+import { PaymentIntentResult } from '@stripe/stripe-js'
+import { useEffect } from 'react'
+import { LoadingLine } from 'ui'
+
+export const PaymentConfirmation = ({
+ paymentIntentSecret,
+ onPaymentIntentConfirm,
+ onLoadingChange,
+ paymentMethodId,
+ onError,
+}: {
+ paymentIntentSecret: string
+ paymentMethodId: string
+ onPaymentIntentConfirm: (response: PaymentIntentResult) => void
+ onLoadingChange: (loading: boolean) => void
+ onError?: (error: Error) => void
+}) => {
+ const stripe = useStripe()
+
+ useEffect(() => {
+ if (stripe && paymentIntentSecret) {
+ onLoadingChange(true)
+ stripe!
+ .confirmCardPayment(paymentIntentSecret, { payment_method: paymentMethodId })
+ .then((res) => {
+ onPaymentIntentConfirm(res)
+ onLoadingChange(false)
+ })
+ .catch((err) => {
+ console.error(err)
+ onError?.(err)
+ onLoadingChange(false)
+ })
+ }
+ }, [paymentIntentSecret, stripe])
+
+ return
+}
diff --git a/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx b/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx
index 3bb5f9edf62..d3654d9377c 100644
--- a/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx
+++ b/apps/studio/components/interfaces/Connect/ConnectionParameters.tsx
@@ -1,13 +1,13 @@
import { Check, ChevronRight, Copy } from 'lucide-react'
import { useState } from 'react'
-import { copyToClipboard } from 'lib/helpers'
import {
Button,
cn,
Collapsible_Shadcn_,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
+ copyToClipboard,
Separator,
} from 'ui'
diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx
index cb595bc58cf..c77fee04b4e 100644
--- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx
+++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx
@@ -1,6 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { noop } from 'lodash'
-import { ChevronLeft, Edit, MoreVertical, Plus, Search, Trash } from 'lucide-react'
+import { Check, ChevronLeft, Edit, MoreVertical, Plus, Search, Trash, X } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
@@ -125,6 +125,9 @@ const ColumnList = ({
,
Data Type,
Format,
+
+ Nullable
+ ,
,
]}
body={columns.map((x) => (
@@ -145,6 +148,13 @@ const ColumnList = ({
{x.format}
+
+ {x.is_nullable ? (
+
+ ) : (
+
+ )}
+
{!isLocked && isTableEntity && (
diff --git a/apps/studio/components/interfaces/Functions/CommandRender.tsx b/apps/studio/components/interfaces/Functions/CommandRender.tsx
index 6b3a1afa803..8285ce8b3c2 100644
--- a/apps/studio/components/interfaces/Functions/CommandRender.tsx
+++ b/apps/studio/components/interfaces/Functions/CommandRender.tsx
@@ -1,6 +1,7 @@
import { Check, Clipboard } from 'lucide-react'
import { forwardRef, useState } from 'react'
-import { cn } from 'ui'
+
+import { cn, copyToClipboard } from 'ui'
const CommandRender = forwardRef(
({ commands, className }, ref) => {
@@ -35,10 +36,8 @@ const Command = ({ item }: any) => {
onClick={() => {
function onCopy(value: any) {
setIsCopied(true)
- navigator.clipboard.writeText(value).then()
- setTimeout(function () {
- setIsCopied(false)
- }, 3000)
+ copyToClipboard(value)
+ setTimeout(() => setIsCopied(false), 3000)
}
onCopy(item.command)
}}
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx
index a0951552654..0d99237296c 100644
--- a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx
+++ b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx
@@ -8,7 +8,7 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
import Table from 'components/to-be-cleaned/Table'
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import type { EdgeFunctionsResponse } from 'data/edge-functions/edge-functions-query'
-import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
+import { copyToClipboard, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
interface EdgeFunctionsListItemProps {
function: EdgeFunctionsResponse
@@ -55,10 +55,8 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
onClick={(event: any) => {
function onCopy(value: any) {
setIsCopied(true)
- navigator.clipboard.writeText(value).then()
- setTimeout(function () {
- setIsCopied(false)
- }, 3000)
+ copyToClipboard(value)
+ setTimeout(() => setIsCopied(false), 3000)
}
event.stopPropagation()
onCopy(endpoint)
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx
index eed17a4eb47..a4954c689e5 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx
@@ -1,6 +1,6 @@
import HCaptcha from '@hcaptcha/react-hcaptcha'
import { zodResolver } from '@hookform/resolvers/zod'
-import { Elements, useStripe } from '@stripe/react-stripe-js'
+import { Elements } from '@stripe/react-stripe-js'
import { loadStripe, PaymentIntentResult } from '@stripe/stripe-js'
import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
@@ -35,10 +35,10 @@ import {
Form_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
- LoadingLine,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import PaymentMethodSelection from './Subscription/PaymentMethodSelection'
+import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
@@ -331,34 +331,3 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
)
}
-
-const PaymentConfirmation = ({
- paymentIntentSecret,
- onPaymentIntentConfirm,
- onLoadingChange,
- paymentMethodId,
-}: {
- paymentIntentSecret: string
- paymentMethodId: string
- onPaymentIntentConfirm: (response: PaymentIntentResult) => void
- onLoadingChange: (loading: boolean) => void
-}) => {
- const stripe = useStripe()
-
- useEffect(() => {
- if (stripe && paymentIntentSecret) {
- onLoadingChange(true)
- stripe!
- .confirmCardPayment(paymentIntentSecret, { payment_method: paymentMethodId })
- .then((res) => {
- onPaymentIntentConfirm(res)
- onLoadingChange(false)
- })
- .catch((err) => {
- onLoadingChange(false)
- })
- }
- }, [paymentIntentSecret, stripe])
-
- return
-}
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx
index cf47851064d..40f7fd83c70 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx
@@ -27,6 +27,7 @@ import ExitSurveyModal from './ExitSurveyModal'
import MembersExceedLimitModal from './MembersExceedLimitModal'
import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog'
import UpgradeSurveyModal from './UpgradeModal'
+import PartnerManagedResource from 'components/ui/PartnerManagedResource'
const PlanUpdateSidePanel = () => {
const router = useRouter()
@@ -143,6 +144,18 @@ const PlanUpdateSidePanel = () => {
}
>
+ {selectedOrganization?.managed_by === 'vercel-marketplace' && (
+
+ )}
{subscriptionsPlans.map((plan) => {
@@ -208,6 +221,9 @@ const PlanUpdateSidePanel = () => {
type={isDowngradeOption ? 'default' : 'primary'}
disabled={
subscription?.plan?.id === 'enterprise' ||
+ // Downgrades to free are still allowed through the dashboard given we have much better control about showing customers the impact + any possible issues with downgrading to free
+ (selectedOrganization?.managed_by !== 'supabase' &&
+ plan.id !== 'tier_free') ||
hasOrioleProjects ||
!canUpdateSubscription
}
diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx
index ee52c8a84a4..d0c2d51407f 100644
--- a/apps/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx
+++ b/apps/studio/components/interfaces/Organization/GeneralSettings/DeleteOrganizationButton.tsx
@@ -37,7 +37,7 @@ export const DeleteOrganizationButton = () => {
if (!values.orgName) {
errors.orgName = 'Enter the name of the organization.'
}
- if (values.orgName !== orgSlug) {
+ if (values.orgName.trim() !== orgSlug?.trim()) {
errors.orgName = 'Value entered does not match the value above.'
}
return errors
diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx
index 2f16d232697..8d52683c54e 100644
--- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx
+++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx
@@ -1,11 +1,11 @@
-import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
-import type { PaymentMethod } from '@stripe/stripe-js'
+import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
+import type { PaymentIntentResult, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
import _ from 'lodash'
-import { Edit2, ExternalLink, HelpCircle } from 'lucide-react'
+import { ExternalLink, HelpCircle } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { parseAsString, useQueryStates } from 'nuqs'
-import { useEffect, useMemo, useState } from 'react'
+import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { z } from 'zod'
@@ -16,9 +16,7 @@ import { useOrganizationCreateMutation } from 'data/organizations/organization-c
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useProjectsQuery } from 'data/projects/projects-query'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
-import { BASE_PATH, PRICING_TIER_LABELS_ORG } from 'lib/constants'
-import { getURL } from 'lib/helpers'
-import { useProfile } from 'lib/profile'
+import { PRICING_TIER_LABELS_ORG, STRIPE_PUBLIC_KEY } from 'lib/constants'
import {
Button,
Input,
@@ -37,6 +35,13 @@ import {
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { BillingCustomerDataNewOrgDialog } from '../BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog'
import { FormCustomerData } from '../BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
+import { useConfirmPendingSubscriptionChangeMutation } from 'data/subscriptions/org-subscription-confirm-pending-change'
+import { loadStripe } from '@stripe/stripe-js'
+import { useTheme } from 'next-themes'
+import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation'
+import { useProfile } from 'lib/profile'
+import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
+import { getURL } from 'lib/helpers'
const ORG_KIND_TYPES = {
PERSONAL: 'Personal',
@@ -59,6 +64,8 @@ const ORG_SIZE_DEFAULT = '1'
interface NewOrgFormProps {
onPaymentMethodReset: () => void
+ setupIntent?: SetupIntentResponse
+ onPlanSelected: (plan: string) => void
}
const formSchema = z.object({
@@ -79,17 +86,18 @@ const formSchema = z.object({
type FormState = z.infer
+const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
+
/**
* No org selected yet, create a new one
* [Joshen] Need to refactor to use Form_Shadcn here
*/
-const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
+const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOrgFormProps) => {
const router = useRouter()
const user = useProfile()
const { data: organizations, isSuccess } = useOrganizationsQuery()
const { data: projects } = useProjectsQuery()
- const stripe = useStripe()
- const elements = useElements()
+ const { resolvedTheme } = useTheme()
const [lastVisitedOrganization] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
@@ -107,6 +115,21 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
const [customerData, setCustomerData] = useState(null)
+ const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
+ () =>
+ ({
+ clientSecret: setupIntent ? setupIntent.client_secret! : '',
+ appearance: {
+ theme: resolvedTheme?.includes('dark') ? 'night' : 'flat',
+ labels: 'floating',
+ },
+ ...(setupIntent?.pending_subscription_flow_enabled_for_creation === true
+ ? { paymentMethodCreation: 'manual' }
+ : {}),
+ }) as const,
+ [setupIntent, resolvedTheme]
+ )
+
const [formState, setFormState] = useState({
plan: 'FREE',
name: '',
@@ -118,7 +141,6 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
const [searchParams] = useQueryStates({
returnTo: parseAsString.withDefault(''),
auth_id: parseAsString.withDefault(''),
- token: parseAsString.withDefault(''),
})
const updateForm = (key: keyof FormState, value: unknown) => {
@@ -132,7 +154,10 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
if (typeof name === 'string') updateForm('name', name)
if (typeof kind === 'string') updateForm('kind', kind)
- if (typeof plan === 'string') updateForm('plan', plan)
+ if (typeof plan === 'string') {
+ updateForm('plan', plan)
+ onPlanSelected(plan)
+ }
if (typeof size === 'string') updateForm('size', size)
if (typeof spend_cap === 'string') updateForm('spend_cap', spend_cap === 'true')
}, [router.isReady])
@@ -147,26 +172,16 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
const [newOrgLoading, setNewOrgLoading] = useState(false)
const [paymentMethod, setPaymentMethod] = useState()
+ const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false)
+ const [paymentIntentSecret, setPaymentIntentSecret] = useState(null)
const { mutate: createOrganization } = useOrganizationCreateMutation({
onSuccess: async (org) => {
- const prefilledProjectName = user.profile?.username
- ? user.profile.username + `'s Project`
- : 'My Project'
-
- if (searchParams.returnTo && searchParams.auth_id) {
- router.push(
- `${searchParams.returnTo}?auth_id=${searchParams.auth_id}${
- searchParams.token && `&token=${searchParams.token}&org=${org.name}`
- }`,
- undefined,
- {
- shallow: false,
- }
- )
+ if ('pending_payment_intent_secret' in org && org.pending_payment_intent_secret) {
+ setPaymentIntentSecret(org.pending_payment_intent_secret)
} else {
- router.push(`/new/${org.slug}?projectName=${prefilledProjectName}`)
+ onOrganizationCreated(org as { slug: string })
}
},
onError: (data) => {
@@ -176,11 +191,59 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
},
})
+ const { mutate: confirmPendingSubscriptionChange } = useConfirmPendingSubscriptionChangeMutation({
+ onSuccess: (data) => {
+ if (data && 'slug' in data) {
+ onOrganizationCreated({ slug: data.slug })
+ }
+ },
+ })
+
+ const paymentIntentConfirmed = async (paymentIntentConfirmation: PaymentIntentResult) => {
+ // Reset payment intent secret to ensure another attempt works as expected
+ setPaymentIntentSecret('')
+
+ if (paymentIntentConfirmation.paymentIntent?.status === 'succeeded') {
+ await confirmPendingSubscriptionChange({
+ payment_intent_id: paymentIntentConfirmation.paymentIntent.id,
+ name: formState.name,
+ kind: formState.kind,
+ size: formState.size,
+ })
+ } else {
+ // If the payment intent is not successful, we reset the payment method and show an error
+ toast.error(`Could not confirm payment. Please try again or use a different card.`)
+ resetPaymentMethod()
+ setNewOrgLoading(false)
+ }
+ }
+
+ const onOrganizationCreated = (org: { slug: string }) => {
+ const prefilledProjectName = user.profile?.username
+ ? user.profile.username + `'s Project`
+ : 'My Project'
+
+ if (searchParams.returnTo && searchParams.auth_id) {
+ router.push(`${searchParams.returnTo}?auth_id=${searchParams.auth_id}`, undefined, {
+ shallow: false,
+ })
+ } else {
+ router.push(`/new/${org.slug}?projectName=${prefilledProjectName}`)
+ }
+ }
+
function validateOrgName(name: any) {
const value = name ? name.trim() : ''
return value.length >= 1
}
+ const stripeOptionsConfirm = useMemo(() => {
+ return {
+ clientSecret: paymentIntentSecret,
+ appearance: { theme: resolvedTheme?.includes('dark') ? 'night' : 'flat', labels: 'floating' },
+ } as StripeElementsOptions
+ }, [paymentIntentSecret, resolvedTheme])
+
async function createOrg(paymentMethodId?: string) {
const dbTier = formState.plan === 'PRO' && !formState.spend_cap ? 'PAYG' : formState.plan
@@ -201,35 +264,21 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
})
}
- const handleSubmit = async () => {
- if (!stripe || !elements) {
- return console.error('Stripe.js has not loaded')
- }
+ const paymentRef = useRef<{ createPaymentMethod: () => Promise }>(null)
+ const handleSubmit = async () => {
setNewOrgLoading(true)
if (formState.plan === 'FREE') {
await createOrg()
} else if (!paymentMethod) {
- const { error, setupIntent } = await stripe.confirmSetup({
- elements,
- redirect: 'if_required',
- confirmParams: {
- return_url: `${getURL()}/new`,
- expand: ['payment_method'],
- },
- })
-
- if (error || !setupIntent.payment_method) {
- toast.error(error?.message ?? ' Failed to save card details')
+ const paymentMethod = await paymentRef.current?.createPaymentMethod()
+ if (paymentMethod) {
+ setPaymentMethod(paymentMethod)
+ createOrg(paymentMethod.id)
+ } else {
setNewOrgLoading(false)
- return
}
-
- const paymentMethodFromSetup = setupIntent.payment_method as PaymentMethod
-
- setPaymentMethod(paymentMethodFromSetup)
- createOrg(paymentMethodFromSetup.id)
} else {
createOrg(paymentMethod.id)
}
@@ -269,7 +318,7 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {