diff --git a/apps/docs/app/api/graphql/route.ts b/apps/docs/app/api/graphql/route.ts index 4ebc246c2ab..4e191061a48 100644 --- a/apps/docs/app/api/graphql/route.ts +++ b/apps/docs/app/api/graphql/route.ts @@ -9,6 +9,31 @@ import { rootGraphQLSchema } from '~/resources/rootSchema' import { createQueryDepthLimiter } from './validators' export const runtime = 'edge' +/* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that + overlap with the OpenAI API regions. + + Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list + Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories + */ +export const preferredRegion = [ + 'arn1', + 'bom1', + 'cdg1', + 'cle1', + 'cpt1', + 'dub1', + 'fra1', + 'gru1', + 'hnd1', + 'iad1', + 'icn1', + 'kix1', + 'lhr1', + 'pdx1', + 'sfo1', + 'sin1', + 'syd1', +] const MAX_DEPTH = 5 diff --git a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx index b1ded944cfc..24a5abe93e1 100644 --- a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx @@ -211,7 +211,20 @@ interface Params { const WrappersDocs = async (props: { params: Promise }) => { const params = await props.params - const { isExternal, meta, ...data } = await getContent(params) + const { isExternal, meta, assetsBaseUrl, ...data } = await getContent(params) + + // Create a combined URL transformer that handles both regular URLs and asset URLs + const combinedUrlTransformer: UrlTransformFunction = (url, node) => { + // First try assets URL transformation (starts with ../assets/) + const transformedUrl = assetUrlTransform(url, assetsBaseUrl) + + // If URL wasn't changed proceed with regular URL transformation + if (transformedUrl === url) { + return urlTransform(url, node) + } + + return transformedUrl + } const options = isExternal ? ({ @@ -222,7 +235,7 @@ const WrappersDocs = async (props: { params: Promise }) => { remarkPyMdownTabs, [removeTitle, meta.title], ], - rehypePlugins: [[linkTransform, urlTransform], rehypeSlug], + rehypePlugins: [[linkTransform, combinedUrlTransformer], rehypeSlug], }, } as SerializeOptions) : undefined @@ -242,6 +255,7 @@ const getContent = async (params: Params) => { let meta: any let content: string let editLink: string + let assetsBaseUrl: string = '' if (!federatedPage) { isExternal = false @@ -278,6 +292,8 @@ const getContent = async (params: Params) => { }) const rawContent = await response.text() + assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${tag}/docs/assets/` + const { content: contentWithoutFrontmatter } = matter(rawContent) content = removeRedundantH1(contentWithoutFrontmatter) } @@ -289,9 +305,20 @@ const getContent = async (params: Params) => { editLink: newEditLink(editLink), meta, content, + assetsBaseUrl, } } +const assetUrlTransform = (url: string, baseUrl: string): string => { + const assetPattern = /(\.\.\/)+assets\// + + if (assetPattern.test(url)) { + return url.replace(assetPattern, baseUrl) + } + + return url +} + const urlTransform: UrlTransformFunction = (url) => { try { const externalSiteUrl = new URL(externalSite) diff --git a/apps/docs/content/guides/auth/social-login/auth-apple.mdx b/apps/docs/content/guides/auth/social-login/auth-apple.mdx index d7ad99022c3..a80abc34d71 100644 --- a/apps/docs/content/guides/auth/social-login/auth-apple.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-apple.mdx @@ -192,6 +192,12 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app, + + + When testing with Expo development build with custom `bundleIdentifier`, for example com.example.app , com.example.app.dev , com.example.app.preview. Make sure to add all these variants to the "Client IDs" list in your [Supabase dashboard Apple provider configuration](https://supabase.com/dashboard/project/_/auth/providers)! + + + 1. Have an **App ID** which uniquely identifies the app you are building. You can create a new App ID from the [Identifiers](https://developer.apple.com/account/resources/identifiers/list/bundleId) section in the Apple Developer Console (use the filter menu in the upper right side to see all App IDs). These usually are a reverse domain name string, for example `com.example.app`. Make sure you configure Sign in with Apple for the App ID you created or already have, in the Capabilities list. At this time Supabase Auth does not support Server-to-Server notification endpoints, so you should leave that setting blank. (In the past App IDs were referred to as _bundle IDs._) 2. Register all of the App IDs that will be using your Supabase project in the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers) under _Client IDs_. 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..e802f490e79 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; @@ -60,3 +60,10 @@ ALTER PUBLICATION example_pub ADD TABLE example_table; ```sql select * from pg_stat_replication; ``` + + + +You can add more tables to the initial publication, but you're going to need to do a REFRESH on the subscribing database. +See https://www.postgresql.org/docs/current/sql-alterpublication.html + + 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/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 9597cb064c2..495ab339596 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -211,6 +211,7 @@ export const UsersV2 = () => { if (userIds.includes(selectedUser)) setSelectedUser(undefined) } catch (error: any) { toast.error(`Failed to delete selected users: ${error.message}`) + } finally { setIsDeletingUsers(false) } } 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/Hooks/DeleteHookModal.tsx b/apps/studio/components/interfaces/Database/Hooks/DeleteHookModal.tsx index 08f466b1c77..9c91091af8d 100644 --- a/apps/studio/components/interfaces/Database/Hooks/DeleteHookModal.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/DeleteHookModal.tsx @@ -53,9 +53,8 @@ const DeleteHookModal = ({ selectedHook, visible, onClose }: DeleteHookModalProp confirmString={name || ''} text={ <> - This will delete the webhook - {name} rom the schema - {schema} + This will delete the webhook {name}{' '} + from the schema {schema} } alert={{ title: 'You cannot recover this webhook once deleted.' }} diff --git a/apps/studio/components/interfaces/EdgeFunctions/DeployEdgeFunctionButton.tsx b/apps/studio/components/interfaces/EdgeFunctions/DeployEdgeFunctionButton.tsx new file mode 100644 index 00000000000..2fb39567cd2 --- /dev/null +++ b/apps/studio/components/interfaces/EdgeFunctions/DeployEdgeFunctionButton.tsx @@ -0,0 +1,118 @@ +import { ChevronDown, Code, Terminal } from 'lucide-react' +import { useRouter } from 'next/router' + +import { useParams } from 'common' +import { TerminalInstructions } from 'components/interfaces/Functions/TerminalInstructions' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { + AiIconAnimation, + Button, + Dialog, + DialogContent, + DialogSection, + DialogTitle, + DialogTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' + +export const DeployEdgeFunctionButton = () => { + const router = useRouter() + const { ref } = useParams() + const org = useSelectedOrganization() + const snap = useAiAssistantStateSnapshot() + + const { mutate: sendEvent } = useSendEventMutation() + + return ( + + + + + + { + router.push(`/project/${ref}/functions/new`) + sendEvent({ + action: 'edge_function_via_editor_button_clicked', + properties: { origin: 'secondary_action' }, + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + }} + className="gap-4" + > + +
+ Via Editor +

Write and deploy in the browser

+
+
+ + + { + e.preventDefault() + sendEvent({ + action: 'edge_function_via_cli_button_clicked', + properties: { origin: 'secondary_action' }, + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + }} + > + +
+ Via CLI +

Write locally, deploy with the CLI

+
+
+
+ + + Create your first Edge Function via the CLI + + + + + +
+ { + snap.newChat({ + name: 'Create new edge function', + open: true, + initialInput: `Create a new edge function that ...`, + suggestions: { + title: + 'I can help you create a new edge function. Here are a few example prompts to get you started:', + prompts: [ + 'Create a new edge function that processes payments with Stripe', + 'Create a new edge function that sends emails with Resend', + 'Create a new edge function that generates PDFs from HTML templates', + ], + }, + }) + sendEvent({ + action: 'edge_function_ai_assistant_button_clicked', + properties: { origin: 'secondary_action' }, + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + }} + > + +
+ Via AI Assistant +

Let the Assistant write and deploy for you

+
+
+
+
+ ) +} 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/Functions/FunctionsEmptyState.tsx b/apps/studio/components/interfaces/Functions/FunctionsEmptyState.tsx index bdb96a65b02..f13adcdacbe 100644 --- a/apps/studio/components/interfaces/Functions/FunctionsEmptyState.tsx +++ b/apps/studio/components/interfaces/Functions/FunctionsEmptyState.tsx @@ -1,12 +1,14 @@ +import { Code, Github, Lock, Play, Server, Terminal } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' + import { useParams } from 'common' import { ScaffoldSectionTitle } from 'components/layouts/Scaffold' +import { DocsButton } from 'components/ui/DocsButton' import { ResourceItem } from 'components/ui/Resource/ResourceItem' import { ResourceList } from 'components/ui/Resource/ResourceList' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { Code, Terminal } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { AiIconAnimation, @@ -15,10 +17,16 @@ import { CardContent, CardHeader, CardTitle, + cn, + CodeBlock, Dialog, DialogContent, + DialogDescription, + DialogHeader, DialogSection, + DialogTitle, DialogTrigger, + Separator, } from 'ui' import { EDGE_FUNCTION_TEMPLATES } from './Functions.templates' import { TerminalInstructions } from './TerminalInstructions' @@ -160,3 +168,191 @@ export const FunctionsEmptyState = () => { ) } + +export const FunctionsEmptyStateLocal = () => { + return ( + <> +
+ + + Developing Edge Functions locally + + +
+
+ +

Create an Edge Function

+
+

+ Create a new edge function called hello-world in your project via the + Supabase CLI. +

+
+ code]:m-0 2xl:min-h-28' + )} + value="supabase functions new hello-world" + /> +
+ +
+ +
+
+ +

Run Edge Functions locally

+
+

+ You can run your Edge Function locally using supabase functions serve. +

+
+ code]:m-0 2xl:min-h-28' + )} + value={` +supabase start # start the supabase stack +supabase functions serve # start the Functions watcher`.trim()} + /> +
+ +
+ +
+
+ +

Invoke Edge Functions locally

+
+

+ While serving your local Edge Functions, you can invoke it using cURL or one of the + client libraries. +

+
+ code]:m-0 2xl:min-h-28' + )} + value={` +curl --request POST 'http://localhost:54321/functions/v1/hello-world' \\ + --header 'Authorization: Bearer SUPABASE_ANON_KEY' \\ + --header 'Content-Type: application/json' \\ + --data '{ "name":"Functions" }'`.trim()} + /> +
+ +
+
+
+ + + + Self-hosting Edge Functions + + +
+
+ +

Self-hosting Edge Functions

+
+

+ Supabase Edge Runtime consists of a web server based on the Deno runtime, capable of + running Javascript, Typescript, and WASM services. You may self-host edge functions + on providers like Fly.io, Digital Ocean, or AWS. +

+
+ + +
+
+
+
+ + Explore our templates + + {EDGE_FUNCTION_TEMPLATES.map((template) => ( + + + } + > +
+

{template.name}

+

{template.description}

+
+
+
+ + + {template.name} + {template.description} + + + + code]:m-0 max-h-[420px]' + )} + value={template.content} + /> + + +
+ ))} +
+
+ + ) +} + +export const FunctionsSecretsEmptyStateLocal = () => { + return ( + + + Managing secrets and environment variables locally + + +
+
+ +

Managing secrets

+
+
+

+ Local secrets and environment variables can be loaded in either of the following two + ways +

+
    +
  • + Through an .env file placed at supabase/functions/.env, + which is automatically loaded on supabase start +
  • +
  • + Through the --env-file option for supabase functions serve + , for example: supabase functions serve --env-file ./path/to/.env-file +
  • +
+
+ +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Home/ProjectUsage.tsx b/apps/studio/components/interfaces/Home/ProjectUsage.tsx index c681a870c78..5ecdfb3e224 100644 --- a/apps/studio/components/interfaces/Home/ProjectUsage.tsx +++ b/apps/studio/components/interfaces/Home/ProjectUsage.tsx @@ -105,14 +105,27 @@ const ProjectUsage = () => { const handleBarClick = ( value: UsageApiCounts, - // TODO (ziinc): link to edge logs with correct filter applied _type: 'rest' | 'realtime' | 'storage' | 'auth' ) => { const unit = selectedInterval.startUnit const selectedStart = dayjs(value?.timestamp) const selectedEnd = selectedStart.add(1, unit) + + if (_type === 'rest') { + router.push( + `/project/${projectRef}/logs/edge-logs?its=${selectedStart.toISOString()}&ite=${selectedEnd.toISOString()}` + ) + return + } + router.push( - `/project/${projectRef}/logs/edge-logs?ite=${encodeURIComponent(selectedEnd.toISOString())}` + `/project/${projectRef}/logs/edge-logs?its=${selectedStart.toISOString()}&ite=${selectedEnd.toISOString()}&f=${JSON.stringify( + { + product: { + [_type]: true, + }, + } + )}` ) } 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/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) => {
- - ) : ( - - )} + + + )} @@ -606,8 +633,88 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { })} + + {stripePromise && paymentIntentSecret && paymentMethod && ( + + + paymentIntentConfirmed(paymentIntentConfirmation) + } + onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} + paymentMethodId={paymentMethod.id} + onError={(err) => { + toast.error(`Failed to confirm payment: ${err.message}`) + setNewOrgLoading(false) + resetPaymentMethod() + }} + /> + + )} ) } export default NewOrgForm + +/** + * Set up as a separate component, as we need any component using stripe/elements to be wrapped in Elements. + * + * If Elements is on a higher level, we risk losing all form state in case a payment fails. + */ +const Payment = forwardRef( + ( + { + pending_subscription_flow_enabled_for_creation, + }: { pending_subscription_flow_enabled_for_creation: boolean }, + ref + ) => { + const stripe = useStripe() + const elements = useElements() + + const createPaymentMethod = async () => { + if (!stripe || !elements) return + await elements.submit() + + if (pending_subscription_flow_enabled_for_creation) { + // To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment + const { error, paymentMethod } = await stripe.createPaymentMethod({ + elements, + }) + if (error || paymentMethod == null) { + toast.error(error?.message ?? ' Failed to process card details') + return + } + return paymentMethod + } else { + 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') + return + } + + return setupIntent.payment_method as PaymentMethod + } + } + + useImperativeHandle(ref, () => ({ + createPaymentMethod, + })) + + return ( + + + + ) + } +) + +Payment.displayName = 'Payment' diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index c911fa36b9a..378d18de2ea 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -189,7 +189,7 @@ export const InviteMemberButton = () => { className="pointer-events-auto flex-grow md:flex-grow-0" onClick={() => setIsOpen(true)} > - Invite + Invite member {!canInviteMembers && ( diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/LeaveTeamButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/LeaveTeamButton.tsx new file mode 100644 index 00000000000..a1ac7ef9d12 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/TeamSettings/LeaveTeamButton.tsx @@ -0,0 +1,121 @@ +import { useRouter } from 'next/router' +import { useState } from 'react' +import { toast } from 'sonner' + +import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' +import { useOrganizationMemberDeleteMutation } from 'data/organizations/organization-member-delete-mutation' +import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useProfile } from 'lib/profile' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { hasMultipleOwners } from './TeamSettings.utils' + +export const LeaveTeamButton = () => { + const router = useRouter() + const { slug } = useParams() + const { profile } = useProfile() + const selectedOrganization = useSelectedOrganization() + + // if organizationMembersDeletionEnabled is false, you also can't delete yourself + const { organizationMembersDelete: organizationMembersDeletionEnabled } = useIsFeatureEnabled([ + 'organization_members:delete', + ]) + + const [isLeaving, setIsLeaving] = useState(false) + const [isLeaveTeamModalOpen, setIsLeaveTeamModalOpen] = useState(false) + const [_, setLastVisitedOrganization] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION, + '' + ) + + const { refetch: refetchOrganizations } = useOrganizationsQuery() + const { data: members } = useOrganizationMembersQuery({ slug }) + const { data: allRoles } = useOrganizationRolesV2Query({ slug }) + + const isOwner = selectedOrganization?.is_owner + const roles = allRoles?.org_scoped_roles ?? [] + const canLeave = !isOwner || (isOwner && hasMultipleOwners(members, roles)) + + const { mutate: deleteMember } = useOrganizationMemberDeleteMutation({ + onSuccess: async () => { + setIsLeaving(false) + setIsLeaveTeamModalOpen(false) + + await refetchOrganizations() + toast.success(`Successfully left ${selectedOrganization?.name}`) + + setLastVisitedOrganization('') + router.push('/organizations') + }, + onError: (error) => { + setIsLeaving(false) + toast.error(`Failed to leave organization: ${error?.message}`) + }, + }) + + const leaveTeam = async () => { + if (!slug) return console.error('Org slug is required') + if (!profile) return console.error('Profile is required') + + setIsLeaving(true) + deleteMember({ slug, gotrueId: profile.gotrue_id }) + } + + return ( + <> + setIsLeaveTeamModalOpen(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canLeave + ? 'An organization requires at least 1 owner' + : !organizationMembersDeletionEnabled + ? 'Unable to leave organization' + : undefined, + }, + }} + > + Leave team + + +

+ Leaving the organization will delete all of your saved content in the projects of + the organization, which includes: +

+
    +
  • + SQL snippets (both private and shared) +
  • +
  • Custom reports
  • +
  • Log Explorer queries
  • +
+ + ), + }} + onCancel={() => setIsLeaveTeamModalOpen(false)} + onConfirm={() => leaveTeam()} + > +

+ Are you sure you want to leave this organization? This is permanent. +

+
+ + ) +} diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx index 1c7b12b6565..fc3683b754a 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx @@ -15,7 +15,6 @@ import { } from 'data/organizations/organization-members-query' import { usePermissionsQuery } from 'data/permissions/permissions-query' import { useProjectsQuery } from 'data/projects/projects-query' -import { useHasAccessToProjectLevelPermissions } from 'data/subscriptions/org-subscription-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' @@ -29,7 +28,8 @@ import { DropdownMenuTrigger, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { useGetRolesManagementPermissions } from './TeamSettings.utils' +import { LeaveTeamButton } from './LeaveTeamButton' +import { hasMultipleOwners, useGetRolesManagementPermissions } from './TeamSettings.utils' import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel' interface MemberActionsProps { @@ -48,7 +48,11 @@ export const MemberActions = ({ member }: MemberActionsProps) => { const { data: allProjects } = useProjectsQuery() const { data: members } = useOrganizationMembersQuery({ slug }) const { data: allRoles } = useOrganizationRolesV2Query({ slug }) - const isOptedIntoProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string) + + const memberIsUser = member.gotrue_id == profile?.gotrue_id + const isOwner = selectedOrganization?.is_owner + const roles = allRoles?.org_scoped_roles ?? [] + const canLeave = !isOwner || (isOwner && hasMultipleOwners(members, roles)) const orgScopedRoles = allRoles?.org_scoped_roles ?? [] const projectScopedRoles = allRoles?.project_scoped_roles ?? [] @@ -166,6 +170,14 @@ export const MemberActions = ({ member }: MemberActionsProps) => { ) } + if (memberIsUser) { + return ( +
+ +
+ ) + } + return ( <>
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx index fc22dd75604..4eccdd17f71 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx @@ -41,7 +41,6 @@ export const MemberRow = ({ member }: MemberRowProps) => { const orgProjects = projects?.filter((p) => p.organization_id === selectedOrganization?.id) const hasProjectScopedRoles = (roles?.project_scoped_roles ?? []).length > 0 - const memberIsUser = member.gotrue_id == profile?.gotrue_id const isInvitedUser = Boolean(member.invited_id) const isEmailUser = member.username === member.primary_email const isFlyUser = Boolean(member.primary_email?.endsWith('customer.fly.io')) @@ -204,7 +203,9 @@ export const MemberRow = ({ member }: MemberRowProps) => { )} - {!memberIsUser && } + + + ) } diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index 272cd47bdb0..2de693bc844 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -7,6 +7,8 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' import { useProfile } from 'lib/profile' +import { partition } from 'lodash' +import { useMemo } from 'react' import { Button, Loading, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { Admonition } from 'ui-patterns' import { MemberRow } from './MemberRow' @@ -20,7 +22,7 @@ const MembersView = ({ searchString }: MembersViewProps) => { const { profile } = useProfile() const { - data: members, + data: members = [], error: membersError, isLoading: isLoadingMembers, isError: isErrorMembers, @@ -35,11 +37,10 @@ const MembersView = ({ searchString }: MembersViewProps) => { slug, }) - const allMembers = members ?? [] - const filteredMembers = ( - !searchString - ? allMembers - : allMembers.filter((member) => { + const filteredMembers = useMemo(() => { + return !searchString + ? members + : members.filter((member) => { if (member.invited_at) { return member.primary_email?.includes(searchString) } @@ -49,15 +50,17 @@ const MembersView = ({ searchString }: MembersViewProps) => { ) } }) - ) - .slice() - .sort((a, b) => { - // [Joshen] Have own account show up top - if (a.primary_email === profile?.primary_email) return -1 - return a.username.localeCompare(b.username) - }) + }, [members, searchString]) - const userMember = allMembers.find((m) => m.primary_email === profile?.primary_email) + const [[user], otherMembers] = partition( + filteredMembers, + (m) => m.primary_email === profile?.primary_email + ) + const sortedMembers = otherMembers.sort((a, b) => + (a.primary_email ?? '').localeCompare(b.primary_email ?? '') + ) + + const userMember = members.find((m) => m.primary_email === profile?.primary_email) const orgScopedRoleIds = (roles?.org_scoped_roles ?? []).map((r) => r.id) const isOrgScopedRole = orgScopedRoleIds.includes(userMember?.role_ids?.[0] ?? -1) @@ -117,7 +120,8 @@ const MembersView = ({ searchString }: MembersViewProps) => { , ] : []), - ...filteredMembers.map((member) => ( + ...(!!user ? [] : []), + ...sortedMembers.map((member) => ( )), ...(searchString.length > 0 && filteredMembers.length === 0 @@ -138,7 +142,7 @@ const MembersView = ({ searchString }: MembersViewProps) => {

{searchString ? `${filteredMembers.length} of ` : ''} - {allMembers.length || '0'} {allMembers.length == 1 ? 'user' : 'users'} + {members.length || '0'} {members.length == 1 ? 'user' : 'users'}

, diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index 7b5bee3a01f..24f831c75be 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -1,9 +1,7 @@ import { Search } from 'lucide-react' -import { useRouter } from 'next/router' import { useState } from 'react' -import { toast } from 'sonner' -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useParams } from 'common' import { ScaffoldActionsContainer, ScaffoldActionsGroup, @@ -12,43 +10,27 @@ import { ScaffoldSectionContent, ScaffoldTitle, } from 'components/layouts/Scaffold' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' -import { useOrganizationMemberDeleteMutation } from 'data/organizations/organization-member-delete-mutation' -import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { usePermissionsQuery } from 'data/permissions/permissions-query' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useProfile } from 'lib/profile' import { Input } from 'ui-patterns/DataInputs/Input' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { InviteMemberButton } from './InviteMemberButton' import MembersView from './MembersView' -import { hasMultipleOwners, useGetRolesManagementPermissions } from './TeamSettings.utils' +import { useGetRolesManagementPermissions } from './TeamSettings.utils' export const TeamSettings = () => { - const [_, setLastVisitedOrganization] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION, - '' - ) - - const { - organizationMembersCreate: organizationMembersCreationEnabled, - organizationMembersDelete: organizationMembersDeletionEnabled, - } = useIsFeatureEnabled(['organization_members:create', 'organization_members:delete']) + const { organizationMembersCreate: organizationMembersCreationEnabled } = useIsFeatureEnabled([ + 'organization_members:create', + ]) const { slug } = useParams() - const router = useRouter() const { profile } = useProfile() const selectedOrganization = useSelectedOrganization() - const isOwner = selectedOrganization?.is_owner const { data: permissions } = usePermissionsQuery() - const { refetch: refetchOrganizations } = useOrganizationsQuery() const { data: rolesData } = useOrganizationRolesV2Query({ slug }) - const { data: members } = useOrganizationMembersQuery({ slug }) const roles = rolesData?.org_scoped_roles ?? [] @@ -58,117 +40,36 @@ export const TeamSettings = () => { permissions ?? [] ) - const [isLeaving, setIsLeaving] = useState(false) const [searchString, setSearchString] = useState('') const canAddMembers = rolesAddable.length > 0 - const canLeave = !isOwner || (isOwner && hasMultipleOwners(members, roles)) - - const { mutate: deleteMember } = useOrganizationMemberDeleteMutation({ - onSuccess: async () => { - setIsLeaving(false) - setIsLeaveTeamModalOpen(false) - - await refetchOrganizations() - toast.success(`Successfully left ${selectedOrganization?.name}`) - - setLastVisitedOrganization('') - router.push('/organizations') - }, - onError: (error) => { - setIsLeaving(false) - toast.error(`Failed to leave organization: ${error?.message}`) - }, - }) - - const [isLeaveTeamModalOpen, setIsLeaveTeamModalOpen] = useState(false) - - const leaveTeam = async () => { - if (!slug) return console.error('Org slug is required') - - setIsLeaving(true) - deleteMember({ slug, gotrueId: profile!.gotrue_id }) - } return ( - <> - - Team - - - } - value={searchString} - onChange={(e: any) => setSearchString(e.target.value)} - name="email" - id="email" - placeholder="Filter members" - /> - - {organizationMembersCreationEnabled && - canAddMembers && - profile !== undefined && - selectedOrganization !== undefined && } - {/* if organizationMembersDeletionEnabled is false, you also can't delete yourself */} - {organizationMembersDeletionEnabled && ( - <> - setIsLeaveTeamModalOpen(true)} - > - Leave team - - - )} - - - - - - - - - -

- Leaving the organization will delete all of your saved content in the projects of - the organization, which includes: -

-
    -
  • - SQL snippets (both private and shared) -
  • -
  • Custom reports
  • -
  • Log Explorer queries
  • -
-
- ), - }} - onCancel={() => setIsLeaveTeamModalOpen(false)} - onConfirm={() => leaveTeam()} - > -

- Are you sure you want to leave this organization? This is permanent. -

- - + + Team + + + } + value={searchString} + onChange={(e: any) => setSearchString(e.target.value)} + name="email" + id="email" + placeholder="Filter members" + /> + + {organizationMembersCreationEnabled && + canAddMembers && + profile !== undefined && + selectedOrganization !== undefined && } + + + + + + + ) } diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx index 10edf7179ef..9ec3c001396 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx @@ -1,8 +1,7 @@ import { useParams } from 'common' -import { Button, Input } from 'ui' +import { Button, Input, copyToClipboard } from 'ui' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { copyToClipboard } from 'lib/helpers' import { Copy } from 'lucide-react' import { useEffect, useState } from 'react' import ContentSnippet from '../ContentSnippet' diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 4d86bfb86e1..054174f05f3 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -316,6 +316,7 @@ export const SQLEditor = () => { sql: wrapWithRoleImpersonation(formattedSql, impersonatedRoleState), autoLimit: appendAutoLimit ? limit : undefined, isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), + isStatementTimeoutDisabled: true, contextualInvalidation: true, handleError: (error) => { throw error diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx index a4bc9080273..32a96d76107 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx @@ -3,13 +3,13 @@ import { useState } from 'react' import DataGrid, { CalculatedColumn } from 'react-data-grid' import { handleCopyCell } from 'components/grid/SupabaseGrid.utils' -import { copyToClipboard } from 'lib/helpers' import { cn, ContextMenu_Shadcn_, ContextMenuContent_Shadcn_, ContextMenuItem_Shadcn_, ContextMenuTrigger_Shadcn_, + copyToClipboard, } from 'ui' import { CellDetailPanel } from './CellDetailPanel' diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx index f6d0086fca5..56b402af109 100644 --- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -8,7 +8,6 @@ import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import DatabaseSelector from 'components/ui/DatabaseSelector' import Panel from 'components/ui/Panel' -import { DisplayApiSettings } from 'components/ui/ProjectSettings' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { configKeys } from 'data/config/keys' @@ -19,9 +18,9 @@ import { PROJECT_STATUS } from 'lib/constants' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { Badge, Input } from 'ui' import { JWT_SECRET_UPDATE_ERROR_MESSAGES } from './API.constants' +import { ApiKeysMoved } from './ApiKeysMoved' import JWTSettings from './JWTSettings' import { PostgrestConfig } from './PostgrestConfig' -import { ApiKeysMoved } from './ApiKeysMoved' const ServiceList = () => { const client = useQueryClient() diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx index c86f94cc5ff..96c1465002a 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx @@ -14,7 +14,7 @@ import { useProjectPauseMutation } from 'data/projects/project-pause-mutation' import { setProjectStatus } from 'data/projects/projects-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useIsOrioleDbInAwsRevamped } from 'hooks/misc/useSelectedProject' +import { useIsAwsNew } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -33,9 +33,9 @@ const PauseProjectButton = () => { 'queue_jobs.projects.pause' ) - const isOrioleDBInAwsNew = useIsOrioleDbInAwsRevamped() + const isAwsNew = useIsAwsNew() const isFreePlan = organization?.plan.id === 'free' - const isPaidAndNotAwsNew = !isFreePlan && !isOrioleDBInAwsNew + const isPaidAndNotAwsNew = !isFreePlan && !isAwsNew const { mutate: pauseProject, isLoading: isPausing } = useProjectPauseMutation({ onSuccess: (_, variables) => { diff --git a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx index c19f5dde1b0..7e043920a4b 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -11,10 +11,16 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DownloadResultsButton } from 'components/ui/DownloadResultsButton' import { useSelectedLog } from 'hooks/analytics/useSelectedLog' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { copyToClipboard } from 'lib/helpers' import { useProfile } from 'lib/profile' import { ResponseError } from 'types' -import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui' +import { + Button, + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + cn, + copyToClipboard, +} from 'ui' import AuthColumnRenderer from './LogColumnRenderers/AuthColumnRenderer' import DatabaseApiColumnRender from './LogColumnRenderers/DatabaseApiColumnRender' import DatabasePostgresColumnRender from './LogColumnRenderers/DatabasePostgresColumnRender' @@ -27,6 +33,7 @@ import { isDefaultLogPreviewFormat } from './Logs.utils' import { DefaultErrorRenderer } from './LogsErrorRenderers/DefaultErrorRenderer' import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer' import { LogsTableEmptyState } from './LogsTableEmptyState' +import { toast } from 'sonner' interface Props { data?: LogData[] @@ -121,7 +128,13 @@ const LogTable = ({ resizable: true, renderCell: ({ row }: any) => { return ( - showContextMenu(e, { id: LOGS_EXPLORER_CONTEXT_MENU_ID })}> + { + e.preventDefault() + setCellPosition({ row, column: { name: v } }) + showContextMenu(e, { id: LOGS_EXPLORER_CONTEXT_MENU_ID }) + }} + > {formatCellValue(row?.[v])} ) @@ -201,9 +214,23 @@ const LogTable = ({ const RowRenderer = useCallback<(key: Key, props: RenderRowProps) => ReactNode>( (key, props) => { - return + const handleContextMenu = (e: React.MouseEvent) => { + if (columns.length > 0) { + setCellPosition({ row: props.row, column: columns[0] }) + } + showContextMenu(e, { id: LOGS_EXPLORER_CONTEXT_MENU_ID }) + } + return ( + + ) }, - [] + [columns, showContextMenu] ) const formatCellValue = (value: any) => { @@ -215,12 +242,11 @@ const LogTable = ({ } const onCopyCell = () => { - if (cellPosition) { - const { row, column } = cellPosition - const cellValue = row?.[column.name] ?? '' - const value = formatCellValue(cellValue) - copyToClipboard(value) - } + if (!cellPosition) return + const eventMessage = cellPosition.row.event_message + copyToClipboard(eventMessage, () => { + toast.success('Copied to clipboard') + }) } const LogsExplorerTableHeader = () => ( @@ -414,7 +440,7 @@ const LogTable = ({ - Copy cell content + Copy event message , document.body diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx index 3e4f08ba924..b0b79c03fa7 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx @@ -7,11 +7,17 @@ import DatePicker from 'react-datepicker' import { Label } from '@ui/components/shadcn/ui/label' import { RadioGroup, RadioGroupItem } from '@ui/components/shadcn/ui/radio-group' import TimeSplitInput from 'components/ui/DatePicker/TimeSplitInput' -import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, cn } from 'ui' +import { useLogsUrlState } from 'hooks/analytics/useLogsUrlState' +import { + Button, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, + cn, + copyToClipboard, +} from 'ui' import { LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD } from './Logs.constants' import type { DatetimeHelper } from './Logs.types' -import { copyToClipboard } from 'lib/helpers' -import { useLogsUrlState } from 'hooks/analytics/useLogsUrlState' export type DatePickerValue = { to: string diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx index d7a25872953..4774a6f7c60 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx @@ -56,7 +56,11 @@ export const SelectionDetailedRow = ({ export const TextFormatter: React.FC<{ className?: string; value: string }> = ({ value, className, -}) => {value} +}) => ( + + {value} + +) export const ResponseCodeFormatter = ({ value }: { value: string }) => { if (!value) { diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index 8eb4915b81f..de852f4e4e5 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -1,17 +1,17 @@ +import dayjs from 'dayjs' +import { BookOpen, Check, ChevronDown, Clipboard, ExternalLink, X } from 'lucide-react' import Link from 'next/link' import { ReactNode, useState } from 'react' import { IS_PLATFORM } from 'common' import Table from 'components/to-be-cleaned/Table' -import dayjs from 'dayjs' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useFlag } from 'hooks/ui/useFlag' -import { copyToClipboard } from 'lib/helpers' -import { BookOpen, Check, ChevronDown, Clipboard, ExternalLink, X } from 'lucide-react' import { logConstants } from 'shared-data' import { Badge, Button, + copyToClipboard, DropdownMenu, DropdownMenuContent, DropdownMenuItem, diff --git a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx index d0044b2823b..e6e1589c44b 100644 --- a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx @@ -122,8 +122,8 @@ const BuildingState = () => { -
- +
+
diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 50449c9e005..96df83e1bb9 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -99,7 +99,7 @@ export const generateProductRoutes = ( }, ] : []), - ...(IS_PLATFORM && edgeFunctionsEnabled + ...(edgeFunctionsEnabled ? [ { key: 'functions', diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 6387d26cb6a..d1e8269143b 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -27,11 +27,11 @@ import { isTableLike } from 'data/table-editor/table-editor-types' import { fetchAllTableRows } from 'data/table-rows/table-rows-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { formatSql } from 'lib/formatSql' -import { copyToClipboard } from 'lib/helpers' import { useTableEditorStateSnapshot } from 'state/table-editor' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { cn, + copyToClipboard, DropdownMenu, DropdownMenuContent, DropdownMenuItem, diff --git a/apps/studio/components/to-be-cleaned/Storage/EmptyBucketModal.tsx b/apps/studio/components/to-be-cleaned/Storage/EmptyBucketModal.tsx index 0cd2db00acf..77696bc4862 100644 --- a/apps/studio/components/to-be-cleaned/Storage/EmptyBucketModal.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/EmptyBucketModal.tsx @@ -19,7 +19,12 @@ export const EmptyBucketModal = ({ visible = false, bucket, onClose }: EmptyBuck const { mutate: emptyBucket, isLoading } = useBucketEmptyMutation({ onSuccess: async () => { if (bucket === undefined) return - await fetchFolderContents({ folderId: bucket.id, folderName: bucket.name, index: -1 }) + await fetchFolderContents({ + bucketId: bucket.id, + folderId: bucket.id, + folderName: bucket.name, + index: -1, + }) toast.success(`Successfully deleted bucket ${bucket!.name}`) onClose() }, diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx index 8cfb5ef7cc4..306eb49098f 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx @@ -22,6 +22,7 @@ import type { ItemRenderer } from 'components/ui/InfiniteList' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' import { formatBytes } from 'lib/helpers' +import { toast } from 'sonner' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' import { Checkbox, @@ -50,6 +51,7 @@ import { StorageItem, StorageItemWithColumn } from '../Storage.types' import FileExplorerRowEditing from './FileExplorerRowEditing' import { copyPathToFolder, downloadFile } from './StorageExplorer.utils' import { useCopyUrl } from './useCopyUrl' +import { useSelectedBucket } from './useSelectedBucket' export const RowIcon = ({ view, @@ -114,6 +116,7 @@ const FileExplorerRow: ItemRenderer = ({ openedFolders = [], }) => { const { ref: projectRef, bucketId } = useParams() + const { bucket } = useSelectedBucket() const { selectedBucket, @@ -152,11 +155,18 @@ const FileExplorerRow: ItemRenderer = ({ } const onSelectFolder = async (columnIndex: number, folder: StorageItem) => { + if (!bucket) return toast.error('Unable to retrieve bucket details') + setSelectedFilePreview(undefined) clearSelectedItems(columnIndex + 1) popOpenedFoldersAtIndex(columnIndex - 1) pushOpenedFolderAtIndex(folder, columnIndex) - await fetchFolderContents({ folderId: folder.id, folderName: folder.name, index: columnIndex }) + await fetchFolderContents({ + bucketId: bucket.id, + folderId: folder.id, + folderName: folder.name, + index: columnIndex, + }) } const onCheckItem = (isShiftKeyHeld: boolean) => { diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx index c26df6d1dcc..e9a20a5e1bd 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx @@ -67,6 +67,7 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => { if (!currentFolder) { // At root of bucket await fetchFolderContents({ + bucketId: bucket.id, folderId: bucket.id, folderName: bucket.name, index: -1, @@ -74,6 +75,7 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => { }) } else { await fetchFolderContents({ + bucketId: bucket.id, folderId: currentFolder.id, folderName: currentFolder.name, index: currentFolderIdx, @@ -83,9 +85,15 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => { } else { if (!currentFolder) { // At root of bucket - await fetchFolderContents({ folderId: bucket.id, folderName: bucket.name, index: -1 }) + await fetchFolderContents({ + bucketId: bucket.id, + folderId: bucket.id, + folderName: bucket.name, + index: -1, + }) } else { await fetchFolderContents({ + bucketId: bucket.id, folderId: currentFolder.id, folderName: currentFolder.name, index: currentFolderIdx, @@ -93,8 +101,17 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => { } } } else if (view === STORAGE_VIEWS.COLUMNS) { - const paths = openedFolders.map((folder) => folder.name) - fetchFoldersByPath({ paths, searchString: itemSearchString, showLoading: true }) + if (openedFolders.length > 0) { + const paths = openedFolders.map((folder) => folder.name) + fetchFoldersByPath({ paths, searchString: itemSearchString, showLoading: true }) + } else { + await fetchFolderContents({ + bucketId: bucket.id, + folderId: bucket.id, + folderName: bucket.name, + index: -1, + }) + } } } diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.utils.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.utils.tsx index f2f2d01d609..e20a143b53b 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.utils.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.utils.tsx @@ -1,8 +1,8 @@ +import { toast } from 'sonner' + import { downloadBucketObject } from 'data/storage/bucket-object-download-mutation' import { StorageObject } from 'data/storage/bucket-objects-list-mutation' -import { copyToClipboard } from 'lib/helpers' -import { toast } from 'sonner' -import { SONNER_DEFAULT_DURATION } from 'ui' +import { SONNER_DEFAULT_DURATION, copyToClipboard } from 'ui' import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants' import { StorageItem, StorageItemMetadata, StorageItemWithColumn } from '../Storage.types' diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx index e31ae431a0f..44a9f3b8125 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx @@ -3,8 +3,8 @@ import { toast } from 'sonner' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' -import { copyToClipboard } from 'lib/helpers' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' +import { copyToClipboard } from 'ui' import { URL_EXPIRY_DURATION } from '../Storage.constants' import { fetchFileUrl } from './useFetchFileUrlQuery' diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useSelectedBucket.ts b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useSelectedBucket.ts new file mode 100644 index 00000000000..2023b7c0446 --- /dev/null +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useSelectedBucket.ts @@ -0,0 +1,11 @@ +import { useParams } from 'common' +import { useBucketsQuery } from 'data/storage/buckets-query' + +export const useSelectedBucket = () => { + const { ref, bucketId } = useParams() + + const { data: buckets = [], isSuccess, isError, error } = useBucketsQuery({ projectRef: ref }) + const bucket = buckets.find((b) => b.id === bucketId) + + return { bucket, isSuccess, isError, error } +} diff --git a/apps/studio/components/ui/CopyButton.tsx b/apps/studio/components/ui/CopyButton.tsx index 38e2848a9f3..9ac56ba9df6 100644 --- a/apps/studio/components/ui/CopyButton.tsx +++ b/apps/studio/components/ui/CopyButton.tsx @@ -1,8 +1,7 @@ import { Check, Clipboard } from 'lucide-react' -import React, { forwardRef, useEffect, useState } from 'react' +import { ComponentProps, forwardRef, useEffect, useState } from 'react' -import { copyToClipboard } from 'lib/helpers' -import { Button, cn } from 'ui' +import { Button, cn, copyToClipboard } from 'ui' type CopyButtonBaseProps = { iconOnly?: boolean @@ -22,7 +21,7 @@ type CopyButtonWithAsyncText = CopyButtonBaseProps & { } export type CopyButtonProps = (CopyButtonWithText | CopyButtonWithAsyncText) & - React.ComponentProps + ComponentProps const CopyButton = forwardRef( ( @@ -52,7 +51,7 @@ const CopyButton = forwardRef( onClick={async (e) => { const textToCopy = asyncText ? await asyncText() : text setShowCopied(true) - await copyToClipboard(textToCopy!) + copyToClipboard(textToCopy) onClick?.(e) }} {...props} diff --git a/apps/studio/components/ui/DownloadResultsButton.tsx b/apps/studio/components/ui/DownloadResultsButton.tsx index ba2dee032bd..715e5aa7acb 100644 --- a/apps/studio/components/ui/DownloadResultsButton.tsx +++ b/apps/studio/components/ui/DownloadResultsButton.tsx @@ -5,9 +5,9 @@ import Papa from 'papaparse' import { useMemo } from 'react' import { toast } from 'sonner' -import { copyToClipboard } from 'lib/helpers' import { Button, + copyToClipboard, DropdownMenu, DropdownMenuContent, DropdownMenuItem, diff --git a/apps/studio/components/ui/Logs/LogsExplorerHeader.tsx b/apps/studio/components/ui/Logs/LogsExplorerHeader.tsx index 547924858ab..ae32da4ba62 100644 --- a/apps/studio/components/ui/Logs/LogsExplorerHeader.tsx +++ b/apps/studio/components/ui/Logs/LogsExplorerHeader.tsx @@ -4,9 +4,16 @@ import { useState } from 'react' import { LOGS_EXPLORER_DOCS_URL } from 'components/interfaces/Settings/Logs/Logs.constants' import Table from 'components/to-be-cleaned/Table' -import { copyToClipboard } from 'lib/helpers' import { logConstants } from 'shared-data' -import { Button, SidePanel, Tabs, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { + Button, + SidePanel, + Tabs, + Tooltip, + TooltipContent, + TooltipTrigger, + copyToClipboard, +} from 'ui' import { DocsButton } from '../DocsButton' export interface LogsExplorerHeaderProps { diff --git a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx index 02161bf0e4a..d6367fc2a7b 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx +++ b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -1,23 +1,27 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { AlertCircle, Loader2 } from 'lucide-react' +import { useMemo } from 'react' +import { toast } from 'sonner' + import { useParams } from 'common' import Panel from 'components/ui/Panel' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useFlag } from 'hooks/ui/useFlag' -import { AlertCircle, Loader2 } from 'lucide-react' -import { useMemo } from 'react' -import { toast } from 'sonner' import { Input } from 'ui' import { getLastUsedAPIKeys, useLastUsedAPIKeysLogQuery } from './DisplayApiSettings.utils' +import { ToggleLegacyApiKeysPanel } from './ToggleLegacyApiKeys' -const DisplayApiSettings = ({ - legacy, +export const DisplayApiSettings = ({ + showTitle = true, showNotice = true, + showLegacyText = true, }: { - legacy?: boolean + showTitle?: boolean showNotice?: boolean + showLegacyText?: boolean }) => { const { ref: projectRef } = useParams() @@ -35,13 +39,16 @@ const DisplayApiSettings = ({ } = useJwtSecretUpdatingStatusQuery({ projectRef }) const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus - const { isLoading, can: canReadAPIKeys } = useAsyncCheckProjectPermissions( + const { isLoading: isLoadingPermissions, can: canReadAPIKeys } = useAsyncCheckProjectPermissions( PermissionAction.READ, 'service_api_keys' ) + + const isLoading = isProjectSettingsLoading || isLoadingPermissions + const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated - const apiKeys = settings?.service_api_keys ?? [] + const apiKeys = useMemo(() => settings?.service_api_keys ?? [], [settings]) // api keys should not be empty. However it can be populated with a delay on project creation const isApiKeysEmpty = apiKeys.length === 0 @@ -66,8 +73,9 @@ const DisplayApiSettings = ({ return ( <>
Project API Keys

@@ -159,9 +167,9 @@ const DisplayApiSettings = ({ descriptionText={ x.tags === 'service_role' ? 'This key has the ability to bypass Row Level Security. Never share it publicly. If leaked, generate a new JWT secret immediately. ' + - (legacy ? 'Prefer using Publishable API keys instead.' : '') + (showLegacyText ? 'Prefer using Publishable API keys instead.' : '') : 'This key is safe to use in a browser if you have enabled Row Level Security for your tables and configured policies. ' + - (legacy ? 'Prefer using Secret API keys instead.' : '') + (showLegacyText ? 'Prefer using Secret API keys instead.' : '') } /> @@ -201,7 +209,7 @@ const DisplayApiSettings = ({ ) ) : null} + {newApiKeysInRollOut && !showNotice && } ) } -export default DisplayApiSettings diff --git a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx new file mode 100644 index 00000000000..0c17e4a676f --- /dev/null +++ b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx @@ -0,0 +1,130 @@ +import { useState } from 'react' +import { toast } from 'sonner' + +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useToggleLegacyAPIKeysMutation } from 'data/api-keys/legacy-api-key-toggle-mutation' +import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, CriticalIcon } from 'ui' +import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' + +export const ToggleLegacyApiKeysPanel = () => { + const { project } = useProjectContext() + const [isConfirmOpen, setIsConfirmOpen] = useState(false) + + const { data: legacyAPIKeysStatusData, isSuccess: isLegacyAPIKeysStatusSuccess } = + useLegacyAPIKeysStatusQuery({ projectRef: project!.ref }) + + const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } = + useAsyncCheckProjectPermissions(PermissionAction.SECRETS_WRITE, '*') + + if (!(isLegacyAPIKeysStatusSuccess && isPermissionsSuccess)) { + return null + } + + return ( +

+ + + + {legacyAPIKeysStatusData.enabled + ? 'Disabling your legacy API keys may cause your applications to break.' + : 'Re-enabling your legacy API keys may expose your applications to security risks.'} + + + {legacyAPIKeysStatusData.enabled + ? 'Make sure you are no longer using your legacy API keys before proceeding.' + : "Make sure you've tested your RLS policies."} + +
+ setIsConfirmOpen(true)} + disabled={!canUpdateAPIKeys} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateAPIKeys + ? 'You need additional permissions to enable or disable JWT-based API keys' + : undefined, + }, + }} + > + {legacyAPIKeysStatusData.enabled + ? 'Disable JWT-based API keys' + : 'Re-enable JWT-based API keys'} + +
+
+ + setIsConfirmOpen(false)} + legacyAPIKeysStatusData={legacyAPIKeysStatusData} + /> +
+ ) +} + +const ToggleApiKeysModal = ({ + visible, + onClose, + legacyAPIKeysStatusData, +}: { + visible: boolean + onClose: () => void + legacyAPIKeysStatusData: { enabled: boolean } +}) => { + const { project } = useProjectContext() + + const { mutate: toggleLegacyAPIKey, isLoading: isTogglingLegacyAPIKey } = + useToggleLegacyAPIKeysMutation() + + const onToggleLegacyAPIKeysEnabled = () => { + const enabled = !legacyAPIKeysStatusData?.enabled + + toggleLegacyAPIKey( + { projectRef: project!.ref, enabled }, + { + onSuccess: () => { + toast.success( + enabled + ? 'Your anon and service_role keys have been re-enabled!' + : 'Your anon and service_role keys have been disabled!' + ) + onClose() + }, + } + ) + } + + return ( + onClose()} + onConfirm={onToggleLegacyAPIKeysEnabled} + title={ + legacyAPIKeysStatusData.enabled ? 'Disable JWT-based keys' : 'Re-enable JWT-based keys' + } + confirmString={legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'} + confirmLabel={`Yes, ${legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'} anon and service_role`} + confirmPlaceholder={legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'} + loading={isTogglingLegacyAPIKey} + variant={legacyAPIKeysStatusData.enabled ? 'destructive' : 'default'} + alert={ + legacyAPIKeysStatusData.enabled + ? { + title: 'Disabling can cause downtime!', + description: `If you disable your anon and service_role keys while they are in use, your applications will stop functioning. All API endpoints will receive HTTP 401 Unauthorized. Make sure you are no longer using them before proceeding.`, + } + : { + title: 'Prefer publishable and secret keys', + description: + 'While re-enabling anon and service_role keys makes sense in some cases, a better and more secure alternative is the publishable or secret key. Consider using those before proceeding!', + } + } + /> + ) +} diff --git a/apps/studio/components/ui/ProjectSettings/index.tsx b/apps/studio/components/ui/ProjectSettings/index.tsx index 37e2dcc5b8b..2d41ad6aaf5 100644 --- a/apps/studio/components/ui/ProjectSettings/index.tsx +++ b/apps/studio/components/ui/ProjectSettings/index.tsx @@ -1,4 +1,4 @@ -import DisplayApiSettings from './DisplayApiSettings' +import { DisplayApiSettings } from './DisplayApiSettings' import DisplayConfigSettings from './DisplayConfigSettings' export { DisplayApiSettings, DisplayConfigSettings } diff --git a/apps/studio/data/api-keys/api-keys-query.ts b/apps/studio/data/api-keys/api-keys-query.ts index 2ade5311c9d..de87ed9c42b 100644 --- a/apps/studio/data/api-keys/api-keys-query.ts +++ b/apps/studio/data/api-keys/api-keys-query.ts @@ -43,12 +43,12 @@ type PublishableKeys = { updated_at?: string } -export interface APIKeysVariables { +interface APIKeysVariables { projectRef?: string reveal: boolean } -export async function getAPIKeys({ projectRef, reveal }: APIKeysVariables, signal?: AbortSignal) { +async function getAPIKeys({ projectRef, reveal }: APIKeysVariables, signal?: AbortSignal) { if (!projectRef) throw new Error('projectRef is required') const { data, error } = await get(`/v1/projects/{ref}/api-keys`, { diff --git a/apps/studio/data/api-keys/keys.ts b/apps/studio/data/api-keys/keys.ts index 96d50623291..84d9c41d1d1 100644 --- a/apps/studio/data/api-keys/keys.ts +++ b/apps/studio/data/api-keys/keys.ts @@ -1,4 +1,5 @@ export const apiKeysKeys = { list: (projectRef?: string) => ['projects', projectRef, 'api-keys'] as const, single: (projectRef?: string, id?: string) => ['projects', projectRef, 'api-keys', id] as const, + status: (projectRef?: string) => ['projects', projectRef, 'api-keys', 'legacy'] as const, } diff --git a/apps/studio/data/api-keys/legacy-api-key-toggle-mutation.ts b/apps/studio/data/api-keys/legacy-api-key-toggle-mutation.ts new file mode 100644 index 00000000000..0f0a07c43e3 --- /dev/null +++ b/apps/studio/data/api-keys/legacy-api-key-toggle-mutation.ts @@ -0,0 +1,60 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { handleError, put } from 'data/fetchers' +import { toast } from 'sonner' +import type { ResponseError } from 'types' +import { apiKeysKeys } from './keys' + +export type ToggleLegacyAPIKeysVariables = { + projectRef?: string + enabled: boolean +} + +export async function toggleLegacyAPIKeys(payload: ToggleLegacyAPIKeysVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await put('/v1/projects/{ref}/api-keys/legacy', { + params: { + path: { ref: payload.projectRef }, + query: { enabled: payload.enabled }, + }, + }) + + if (error) handleError(error) + return data +} + +type ToggleLegacyAPIKeysData = Awaited> + +export const useToggleLegacyAPIKeysMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => toggleLegacyAPIKeys(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(apiKeysKeys.status(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error( + `Failed to ${variables.enabled ? 're-enable' : 'disable'} JWT-based API keys: ${data.message}` + ) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/api-keys/legacy-api-keys-status-query.ts b/apps/studio/data/api-keys/legacy-api-keys-status-query.ts new file mode 100644 index 00000000000..964f2f49d54 --- /dev/null +++ b/apps/studio/data/api-keys/legacy-api-keys-status-query.ts @@ -0,0 +1,41 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { apiKeysKeys } from './keys' + +interface LegacyAPIKeysStatusVariables { + projectRef?: string +} + +async function getLegacyAPIKeysStatus( + { projectRef }: LegacyAPIKeysStatusVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get(`/v1/projects/{ref}/api-keys/legacy`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) { + handleError(error) + } + + return data +} + +type LegacyAPIKeysStatusData = Awaited> + +export const useLegacyAPIKeysStatusQuery = ( + { projectRef }: LegacyAPIKeysStatusVariables, + { ...options }: UseQueryOptions = {} +) => + useQuery( + apiKeysKeys.status(projectRef), + ({ signal }) => getLegacyAPIKeysStatus({ projectRef }, signal), + { + enabled: !!projectRef, + ...options, + } + ) diff --git a/apps/studio/data/organizations/organization-create-mutation.ts b/apps/studio/data/organizations/organization-create-mutation.ts index 4273e98b2d3..3c28c0a4941 100644 --- a/apps/studio/data/organizations/organization-create-mutation.ts +++ b/apps/studio/data/organizations/organization-create-mutation.ts @@ -62,21 +62,24 @@ export const useOrganizationCreateMutation = ({ (vars) => createOrganization(vars), { async onSuccess(data, variables, context) { - // [Joshen] We're manually updating the query client here as the org's subscription is - // created async, and the invalidation will happen too quick where the GET organizations - // endpoint will error out with a 500 since the subscription isn't created yet. - queryClient.setQueriesData( - { - queryKey: organizationKeys.list(), - exact: true, - }, - (prev: any) => { - if (!prev) return prev - return [...prev, castOrganizationResponseToOrganization(data)] - } - ) + if (data && !('pending_payment_intent_secret' in data)) { + // [Joshen] We're manually updating the query client here as the org's subscription is + // created async, and the invalidation will happen too quick where the GET organizations + // endpoint will error out with a 500 since the subscription isn't created yet. + queryClient.setQueriesData( + { + queryKey: organizationKeys.list(), + exact: true, + }, + (prev: any) => { + if (!prev) return prev + return [...prev, castOrganizationResponseToOrganization(data)] + } + ) + + await queryClient.invalidateQueries(permissionKeys.list()) + } - await queryClient.invalidateQueries(permissionKeys.list()) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/organizations/organization-project-claim-mutation.ts b/apps/studio/data/organizations/organization-project-claim-mutation.ts index 16d42a31571..25b29c20ea4 100644 --- a/apps/studio/data/organizations/organization-project-claim-mutation.ts +++ b/apps/studio/data/organizations/organization-project-claim-mutation.ts @@ -10,6 +10,7 @@ type OrganizationProjectClaimVariables = { } async function claimOrganizationProject({ slug, token }: OrganizationProjectClaimVariables) { + // @ts-expect-error the endpoint is hidden in the spec for now const { data, error } = await post('/v1/organizations/{slug}/project-claim/{token}', { params: { path: { slug, token } }, }) diff --git a/apps/studio/data/organizations/organization-project-claim-query.ts b/apps/studio/data/organizations/organization-project-claim-query.ts index b02534661a2..f620074a041 100644 --- a/apps/studio/data/organizations/organization-project-claim-query.ts +++ b/apps/studio/data/organizations/organization-project-claim-query.ts @@ -1,7 +1,5 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { organizationKeys } from './keys' - -import { components } from 'api-types' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' @@ -10,8 +8,42 @@ type OrganizationProjectClaimVariables = { token: string } -export type OrganizationProjectClaimResponse = - components['schemas']['OrganizationProjectClaimResponse'] +// todo replace with API type once exposed again - components['schemas']['OrganizationProjectClaimResponse'] +export type OrganizationProjectClaimResponse = { + created_at: string + /** Format: uuid */ + created_by: string + expires_at: string + preview: { + errors: { + key: string + message: string + }[] + info: { + key: string + message: string + }[] + members_exceeding_free_project_limit: { + limit: number + name: string + }[] + /** @enum {string} */ + source_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise' + target_organization_eligible: boolean | null + target_organization_has_free_project_slots: boolean | null + /** @enum {string|null} */ + target_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise' | null + valid: boolean + warnings: { + key: string + message: string + }[] + } + project: { + name: string + ref: string + } +} async function getOrganizationProjectClaim( { slug, token }: OrganizationProjectClaimVariables, @@ -19,13 +51,14 @@ async function getOrganizationProjectClaim( ) { if (!slug || !token) throw new Error('Slug and token are required') + // @ts-expect-error the endpoint is hidden in the spec for now const { data, error } = await get(`/v1/organizations/{slug}/project-claim/{token}`, { params: { path: { slug, token } }, signal, }) if (error) handleError(error) - return data + return data as OrganizationProjectClaimResponse } export type OrganizationProjectClaimData = Awaited> diff --git a/apps/studio/data/sql/execute-sql-query.ts b/apps/studio/data/sql/execute-sql-query.ts index 8e2b1536721..a7fe145fc9e 100644 --- a/apps/studio/data/sql/execute-sql-query.ts +++ b/apps/studio/data/sql/execute-sql-query.ts @@ -17,6 +17,7 @@ export type ExecuteSqlVariables = { queryKey?: QueryKey handleError?: (error: ResponseError) => { result: any } isRoleImpersonationEnabled?: boolean + isStatementTimeoutDisabled?: boolean autoLimit?: number contextualInvalidation?: boolean } @@ -29,6 +30,7 @@ export async function executeSql( queryKey, handleError, isRoleImpersonationEnabled = false, + isStatementTimeoutDisabled = false, }: Pick< ExecuteSqlVariables, | 'projectRef' @@ -37,6 +39,7 @@ export async function executeSql( | 'queryKey' | 'handleError' | 'isRoleImpersonationEnabled' + | 'isStatementTimeoutDisabled' >, signal?: AbortSignal, headersInit?: HeadersInit, @@ -80,7 +83,7 @@ export async function executeSql( .join('-') ?? '', }, }, - body: { query: sql }, + body: { query: sql, disable_statement_timeout: isStatementTimeoutDisabled }, headers, }) diff --git a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts new file mode 100644 index 00000000000..ed6065771c8 --- /dev/null +++ b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts @@ -0,0 +1,92 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { organizationKeys } from 'data/organizations/keys' +import { permissionKeys } from 'data/permissions/keys' +import { castOrganizationResponseToOrganization } from 'data/organizations/organizations-query' +import type { components } from 'api-types' + +export type PendingSubscriptionChangeVariables = { + payment_intent_id: string + name: string + kind?: string + size?: string +} + +export async function confirmPendingSubscriptionChange({ + payment_intent_id, + name, + kind, + size, +}: PendingSubscriptionChangeVariables) { + const { data, error } = await post('/platform/organizations/confirm-subscription', { + body: { + payment_intent_id, + name, + kind, + size, + }, + }) + + if (error) handleError(error) + return data +} + +type PendingSubscriptionChangeData = Awaited> + +export const useConfirmPendingSubscriptionChangeMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + PendingSubscriptionChangeData, + ResponseError, + PendingSubscriptionChangeVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + PendingSubscriptionChangeData, + ResponseError, + PendingSubscriptionChangeVariables + >((vars) => confirmPendingSubscriptionChange(vars), { + async onSuccess(data, variables, context) { + // [Joshen] We're manually updating the query client here as the org's subscription is + // created async, and the invalidation will happen too quick where the GET organizations + // endpoint will error out with a 500 since the subscription isn't created yet. + queryClient.setQueriesData( + { + queryKey: organizationKeys.list(), + exact: true, + }, + (prev: any) => { + if (!prev) return prev + return [ + ...prev, + castOrganizationResponseToOrganization( + data as components['schemas']['OrganizationResponse'] + ), + ] + } + ) + + await queryClient.invalidateQueries(permissionKeys.list()) + + // todo replace plan in org + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to confirm payment: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts index 54219afe9b0..ab898cac62c 100644 --- a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts +++ b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts @@ -1,6 +1,9 @@ +import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useDisallowHipaa } from 'hooks/misc/useDisallowHipaa' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { OPT_IN_TAGS } from 'lib/constants' /** @@ -26,10 +29,18 @@ export function useOrgOptedIntoAi() { } export function useOrgOptedIntoAiAndHippaProject() { + const selectedProject = useSelectedProject() const selectedOrganization = useSelectedOrganization() const optInTags = selectedOrganization?.opt_in_tags const isOptedIntoAI = optInTags?.includes(OPT_IN_TAGS.AI_SQL) ?? false - const disallowHipaa = useDisallowHipaa() - return { isOptedInToAI: isOptedIntoAI, isHipaaProjectDisallowed: disallowHipaa(isOptedIntoAI) } + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug }) + const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) + + const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: selectedProject?.ref }) + const isProjectSensitive = !!projectSettings?.is_sensitive + + const preventProjectFromUsingAI = hasHipaaAddon && isProjectSensitive + + return { isOptedInToAI: isOptedIntoAI, isHipaaProjectDisallowed: preventProjectFromUsingAI } } diff --git a/apps/studio/hooks/misc/useSelectedProject.ts b/apps/studio/hooks/misc/useSelectedProject.ts index 4b88355116a..af954876684 100644 --- a/apps/studio/hooks/misc/useSelectedProject.ts +++ b/apps/studio/hooks/misc/useSelectedProject.ts @@ -34,6 +34,13 @@ export function useProjectByRef( }, [project, projects, ref]) } +export const useIsAwsNew = () => { + const project = useSelectedProject() + const isAwsNew = + project?.cloud_provider !== PROVIDERS.AWS.id && project?.cloud_provider !== PROVIDERS.FLY.id + return isAwsNew +} + export const useIsOrioleDb = () => { const project = useSelectedProject() const isOrioleDb = project?.dbVersion?.endsWith('orioledb') @@ -46,14 +53,3 @@ export const useIsOrioleDbInAws = () => { project?.dbVersion?.endsWith('orioledb') && project?.cloud_provider === PROVIDERS.AWS.id return isOrioleDbInAws } - -export const useIsOrioleDbInAwsRevamped = () => { - const project = useSelectedProject() - - const isOrioleDb = project?.dbVersion?.endsWith('orioledb') - const isOrioleDbInAws = - project?.dbVersion?.endsWith('orioledb') && project?.cloud_provider === PROVIDERS.AWS.id - const isOrioleDbInAwsRevamped = isOrioleDb && !isOrioleDbInAws - - return isOrioleDbInAwsRevamped -} diff --git a/apps/studio/lib/helpers.test.ts b/apps/studio/lib/helpers.test.ts index 63e4f88ebcf..419e90a75eb 100644 --- a/apps/studio/lib/helpers.test.ts +++ b/apps/studio/lib/helpers.test.ts @@ -1,29 +1,30 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - tryParseJson, - minifyJSON, - prettifyJSON, - removeJSONTrailingComma, - timeout, - getURL, - makeRandomString, - pluckObjectFields, - tryParseInt, - propsAreEqual, - formatBytes, - snakeToCamel, - copyToClipboard, detectBrowser, detectOS, - pluralize, - isValidHttpUrl, - removeCommentsFromSql, - getSemanticVersion, + formatBytes, + formatCurrency, getDatabaseMajorVersion, getDistanceLatLonKM, - formatCurrency, + getSemanticVersion, + getURL, + isValidHttpUrl, + makeRandomString, + minifyJSON, + pluckObjectFields, + pluralize, + prettifyJSON, + propsAreEqual, + removeCommentsFromSql, + removeJSONTrailingComma, + snakeToCamel, + timeout, + tryParseInt, + tryParseJson, } from './helpers' +import { copyToClipboard } from 'ui' + describe('tryParseJson', () => { it('should return the parsed JSON', () => { const result = tryParseJson('{"test": "test"}') @@ -170,6 +171,9 @@ describe('copyToClipboard', () => { }, }) + // CopyToClipboard uses setTimeout to call the callback + vi.useFakeTimers() + // If ClipboardItem is used vi.stubGlobal('ClipboardItem', function (items: any) { return items @@ -181,10 +185,12 @@ describe('copyToClipboard', () => { afterEach(() => { vi.unstubAllGlobals() + vi.useRealTimers() }) it('uses clipboard.write if available', async () => { await copyToClipboard('hello') + vi.runAllTimers() expect(writeMock).toHaveBeenCalled() }) diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index e5baf72bfd8..268297a597e 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -1,6 +1,3 @@ -import { noop } from 'lodash' -import { toast } from 'sonner' - export { default as passwordStrength } from './password-strength' export { default as uuidv4 } from './uuid' @@ -146,42 +143,6 @@ export const snakeToCamel = (str: string) => group.toUpperCase().replace('-', '').replace('_', '') ) -/** - * Copy text content (string or Promise) into Clipboard. Safari doesn't support write text into clipboard async, - * so if you need to load text content async before coping, please use Promise for the 1st arg. - * - * IF YOU NEED TO CHANGE THIS FUNCTION, PLEASE TEST IT IN SAFARI with a promised string. Expiring URL to a file in a - * private bucket will do. - * - * Copied code from https://wolfgangrittner.dev/how-to-use-clipboard-api-in-firefox/ - */ -export const copyToClipboard = async (str: string | Promise, callback = noop) => { - const focused = window.document.hasFocus() - if (focused) { - if (typeof ClipboardItem && navigator.clipboard?.write) { - // NOTE: Safari locks down the clipboard API to only work when triggered - // by a direct user interaction. You can't use it async in a promise. - // But! You can wrap the promise in a ClipboardItem, and give that to - // the clipboard API. - // Found this on https://developer.apple.com/forums/thread/691873 - const text = new ClipboardItem({ - 'text/plain': Promise.resolve(str).then((text) => new Blob([text], { type: 'text/plain' })), - }) - navigator.clipboard.write([text]).then(callback) - } else { - // NOTE: Firefox has support for ClipboardItem and navigator.clipboard.write, - // but those are behind `dom.events.asyncClipboard.clipboardItem` preference. - // Good news is that other than Safari, Firefox does not care about - // Clipboard API being used async in a Promise. - Promise.resolve(str) - .then((text) => navigator.clipboard?.writeText(text)) - .then(callback) - } - } else { - toast.error('Unable to copy to clipboard') - } -} - export const detectBrowser = () => { if (!navigator) return undefined diff --git a/apps/studio/pages/new/index.tsx b/apps/studio/pages/new/index.tsx index 01395da9a8f..4c98990860f 100644 --- a/apps/studio/pages/new/index.tsx +++ b/apps/studio/pages/new/index.tsx @@ -1,7 +1,4 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' -import { Elements } from '@stripe/react-stripe-js' -import { loadStripe } from '@stripe/stripe-js' -import { useTheme } from 'next-themes' import { useCallback, useEffect, useState } from 'react' import { NewOrgForm } from 'components/interfaces/Organization' @@ -9,24 +6,21 @@ import AppLayout from 'components/layouts/AppLayout/AppLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import WizardLayout from 'components/layouts/WizardLayout' import { SetupIntentResponse, useSetupIntent } from 'data/stripe/setup-intent-mutation' -import { STRIPE_PUBLIC_KEY } from 'lib/constants' import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' import type { NextPageWithLayout } from 'types' -const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) - /** * No org selected yet, create a new one */ const Wizard: NextPageWithLayout = () => { - const { resolvedTheme } = useTheme() - const [intent, setIntent] = useState() const captchaLoaded = useIsHCaptchaLoaded() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) + const [selectedPlan, setSelectedPlan] = useState(null) + const { mutate: setupIntent } = useSetupIntent({ onSuccess: (res) => setIntent(res) }) const captchaRefCallback = useCallback((node: any) => { @@ -37,16 +31,15 @@ const Wizard: NextPageWithLayout = () => { if (!hcaptchaToken) return console.error('Hcaptcha token is required') // Force a reload of Elements, necessary for Stripe + // Also mitigates card testing to some extent as we generate a new captcha token setIntent(undefined) setupIntent({ hcaptchaToken }) } - const options = { - clientSecret: intent ? intent.client_secret : '', - appearance: { theme: resolvedTheme?.includes('dark') ? 'night' : 'flat', labels: 'floating' }, - } as const + const loadPaymentForm = async (force = false) => { + if (selectedPlan == null || selectedPlan === 'FREE') return + if (intent != null && !force) return - const loadPaymentForm = async () => { if (captchaRef && captchaLoaded) { let token = captchaToken @@ -66,10 +59,11 @@ const Wizard: NextPageWithLayout = () => { useEffect(() => { loadPaymentForm() - }, [captchaRef, captchaLoaded]) + }, [captchaRef, captchaLoaded, selectedPlan]) const resetSetupIntent = () => { - return loadPaymentForm() + setIntent(undefined) + return loadPaymentForm(true) } const onLocalCancel = () => { @@ -96,11 +90,11 @@ const Wizard: NextPageWithLayout = () => { }} /> - {intent && ( - - resetSetupIntent()} /> - - )} + resetSetupIntent()} + onPlanSelected={(plan) => setSelectedPlan(plan)} + /> ) } diff --git a/apps/studio/pages/organizations.tsx b/apps/studio/pages/organizations.tsx index 6bd56aa5e3a..e95006b29b2 100644 --- a/apps/studio/pages/organizations.tsx +++ b/apps/studio/pages/organizations.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' +import { useParams } from 'common' import AppLayout from 'components/layouts/AppLayout/AppLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainerLegacy, ScaffoldTitle } from 'components/layouts/Scaffold' @@ -14,12 +15,21 @@ import { useProjectsQuery } from 'data/projects/projects-query' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { withAuth } from 'hooks/misc/withAuth' import { NextPageWithLayout } from 'types' -import { Button, Skeleton } from 'ui' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + CriticalIcon, + Skeleton, +} from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' const OrganizationsPage: NextPageWithLayout = () => { const router = useRouter() const [search, setSearch] = useState('') + const { error: orgNotFoundError, org: orgSlug } = useParams() + const orgNotFound = orgNotFoundError === 'org_not_found' const { data: projects = [] } = useProjectsQuery() const { data: organizations = [], error, isLoading, isError, isSuccess } = useOrganizationsQuery() @@ -34,29 +44,51 @@ const OrganizationsPage: NextPageWithLayout = () => { useEffect(() => { // If there are no organizations, force the user to create one - if (isSuccess && organizations.length <= 0) { + // unless the user is on the not found page + if (isSuccess && organizations.length <= 0 && !orgNotFound) { router.push('/new') } }, [isSuccess, organizations]) return ( + {orgNotFound && ( + + + Organization not found + + That organization ({orgSlug}) does not exist or you don't have access to + it. + + + If you think this is an error, please reach out to the org owner to get access. + + + )} + Your Organizations + {organizations.length === 0 && orgNotFound && ( +

You don't have any organizations yet. Create one to get started.

+ )} +
{organizationCreationEnabled && ( )} - } - className="w-full flex-1 md:w-64 [&>div>div>div>input]:!pl-7 [&>div>div>div>div]:!pl-2" - value={search} - onChange={(event) => setSearch(event.target.value)} - /> + + {organizations.length > 0 && ( + } + className="w-full flex-1 md:w-64 [&>div>div>div>input]:!pl-7 [&>div>div>div>div]:!pl-2" + value={search} + onChange={(event) => setSearch(event.target.value)} + /> + )}
{search.length > 0 && filteredOrganizations.length === 0 && ( diff --git a/apps/studio/pages/project/[ref]/functions/index.tsx b/apps/studio/pages/project/[ref]/functions/index.tsx index 5731e692c60..29bb3e206c2 100644 --- a/apps/studio/pages/project/[ref]/functions/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/index.tsx @@ -1,10 +1,13 @@ -import { ChevronDown, Code, ExternalLink, Terminal } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import { useRouter } from 'next/router' import { useParams } from 'common' +import { DeployEdgeFunctionButton } from 'components/interfaces/EdgeFunctions/DeployEdgeFunctionButton' import { EdgeFunctionsListItem } from 'components/interfaces/Functions/EdgeFunctionsListItem' -import { FunctionsEmptyState } from 'components/interfaces/Functions/FunctionsEmptyState' -import { TerminalInstructions } from 'components/interfaces/Functions/TerminalInstructions' +import { + FunctionsEmptyState, + FunctionsEmptyStateLocal, +} from 'components/interfaces/Functions/FunctionsEmptyState' import DefaultLayout from 'components/layouts/DefaultLayout' import EdgeFunctionsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' @@ -16,21 +19,10 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { IS_PLATFORM } from 'lib/constants' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import type { NextPageWithLayout } from 'types' -import { - AiIconAnimation, - Button, - Dialog, - DialogContent, - DialogSection, - DialogTitle, - DialogTrigger, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from 'ui' +import { Button } from 'ui' const EdgeFunctionsPage: NextPageWithLayout = () => { const { ref } = useParams() @@ -48,94 +40,6 @@ const EdgeFunctionsPage: NextPageWithLayout = () => { const hasFunctions = (functions ?? []).length > 0 - const deployButton = ( - - - - - - { - router.push(`/project/${ref}/functions/new`) - sendEvent({ - action: 'edge_function_via_editor_button_clicked', - properties: { origin: 'secondary_action' }, - groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, - }) - }} - className="gap-4" - > - -
- Via Editor -

Write and deploy in the browser

-
-
- - - { - e.preventDefault() - sendEvent({ - action: 'edge_function_via_cli_button_clicked', - properties: { origin: 'secondary_action' }, - groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, - }) - }} - > - -
- Via CLI -

Write locally, deploy with the CLI

-
-
-
- - - Create your first Edge Function via the CLI - - - - - -
- { - snap.newChat({ - name: 'Create new edge function', - open: true, - initialInput: `Create a new edge function that ...`, - suggestions: { - title: - 'I can help you create a new edge function. Here are a few example prompts to get you started:', - prompts: [ - 'Create a new edge function that processes payments with Stripe', - 'Create a new edge function that sends emails with Resend', - 'Create a new edge function that generates PDFs from HTML templates', - ], - }, - }) - sendEvent({ - action: 'edge_function_ai_assistant_button_clicked', - properties: { origin: 'secondary_action' }, - groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, - }) - }} - > - -
- Via AI Assistant -

Let the Assistant write and deploy for you

-
-
-
-
- ) - const secondaryActions = [ ,