diff --git a/.cursor/rules/docs-embeddings-generation.md b/.cursor/rules/docs-embeddings-generation.md new file mode 100644 index 00000000000..22ce5fd4359 --- /dev/null +++ b/.cursor/rules/docs-embeddings-generation.md @@ -0,0 +1,59 @@ +# Documentation Embeddings Generation System + +## Overview + +The documentation embeddings generation system processes various documentation sources and uploads their metadata to a database for semantic search functionality. The system is located in `apps/docs/scripts/search/` and works by: + +1. **Discovering content sources** from multiple types of documentation +2. **Processing content** into structured sections with checksums +3. **Generating embeddings** using OpenAI's text-embedding-ada-002 model +4. **Storing in database** with vector embeddings for semantic search + +## Architecture + +### Main Entry Point +- `generate-embeddings.ts` - Main script that orchestrates the entire process +- Supports `--refresh` flag to force regeneration of all content + +### Content Sources (`sources/` directory) + +#### Base Classes +- `BaseLoader` - Abstract class for loading content from different sources +- `BaseSource` - Abstract class for processing and formatting content + +#### Source Types +1. **Markdown Sources** (`markdown.ts`) + - Processes `.mdx` files from guides and documentation + - Extracts frontmatter metadata and content sections + +2. **Reference Documentation** (`reference-doc.ts`) + - **OpenAPI References** - Management API documentation from OpenAPI specs + - **Client Library References** - JavaScript, Dart, Python, C#, Swift, Kotlin SDKs + - **CLI References** - Command-line interface documentation + - Processes YAML/JSON specs and matches with common sections + +3. **GitHub Discussions** (`github-discussion.ts`) + - Fetches troubleshooting discussions from GitHub using GraphQL API + - Uses GitHub App authentication for access + +4. **Partner Integrations** (`partner-integrations.ts`) + - Fetches approved partner integration documentation from Supabase database + - Technology integrations only (excludes agencies) + +### Processing Flow + +1. **Content Discovery**: Each source loader discovers and loads content files/data +2. **Content Processing**: Each source processes content into: + - Checksum for change detection + - Metadata (title, subtitle, etc.) + - Sections with headings and content +3. **Change Detection**: Compares checksums against existing database records +4. **Embedding Generation**: Uses OpenAI to generate embeddings for new/changed content +5. **Database Storage**: Stores in `page` and `page_section` tables with embeddings +6. **Cleanup**: Removes outdated pages using version tracking + +### Database Schema + +- **`page`** table: Stores page metadata, content, checksum, version +- **`page_section`** table: Stores individual sections with embeddings, token counts + diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 436f6aeeff6..f7ea12c26a5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,6 @@ /packages/ui/ @supabase/design -/packages/shared-data/pricing.ts @roryw10 @kevcodez -/packages/shared-data/plans.ts @roryw10 @kevcodez +/packages/shared-data/pricing.ts @roryw10 @supabase/billing +/packages/shared-data/plans.ts @roryw10 @supabase/billing /packages/common/telemetry-constants.ts @4L3k51 @supabase/growth-eng /apps/studio/ @supabase/Dashboard diff --git a/.nvmrc b/.nvmrc index 2edeafb09db..8fdd954df98 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 \ No newline at end of file +22 \ No newline at end of file diff --git a/apps/docs/app/api/graphql/__snapshots__/route.test.ts.snap b/apps/docs/app/api/graphql/__snapshots__/route.test.ts.snap index 9578e325419..96c154a5a28 100644 --- a/apps/docs/app/api/graphql/__snapshots__/route.test.ts.snap +++ b/apps/docs/app/api/graphql/__snapshots__/route.test.ts.snap @@ -84,6 +84,20 @@ type CLICommandReference implements SearchResult { content: String } +""" +A reference document containing a description of a Supabase Management API endpoint +""" +type ManagementApiReference implements SearchResult { + """The title of the document""" + title: String + + """The URL of the document""" + href: String + + """The content of the reference document, as text""" + content: String +} + """ A reference document containing a description of a function from a Supabase client library """ diff --git a/apps/docs/app/api/graphql/route.ts b/apps/docs/app/api/graphql/route.ts index 4ebc246c2ab..2b7020fabc1 100644 --- a/apps/docs/app/api/graphql/route.ts +++ b/apps/docs/app/api/graphql/route.ts @@ -9,9 +9,59 @@ 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 +function isAllowedCorsOrigin(origin: string): boolean { + const exactMatches = IS_DEV + ? ['http://localhost:8082', 'https://supabase.com'] + : ['https://supabase.com'] + if (exactMatches.includes(origin)) { + return true + } + + return /^https:\/\/[\w-]+\w-supabase.vercel.app$/.test(origin) +} + +function getCorsHeaders(request: Request): Record { + const origin = request.headers.get('Origin') + + if (origin && isAllowedCorsOrigin(origin)) { + return { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Accept', + } + } + + return {} +} + const validationRules = [ ...specifiedRules, createQueryDepthLimiter(MAX_DEPTH), @@ -53,13 +103,18 @@ async function handleGraphQLRequest(request: Request): Promise { const { query, variables, operationName } = parsedBody.data const validationErrors = validateGraphQLRequest(query, isDevGraphiQL(request)) if (validationErrors.length > 0) { - return NextResponse.json({ - errors: validationErrors.map((error) => ({ - message: error.message, - locations: error.locations, - path: error.path, - })), - }) + return NextResponse.json( + { + errors: validationErrors.map((error) => ({ + message: error.message, + locations: error.locations, + path: error.path, + })), + }, + { + headers: getCorsHeaders(request), + } + ) } const result = await graphql({ @@ -69,7 +124,9 @@ async function handleGraphQLRequest(request: Request): Promise { variableValues: variables, operationName, }) - return NextResponse.json(result) + return NextResponse.json(result, { + headers: getCorsHeaders(request), + }) } function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyArray { @@ -87,9 +144,21 @@ function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyA return validate(rootGraphQLSchema, documentAST, rules) } +export async function OPTIONS(request: Request): Promise { + const corsHeaders = getCorsHeaders(request) + return new NextResponse(null, { + status: 204, + headers: corsHeaders, + }) +} + export async function POST(request: Request): Promise { try { - return await handleGraphQLRequest(request) + const result = await handleGraphQLRequest(request) + // Do not let Vercel close the process until Sentry has flushed + // https://github.com/getsentry/sentry-javascript/issues/9626 + await Sentry.flush(2000) + return result } catch (error: unknown) { console.error(error) @@ -97,16 +166,32 @@ export async function POST(request: Request): Promise { if (!error.isUserError()) { Sentry.captureException(error) } + // Do not let Vercel close the process until Sentry has flushed + // https://github.com/getsentry/sentry-javascript/issues/9626 + await Sentry.flush(2000) - return NextResponse.json({ - errors: [{ message: error.isPrivate() ? 'Internal Server Error' : error.message }], - }) + return NextResponse.json( + { + errors: [{ message: error.isPrivate() ? 'Internal Server Error' : error.message }], + }, + { + headers: getCorsHeaders(request), + } + ) } else { Sentry.captureException(error) + // Do not let Vercel close the process until Sentry has flushed + // https://github.com/getsentry/sentry-javascript/issues/9626 + await Sentry.flush(2000) - return NextResponse.json({ - errors: [{ message: 'Internal Server Error' }], - }) + return NextResponse.json( + { + errors: [{ message: 'Internal Server Error' }], + }, + { + headers: getCorsHeaders(request), + } + ) } } } diff --git a/apps/docs/app/api/graphql/tests/searchDocs.smoke.test.ts b/apps/docs/app/api/graphql/tests/searchDocs.smoke.test.ts index 5af40e4f2f4..87cac6b1d32 100644 --- a/apps/docs/app/api/graphql/tests/searchDocs.smoke.test.ts +++ b/apps/docs/app/api/graphql/tests/searchDocs.smoke.test.ts @@ -204,4 +204,40 @@ describe('prod smoke test: graphql: searchDocs', () => { expect(guideNode).toHaveProperty('href') expect(guideNode).toHaveProperty('content') }) + + it('searchDocs query includes Management API references', async () => { + const query = ` + query SearchDocsQuery($query: String!) { + searchDocs(query: $query) { + nodes { + ...on ManagementApiReference { + title + href + content + } + } + } + } + ` + const result = await fetch(GRAPHQL_URL, { + method: 'POST', + body: JSON.stringify({ query, variables: { query: 'create SSO provider' } }), + }) + + expect(result.status).toBe(200) + const { data, errors } = await result.json() + expect(errors).toBeUndefined() + + const { + searchDocs: { nodes }, + } = data + expect(Array.isArray(nodes)).toBe(true) + expect(nodes.length).toBeGreaterThan(0) + + const managementApiNode = nodes.find((node: any) => !!node.title) + expect(managementApiNode).toBeDefined() + expect(managementApiNode).toHaveProperty('title') + expect(managementApiNode).toHaveProperty('href') + expect(managementApiNode).toHaveProperty('content') + }) }) diff --git a/apps/docs/app/api/graphql/tests/searchDocs.test.ts b/apps/docs/app/api/graphql/tests/searchDocs.test.ts index 3788f58697d..f767021daa2 100644 --- a/apps/docs/app/api/graphql/tests/searchDocs.test.ts +++ b/apps/docs/app/api/graphql/tests/searchDocs.test.ts @@ -33,6 +33,16 @@ const rpcSpy = vi.fn().mockImplementation((funcName, params) => { content: params?.include_full_content ? 'Another content' : null, subsections: [{ title: 'Getting Started', content: 'Getting Started content' }], }, + { + type: 'reference', + page_title: 'Create a SSO provider', + href: 'https://supabase.com/docs/reference/api/v1-create-a-sso-provider', + content: params?.include_full_content ? 'Creates a new SSO provider for a project' : null, + metadata: { + title: 'Create a SSO provider', + subtitle: 'Management API Reference: Create a SSO provider', + }, + }, ] return Promise.resolve({ data: mockResults.slice(0, limit), error: null }) } @@ -190,4 +200,40 @@ describe('/api/graphql searchDocs', () => { expect(json.errors).toBeDefined() expect(json.errors[0].message).toContain('required') }) + + it('should return Management API references with proper fields', async () => { + const searchQuery = ` + query { + searchDocs(query: "SSO provider", limit: 3) { + nodes { + ... on ManagementApiReference { + title + href + content + } + } + } + } + ` + const request = new Request('http://localhost/api/graphql', { + method: 'POST', + body: JSON.stringify({ query: searchQuery }), + }) + + const response = await POST(request) + const json = await response.json() + + expect(json.errors).toBeUndefined() + expect(json.data).toBeDefined() + expect(json.data.searchDocs).toBeDefined() + expect(json.data.searchDocs.nodes).toBeInstanceOf(Array) + expect(json.data.searchDocs.nodes).toHaveLength(3) + + const managementApiNode = json.data.searchDocs.nodes[2] + expect(managementApiNode).toMatchObject({ + title: 'Create a SSO provider', + href: 'https://supabase.com/docs/reference/api/v1-create-a-sso-provider', + content: 'Creates a new SSO provider for a project', + }) + }) }) 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/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 880387ea4fb..389a9694273 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -663,6 +663,7 @@ export const auth = { { name: 'Firebase Auth', url: '/guides/auth/third-party/firebase-auth' }, { name: 'Auth0', url: '/guides/auth/third-party/auth0' }, { name: 'AWS Cognito (Amplify)', url: '/guides/auth/third-party/aws-cognito' }, + { name: 'WorkOS', url: '/guides/auth/third-party/workos' }, ], }, { @@ -714,6 +715,7 @@ export const auth = { { name: 'Rate Limits', url: '/guides/auth/rate-limits' }, { name: 'Bot Detection (CAPTCHA)', url: '/guides/auth/auth-captcha' }, { name: 'JWTs', url: '/guides/auth/jwts' }, + { name: 'JWT Fields Reference', url: '/guides/auth/jwt-fields' }, { name: 'Row Level Security', url: '/guides/database/postgres/row-level-security' }, { name: 'Column Level Security', @@ -2160,6 +2162,16 @@ export const platform: NavMenuConstant = { { name: 'Multi-factor Authentication', url: '/guides/platform/multi-factor-authentication', + items: [ + { + name: 'Enable MFA', + url: '/guides/platform/multi-factor-authentication', + }, + { + name: 'Require MFA for organization members', + url: '/guides/platform/org-mfa-enforcement', + }, + ], }, { name: 'Transfer Project', diff --git a/apps/docs/content/_partials/billing/pricing/pricing_branching.mdx b/apps/docs/content/_partials/billing/pricing/pricing_branching.mdx index ff9420f1ca3..362d92da9c6 100644 --- a/apps/docs/content/_partials/billing/pricing/pricing_branching.mdx +++ b/apps/docs/content/_partials/billing/pricing/pricing_branching.mdx @@ -1 +1 @@ -There is no fixed fee for a Preview branch. You only pay for the usage it incurs. With Compute costs of per hour, a branch running on Micro Compute size starts at per day. +There is no fixed fee for a Preview branch. You only pay for the usage it incurs. A branch running on the default Micro Compute size starts at per hour. diff --git a/apps/docs/content/_partials/mcp_supabase_config.mdx b/apps/docs/content/_partials/mcp_supabase_config.mdx index 213a6dedde8..fd5068f69fb 100644 --- a/apps/docs/content/_partials/mcp_supabase_config.mdx +++ b/apps/docs/content/_partials/mcp_supabase_config.mdx @@ -16,15 +16,18 @@ "args": [ "-y", "@supabase/mcp-server-supabase@latest", - "--access-token", - "" - ] + "--read-only", + "--project-ref=" + ], + "env": { + "SUPABASE_ACCESS_TOKEN": "" + } } } } ``` - Replace `` with your personal access token. + Replace `` with your project ref, and `` with your personal access token. @@ -40,15 +43,18 @@ "npx", "-y", "@supabase/mcp-server-supabase@latest", - "--access-token", - "" - ] + "--read-only", + "--project-ref=" + ], + "env": { + "SUPABASE_ACCESS_TOKEN": "" + } } } } ``` - Replace `` with your personal access token. + Replace `` with your project ref, and `` with your personal access token. @@ -83,15 +89,18 @@ "npx", "-y", "@supabase/mcp-server-supabase@latest", - "--access-token", - "" - ] + "--read-only", + "--project-ref=" + ], + "env": { + "SUPABASE_ACCESS_TOKEN": "" + } } } } ``` - Replace `` with your personal access token. + Replace `` with your project ref, and `` with your personal access token. This assumes you have Windows Subsystem for Linux (WSL) enabled and `node`/`npx` are installed within the WSL environment. @@ -107,15 +116,18 @@ "args": [ "-y", "@supabase/mcp-server-supabase@latest", - "--access-token", - "" - ] + "--read-only", + "--project-ref=" + ], + "env": { + "SUPABASE_ACCESS_TOKEN": "" + } } } } ``` - Replace `` with your personal access token. + Replace `` with your project ref, and `` with your personal access token. diff --git a/apps/docs/content/_partials/mcp_supabase_vscode_config.mdx b/apps/docs/content/_partials/mcp_supabase_vscode_config.mdx index df8aacc259e..0117cb7d3e3 100644 --- a/apps/docs/content/_partials/mcp_supabase_vscode_config.mdx +++ b/apps/docs/content/_partials/mcp_supabase_vscode_config.mdx @@ -21,7 +21,7 @@ "servers": { "supabase": { "command": "npx", - "args": ["-y", "@supabase/mcp-server-supabase@latest"], + "args": ["-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref="], "env": { "SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}" } @@ -30,6 +30,8 @@ } ``` + Replace `` with your project ref. + @@ -47,7 +49,7 @@ "servers": { "supabase": { "command": "cmd", - "args": ["/c", "npx", "-y", "@supabase/mcp-server-supabase@latest"], + "args": ["/c", "npx", "-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref="], "env": { "SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}" } @@ -56,6 +58,8 @@ } ``` + Replace `` with your project ref. + Make sure that `node` and `npx` are available in your system `PATH`. Assuming `node` is installed, you can get the path by running: @@ -93,7 +97,7 @@ "servers": { "supabase": { "command": "wsl", - "args": ["npx", "-y", "@supabase/mcp-server-supabase@latest"], + "args": ["npx", "-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref="], "env": { "SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}" } @@ -102,6 +106,8 @@ } ``` + Replace `` with your project ref. + This assumes you have Windows Subsystem for Linux (WSL) enabled and `node`/`npx` are installed within the WSL environment. @@ -121,7 +127,7 @@ "servers": { "supabase": { "command": "npx", - "args": ["-y", "@supabase/mcp-server-supabase@latest"], + "args": ["-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref="], "env": { "SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}" } @@ -130,6 +136,8 @@ } ``` + Replace `` with your project ref. + diff --git a/apps/docs/content/_partials/quickstart_db_setup.mdx b/apps/docs/content/_partials/quickstart_db_setup.mdx index cd282520da8..161ea8b7f5e 100644 --- a/apps/docs/content/_partials/quickstart_db_setup.mdx +++ b/apps/docs/content/_partials/quickstart_db_setup.mdx @@ -2,6 +2,28 @@ Go to [database.new](https://database.new) and create a new Supabase project. +Alternatively, you can create a project using the Management API: + +```bash +# First, get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" + +# List your organizations to get the organization ID +curl -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + https://api.supabase.com/v1/organizations + +# Create a new project (replace with your organization ID) +curl -X POST https://api.supabase.com/v1/projects \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "organization_id": "", + "name": "My Project", + "region": "us-east-1", + "password": "" + }' +``` + When your project is up and running, go to the [Table Editor](https://supabase.com/dashboard/project/_/editor), create a new table and insert some data. Alternatively, you can run the following snippet in your project's [SQL Editor](https://supabase.com/dashboard/project/_/sql/new). This will create a `instruments` table with some sample data. diff --git a/apps/docs/content/guides/api/api-keys.mdx b/apps/docs/content/guides/api/api-keys.mdx index 33c869cf011..5b26b8ca72e 100644 --- a/apps/docs/content/guides/api/api-keys.mdx +++ b/apps/docs/content/guides/api/api-keys.mdx @@ -1,66 +1,199 @@ --- id: 'api-keys' -title: 'Understanding API Keys' -description: 'Securing your Data API with Postgres Row Level Security.' +title: 'Understanding API keys' +description: "First-layer protection for your project's data" --- -Supabase provides two default keys when you create a project: an `anon` key, and a `service_role` key. You can find both keys in the [API Settings](https://supabase.com/dashboard/project/_/settings/api). +Supabase gives you fine-grained control over which application components are allowed to access your project through API keys. -The data APIs are designed to work with Postgres Row Level Security (RLS). These keys both map to Postgres roles. You can find an `anon` user and a `service_role` user in the [Roles](http://supabase.com/dashboard/project/_/database/roles) section of the dashboard. +API keys provide the first layer of authentication for data access. Auth then builds upon that. This chart covers the differences: -The keys are both long-lived JWTs. If you decode these keys, you will see that they contain the "role", an "issued date", and an "expiry date" ~10 years in the future. +| Responsibility | Question | Answer | +| ---------------------------------- | ---------------------------------- | -------------------------------------------------- | +| API keys | **What** is accessing the project? | Web page, mobile app, server, Edge Function... | +| [Supabase Auth](/docs/guides/auth) | **Who** is accessing the project? | Monica, Jian Yang, Gavin, Dinesh, Laurie, Fiona... | -```json -{ - "role": "anon", - "iat": 1625137684, - "exp": 1940713684 -} -``` +## Overview -## The `anon` key +An API key authenticates an application component to give it access to Supabase services. An application component might be a web page, a mobile app, or a server. The API key _does not_ distinguish between users, only between applications. -The `anon` key has very few privileges. You can use it in your [RLS policies](/docs/guides/database/postgres/row-level-security) to allow unauthenticated access. For example, this policy will allow unauthenticated access to the `profiles` table: +There are 4 types of API keys that can be used with Supabase: -```sql -create policy "Allow public access" on profiles to anon for -select - using (true); -``` +| Type | Format | Privileges | Availability | Use | +| ---------------------------------------------------------- | ---------------------------------------------------------------- | ---------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Publishable key | `sb_publishable_...` | Low | Platform | Safe to expose online: web page, mobile or desktop app, GitHub actions, CLIs, source code. | +| Secret keys | `sb_secret_...` | Elevated | Platform | **Only use in backend components of your app:** servers, already secured APIs (admin panels), [Edge Functions](/docs/guides/functions), microservices, etc. They provide _full access_ to your project's data, bypassing [Row Level Security](/docs/guides/database/postgres/row-level-security). | +| `anon` | JWT (long lived) | Low | Platform, CLI | Exactly like the publishable key. | +| `service_role` | JWT (long lived) | Elevated | Platform, CLI | Exactly like secret keys. | -And similarly for disallowing access: + -```sql -create policy "Disallow public access" on profiles to anon for -select - using (false); -``` - -If you are using [Supabase Auth](/docs/guides/auth/overview), then the `anon` role will automatically update to `authenticated` once a user is logged in: - -```sql -create policy "Allow access to authenticated users" on profiles to authenticated for -select - using (true); -``` - -## The `service_role` key - -The "service_role" is a predefined Postgres role with elevated privileges, designed to perform various administrative and service-related tasks. It can bypass Row Level Security, so it should only be used on a private server. - - - -Never expose the `service_role` key in a browser or anywhere where a user can see it. +`anon` and `service_role` keys are based on the project's JWT secret. They are generated when your project is created and can only be changed when you rotate the JWT secret. This can cause significant issues in production applications. Use the publishable and secret keys instead. -A common use case for the `service_role` key is running data analytics jobs on the backend. To support joins on user id, it is often useful to grant the service role read access to `auth.users` table. +## `anon` and publishable keys -```sql -grant -select - on table auth.users to service_role; -``` +The `anon` and publishable keys secure the public components of your application. Public components run in environments where it is impossible to secure any secrets. These include: -We have [partnered with GitHub](https://github.blog/changelog/2022-03-28-supabase-is-now-a-github-secret-scanning-partner/) to scan for Supabase `service_role` keys pushed to public repositories. -If they detect any keys with service_role privileges being pushed to GitHub, they will forward the API key to us, so that we can automatically revoke the detected secrets and notify you, protecting your data against malicious actors. +- Web pages, where the key is bundled in source code. +- Mobile or desktop applications, where the key is bundled inside the compiled packages or executables. +- CLI, scripts, tools, or other pre-built executables. +- Other publicly available APIs that return the key without prior additional authorization. + +These environments are always considered public because anyone can retrieve the key from the source code or build artifacts. Obfuscation can increase the difficulty, but never eliminate the possibility. (In general, obfuscation, Turing test challenges, and specialized knowledge do not count as authorization for the purpose of securing secrets.) + +### Interaction with Supabase Auth + +Using the `anon` or publishable key does not mean that your user is anonymous. (Thinking of both these keys as publishable rather than `anon` makes the mental model clearer.) + +Your application can be authenticated with the publishable key, while your user is authenticated (via Supabase Auth) with their personal JWT: + +| Key | User logged in via Supabase Auth | Postgres role used for RLS, etc. | +| --------------- | -------------------------------- | -------------------------------- | +| Publishable key | No | `anon` | +| `anon` | No | `anon` | +| Publishable key | Yes | `authenticated` | +| `anon` | Yes | `authenticated` | + +### Protection + +These keys provide first-layer protection to your project's data, performance and bill, such as: + +- Providing basic Denial-of-Service protection, by requiring a minimal threshold of knowledge. +- Protecting your bill by ignoring bots, scrapers, automated vulnerability scanners and other well meaning or random Internet activity. + +### Security considerations + +The publishable and `anon` keys are not intended to protect from the following, since key retrieval is always possible from a public component: + +- Static or dynamic code analysis and reverse engineering attempts. +- Use of the Network inspector in the browser. +- Cross-site request forgery, cross-site scripting, phishing attacks. +- Man-in-the-middle attacks. + +When using the publishable or `anon` key, access to your project's data is guarded by Postgres via the built-in `anon` and `authenticated` roles. For full protection make sure: + +- You have enabled Row Level Security on all tables. +- You regularly review your Row Level Security policies for permissions granted to the `anon` and `authenticated` roles. +- You do not modify the role's attributes without understanding the changes you are making. + +Your project's [Security Advisor](/dashboard/project/_/advisors/security) constantly checks for common security problems with the built-in Postgres roles. Make sure you carefully review each finding before dismissing it. + +## `service_role` and secret keys + +Unlike the `anon` and publishable key, the `service_role` and secret keys allow elevated access to your project's data. It is meant to be used only in secure, developer-controlled components of your application, such as: + +- Servers that implement prior authorization themselves, such as Edge Functions, microservices, traditional or specialized web servers. +- Periodic jobs, queue processors, topic subscribers. +- Admin and back-office tools, with prior authorization checks only. +- Data processing pipelines, such as for analytics, reports, backups, or database synchronization. + + + +Never expose your `service_role` and secret keys publicly. Your data is at risk. **Do not:** + +- Add in web pages, public documents, source code, bundle in executables or packages for mobile, desktop or CLI apps. +- Send over chat applications, email or SMS to your peers. +- Never use in a browser, even on `localhost`! +- Do not pass in URLs or query params, as these are often logged. +- Be careful passing them in request headers without prior log sanitization. +- Take extra care logging even potentially **invalid API keys**. Simple typos might reveal the real key in the future. +- Reveal, copy, use or manipulate on hardware devices without full disk encryption and which you do not directly own or control (such as public computers, friend's laptop, etc.) + +Ensure you handle them with care and using [secure coding practices](https://owasp.org/www-project-secure-coding-practices-quick-reference-guide/stable-en/). + + + +Secret keys and the `service_role` JWT-based API key authorize access to your project's data via the built-in `service_role` Postgres role. By design, this role has full access to your project's data. It also uses the [`BYPASSRLS` attribute](https://www.postgresql.org/docs/current/ddl-rowsecurity.html#:~:text=BYPASSRLS), skipping any and all Row Level Security policies you attach. + +The secret key is an improvement over the old JWT-based `service_role` key, and we recommend using it where possible. It adds more checks to prevent misuse, specifically: + +- You cannot use a secret key in the browser (matches on the `User-Agent` header) and it will always reply with HTTP 401 Unauthorized. +- You don't need to have any secret keys if you are not using them. + +### Best practices for handling secret keys + +Below are some starting guidelines on how to securely work with secret keys: + +- Always work with secret keys on computers you fully own or control. +- Use secure & encrypted send tools to share API keys with others (often provided by good password managers), but prefer the [API Keys](/dashboard/project/_/settings/api-keys/new) dashboard instead. +- Prefer encrypting them when stored in files or environment variables. +- Do not add in source control, especially for CI scripts and tools. Prefer using the tool's native secrets capability instead. +- Prefer using a separate secret key for each separate backend component of your application, so that if one is found to be vulnerable or to have leaked the key you will only need to change it and not all. +- Even though a secret key will always return HTTP 401 Unauthorized error when used in a browser, it does not mean that attackers will not use it with other tools. Delete immediately! +- If you must include them in logs, log the first few random characters (but never more than 6). +- If you wish to log or store which valid API key was used, store it as a SHA256 hash. + +### What to do if a secret key or `service_role` has been leaked or compromised? + +Don't rush if this has happened, or you are suspecting it has. Make sure you have fully considered the situation and have remediated the root cause of the suspicion or vulnerability **first**. Consider using the [OWASP Risk Rating Methodology](https://owasp.org/www-community/OWASP_Risk_Rating_Methodology) as an easy way to identify the severity of the incident and to plan your next steps. + +Rotating a secret key (`sb_secret_...`) is easy and painless. Use the [API Keys](/dashboard/project/_/settings/api-keys/new) dashboard to create a new secret API key, then replace it with the compromised key. Once all components are using the new key, delete the compromised one. + +**Deleting a secret key is irreversible and once done it will be gone forever.** + +If you are still using the JWT-based `service_role` key, there are two options. + +1. **Strongly recommended:** Replace the `service_role` key with a new secret key instead. Follow the guide from above as if you are rotating an existing secret key. +2. [Rotate your project's JWT secret.](/dashboard/project/_/settings/api) This operation is only recommended if you suspect that the JWT secret has leaked itself. Consider switching your `anon` JWT-based key to the publishable key, and all `service_role` JWT-based keys to secret keys. Only then rotate the JWT secret. Check the FAQ below if you use the JWT-based keys in mobile, desktop or CLI applications! + +## Known limitations and compatibility differences + +As the publishable and secret keys are no longer JWT-based, there are some known limitations and compatibility differences that you may need to plan for: + +- You cannot send a publishable or secret key in the `Authorization: Bearer ...` header, except if the value exactly equals the `apikey` header. In this case, your request will be forwarded down to your project's database, but will be rejected as the value is not a JWT. +- Edge Functions **only support JWT verification** via the `anon` and `service_role` JWT-based API keys. You will need to use the `--no-verify-jwt` option when using publishable and secret keys. The Supabase platform does not verify the `apikey` header when using Edge Functions in this way. Implement your own `apikey`-header authorization logic inside the Edge Function code itself. +- Public Realtime connections are limited to 24 hours in duration, unless the connection is upgraded and further maintained with user-level authentication via Supabase Auth or a supported Third-Party Auth provider. + +## Frequently asked questions + +{/* supa-mdx-lint-disable Rule004ExcludeWords */} + +### I am using JWT-based `anon` key in a mobile, desktop, or CLI application and need to rotate my `service_role` JWT secret? + +If you know or suspect that the JWT secret itself is leaked, refer to the section on [rotating the JWT](#what-to-do-if-a-secret-key-or-servicerole-has-been-leaked-or-compromised). + +If the JWT secret is secure, prefer substituting the `service_role` JWT-based key with a new secret key which you can create in the [API Keys](/dashboard/project/_/settings/api-keys/new) dashboard. This will prevent downtime for your application. + +### How do I deactivate the `anon` and `service_role` JWT-based API keys after moving to publishable and secret keys? + +You can do this in the [API Keys](/dashboard/project/_/settings/api-keys/new) dashboard. To prevent downtime in your application's components, use the last used indicators on the page to confirm that these are no longer used before deactivating. + +You can re-activate them should you need to. + +### Why are `anon` and `service_role` JWT-based keys no longer recommended? + +Since the start of Supabase, the JWT-based `anon` and `service_role` keys were the right trade-off against simplicity and relative security for your project. Unfortunately they pose some real challenges in live applications, especially around rotation and security best practices. + +The main reasons for preferring the publishable and secret keys (`sb_publishable_...` and `sb_secret_...`) are: + +- Tight coupling between the JWT secret (which itself can be compromised, if you mint your own JWTs), the `anon` (low privilege) and `service_role` (high privilege) and `authenticated` (issued by Supabase Auth) Postgres roles. +- Inability to independently rotate each aspect of the keys, without downtime. +- Inability to roll-back an unnecessary or problematic JWT secret rotation. +- Publishing new versions of mobile applications can take days and often weeks in the app review phase with Apple's App Store and Google's Play Store. A forced rotation can cause weeks of downtime for mobile app users. +- Users may continue using desktop, CLI and mobile apps with very old versions, making rotation impossible without a forced version upgrade. +- JWTs had 10-year expiry duration, giving malicious actors more to work with. +- JWTs were self-referential and full of redundant information not necessary for achieving their primary purpose. +- JWTs are large, hard to parse, verify, and manipulate -- leading to insecure logging or bad security practices. +- They were signed with a symmetric JWT secret. + +### Why is there no publishable or secret keys in the CLI / self-hosting? + +Publishable and secret keys are only available on the Supabase hosted platform. They are managed by our API Gateway component, which does not currently have a CLI equivalent. + +We are looking into providing similar but limited in scope support for publishable or secret keys in the future. For now you can only use the `anon` and `service_role` JWT-based keys there. + +For advanced users, see the following question on how these keys are implemented on the hosted platform for an idea on how to provide similar functionality for yourself. + +### How are publishable and secret keys implemented on the hosted platform? + +When your applications use the Supabase APIs they go through a component called the API Gateway on the Supabase hosted platform. This provides us (and therefore you) with the following features: + +- Observability and logging. +- Performance and request routing (such as to read-replicas). +- Security, for blocking malicious patterns or behavior on a global scale. + +This API Gateway component is able to verify the API key (sent in the `apikey` request header, or for WebSocket in a query param) against your project's publishable and secret key list. If the match is found, it mints a temporary, short-lived JWT that is then forwarded down to your project's servers. + +It may be possible to replicate similar behavior if you self-host by using programmable proxies such as [Kong](https://konghq.com/), [Envoy](https://www.envoyproxy.io/), [NGINX](https://nginx.org/) or similar. diff --git a/apps/docs/content/guides/auth.mdx b/apps/docs/content/guides/auth.mdx index 8fefd30e7a3..213d1b91a1c 100644 --- a/apps/docs/content/guides/auth.mdx +++ b/apps/docs/content/guides/auth.mdx @@ -17,7 +17,7 @@ Authentication and authorization are the core responsibilities of any Auth syste - **Authentication** means checking that a user is who they say they are. - **Authorization** means checking what resources a user is allowed to access. -Supabase Auth uses [JSON Web Tokens (JWTs)](/docs/guides/auth/jwts) for authentication. Auth integrates with Supabase's database features, making it easy to use [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security) for authorization. +Supabase Auth uses [JSON Web Tokens (JWTs)](/docs/guides/auth/jwts) for authentication. For a complete reference of all JWT fields, see the [JWT Fields Reference](/docs/guides/auth/jwt-fields). Auth integrates with Supabase's database features, making it easy to use [Row Level Security (RLS)](/docs/guides/database/postgres/row-level-security) for authorization. ## The Supabase ecosystem diff --git a/apps/docs/content/guides/auth/auth-email-templates.mdx b/apps/docs/content/guides/auth/auth-email-templates.mdx index af34bfd7f4c..20167f74ac3 100644 --- a/apps/docs/content/guides/auth/auth-email-templates.mdx +++ b/apps/docs/content/guides/auth/auth-email-templates.mdx @@ -29,6 +29,36 @@ The templating system provides the following variables for use: On hosted Supabase projects, edit your email templates on the [Email Templates](/dashboard/project/_/auth/templates) page. On self-hosted projects or in local development, edit your [configuration files](/docs/guides/local-development/customizing-email-templates). +You can also manage email templates using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Get current email templates +curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + | jq 'to_entries | map(select(.key | startswith("mailer_templates"))) | from_entries' + +# Update email templates +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "mailer_subjects_confirmation": "Confirm your signup", + "mailer_templates_confirmation_content": "

Confirm your signup

Follow this link to confirm your user:

Confirm your email

", + "mailer_subjects_magic_link": "Your Magic Link", + "mailer_templates_magic_link_content": "

Magic Link

Follow this link to login:

Log In

", + "mailer_subjects_recovery": "Rest Your Password", + "mailer_templates_recovery_content": "

Reset Password

Follow this link to reset the password for your user:

Reset Password

", + "mailer_subjects_invite": "You have been invited", + "mailer_templates_invite_content": "

You have been invited

You have been invited to create a user on {{ .SiteURL }}. Follow this link to accept the invite:

Accept the invite

", + "mailer_subjects_email_change": "Confirm email change", + "mailer_templates_email_change_content": "

Confirm email change

Follow this link to confirm the update of your email:

Change email

", + }' +``` + ## Mobile deep linking For mobile applications, you might need to link or redirect to a specific page within your app. See the [Mobile Deep Linking guide](/docs/guides/auth/native-mobile-deep-linking) to set this up. diff --git a/apps/docs/content/guides/auth/auth-smtp.mdx b/apps/docs/content/guides/auth/auth-smtp.mdx index 1d1558d3c18..5f7f05b0ed5 100644 --- a/apps/docs/content/guides/auth/auth-smtp.mdx +++ b/apps/docs/content/guides/auth/auth-smtp.mdx @@ -50,6 +50,30 @@ A non-exhaustive list of services that work with Supabase Auth is: Once you've set up your account with an email sending service, head to the [Authentication settings page](/dashboard/project/_/settings/auth) to enable and configure custom SMTP. +You can also configure custom SMTP using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure custom SMTP +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_email_enabled": true, + "mailer_secure_email_change_enabled": true, + "mailer_autoconfirm": false, + "smtp_admin_email": "no-reply@example.com", + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "smtp_user": "your-smtp-user", + "smtp_pass": "your-smtp-password", + "smtp_sender_name": "Your App Name" + }' +``` + Once you save these settings, your project's Auth server will send messages to all addresses. To protect the reputation of your newly set up service a low rate-limit of 30 messages per hour is imposed. To adjust this to an acceptable value for your use case head to the [Rate Limits configuration page](/dashboard/project/_/auth/rate-limits). ## Dealing with abuse: How to maintain the sending reputation of your SMTP server? diff --git a/apps/docs/content/guides/auth/jwt-fields.mdx b/apps/docs/content/guides/auth/jwt-fields.mdx new file mode 100644 index 00000000000..2a154286456 --- /dev/null +++ b/apps/docs/content/guides/auth/jwt-fields.mdx @@ -0,0 +1,288 @@ +--- +id: 'jwt-fields' +title: 'JWT Fields Reference' +subtitle: 'Complete reference for JWT fields in Supabase' +--- + +This page provides a comprehensive reference for all JWT fields used in Supabase authentication tokens. This information is essential for server-side JWT validation and serialization, especially when implementing authentication in languages like Rust where field names like `ref` are reserved keywords. + +## JWT structure overview + +Supabase JWTs follow the standard JWT structure with three parts: + +- **Header**: Contains algorithm and key information +- **Payload**: Contains the claims (user data and metadata) +- **Signature**: Cryptographic signature for verification + +The payload contains various claims that provide user identity, authentication level, and authorization information. + +## Required claims + +These claims are always present in Supabase JWTs and cannot be removed: + +| Field | Type | Description | Example | +| -------------- | -------------------- | ----------------------------------------------------------- | --------------------------------------------- | +| `iss` | `string` | **Issuer** - The entity that issued the JWT | `"https://project-ref.supabase.co/auth/v1"` | +| `aud` | `string \| string[]` | **Audience** - The intended recipient of the JWT | `"authenticated"` or `"anon"` | +| `exp` | `number` | **Expiration Time** - Unix timestamp when the token expires | `1640995200` | +| `iat` | `number` | **Issued At** - Unix timestamp when the token was issued | `1640991600` | +| `sub` | `string` | **Subject** - The user ID (UUID) | `"123e4567-e89b-12d3-a456-426614174000"` | +| `role` | `string` | **Role** - User's role in the system | `"authenticated"`, `"anon"`, `"service_role"` | +| `aal` | `string` | **Authenticator Assurance Level** - Authentication strength | `"aal1"`, `"aal2"` | +| `session_id` | `string` | **Session ID** - Unique session identifier | `"session-uuid"` | +| `email` | `string` | **Email** - User's email address | `"user@example.com"` | +| `phone` | `string` | **Phone** - User's phone number | `"+1234567890"` | +| `is_anonymous` | `boolean` | **Anonymous Flag** - Whether the user is anonymous | `false` | + +## Optional claims + +These claims may be present depending on the authentication context: + +| Field | Type | Description | Example | +| --------------- | -------- | -------------------------------------------------------------------------- | --------------------------------------------------- | +| `jti` | `string` | **JWT ID** - Unique identifier for the JWT | `"jwt-uuid"` | +| `nbf` | `number` | **Not Before** - Unix timestamp before which the token is invalid | `1640991600` | +| `app_metadata` | `object` | **App Metadata** - Application-specific user data | `{"provider": "email"}` | +| `user_metadata` | `object` | **User Metadata** - User-specific data | `{"name": "John Doe"}` | +| `amr` | `array` | **Authentication Methods Reference** - List of authentication methods used | `[{"method": "password", "timestamp": 1640991600}]` | + +## Special fields + +| Field | Type | Description | Example | Context | +| ----- | -------- | --------------------------------------------------- | ------------------------ | ----------------------------- | +| `ref` | `string` | **Project Reference** - Supabase project identifier | `"abcdefghijklmnopqrst"` | Anon/Service role tokens only | + +## Field value constraints + +### Authenticator assurance level (`aal`) + +| Value | Description | +| -------- | ---------------------------------------------------- | +| `"aal1"` | Single-factor authentication (password, OAuth, etc.) | +| `"aal2"` | Multi-factor authentication (password + TOTP, etc.) | + +### Role values (`role`) + +| Value | Description | Use Case | +| ----------------- | ------------------ | ----------------------------------- | +| `"anon"` | Anonymous user | Public access with RLS policies | +| `"authenticated"` | Authenticated user | Standard user access | +| `"service_role"` | Service role | Admin privileges (server-side only) | + +### Audience values (`aud`) + +| Value | Description | +| ----------------- | ----------------------------- | +| `"authenticated"` | For authenticated user tokens | +| `"anon"` | For anonymous user tokens | + +### Authentication methods (`amr.method`) + +| Value | Description | +| ----------------- | ----------------------------- | +| `"oauth"` | OAuth provider authentication | +| `"password"` | Email/password authentication | +| `"otp"` | One-time password | +| `"totp"` | Time-based one-time password | +| `"recovery"` | Account recovery | +| `"invite"` | Invitation-based signup | +| `"sso/saml"` | SAML single sign-on | +| `"magiclink"` | Magic link authentication | +| `"email/signup"` | Email signup | +| `"email_change"` | Email change | +| `"token_refresh"` | Token refresh | +| `"anonymous"` | Anonymous authentication | + +## JWT examples + +### Authenticated user token + +```json +{ + "aal": "aal1", + "amr": [ + { + "method": "password", + "timestamp": 1640991600 + } + ], + "app_metadata": { + "provider": "email", + "providers": ["email"] + }, + "aud": "authenticated", + "email": "user@example.com", + "exp": 1640995200, + "iat": 1640991600, + "iss": "https://abcdefghijklmnopqrst.supabase.co/auth/v1", + "phone": "", + "role": "authenticated", + "session_id": "123e4567-e89b-12d3-a456-426614174000", + "sub": "123e4567-e89b-12d3-a456-426614174000", + "user_metadata": { + "name": "John Doe" + }, + "is_anonymous": false +} +``` + +### Anonymous user token + +```json +{ + "iss": "supabase", + "ref": "abcdefghijklmnopqrst", + "role": "anon", + "iat": 1640991600, + "exp": 1640995200 +} +``` + +### Service role token + +```json +{ + "iss": "supabase", + "ref": "abcdefghijklmnopqrst", + "role": "service_role", + "iat": 1640991600, + "exp": 1640995200 +} +``` + +## Language-Specific considerations + +### Rust + +In Rust, the `ref` field is a reserved keyword. When deserializing JWTs, you'll need to handle this: + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +struct JwtClaims { + iss: String, + #[serde(rename = "ref")] // Handle reserved keyword + project_ref: Option, + role: String, + iat: i64, + exp: i64, + // ... other fields +} +``` + +### TypeScript/JavaScript + +```typescript +interface JwtClaims { + iss: string + aud: string | string[] + exp: number + iat: number + sub: string + role: string + aal: 'aal1' | 'aal2' + session_id: string + email: string + phone: string + is_anonymous: boolean + jti?: string + nbf?: number + app_metadata?: Record + user_metadata?: Record + amr?: Array<{ + method: string + timestamp: number + }> + ref?: string // Only in anon/service role tokens +} +``` + +### Python + +```python +from typing import Optional, Union, List, Dict, Any +from dataclasses import dataclass + +@dataclass +class AmrEntry: + method: str + timestamp: int + +@dataclass +class JwtClaims: + iss: str + aud: Union[str, List[str]] + exp: int + iat: int + sub: str + role: str + aal: str + session_id: str + email: str + phone: str + is_anonymous: bool + jti: Optional[str] = None + nbf: Optional[int] = None + app_metadata: Optional[Dict[str, Any]] = None + user_metadata: Optional[Dict[str, Any]] = None + amr: Optional[List[AmrEntry]] = None + ref: Optional[str] = None # Only in anon/service role tokens +``` + +### Go + +```go +type AmrEntry struct { + Method string `json:"method"` + Timestamp int64 `json:"timestamp"` +} + +type JwtClaims struct { + Iss string `json:"iss"` + Aud interface{} `json:"aud"` // string or []string + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + Sub string `json:"sub"` + Role string `json:"role"` + Aal string `json:"aal"` + SessionID string `json:"session_id"` + Email string `json:"email"` + Phone string `json:"phone"` + IsAnonymous bool `json:"is_anonymous"` + Jti *string `json:"jti,omitempty"` + Nbf *int64 `json:"nbf,omitempty"` + AppMetadata map[string]interface{} `json:"app_metadata,omitempty"` + UserMetadata map[string]interface{} `json:"user_metadata,omitempty"` + Amr []AmrEntry `json:"amr,omitempty"` + Ref *string `json:"ref,omitempty"` // Only in anon/service role tokens +} +``` + +## Validation guidelines + +When implementing JWT validation on your server: + +1. **Check Required Fields**: Ensure all required claims are present +2. **Validate Types**: Verify field types match expected types +3. **Check Expiration**: Validate `exp` timestamp is in the future +4. **Verify Issuer**: Ensure `iss` matches your Supabase project +5. **Check Audience**: Validate `aud` matches expected audience +6. **Handle Reserved Keywords**: Use field renaming for languages like Rust + +## Security considerations + +- **Always validate the JWT signature** before trusting any claims +- **Never expose service role tokens** to client-side code +- **Validate all claims** before trusting the JWT +- **Check token expiration** on every request +- **Use HTTPS** for all JWT transmission +- **Rotate JWT secrets** regularly +- **Implement proper error handling** for invalid tokens + +## Related documentation + +- [JWT Overview](/docs/guides/auth/jwts) +- [Custom Access Token Hooks](/docs/guides/auth/auth-hooks/custom-access-token-hook) +- [Row Level Security](/docs/guides/database/postgres/row-level-security) +- [Server-Side Auth](/docs/guides/auth/server-side) diff --git a/apps/docs/content/guides/auth/jwts.mdx b/apps/docs/content/guides/auth/jwts.mdx index b568d6c7b66..0e68d9952f4 100644 --- a/apps/docs/content/guides/auth/jwts.mdx +++ b/apps/docs/content/guides/auth/jwts.mdx @@ -177,3 +177,4 @@ Now that you understand what JWTs are and where they're used in Supabase, you ca ## Resources - JWT debugger: https://jwt.io/ +- [JWT Fields Reference](/docs/guides/auth/jwt-fields) - Complete reference for all JWT fields in Supabase diff --git a/apps/docs/content/guides/auth/password-security.mdx b/apps/docs/content/guides/auth/password-security.mdx index 4582c5ed77a..4a9124eaa1f 100644 --- a/apps/docs/content/guides/auth/password-security.mdx +++ b/apps/docs/content/guides/auth/password-security.mdx @@ -23,7 +23,7 @@ There are hundreds of millions (and growing!) known passwords out there. Malicio To help protect your users, Supabase Auth allows you fine-grained control over the strength of the passwords used on your project. You can configure these in your project's [Auth settings](/dashboard/project/_/auth/providers?provider=Email): - Set a large minimum password length. Anything less than 8 characters is not recommended. -- Set the required characters that must appear at least once in a user's password. Use the strongest option of requiring digits, lowercase and uppercase letters, and symbols. +- Set the required characters that must appear at least once in a user's password. Use the strongest option of requiring digits, lowercase and uppercase letters, and symbols. The allowed symbols are: ``!@#$%^&*()_+-=[]{};'\:"|<>?,./`~`` - Prevent the use of leaked passwords. Supabase Auth uses the open-source [HaveIBeenPwned.org Pwned Passwords API](https://haveibeenpwned.com/Passwords) to reject passwords that have been leaked and are known by malicious actors. diff --git a/apps/docs/content/guides/auth/rate-limits.mdx b/apps/docs/content/guides/auth/rate-limits.mdx index 6edd22b0df8..1233f2c06b3 100644 --- a/apps/docs/content/guides/auth/rate-limits.mdx +++ b/apps/docs/content/guides/auth/rate-limits.mdx @@ -5,4 +5,31 @@ subtitle: 'Rate limits protect your services from abuse' Supabase Auth enforces rate limits on endpoints to prevent abuse. Some rate limits are [customizable](/dashboard/project/_/auth/rate-limits). +You can also manage rate limits using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Get current rate limits +curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + | jq 'to_entries | map(select(.key | startswith("rate_limit_"))) | from_entries' + +# Update rate limits +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "rate_limit_anonymous_users": 10, + "rate_limit_email_sent": 10, + "rate_limit_sms_sent": 10, + "rate_limit_verify": 10, + "rate_limit_token_refresh": 10, + "rate_limit_otp": 10, + "rate_limit_web3": 10 + }' +``` + <$Partial path="auth_rate_limits.mdx" /> diff --git a/apps/docs/content/guides/auth/server-side/sveltekit.mdx b/apps/docs/content/guides/auth/server-side/sveltekit.mdx index 845cd0aebbc..f8b0719df4b 100644 --- a/apps/docs/content/guides/auth/server-side/sveltekit.mdx +++ b/apps/docs/content/guides/auth/server-side/sveltekit.mdx @@ -321,7 +321,7 @@ Set up a listener for Auth events on the client, to handle session refreshes and -Create your first page. This example page calls Supabase from the server to get a list of countries from the database. +Create your first page. This example page calls Supabase from the server to get a list of colors from the database. This is an example of a public page that uses publicly readable data. 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..b2fd02f6eec 100644 --- a/apps/docs/content/guides/auth/social-login/auth-apple.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-apple.mdx @@ -67,6 +67,24 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app, 6. Create a signing **Key** in the [Keys](https://developer.apple.com/account/resources/authkeys/list) section of the Apple Developer Console. You can use this key to generate a secret key using the tool below, which is added to your Supabase project's Auth configuration. Make sure you safely store the `AuthKey_XXXXXXXXXX.p8` file. If you ever lose access to it, or make it public accidentally, revoke it from the Apple Developer Console and create a new one immediately. You will have to generate a new secret key using this file every 6 months, so make sure you schedule a recurring meeting in your calendar! 7. Finally, add the information you configured above to the [Apple provider configuration in the Supabase dashboard](https://supabase.com/dashboard/project/_/auth/providers). +You can also configure the Apple auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Apple auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_apple_enabled": true, + "external_apple_client_id": "your-services-id", + "external_apple_secret": "your-generated-secret-key" + }' +``` + Use this tool to generate a new Apple client secret. No keys leave your browser! Be aware that this tool does not currently work in Safari, so use Firefox or a Chrome-based browser instead. @@ -192,6 +210,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/auth/social-login/auth-azure.mdx b/apps/docs/content/guides/auth/social-login/auth-azure.mdx index 1b31c75b679..1057da8f831 100644 --- a/apps/docs/content/guides/auth/social-login/auth-azure.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-azure.mdx @@ -44,6 +44,25 @@ Setting up OAuth with Azure consists of four broad steps: ![Obtain the client secret](/docs/img/guides/auth-azure/azure-client-secret.png) +You can also configure the Azure auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Azure auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_azure_enabled": true, + "external_azure_client_id": "your-azure-client-id", + "external_azure_secret": "your-azure-client-secret", + "external_azure_url": "your-azure-url" + }' +``` + ## Guarding against unverified email domains Microsoft Entra ID can send out unverified email domains in certain cases. This may open up your project to a vulnerability where a malicious user can impersonate already existing accounts on your project. diff --git a/apps/docs/content/guides/auth/social-login/auth-discord.mdx b/apps/docs/content/guides/auth/social-login/auth-discord.mdx index 64ca269b7c4..18992be42c0 100644 --- a/apps/docs/content/guides/auth/social-login/auth-discord.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-discord.mdx @@ -43,6 +43,24 @@ Setting up Discord logins for your application consists of 3 parts: <$Partial path="social_provider_settings_supabase.mdx" variables={{ "provider": "Discord" }} /> +You can also configure the Discord auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Discord auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_discord_enabled": true, + "external_discord_client_id": "your-discord-client-id", + "external_discord_secret": "your-discord-client-secret" + }' +``` + ## Add login code to your client app +You can also configure the Facebook auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Facebook auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_facebook_enabled": true, + "external_facebook_client_id": "your-facebook-app-id", + "external_facebook_secret": "your-facebook-app-secret" + }' +``` + ## Add login code to your client app +You can also configure the GitHub auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure GitHub auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_github_enabled": true, + "external_github_client_id": "your-github-client-id", + "external_github_secret": "your-github-client-secret" + }' +``` + ## Add login code to your client app In local development, you can add the client ID and secret to your `config.toml` file. diff --git a/apps/docs/content/guides/auth/social-login/auth-linkedin.mdx b/apps/docs/content/guides/auth/social-login/auth-linkedin.mdx index 4d1c8364493..c01d491f023 100644 --- a/apps/docs/content/guides/auth/social-login/auth-linkedin.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-linkedin.mdx @@ -46,6 +46,24 @@ Ensure that the appropriate scopes have been added under OAuth 2.0 Scopes at the <$Partial path="social_provider_settings_supabase.mdx" variables={{ "provider": "LinkedIn (OIDC)" }} /> +You can also configure the LinkedIn (OIDC) auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure LinkedIn (OIDC) auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_linkedin_oidc_enabled": true, + "external_linkedin_oidc_client_id": "your-linkedin-client-id", + "external_linkedin_oidc_secret": "your-linkedin-client-secret" + }' +``` + ## Add login code to your client app +You can also configure the Slack (OIDC) auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Slack (OIDC) auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_slack_oidc_enabled": true, + "external_slack_oidc_client_id": "your-slack-client-id", + "external_slack_oidc_secret": "your-slack-client-secret" + }' +``` + ## Add login code to your client app +You can also configure the Spotify auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Spotify auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_spotify_enabled": true, + "external_spotify_client_id": "your-spotify-client-id", + "external_spotify_secret": "your-spotify-client-secret" + }' +``` + ## Add login code to your client app The following outlines the steps to sign in using Spotify with Supabase Auth. diff --git a/apps/docs/content/guides/auth/social-login/auth-twitter.mdx b/apps/docs/content/guides/auth/social-login/auth-twitter.mdx index 8c181621f0b..f1ed101fbc1 100644 --- a/apps/docs/content/guides/auth/social-login/auth-twitter.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-twitter.mdx @@ -50,6 +50,24 @@ Setting up Twitter logins for your application consists of 3 parts: <$Partial path="social_provider_settings_supabase.mdx" variables={{ "provider": "Twitter" }} /> +You can also configure the Twitter auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Twitter auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_twitter_enabled": true, + "external_twitter_client_id": "your-twitter-api-key", + "external_twitter_secret": "your-twitter-api-secret-key" + }' +``` + ## Add login code to your client app +You can also configure the Zoom auth provider using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Configure Zoom auth provider +curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "external_zoom_enabled": true, + "external_zoom_client_id": "your-zoom-client-id", + "external_zoom_secret": "your-zoom-client-secret" + }' +``` + ## Add login code to your client app `. Substitute your [custom auth domain](https://workos.com/docs/custom-domains/auth-api) for "api.workos.com" if configured. +2. Add a new Third-party Auth integration in your project's [Authentication settings](/dashboard/project/_/settings/auth). +3. Set up a JWT template to assign the `role: 'authenticated'` claim to your access token. + +## Setup the Supabase client library + + + + + +```typescript +import { createClient } from '@supabase/supabase-js' +import { createClient as createAuthKitClient } from '@workos-inc/authkit-js' + +const authkit = await createAuthKitClient('WORKOS_CLIENT_ID', { + apiHostname: '', +}) + +const supabase = createClient('https://.supabase.co', 'SUPABASE_ANON_KEY', { + accessToken: async () => { + return authkit.getAccessToken() + }, +}) +``` + + + + + +## Add a new Third-Party Auth integration to your project + +In the dashboard navigate to your project's [Authentication settings](/dashboard/project/_/settings/auth) and find the Third-Party Auth section to add a new integration. + +## Set up a JWT template to add the authenticated role. + +Your Supabase project inspects the `role` claim present in all JWTs sent to it, to assign the correct Postgres role when using the Data API, Storage or Realtime authorization. + +WorkOS JWTs already contain a `role` claim that corresponds to the user's role in their organization. It is necessary to adjust the `role` claim to be `"authenticated"` like Supabase expects. This can be done using JWT templates (navigate to Authentication -> Sessions -> JWT Template in the WorkOS Dashboard). + +This template overrides the `role` claim to meet Supabase's expectations, and adds the WorkOS role in a new `user_role` claim: + +```json +{ + "role": "authenticated", + "user_role": {{organization_membership.role}} +} +``` diff --git a/apps/docs/content/guides/database/extensions/timescaledb.mdx b/apps/docs/content/guides/database/extensions/timescaledb.mdx index 5d4120418ae..8f06cc54bdf 100644 --- a/apps/docs/content/guides/database/extensions/timescaledb.mdx +++ b/apps/docs/content/guides/database/extensions/timescaledb.mdx @@ -6,7 +6,7 @@ description: 'Scalable time-series data storage and analysis' -The `timescaledb` extension is deprecated in projects using Postgres 17. It continues to be supported in projects using Postgres 15, but will need to dropped before those projects are upgraded to Postgres 17. See the [Upgrading to Postgres 17 notes](/guides/platform/upgrading#upgrading-to-postgres-17) for more information. +The `timescaledb` extension is deprecated in projects using Postgres 17. It continues to be supported in projects using Postgres 15, but will need to dropped before those projects are upgraded to Postgres 17. See the [Upgrading to Postgres 17 notes](/docs/guides/platform/upgrading#upgrading-to-postgres-17) for more information. 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 a29c9156df7..b1a10c3a719 100644 --- a/apps/docs/content/guides/database/postgres/setup-replication-external.mdx +++ b/apps/docs/content/guides/database/postgres/setup-replication-external.mdx @@ -29,7 +29,7 @@ select pg_create_logical_replication_slot('example_slot', 'pgoutput'); This will need a **direct** connection (not a Connection Pooler) to your database and you can find the connection info in the [Dashboard](https://supabase.com/dashboard/project/_/settings/database). -You will also need to ensure that IPv6 is supported by your replication destination (or you can enable the [IPv4 add-on](/guides/platform/ipv4-address)) +You will also need to ensure that IPv6 is supported by your replication destination (or you can enable the [IPv4 add-on](/docs/guides/platform/ipv4-address)) If you would prefer not to use the `postgres` user, then you can run `CREATE ROLE WITH REPLICATION;` using the `postgres` user. @@ -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/database/replication.mdx b/apps/docs/content/guides/database/replication.mdx index ee5445662ff..57bd900485d 100644 --- a/apps/docs/content/guides/database/replication.mdx +++ b/apps/docs/content/guides/database/replication.mdx @@ -59,7 +59,7 @@ Logical replication is typically output in 2 forms, `pgoutput` and `wal2json`. T When using logical replication, Postgres is then configured to keep WAL files around for longer than it needs them. If the files are removed too quickly, then your `replication slot` will become inactive and, if the database receives a large number of changes in a short time, then the `replication slot` can become lost as it was not able to keep up. -In order to mitigate this, Postgres has many options and settings that can be [tweaked](/guides/database/custom-postgres-config) to manage the WAL usage effectively. Not all of these settings are user configurable as they can impact the stability of your database. For those that are, these should be considered as advanced configuration and not changed without understanding that they can cause additional disk space and resources to be used, as well as incur additional costs. +In order to mitigate this, Postgres has many options and settings that can be [tweaked](/docs/guides/database/custom-postgres-config) to manage the WAL usage effectively. Not all of these settings are user configurable as they can impact the stability of your database. For those that are, these should be considered as advanced configuration and not changed without understanding that they can cause additional disk space and resources to be used, as well as incur additional costs. | Setting | Description | User-facing | Default | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------- | ------- | diff --git a/apps/docs/content/guides/database/replication/faq.mdx b/apps/docs/content/guides/database/replication/faq.mdx index f0bdd97f465..804976d0923 100644 --- a/apps/docs/content/guides/database/replication/faq.mdx +++ b/apps/docs/content/guides/database/replication/faq.mdx @@ -13,7 +13,7 @@ Connections through a pooler, such as Supavisor, will not work. # The tool in use does not support IPv6 -You can enable the [IPv4 add-on](/guides/platform/ipv4-address) for your project. +You can enable the [IPv4 add-on](/docs/guides/platform/ipv4-address) for your project. # What is XMIN and should it be used? @@ -29,7 +29,7 @@ You can view [publications](https://supabase.com/dashboard/project/default/datab # How to configure database settings for replication? -Yes. Using the Supabase CLI, you can [configure database settings](/guides/database/custom-postgres-config#cli-configurable-settings) to optimize them for your replication needs. These values can vary depending on the activity of your database size and activity. +Yes. Using the Supabase CLI, you can [configure database settings](/docs/guides/database/custom-postgres-config#cli-configurable-settings) to optimize them for your replication needs. These values can vary depending on the activity of your database size and activity. # What are some important configuration options? diff --git a/apps/docs/content/guides/database/replication/monitoring-replication.mdx b/apps/docs/content/guides/database/replication/monitoring-replication.mdx index a49b8a8f27a..300ca966898 100644 --- a/apps/docs/content/guides/database/replication/monitoring-replication.mdx +++ b/apps/docs/content/guides/database/replication/monitoring-replication.mdx @@ -4,12 +4,12 @@ title: 'Monitoring replication' Monitoring replication lag is important and there are 3 ways to do this: -1. Dashboard - Under the [Reports](/guides/platform/reports) of the dashboard, you can view the replication lag of your project +1. Dashboard - Under the [Reports](/docs/guides/platform/reports) of the dashboard, you can view the replication lag of your project 2. Database - - pg_stat_subscription (subscriber) - if PID is null, then the subscription is not active - pg_stat_subscription_stats - look here for error_count to see if there were issues applying or syncing (if yes, check the logs for why) - pg_replication_slots - use this to check if the slot is active and you can also calculate the lag from here -3. [Metrics](/guides/telemetry/metrics) - Using the prometheus endpoint for your project +3. [Metrics](/docs/guides/telemetry/metrics) - Using the prometheus endpoint for your project - replication_slots_max_lag_bytes - this is the more important one - pg_stat_replication_replay_lag - lag to replay WAL files from the source DB on the target DB (throttled by disk or high activity) - pg_stat_replication_send_lag - lag in sending WAL files from the source DB (a high lag means that the publisher is not being asked to send new WAL files OR a network issues) diff --git a/apps/docs/content/guides/database/replication/setting-up-replication.mdx b/apps/docs/content/guides/database/replication/setting-up-replication.mdx index 36d4d528705..665356ed99f 100644 --- a/apps/docs/content/guides/database/replication/setting-up-replication.mdx +++ b/apps/docs/content/guides/database/replication/setting-up-replication.mdx @@ -68,7 +68,7 @@ Materialize has the following [documentation](https://materialize.com/docs/sql/c You can follow those steps with the following modifications: -1. Follow the steps in our [guide](/guides/database/postgres/setup-replication-external) to create a publication slot +1. Follow the steps in our [guide](/docs/guides/database/postgres/setup-replication-external) to create a publication slot diff --git a/apps/docs/content/guides/deployment/branching.mdx b/apps/docs/content/guides/deployment/branching.mdx index 2f107c17a01..7fe2fc29772 100644 --- a/apps/docs/content/guides/deployment/branching.mdx +++ b/apps/docs/content/guides/deployment/branching.mdx @@ -716,7 +716,7 @@ You can make a similar setup with a distinct project for each environment. Or ju Branching is available on the Pro Plan and above. The price is: -- Each Preview branch costs per day +- Each Preview branch costs per hour - Each Preview branch is billed until it is removed ## Troubleshooting diff --git a/apps/docs/content/guides/deployment/going-into-prod.mdx b/apps/docs/content/guides/deployment/going-into-prod.mdx index 8d36e73e709..2c857ff02e1 100644 --- a/apps/docs/content/guides/deployment/going-into-prod.mdx +++ b/apps/docs/content/guides/deployment/going-into-prod.mdx @@ -23,6 +23,7 @@ After developing your project and deciding it's Production Ready, you should run - Ensure that your Supabase Account is protected with multi-factor authentication (MFA). - If using a GitHub signin, [enable 2FA on GitHub](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication). Since your GitHub account gives you administrative rights to your Supabase org, you should protect it with a strong password and 2FA using a U2F key or a TOTP app. - If using email+password signin, set up [MFA for your Supabase account](https://supabase.com/docs/guides/platform/multi-factor-authentication#enable-mfa). +- Enable [MFA enforcement on your organization](/docs/guides/platform/network-restrictions). This ensures all users must have a valid MFA backed session to interact with organization and project resources. - Consider [adding multiple owners on your Supabase org](https://supabase.com/dashboard/org/_/team). This ensures that if one of the owners is unreachable or loses access to their account, you still have Owner access to your org. - Ensure email confirmations are [enabled](https://supabase.com/dashboard/project/_/auth/providers) in the `Settings > Auth` page. - Ensure that you've [set the expiry](https://supabase.com/dashboard/project/_/auth/providers) for one-time passwords (OTPs) to a reasonable value that you are comfortable with. We recommend setting this to 3600 seconds (1 hour) or lower. diff --git a/apps/docs/content/guides/deployment/shared-responsibility-model.mdx b/apps/docs/content/guides/deployment/shared-responsibility-model.mdx index 01ae5b36714..be95087802e 100644 --- a/apps/docs/content/guides/deployment/shared-responsibility-model.mdx +++ b/apps/docs/content/guides/deployment/shared-responsibility-model.mdx @@ -92,6 +92,7 @@ You can use Supabase to store and process Protected Health Information (PHI). Yo - Signing a Business Associate Agreement (BAA) with Supabase. Submit a [HIPAA add-on request](https://forms.supabase.com/hipaa2) to get started. You will need to be at least on the [Team Plan](https://supabase.com/pricing) to sign a BAA with us. - [Marking specific projects as HIPAA projects](/docs/guides/platform/hipaa-projects) and addressing security issues raised by the advisor. - Ensuring [MFA is enabled](/docs/guides/platform/multi-factor-authentication) on all Supabase accounts. + - [Enforce MFA](/docs/guides/platform/org-mfa-enforcement) as a requirement to access the organization - Enabling [Point in Time Recovery](/docs/guides/platform/backups#point-in-time-recovery) which requires at least a [small compute add-on](/docs/guides/platform/compute-add-ons). - Turning on [SSL Enforcement](/docs/guides/platform/ssl-enforcement). - Enabling [Network Restrictions](/docs/guides/platform/network-restrictions). diff --git a/apps/docs/content/guides/functions/examples/sentry-monitoring.mdx b/apps/docs/content/guides/functions/examples/sentry-monitoring.mdx index 5547de7496c..13dac78103f 100644 --- a/apps/docs/content/guides/functions/examples/sentry-monitoring.mdx +++ b/apps/docs/content/guides/functions/examples/sentry-monitoring.mdx @@ -50,6 +50,8 @@ Deno.serve(async (req) => { return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }) } catch (e) { Sentry.captureException(e) + // Flush Sentry before the running process closes + await Sentry.flush(2000) return new Response(JSON.stringify({ msg: 'error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, @@ -81,9 +83,4 @@ Find the complete example on [GitHub](https://github.com/supabase/supabase/tree/ ## Working with scopes -Sentry Deno SDK currently do not support `Deno.serve` instrumentation, which means that there is no scope separation between requests. -Because of that, when the Edge Functions runtime is reused between multiple requests, all globally captured breadcrumbs and contextual data -will be shared, which is not the desired behavior. To work around this, all default integrations in the example code above are disabled, -and you should be relying on [`withScope`](https://docs.sentry.io/platforms/javascript/enriching-events/scopes/#using-withscope) to encapsulate -all Sentry SDK API calls, or [pass context directly](https://docs.sentry.io/platforms/javascript/enriching-events/context/#passing-context-directly) -to the `captureException` or `captureMessage` calls. +Sentry Deno SDK currently do not support `Deno.serve` instrumentation, which means that there is no scope separation between requests. Because of that, when the Edge Functions runtime is reused between multiple requests, all globally captured breadcrumbs and contextual data will be shared, which is not the desired behavior. To work around this, all default integrations in the example code above are disabled, and you should be relying on [`withScope`](https://docs.sentry.io/platforms/javascript/enriching-events/scopes/#using-withscope) to encapsulate all Sentry SDK API calls, or [pass context directly](https://docs.sentry.io/platforms/javascript/enriching-events/context/#passing-context-directly) to the `captureException` or `captureMessage` calls. diff --git a/apps/docs/content/guides/functions/secrets.mdx b/apps/docs/content/guides/functions/secrets.mdx index 5439ff3c8c3..7dc1e1362c2 100644 --- a/apps/docs/content/guides/functions/secrets.mdx +++ b/apps/docs/content/guides/functions/secrets.mdx @@ -75,6 +75,35 @@ You will also need to set secrets for your production Edge Functions. You can do }} /> +### Using the Management API + +You can also manage secrets programmatically using the Management API: + +```bash +# First, get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Create a secret +curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/secrets" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '[{ + "name": "MY_SECRET_NAME", + "value": "my-secret-value" + }]' + +# List all secrets +curl -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + "https://api.supabase.com/v1/projects/$PROJECT_REF/secrets" + +# Delete a secret +curl -X DELETE "https://api.supabase.com/v1/projects/$PROJECT_REF/secrets" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '["MY_SECRET_NAME"]' +``` + ### Using the CLI Let's create a `.env` to help us deploy our secrets to production. In this case we'll just use the same as our local secrets: diff --git a/apps/docs/content/guides/getting-started/architecture.mdx b/apps/docs/content/guides/getting-started/architecture.mdx index 970431df96a..559bb711276 100644 --- a/apps/docs/content/guides/getting-started/architecture.mdx +++ b/apps/docs/content/guides/getting-started/architecture.mdx @@ -29,7 +29,7 @@ Each Supabase project consists of several tools: ### Postgres (database) -Postgres is the core of Supabase. We do not abstract the Postgres database—you can access it and use it with full privileges. We provide tools which makes Postgres as easy to use as Firebase. +Postgres is the core of Supabase. We do not abstract the Postgres database—you can access it and use it with full privileges. We provide tools which make Postgres as easy to use as Firebase. - Official Docs: [postgresql.org/docs](https://www.postgresql.org/docs/current/index.html) - Source code: [github.com/postgres/postgres](https://github.com/postgres/postgres) (mirror) diff --git a/apps/docs/content/guides/getting-started/features.mdx b/apps/docs/content/guides/getting-started/features.mdx index 80a3a939245..b1993b861a7 100644 --- a/apps/docs/content/guides/getting-started/features.mdx +++ b/apps/docs/content/guides/getting-started/features.mdx @@ -64,7 +64,7 @@ Deploy read-only databases across multiple regions, for lower latency and better ### Log drains -Export Supabase logs at to 3rd party providers and external tooling. [Docs](/docs/guides/platform/log-drains). +Export Supabase logs to 3rd party providers and external tooling. [Docs](/docs/guides/platform/log-drains). ## Studio @@ -195,53 +195,56 @@ Features in Beta are tested by an external penetration tester for security issue In addition to the Beta requirements, features in GA are covered by the [uptime SLA](https://supabase.com/sla). -| Product | Feature | Stage | Available on self-hosted | -| -------------- | -------------------------- | --------------- | ------------------------------------------- | -| Database | Postgres | `GA` | ✅ | -| Database | Vector Database | `GA` | ✅ | -| Database | Auto-generated Rest API | `GA` | ✅ | -| Database | Auto-generated GraphQL API | `GA` | ✅ | -| Database | Webhooks | `beta` | ✅ | -| Database | Vault | `public alpha` | ✅ | -| Platform | | `GA` | ✅ | -| Platform | Point-in-Time Recovery | `GA` | 🚧 [wal-g](https://github.com/wal-g/wal-g) | -| Platform | Custom Domains | `GA` | N/A | -| Platform | Network Restrictions | `beta` | N/A | -| Platform | SSL enforcement | `GA` | N/A | -| Platform | Branching | `public alpha` | N/A | -| Platform | Terraform Provider | `public alpha` | N/A | -| Platform | Read Replicas | `private alpha` | N/A | -| Platform | Log Drains | `public alpha` | ✅ | -| Studio | | `GA` | ✅ | -| Studio | SSO | `GA` | ✅ | -| Realtime | Postgres Changes | `GA` | ✅ | -| Realtime | Broadcast | `GA` | ✅ | -| Realtime | Presence | `GA` | ✅ | -| Realtime | Broadcast Authorization | `public beta` | ✅ | -| Realtime | Presence Authorization | `public beta` | ✅ | -| Storage | | `GA` | ✅ | -| Storage | CDN | `GA` | 🚧 [Cloudflare](https://www.cloudflare.com) | -| Storage | Smart CDN | `GA` | 🚧 [Cloudflare](https://www.cloudflare.com) | -| Storage | Image Transformations | `GA` | ✅ | -| Storage | Resumable Uploads | `GA` | ✅ | -| Storage | S3 compatibility | `public alpha` | ✅ | -| Edge Functions | | `beta` | ✅ | -| Edge Functions | Regional Invocations | `beta` | ✅ | -| Edge Functions | NPM compatibility | `beta` | ✅ | -| Auth | | `GA` | ✅ | -| Auth | Email login | `GA` | ✅ | -| Auth | Social login | `GA` | ✅ | -| Auth | Phone login | `GA` | ✅ | -| Auth | Passwordless login | `GA` | ✅ | -| Auth | SSO with SAML | `GA` | ✅ | -| Auth | Authorization via RLS | `GA` | ✅ | -| Auth | CAPTCHA protection | `GA` | ✅ | -| Auth | Server-side Auth | `beta` | ✅ | -| CLI | | `GA` | ✅ Works with self-hosted | -| Management API | | `GA` | N/A | -| Client Library | JavaScript | `GA` | N/A | -| Client Library | Flutter | `beta` | N/A | -| Client Library | Swift | `beta` | N/A | +| Product | Feature | Stage | Available on self-hosted | +| -------------- | -------------------------- | -------------- | ------------------------------------------- | +| Database | Postgres | `GA` | ✅ | +| Database | Vector Database | `GA` | ✅ | +| Database | Auto-generated Rest API | `GA` | ✅ | +| Database | Auto-generated GraphQL API | `GA` | ✅ | +| Database | Webhooks | `beta` | ✅ | +| Database | Vault | `public alpha` | ✅ | +| Platform | | `GA` | ✅ | +| Platform | Point-in-Time Recovery | `GA` | 🚧 [wal-g](https://github.com/wal-g/wal-g) | +| Platform | Custom Domains | `GA` | N/A | +| Platform | Network Restrictions | `GA` | N/A | +| Platform | SSL enforcement | `GA` | N/A | +| Platform | Branching | `beta` | N/A | +| Platform | Terraform Provider | `public alpha` | N/A | +| Platform | Read Replicas | `GA` | N/A | +| Platform | Log Drains | `public alpha` | ✅ | +| Studio | | `GA` | ✅ | +| Studio | SSO | `GA` | ✅ | +| Realtime | Postgres Changes | `GA` | ✅ | +| Realtime | Broadcast | `GA` | ✅ | +| Realtime | Presence | `GA` | ✅ | +| Realtime | Broadcast Authorization | `public beta` | ✅ | +| Realtime | Presence Authorization | `public beta` | ✅ | +| Realtime | Broadcast from Database | `public beta` | ✅ | +| Storage | | `GA` | ✅ | +| Storage | CDN | `GA` | 🚧 [Cloudflare](https://www.cloudflare.com) | +| Storage | Smart CDN | `GA` | 🚧 [Cloudflare](https://www.cloudflare.com) | +| Storage | Image Transformations | `GA` | ✅ | +| Storage | Resumable Uploads | `GA` | ✅ | +| Storage | S3 compatibility | `GA` | ✅ | +| Edge Functions | | `GA` | ✅ | +| Edge Functions | Regional Invocations | `GA` | ✅ | +| Edge Functions | NPM compatibility | `GA` | ✅ | +| Auth | | `GA` | ✅ | +| Auth | Email login | `GA` | ✅ | +| Auth | Social login | `GA` | ✅ | +| Auth | Phone login | `GA` | ✅ | +| Auth | Passwordless login | `GA` | ✅ | +| Auth | SSO with SAML | `GA` | ✅ | +| Auth | Authorization via RLS | `GA` | ✅ | +| Auth | CAPTCHA protection | `GA` | ✅ | +| Auth | Server-side Auth | `beta` | ✅ | +| Auth | Third-Party Auth | `GA` | ✅ | +| CLI | | `GA` | ✅ Works with self-hosted | +| Management API | | `GA` | N/A | +| Client Library | JavaScript | `GA` | N/A | +| Client Library | Flutter | `GA` | N/A | +| Client Library | Swift | `GA` | N/A | +| Client Library | Python | `beta` | N/A | - ✅ = Fully Available - 🚧 = Available, but requires external tools or configuration diff --git a/apps/docs/content/guides/getting-started/mcp.mdx b/apps/docs/content/guides/getting-started/mcp.mdx index fa878830241..44d85683efb 100644 --- a/apps/docs/content/guides/getting-started/mcp.mdx +++ b/apps/docs/content/guides/getting-started/mcp.mdx @@ -14,6 +14,7 @@ The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) - [Cline](#cline) (VS Code extension) - [Claude desktop](#claude-desktop) - [Claude code](#claude-code) +- [Amp](#amp) Once connected, your AI assistants can interact with and query your Supabase projects on your behalf. @@ -23,7 +24,9 @@ First, go to your [Supabase settings](https://supabase.com/dashboard/account/tok ## Step 2: Configure in your AI tool -MCP compatible tools can connect to Supabase using the [Supabase MCP server](https://github.com/supabase-community/supabase-mcp). Below are instructions for connecting to this server using popular AI tools: +MCP compatible tools can connect to Supabase using the [Supabase MCP server](https://github.com/supabase-community/supabase-mcp). + +Follow the instructions for your AI tool to connect the Supabase MCP server. The configuration below uses read-only, project-scoped mode by default. We recommend these settings to prevent the agent from making unintended changes to your database. Note that read-only mode applies only to database operations. Write operations on project-management tools, such as `create_project`, are still available. ### Cursor @@ -53,9 +56,9 @@ MCP compatible tools can connect to Supabase using the [Supabase MCP server](htt
[![Install with NPX in VS - Code](https://img.shields.io/badge/VS_Code-NPM-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=supabase&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22supabase-access-token%22%2C%22description%22%3A%22Supabase%20personal%20access%20token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40supabase%2Fmcp-server-supabase%40latest%22%5D%2C%22env%22%3A%7B%22SUPABASE_ACCESS_TOKEN%22%3A%22%24%7Binput%3Asupabase-access-token%7D%22%7D%7D) + Code](https://img.shields.io/badge/VS_Code-NPM-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=supabase&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22supabase-access-token%22%2C%22description%22%3A%22Supabase%20personal%20access%20token%22%2C%22password%22%3Atrue%7D%5D%26config%3D%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40supabase%2Fmcp-server-supabase%40latest%22%2C%22--readonly%22%2C%22--project-ref%3D%24SUPABASE_MCP_PROJECT_REF%22%5D%2C%22env%22%3A%7B%22SUPABASE_ACCESS_TOKEN%22%3A%22%24%7Binput%3Asupabase-access-token%7D%22%2C%22SUPABASE_MCP_PROJECT_REF%22%3A%22%24%7Binput%3Asupabase-project-ref%7D%22%7D%7D) [![Install with NPX in VS Code - Insiders](https://img.shields.io/badge/VS_Code_Insiders-NPM-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=supabase&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22supabase-access-token%22%2C%22description%22%3A%22Supabase%20personal%20access%20token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40supabase%2Fmcp-server-supabase%40latest%22%5D%2C%22env%22%3A%7B%22SUPABASE_ACCESS_TOKEN%22%3A%22%24%7Binput%3Asupabase-access-token%7D%22%7D%7D&quality=insiders) + Insiders](https://img.shields.io/badge/VS_Code_Insiders-NPM-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=supabase&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22supabase-access-token%22%2C%22description%22%3A%22Supabase%20personal%20access%20token%22%2C%22password%22%3Atrue%7D%5D%26config%3D%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40supabase%2Fmcp-server-supabase%40latest%22%2C%22--readonly%22%2C%22--project-ref%3D%24SUPABASE_MCP_PROJECT_REF%22%5D%2C%22env%22%3A%7B%22SUPABASE_ACCESS_TOKEN%22%3A%22%24%7Binput%3Asupabase-access-token%7D%22%2C%22SUPABASE_MCP_PROJECT_REF%22%3A%22%24%7Binput%3Asupabase-project-ref%7D%22%7D%7D)
1. Open [VS Code](https://code.visualstudio.com/) and create a `.vscode` directory in your project root if it doesn't exist. @@ -115,11 +118,84 @@ You can also add the Supabase MCP server as a locally-scoped server, which will 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 + 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. +### Amp + +You can add the Supabase MCP server to Amp in two ways: + +#### Option 1: VSCode settings.json + +1. Open "Preferences: Open User Settings (JSON)" +2. Add the following configuration: + + ```json + { + "amp.mcpServers": { + "supabase": { + "command": "npx", + "args": [ + "-y", + "@supabase/mcp-server-supabase@latest", + "--read-only", + "--project-ref=" + ], + "env": { + "SUPABASE_ACCESS_TOKEN": "" + } + } + } + } + ``` + + Replace `project-ref` and `personal-access-token` with your project ref and personal access token. + +3. Save the configuration file. +4. Restart VS Code to apply the new configuration. + +#### Option 2: Amp CLI + +1. Edit `~/.config/amp/settings.json` +1. Add the following configuration: + + ```json + { + "amp.mcpServers": { + "supabase": { + "command": "npx", + "args": [ + "-y", + "@supabase/mcp-server-supabase@latest", + "--read-only", + "--project-ref=" + ], + "env": { + "SUPABASE_ACCESS_TOKEN": "" + } + } + } + } + ``` + + Replace `project-ref` and `personal-access-token` with your project ref and personal access token. + +1. Save the configuration file. +1. Restart Amp to apply the new configuration. + +### Qodo Gen + +1. Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ. +1. Click `Connect more tools`. +1. Click `+ Add new MCP`. +1. Add the following configuration: + + <$Partial path="mcp_supabase_config.mdx" variables={{ "app": "Qodo Gen" }} /> + +1. Click `Save`. + ### 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/local-development.mdx b/apps/docs/content/guides/local-development.mdx index 309996efffa..7ea2ec792e1 100644 --- a/apps/docs/content/guides/local-development.mdx +++ b/apps/docs/content/guides/local-development.mdx @@ -18,7 +18,7 @@ Develop locally while running the Supabase stack on your machine. ```sh - yarn add supabase --dev + NODE_OPTIONS=--no-experimental-fetch yarn add supabase --dev ``` @@ -95,6 +95,17 @@ Develop locally while running the Supabase stack on your machine.
+ + + As a prerequisite, you must install a container runtime compatible with Docker APIs. + + - [Docker Desktop](https://docs.docker.com/desktop/) (macOS, Windows, Linux) + - [Rancher Desktop](https://rancherdesktop.io/) (macOS, Windows, Linux) + - [Podman](https://podman.io/) (macOS, Windows, Linux) + - [OrbStack](https://orbstack.dev/) (macOS) + + + 4. View your local Supabase instance at [http://localhost:54323](http://localhost:54323). ## Local development diff --git a/apps/docs/content/guides/local-development/customizing-email-templates.mdx b/apps/docs/content/guides/local-development/customizing-email-templates.mdx index dfbe7aa1307..0935886c4f5 100644 --- a/apps/docs/content/guides/local-development/customizing-email-templates.mdx +++ b/apps/docs/content/guides/local-development/customizing-email-templates.mdx @@ -32,13 +32,49 @@ content_path = "./supabase/templates/invite.html" ## Available email templates -There are several Auth email templates which can be configured: +There are several Auth email templates which can be configured. Each template serves a specific authentication flow: -- `auth.email.template.invite` -- `auth.email.template.confirmation` -- `auth.email.template.recovery` -- `auth.email.template.magic_link` -- `auth.email.template.email_change` +### `auth.email.template.invite` + +**Default subject**: "You have been invited" +**When sent**: When a user is invited to join your application via email invitation +**Purpose**: Allows administrators to invite users who don't have accounts yet +**Content**: Contains a link for the invited user to accept the invitation and create their account + +### `auth.email.template.confirmation` + +**Default subject**: "Confirm Your Signup" +**When sent**: When a user signs up and needs to verify their email address +**Purpose**: Email verification for new user registrations +**Content**: Contains a confirmation link to verify the user's email address + +### `auth.email.template.recovery` + +**Default subject**: "Reset Your Password" +**When sent**: When a user requests a password reset +**Purpose**: Password recovery flow for users who forgot their password +**Content**: Contains a link to reset the user's password + +### `auth.email.template.magic_link` + +**Default subject**: "Your Magic Link" +**When sent**: When a user requests a magic link for passwordless authentication +**Purpose**: Passwordless login using email links +**Content**: Contains a secure link that automatically logs the user in when clicked + +### `auth.email.template.email_change` + +**Default subject**: "Confirm Email Change" +**When sent**: When a user requests to change their email address +**Purpose**: Verification for email address changes +**Content**: Contains a confirmation link to verify the new email address + +### `auth.email.template.reauthentication` + +**Default subject**: "Confirm Reauthentication" +**When sent**: When a user needs to re-authenticate for sensitive operations +**Purpose**: Additional verification for sensitive actions (like changing password, deleting account) +**Content**: Contains a 6-digit OTP code for verification ## Template variables diff --git a/apps/docs/content/guides/local-development/declarative-database-schemas.mdx b/apps/docs/content/guides/local-development/declarative-database-schemas.mdx index 6787f77ba8a..bc71dc34edf 100644 --- a/apps/docs/content/guides/local-development/declarative-database-schemas.mdx +++ b/apps/docs/content/guides/local-development/declarative-database-schemas.mdx @@ -74,7 +74,7 @@ supabase db diff -f create_employees_table - Start the local database and then apply the migration file to see your schema changes in the local Dashboard. + Start the local database first. Then, apply the migration manually to see your schema changes in the local Dashboard. @@ -82,7 +82,8 @@ supabase db diff -f create_employees_table <$CodeTabs> ```bash name=Terminal -supabase start && supabase migration up +supabase start +supabase migration up ``` @@ -97,28 +98,6 @@ supabase start && supabase migration up - - Before updating your schema files, stop the local development environment. - - - - -<$CodeTabs> - -```bash name=Terminal -supabase stop -``` - - - - - - - - - - - Edit `supabase/schemas/employees.sql` file to add a new column to `employees` table. @@ -150,7 +129,7 @@ Some entities like views and enums expect columns to be declared in a specific o - + Diff existing migrations against your declared schema. @@ -172,7 +151,7 @@ supabase db diff -f add_age - + Verify that the generated migration contain a single incremental change. @@ -193,7 +172,7 @@ alter table "public"."employees" add column "age" smallint not null; - + Start the database locally and apply the pending migration. @@ -203,7 +182,7 @@ alter table "public"."employees" add column "age" smallint not null; <$CodeTabs> ```bash name=Terminal -supabase start && supabase migration up +supabase migration up ``` diff --git a/apps/docs/content/guides/local-development/managing-config.mdx b/apps/docs/content/guides/local-development/managing-config.mdx index 7ac8d864c72..018aba75e3f 100644 --- a/apps/docs/content/guides/local-development/managing-config.mdx +++ b/apps/docs/content/guides/local-development/managing-config.mdx @@ -56,3 +56,13 @@ client_id = "env(GITHUB_CLIENT_ID)" secret = "env(GITHUB_SECRET)" redirect_uri = "" # Overrides the default auth redirectUrl. ``` + +### Going further + +For more advanced secrets management workflows, including: + +- **Using dotenvx for encrypted secrets**: Learn how to securely manage environment variables across different branches and environments +- **Branch-specific secrets**: Understand how to manage secrets for different deployment environments +- **Encrypted configuration values**: Use encrypted values directly in your `config.toml` + +See the [Managing secrets for branches](/docs/guides/deployment/branching#managing-secrets-for-branches) section in our branching documentation, or check out the [dotenvx example repository](https://github.com/supabase/supabase/blob/master/examples/slack-clone/nextjs-slack-clone-dotenvx/README.md) for a complete implementation. diff --git a/apps/docs/content/guides/local-development/restoring-downloaded-backup.mdx b/apps/docs/content/guides/local-development/restoring-downloaded-backup.mdx index d8812196b6e..0da384c9822 100644 --- a/apps/docs/content/guides/local-development/restoring-downloaded-backup.mdx +++ b/apps/docs/content/guides/local-development/restoring-downloaded-backup.mdx @@ -3,7 +3,7 @@ title: Restoring a downloaded backup locally subtitle: Restore a backup of a remote database on a local instance to inspect and extract data --- -You can restore a downloaded backup to a local Supabase instance. This might be useful if your paused project has exceeded its [restoring time limit](/docs/guides/platform/upgrading#time-limits). You can download the latest backup, then load it locally to inspect and extract your data. +If your paused project has exceeded its [restoring time limit](/docs/guides/platform/upgrading#time-limits), you can download a backup from the dashboard and restore it to your local development environment. This might be useful for inspecting and extracting data from your paused project. diff --git a/apps/docs/content/guides/platform/access-control.mdx b/apps/docs/content/guides/platform/access-control.mdx index 444a153b35b..37b7b85dd52 100644 --- a/apps/docs/content/guides/platform/access-control.mdx +++ b/apps/docs/content/guides/platform/access-control.mdx @@ -32,6 +32,20 @@ This is a security measure to prevent accidental invites to accounts not managed +### Viewing organization members using the Management API + +You can also view organization members using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export ORG_ID="your-organization-id" + +# List organization members +curl "https://api.supabase.com/v1/organizations/$ORG_ID/members" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" +``` + ### Transferring ownership of an organization Each Supabase organization must have at least one owner. If your organization has other owners then you can relinquish ownership and leave the organization by clicking **Leave team** in your organization's team [settings](/dashboard/org/_/team). diff --git a/apps/docs/content/guides/platform/backups.mdx b/apps/docs/content/guides/platform/backups.mdx index aadb728ba21..64efb201ba5 100644 --- a/apps/docs/content/guides/platform/backups.mdx +++ b/apps/docs/content/guides/platform/backups.mdx @@ -71,6 +71,26 @@ The Postgres utility [pg_dumpall](https://www.postgresql.org/docs/current/app-pg You can access daily backups in the [Scheduled backups](https://supabase.com/dashboard/project/_/database/backups/scheduled) settings in the Dashboard. Pro Plan projects can access the last 7 days' worth of daily backups. Team Plan projects can access the last 14 days' worth of daily backups, while Enterprise Plan projects can access up to 30 days' worth of daily backups. Users can restore their project to any one of the backups. If you wish to generate a logical backup on your own, you can do so through the [Supabase CLI](/docs/reference/cli/supabase-db-dump). +You can also manage backups programmatically using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# List all available backups +curl -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + "https://api.supabase.com/v1/projects/$PROJECT_REF/database/backups" + +# Restore from a PITR (not logical) backup (replace ISO timestamp with desired restore point) +curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/database/backups/restore-pitr" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "recovery_time_target_unix": "1735689600" + }' +``` + #### Backup process for large databases Databases larger than 15GB[^1], if they're on a recent build[^2] of the Supabase platform, get automatically transitioned[^3] to use daily physical backups. Physical backups are a more performant backup mechanism that lowers the overhead and impact on the database being backed up, and also avoids holding locks on objects in your database for a long period of time. While restores are unaffected, the backups created using this method cannot be downloaded from the Backups section of the dashboard. diff --git a/apps/docs/content/guides/platform/ipv4-address.mdx b/apps/docs/content/guides/platform/ipv4-address.mdx index b8510e7ba3d..790ea522196 100644 --- a/apps/docs/content/guides/platform/ipv4-address.mdx +++ b/apps/docs/content/guides/platform/ipv4-address.mdx @@ -28,6 +28,30 @@ IPv4 addresses are guaranteed to be static for ingress traffic. If your database You can enable the IPv4 add-on in your project's [add-ons settings](/dashboard/project/_/settings/addons). +You can also manage the IPv4 add-on using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Get current IPv4 add-on status +curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/billing/addons" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" + +# Enable IPv4 add-on +curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/addons" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "addon_type": "ipv4" + }' + +# Disable IPv4 add-on +curl -X DELETE "https://api.supabase.com/v1/projects/$PROJECT_REF/billing/addons/ipv4" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" +``` + Note that direct database connections can experience a short amount of downtime when toggling the add-on due to DNS reconfiguration and propagation. Generally, this should be less than a minute. diff --git a/apps/docs/content/guides/platform/multi-factor-authentication.mdx b/apps/docs/content/guides/platform/multi-factor-authentication.mdx index e0e04a6a81c..068000e9fc9 100644 --- a/apps/docs/content/guides/platform/multi-factor-authentication.mdx +++ b/apps/docs/content/guides/platform/multi-factor-authentication.mdx @@ -36,6 +36,8 @@ For security reasons, we will not be able to restore access to your account if y Once you've enabled MFA for your Supabase user account, you will be prompted to enter your second factor challenge code as seen in your preferred TOTP app. +If you are an organization owner and on the Pro, Team or Enterprise plan, you can enforce that all organization members [must have MFA enabled](/docs/guides/platform/org-mfa-enforcement). + ## Disable MFA You can disable MFA for your user account under your [Supabase account settings](/dashboard/account/security). On subsequent login attempts, you will not be prompted to enter a MFA code. diff --git a/apps/docs/content/guides/platform/network-restrictions.mdx b/apps/docs/content/guides/platform/network-restrictions.mdx index aa80fbbc638..95c84224460 100644 --- a/apps/docs/content/guides/platform/network-restrictions.mdx +++ b/apps/docs/content/guides/platform/network-restrictions.mdx @@ -18,6 +18,30 @@ If direct connections to your database [resolve to a IPv6 address](https://supab Network restrictions can be configured in the [Database Settings](https://supabase.com/dashboard/project/_/settings/database) page. Ensure that you have [Owner or Admin permissions](/docs/guides/platform/access-control#manage-team-members) for the project that you are enabling network restrictions. +## To get started via the Management API: + +You can also manage network restrictions using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Get current network restrictions +curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/network-restrictions" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" + +# Update network restrictions +curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/network-restrictions/apply" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "db_allowed_cidrs": [ + "192.168.0.1/24", + ] + }' +``` + ## To get started via the CLI: 1. [Install](/docs/guides/cli) the Supabase CLI 1.22.0+. diff --git a/apps/docs/content/guides/platform/org-mfa-enforcement.mdx b/apps/docs/content/guides/platform/org-mfa-enforcement.mdx new file mode 100644 index 00000000000..6f5a3b95bc9 --- /dev/null +++ b/apps/docs/content/guides/platform/org-mfa-enforcement.mdx @@ -0,0 +1,32 @@ +--- +title: 'Enforce MFA on Organization' +description: 'All users in an organization must have a valid MFA session to interact with organization resources' +--- + +Supabase provides multi-factor authentication (MFA) enforcement on the organization level. With MFA enforcement, you can ensure that all organization members use MFA. Members cannot interact with your organization or your organization's projects without a valid MFA-backed session. + + + +MFA enforcement is only available on the [Pro, Team and Enterprise plans](https://supabase.com/pricing). + +This feature is currently in limited preview. If you would like to opt-in to try it, contact support. + + + +## Manage MFA enforcement + +To enable MFA on an organization, visit the [security settings](/dashboard/org/_/security) page and toggle `Require MFA to access organization` on. + +- Only organization **owners** can modify this setting +- The owner must have [MFA on their own account](/docs/guides/platform/multi-factor-authentication) +- Supabase recommends creating two distinct MFA apps on your user account + + + +When MFA enforcement is enabled, users without MFA will immediately lose access all resources in the organization. The users will still be members of the organization and will regain their original permissions once they enable MFA on their account. + + + +## Personal access tokens + +Personal access tokens are not affected by MFA enforcement. Personal access tokens are designed for programmatic access and issuing of these require a valid Supabase session backed by MFA, if enabled on the account. diff --git a/apps/docs/content/guides/platform/read-replicas.mdx b/apps/docs/content/guides/platform/read-replicas.mdx index 358b348332f..4d6b836fbf2 100644 --- a/apps/docs/content/guides/platform/read-replicas.mdx +++ b/apps/docs/content/guides/platform/read-replicas.mdx @@ -51,6 +51,30 @@ Projects must meet these requirements to use Read Replicas: To add a Read Replica, go to the [Infrastructure Settings page](/dashboard/project/_/settings/infrastructure) in your dashboard. +You can also manage Read Replicas using the Management API (beta functionality): + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Create a new Read Replica +curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/read-replicas/setup" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "region": "us-east-1" + }' + +# Delete a Read Replica +curl -X POST "https://api.supabase.com/v1/projects/$PROJECT_REF/read-replicas/remove" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "database_identifier": "abcdefghijklmnopqrst" + }' +``` + Projects on an XL compute add-on or larger can create up to five Read Replicas. Projects on compute add-ons smaller than XL can create up to two Read Replicas. All Read Replicas inherit the compute size of their Primary database. ### Deploying a Read Replica diff --git a/apps/docs/content/guides/platform/ssl-enforcement.mdx b/apps/docs/content/guides/platform/ssl-enforcement.mdx index d0b4c82efa6..5f9b5d62167 100644 --- a/apps/docs/content/guides/platform/ssl-enforcement.mdx +++ b/apps/docs/content/guides/platform/ssl-enforcement.mdx @@ -18,6 +18,40 @@ Projects need to be at least on Postgres 13.3.0 to enable SSL enforcement. You c SSL enforcement can be configured via the "Enforce SSL on incoming connections" setting under the SSL Configuration section in [Database Settings page](https://supabase.com/dashboard/project/_/settings/database) of the dashboard. +## Manage SSL enforcement via the Management API + +You can also manage SSL enforcement using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Get current SSL enforcement status +curl -X GET "https://api.supabase.com/v1/projects/$PROJECT_REF/ssl-enforceemnt" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" + +# Enable SSL enforcement +curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/ssl-enforcement" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "requestedConfig": { + "database": true + } + }' + +# Disable SSL enforcement +curl -X PUT "https://api.supabase.com/v1/projects/$PROJECT_REF/ssl-enforcement" \ + -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "requestedConfig": { + "database": false + } + }' +``` + ## Manage SSL enforcement via the CLI To get started: diff --git a/apps/docs/content/guides/security/hipaa-compliance.mdx b/apps/docs/content/guides/security/hipaa-compliance.mdx index 05da2d2d670..59b96c4d85b 100644 --- a/apps/docs/content/guides/security/hipaa-compliance.mdx +++ b/apps/docs/content/guides/security/hipaa-compliance.mdx @@ -8,6 +8,12 @@ The [Health Insurance Portability and Accountability Act (HIPAA)](https://www.hh Under HIPAA, both covered entities and business associates have distinct responsibilities to ensure the protection of PHI. Supabase acts as a business associate for customers (the covered entity) who wish to provide healthcare related services. As a business associate, Supabase has a number of obligations and has undergone auditing of the security and privacy controls that are in place to meet these. Supabase has signed a Business Associate Agreement (BAA) with all of our vendors who would have access to ePHI, such as AWS, and ensure that we follow their terms listed in the agreements. Similarly when a customer signs a BAA with us, they have some responsibilities they agree to when using Supabase to store PHI. + + +The hosted Supabase platform has the necessary controls to meet HIPAA requirements. These controls are not supported out of the box in self-hosted Supabase. HIPAA controls extend further than the Supabase product, encompassing legal agreements (BAAs) with providers, operating controls and policies. Achieving HIPAA compliance with self-hosted Supabase is out of scope for this documentation and you should consult your auditor for further guidance. + + + ### Customer responsibilities Covered entities (the customer) are organizations that directly handle PHI, such as health plans, healthcare clearinghouses, and healthcare providers that conduct certain electronic transactions. diff --git a/apps/docs/content/guides/telemetry/metrics.mdx b/apps/docs/content/guides/telemetry/metrics.mdx index 0309a85ab88..267e4692747 100644 --- a/apps/docs/content/guides/telemetry/metrics.mdx +++ b/apps/docs/content/guides/telemetry/metrics.mdx @@ -22,10 +22,30 @@ The endpoint discussed in this article is not available on self-hosted. ## Accessing the metrics endpoint -Your project's metrics endpoint is accessible at `https://.supabase.co/customer/v1/privileged/metrics`. Access to the endpoint is secured via HTTP Basic Auth; the username is `service_role`, while the password is the service role JWT available through the Supabase dashboard. +Your project's metrics endpoint is accessible at `https://.supabase.co/customer/v1/privileged/metrics`. + +Access to the endpoint is secured via HTTP Basic Auth: + +- username: `service_role` +- password: the service role JWT from the [Supabase dashboard](https://supabase.com/dashboard/project/_/settings/api-keys) + +You can also retrieve your service role key programmatically using the Management API: + +```bash +# Get your access token from https://supabase.com/dashboard/account/tokens +export SUPABASE_ACCESS_TOKEN="your-access-token" +export PROJECT_REF="your-project-ref" + +# Get project API keys including service_role key +curl -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ + "https://api.supabase.com/v1/projects/$PROJECT_REF/api-keys?reveal=true" +``` + + ```shell -> curl https://.supabase.co/customer/v1/privileged/metrics --user 'service_role:' +curl /customer/v1/privileged/metrics \ + --user 'service_role:' ``` ## Supabase Grafana diff --git a/apps/docs/lib/supabase.ts b/apps/docs/lib/supabase.ts index 7c46ef9c993..65621a38ab3 100644 --- a/apps/docs/lib/supabase.ts +++ b/apps/docs/lib/supabase.ts @@ -18,7 +18,12 @@ type Database = { DatabaseGenerated['public']['Functions']['search_content']['Returns'][number], 'subsections' | 'metadata' > & { - metadata: { language?: string; methodName?: string; platform?: string } + metadata: { + subtitle?: string + language?: string + methodName?: string + platform?: string + } subsections: Array<{ title?: string; href?: string; content?: string }> } > diff --git a/apps/docs/package.json b/apps/docs/package.json index 081756fd921..bfb0967f5e0 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -25,6 +25,7 @@ "postbuild": "pnpm run build:sitemap && pnpm run build:llms && ./../../scripts/upload-static-assets.sh", "prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples", "predev": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples", + "preembeddings": "pnpm run codegen:references", "preinstall": "npx only-allow pnpm", "presync": "pnpm run codegen:graphql", "pretest": "pnpm run codegen:examples", diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 1dba617c869..ff079ef5f47 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -86,6 +86,7 @@ Qiao Han Raminder Singh Riccardo Busetti Rodrigo Mansueli +Ronan Lehane Rory Wilding Sam Rose Sergio Cioban Filho diff --git a/apps/docs/resources/error/errorResolver.ts b/apps/docs/resources/error/errorResolver.ts index 6cced106600..d64a059ac70 100644 --- a/apps/docs/resources/error/errorResolver.ts +++ b/apps/docs/resources/error/errorResolver.ts @@ -6,7 +6,7 @@ import type { RootQueryTypeErrorsArgs, Service, } from '~/__generated__/graphql' -import { ApiError, convertUnknownToApiError } from '~/app/api/utils' +import { ApiError, convertUnknownToApiError, extractMessageFromAnyError } from '~/app/api/utils' import { Result } from '~/features/helpers.fn' import { createCollectionType, @@ -77,7 +77,13 @@ async function resolveErrors( code: args[0].code ?? undefined, }, }) - return result.mapError((error) => new ApiError('Failed to resolve error codes', error)) + return result.mapError( + (error) => + new ApiError( + `Failed to resolve error codes: ${extractMessageFromAnyError(error)}`, + error + ) + ) } return await GraphQLCollectionBuilder.create< ErrorModel, diff --git a/apps/docs/resources/globalSearch/globalSearchModel.ts b/apps/docs/resources/globalSearch/globalSearchModel.ts index a00031c25a7..47cdd2f12dd 100644 --- a/apps/docs/resources/globalSearch/globalSearchModel.ts +++ b/apps/docs/resources/globalSearch/globalSearchModel.ts @@ -8,6 +8,7 @@ import { DB_METADATA_TAG_PLATFORM_CLI, ReferenceCLICommandModel, } from '../reference/referenceCLIModel' +import { ReferenceManagementApiModel } from '../reference/referenceManagementApiModel' import { ReferenceSDKFunctionModel, SDKLanguageValues } from '../reference/referenceSDKModel' import { TroubleshootingModel } from '../troubleshooting/troubleshootingModel' import { SearchResultInterface } from './globalSearchInterface' @@ -74,6 +75,13 @@ function createModelFromMatch({ content, subsections, }) + // TODO [Charis 2025-06-09] replace with less hacky check + } else if (metadata.subtitle?.startsWith('Management API Reference')) { + return new ReferenceManagementApiModel({ + title: page_title, + href, + content, + }) } else { return null } diff --git a/apps/docs/resources/reference/referenceManagementApiModel.ts b/apps/docs/resources/reference/referenceManagementApiModel.ts new file mode 100644 index 00000000000..d0524587ea9 --- /dev/null +++ b/apps/docs/resources/reference/referenceManagementApiModel.ts @@ -0,0 +1,13 @@ +import { type SearchResultInterface } from '../globalSearch/globalSearchInterface' + +export class ReferenceManagementApiModel implements SearchResultInterface { + public title?: string + public href?: string + public content?: string + + constructor({ title, href, content }: { title?: string; href?: string; content?: string }) { + this.title = title + this.href = href + this.content = content + } +} diff --git a/apps/docs/resources/reference/referenceManagementApiSchema.ts b/apps/docs/resources/reference/referenceManagementApiSchema.ts new file mode 100644 index 00000000000..c6c3f625adc --- /dev/null +++ b/apps/docs/resources/reference/referenceManagementApiSchema.ts @@ -0,0 +1,25 @@ +import { GraphQLObjectType, GraphQLString } from 'graphql' +import { GraphQLInterfaceTypeSearchResult } from '../globalSearch/globalSearchSchema' +import { ReferenceManagementApiModel } from './referenceManagementApiModel' + +export const GraphQLObjectTypeReferenceManagementApi = new GraphQLObjectType({ + name: 'ManagementApiReference', + interfaces: [GraphQLInterfaceTypeSearchResult], + isTypeOf: (value: unknown) => value instanceof ReferenceManagementApiModel, + description: + 'A reference document containing a description of a Supabase Management API endpoint', + fields: { + title: { + type: GraphQLString, + description: 'The title of the document', + }, + href: { + type: GraphQLString, + description: 'The URL of the document', + }, + content: { + type: GraphQLString, + description: 'The content of the reference document, as text', + }, + }, +}) diff --git a/apps/docs/resources/rootSchema.ts b/apps/docs/resources/rootSchema.ts index f4e03b0dd0d..6200c916566 100644 --- a/apps/docs/resources/rootSchema.ts +++ b/apps/docs/resources/rootSchema.ts @@ -10,6 +10,7 @@ import { errorRoot, errorsRoot } from './error/errorResolver' import { searchRoot } from './globalSearch/globalSearchResolver' import { GraphQLObjectTypeGuide } from './guide/guideSchema' import { GraphQLObjectTypeReferenceCLICommand } from './reference/referenceCLISchema' +import { GraphQLObjectTypeReferenceManagementApi } from './reference/referenceManagementApiSchema' import { GraphQLObjectTypeReferenceSDKFunction } from './reference/referenceSDKSchema' import { GraphQLObjectTypeTroubleshooting } from './troubleshooting/troubleshootingSchema' @@ -43,6 +44,7 @@ export const rootGraphQLSchema = new GraphQLSchema({ types: [ GraphQLObjectTypeGuide, GraphQLObjectTypeReferenceCLICommand, + GraphQLObjectTypeReferenceManagementApi, GraphQLObjectTypeReferenceSDKFunction, GraphQLObjectTypeTroubleshooting, ], diff --git a/apps/docs/scripts/search/sources/reference-doc.ts b/apps/docs/scripts/search/sources/reference-doc.ts index 4a582b395f4..c8c54211fa7 100644 --- a/apps/docs/scripts/search/sources/reference-doc.ts +++ b/apps/docs/scripts/search/sources/reference-doc.ts @@ -8,6 +8,7 @@ import type { IFunctionDefinition, ISpec, } from '../../../components/reference/Reference.types.js' +import { getApiEndpointById } from '../../../features/docs/Reference.generated.singleton.js' import type { CliCommand, CliSpec } from '../../../generator/types/CliSpec.js' import { flattenSections } from '../../../lib/helpers.js' import { enrichedOperation, gen_v3 } from '../../../lib/refGenerator/helpers.js' @@ -39,30 +40,35 @@ export abstract class ReferenceLoader extends BaseLoader { const specSections = this.getSpecSections(specContents) - const sections = flattenedRefSections - .map((refSection) => { - const specSection = this.matchSpecSection(specSections, refSection.id) + const sections = ( + await Promise.all( + flattenedRefSections.map(async (refSection) => { + const specSection = await this.matchSpecSection(specSections, refSection.id) - if (!specSection) { - return - } + if (!specSection) { + return + } - return this.sourceConstructor( - this.source, - `${this.path}/${refSection.slug}`, - refSection, - specSection, - this.enhanceMeta(specSection) - ) - }) - .filter((item): item is ReferenceSource => item !== undefined) + return this.sourceConstructor( + this.source, + `${this.path}/${refSection.slug}`, + refSection, + specSection, + this.enhanceMeta(specSection) + ) + }) + ) + ).filter((item): item is ReferenceSource => item !== undefined) return sections as BaseSource[] } abstract getSpecSections(specContents: string): SpecSection[] - abstract matchSpecSection(specSections: SpecSection[], id: string): SpecSection | undefined - enhanceMeta(section: SpecSection): Json { + abstract matchSpecSection( + specSections: SpecSection[], + id: string + ): SpecSection | undefined | Promise + enhanceMeta(_section: SpecSection): Json { return this.meta } } @@ -115,7 +121,7 @@ export abstract class ReferenceSource extends BaseSource { abstract extractSubtitle(): string } -export class OpenApiReferenceLoader extends ReferenceLoader { +export class OpenApiReferenceLoader extends ReferenceLoader> { constructor( source: string, path: string, @@ -136,39 +142,108 @@ export class OpenApiReferenceLoader extends ReferenceLoader { return generatedSpec.operations } - matchSpecSection(operations: enrichedOperation[], id: string): enrichedOperation | undefined { - return operations.find((operation) => operation.operationId === id) + async matchSpecSection( + _operations: enrichedOperation[], + id: string + ): Promise | undefined> { + const apiEndpoint = await getApiEndpointById(id) + if (!apiEndpoint) return undefined + + const enrichedOp: Partial = { + operationId: apiEndpoint.id, + operation: apiEndpoint.method, + path: apiEndpoint.path, + summary: apiEndpoint.summary, + description: apiEndpoint.description, + deprecated: apiEndpoint.deprecated, + parameters: apiEndpoint.parameters as any, + requestBody: apiEndpoint.requestBody as any, + responses: apiEndpoint.responses as any, + } + + return enrichedOp } } -export class OpenApiReferenceSource extends ReferenceSource { +export class OpenApiReferenceSource extends ReferenceSource> { formatSection(specOperation: enrichedOperation, _: ICommonItem) { - const { summary, description, operation, path, tags } = specOperation + const { summary, description, operation, path, tags, parameters, responses, operationId } = + specOperation return JSON.stringify({ summary, description, operation, path, tags, + parameters, + responses, + operationId, }) } extractSubtitle() { - return `${this.meta.title}: ${this.specSection.description}` + return `${this.meta.title}: ${this.specSection.description || this.specSection.operationId || ''}` } extractTitle() { return ( this.specSection.summary || - (typeof this.meta.title === 'string' ? this.meta.title : this.specSection.operation) + (typeof this.meta.title === 'string' ? this.meta.title : this.specSection.operation) || + '' ) } extractIndexedContent(): string { - const { summary, description, operation, tags } = this.specSection - return `# ${this.meta.title ?? ''}\n\n${summary ?? ''}\n\n${description ?? ''}\n\n${operation ?? ''}\n\n${ - tags?.join(', ') ?? '' - }` + const { summary, description, operation, tags, path, parameters, responses } = this.specSection + + const sections: string[] = [] + + // Title + sections.push(`# ${this.meta.title ?? ''}`) + + // Summary + if (summary) { + sections.push(summary) + } + + // Description + if (description) { + sections.push(`Description: ${description}`) + } + + // Path and Method + if (path) { + sections.push(`Path: ${operation?.toUpperCase() || 'GET'} ${path}`) + } + + // Parameters + if (parameters && parameters.length > 0) { + const paramList = parameters + .map((param: any) => { + const required = param.required ? 'required' : 'optional' + return `- ${param.name} (${param.schema?.type || 'string'}, ${required}): ${param.description || ''}` + }) + .join('\n') + sections.push(`Parameters:\n${paramList}`) + } + + // Response Types + if (responses) { + const responseList = Object.entries(responses) + .map(([code, response]: [string, any]) => { + const desc = response.description || 'No description' + return `- ${code}: ${desc}` + }) + .join('\n') + sections.push(`Responses:\n${responseList}`) + } + + // Tags + if (tags && tags.length > 0) { + sections.push(`Tags: ${tags.join(', ')}`) + } + + return sections.filter(Boolean).join('\n\n') } } diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index 05c5bb84268..14b6bbc9dba 100644 --- a/apps/docs/spec/cli_v1_commands.yaml +++ b/apps/docs/spec/cli_v1_commands.yaml @@ -1,7 +1,7 @@ clispec: '001' info: id: cli - version: 2.24.3 + version: 2.26.9 title: Supabase CLI language: sh source: https://github.com/supabase/cli @@ -1366,6 +1366,7 @@ commands: - local-dev links: [] subcommands: + - supabase-migration-down - supabase-migration-fetch - supabase-migration-list - supabase-migration-new @@ -1644,6 +1645,31 @@ commands: name: --local description: Fetches migration history from the local database. default_value: 'false' + - id: supabase-migration-down + title: supabase migration down + summary: Resets applied migrations up to the last n versions + tags: [] + links: [] + usage: supabase migration down [flags] + subcommands: [] + flags: + - id: db-url + name: --db-url + description: | + Resets applied migrations on the database specified by the connection string (must be percent-encoded). + default_value: '' + - id: last + name: --last + description: Reset up to the last n migration versions. + default_value: '1' + - id: linked + name: --linked + description: Resets applied migrations on the linked project. + default_value: 'false' + - id: local + name: --local + description: Resets applied migrations on the local database. + default_value: 'true' - id: supabase-logout title: supabase logout summary: Log out and delete access tokens locally @@ -3016,6 +3042,10 @@ commands: description: | Resets the database specified by the connection string (must be percent-encoded). default_value: '' + - id: last + name: --last + description: Reset up to the last n migration versions. + default_value: '0' - id: linked name: --linked description: Resets the linked project with local migrations. @@ -3876,3 +3906,41 @@ commands: name: -p, --password description: Password to your remote Postgres database. default_value: '' + - id: supabase-backups + title: supabase backups + summary: Manage Supabase physical backups + tags: + - management-api + links: [] + subcommands: + - supabase-backups-list + - supabase-backups-restore + flags: [] + - id: supabase-backups-restore + title: supabase backups restore + summary: Restore to a specific timestamp using PITR + tags: [] + links: [] + usage: supabase backups restore [flags] + subcommands: [] + flags: + - id: timestamp + name: -t, --timestamp + description: The recovery time target in seconds since epoch. + default_value: '0' + - id: project-ref + name: --project-ref + description: Project ref of the Supabase project. + default_value: '' + - id: supabase-backups-list + title: supabase backups list + summary: Lists available physical backups + tags: [] + links: [] + usage: supabase backups list + subcommands: [] + flags: + - id: project-ref + name: --project-ref + description: Project ref of the Supabase project. + default_value: '' diff --git a/apps/docs/spec/cli_v1_config.yaml b/apps/docs/spec/cli_v1_config.yaml index 0d858465f2d..803059e6e56 100644 --- a/apps/docs/spec/cli_v1_config.yaml +++ b/apps/docs/spec/cli_v1_config.yaml @@ -895,14 +895,14 @@ parameters: - name: 'Auth Server configuration' link: 'https://supabase.com/docs/reference/auth' - - id: 'auth.email.otp_exp' - title: 'auth.email.otp_exp' + - id: 'auth.email.otp_expiry' + title: 'auth.email.otp_expiry' tags: ['auth'] required: false - default: '300' + default: '3600' description: | The expiry time for an OTP code in seconds. - Default is 300 seconds (5 minutes). + Default is 3600 seconds (1 hour). links: - name: 'Auth Server configuration' link: 'https://supabase.com/docs/reference/auth' diff --git a/apps/docs/spec/supabase_js_v2.yml b/apps/docs/spec/supabase_js_v2.yml index 1a415da3fef..b69b6cb68af 100644 --- a/apps/docs/spec/supabase_js_v2.yml +++ b/apps/docs/spec/supabase_js_v2.yml @@ -4406,7 +4406,7 @@ functions: name: Call a read-only Postgres function code: | ```ts - const { data, error } = await supabase.rpc('hello_world', { get: true }) + const { data, error } = await supabase.rpc('hello_world', undefined, { get: true }) ``` data: sql: | diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile index 3fbac7d88bb..e0bba48dbed 100644 --- a/apps/studio/Dockerfile +++ b/apps/studio/Dockerfile @@ -8,7 +8,7 @@ # docker builder prune # docker build . -f apps/studio/Dockerfile --target production -t studio:latest --no-cache -FROM node:20-slim AS base +FROM node:22-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx index a7f16a5c889..6024d017720 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx @@ -79,7 +79,7 @@ export function ApiKeyPill({ async function onCopy() { // If key is already revealed, use that value - if (data?.api_key) return data?.api_key + if (data?.api_key) return data?.api_key ?? '' try { // Fetch full key and immediately clear from cache after copying @@ -89,13 +89,15 @@ export function ApiKeyPill({ exact: true, }) - if (result.isSuccess) return result.data.api_key + if (result.isSuccess) return result.data.api_key ?? '' if (error) { toast.error('Failed to copy secret API key') + return '' } } catch (error) { console.error('Failed to fetch API key:', error) + return '' } // Fallback to the masked version if fetch fails diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx index 20ee3067400..c60ecf73031 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx @@ -1,4 +1,4 @@ -import { ExternalLink, Github, Mail } from 'lucide-react' +import { ExternalLink, Github } from 'lucide-react' import { LOCAL_STORAGE_KEYS } from 'common' import { FeatureBanner } from 'components/ui/FeatureBanner' @@ -119,7 +119,13 @@ export const ApiKeysComingSoonBanner = () => {

@@ -188,7 +194,7 @@ export const ApiKeysFeedbackBanner = () => {

Having trouble with the new API keys?{' '} Contact support diff --git a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx index 09a14f8a2a4..a52b21a92f6 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx @@ -4,7 +4,6 @@ import { useQueryTableCommands, useSnippetCommands, } from 'components/layouts/SQLEditorLayout/SqlEditor.Commands' -import { useGenerateSqlCommand } from 'components/interfaces/SqlGenerator/SqlGenerator.Commands' import { useProjectLevelTableEditorCommands } from 'components/layouts/TableEditorLayout/TableEditor.Commands' import { useLayoutNavCommands } from 'components/layouts/useLayoutNavCommands' import { CommandHeader, CommandInput, CommandList, CommandMenu } from 'ui-patterns/CommandMenu' @@ -19,7 +18,6 @@ import { useSupportCommands } from './Support' import { orderCommandSectionsByPriority } from './ordering' export default function StudioCommandMenu() { - useGenerateSqlCommand() useApiKeysCommands() useApiUrlCommand() useProjectLevelTableEditorCommands() diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx index ceadbd396bf..c2b2c54f742 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx @@ -8,20 +8,6 @@ export const FEATURE_PREVIEWS = [ isNew: true, isPlatformOnly: false, }, - { - key: LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS, - name: 'Table Editor Tabs', - discussionsUrl: 'https://github.com/orgs/supabase/discussions/35636', - isNew: true, - isPlatformOnly: false, - }, - { - key: LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS, - name: 'SQL Editor Tabs', - discussionsUrl: 'https://github.com/orgs/supabase/discussions/35636', - isNew: true, - isPlatformOnly: true, - }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, name: 'Project API documentation', diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 4fa0a7dcf67..bef4199d7cc 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -2,8 +2,6 @@ import { noop } from 'lodash' import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react' import { FeatureFlagContext, LOCAL_STORAGE_KEYS } from 'common' -import { useFlag } from 'hooks/ui/useFlag' -import { IS_PLATFORM } from 'lib/constants' import { EMPTY_OBJ } from 'lib/void' import { FEATURE_PREVIEWS } from './FeaturePreview.constants' @@ -21,15 +19,10 @@ export const useFeaturePreviewContext = () => useContext(FeaturePreviewContext) export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{}>) => { const { hasLoaded } = useContext(FeatureFlagContext) - const enableTabsInterface = useFlag('tabsInterface') // [Joshen] Similar logic to feature flagging previews, we can use flags to default opt in previews const isDefaultOptIn = (feature: (typeof FEATURE_PREVIEWS)[number]) => { switch (feature.key) { - case LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS: - return enableTabsInterface - case LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS: - return enableTabsInterface default: return false } @@ -86,14 +79,3 @@ export const useIsInlineEditorEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR] } - -export const useIsTableEditorTabsEnabled = () => { - const { flags } = useFeaturePreviewContext() - return flags[LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS] -} - -export const useIsSQLEditorTabsEnabled = () => { - const { flags } = useFeaturePreviewContext() - if (!IS_PLATFORM) return false - return flags[LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS] -} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 94bc324361f..725499cb60d 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -7,22 +7,17 @@ import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' -import { removeTabsByEditor } from 'state/tabs' import { Badge, Button, Modal, ScrollArea, cn } from 'ui' import { APISidePanelPreview } from './APISidePanelPreview' import { CLSPreview } from './CLSPreview' import { FEATURE_PREVIEWS } from './FeaturePreview.constants' import { useFeaturePreviewContext } from './FeaturePreviewContext' import { InlineEditorPreview } from './InlineEditorPreview' -import { SqlEditorTabsPreview } from './SqlEditorTabs' -import { TableEditorTabsPreview } from './TableEditorTabs' const FEATURE_PREVIEW_KEY_TO_CONTENT: { [key: string]: ReactNode } = { [LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR]: , - [LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS]: , - [LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: , } @@ -62,13 +57,6 @@ const FeaturePreviewModal = () => { properties: { feature: selectedFeatureKey }, groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) - - if (ref && selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS) { - removeTabsByEditor(ref, 'table') - } - if (ref && selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS) { - removeTabsByEditor(ref, 'sql') - } } function handleCloseFeaturePreviewModal() { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx index 08a82c3f74c..228da9d8b4d 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx @@ -1,7 +1,6 @@ import Image from 'next/image' import { BASE_PATH } from 'lib/constants' -import { Admonition } from 'ui-patterns' export const InlineEditorPreview = () => { return ( diff --git a/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx deleted file mode 100644 index ee053af8657..00000000000 --- a/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { BASE_PATH } from 'lib/constants' -import Image from 'next/image' - -export const SqlEditorTabsPreview = () => { - return ( -

- ) -} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx b/apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx deleted file mode 100644 index 1a83d1e70dd..00000000000 --- a/apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { ExternalLink, X } from 'lucide-react' -import Image from 'next/image' - -import { LOCAL_STORAGE_KEYS } from 'common' -import { useEditorType } from 'components/layouts/editors/EditorsLayout.hooks' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { InlineLinkClassName } from 'components/ui/InlineLink' -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { BASE_PATH } from 'lib/constants' -import { useAppStateSnapshot } from 'state/app-state' -import { - Alert_Shadcn_, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Badge, - Button, - cn, - HoverCard, - HoverCardContent_Shadcn_, - HoverCardTrigger_Shadcn_, -} from 'ui' -import { useIsSQLEditorTabsEnabled, useIsTableEditorTabsEnabled } from './FeaturePreviewContext' - -export const TableEditorTabsPreview = () => { - return ( -
- api-docs-side-panel-preview -

- The Table Editor now features tabs for improved navigation and organization. Have multiple - tables opened across schemas and conveniently go across them without having to switch - schemas. Collapse the sidebar for a bigger real estate while browsing your data. -

-
-

Enabling this preview will:

-
    -
  • Enable opening of tables in the Table Editor as tabs
  • -
  • Support closing of the navigation sidebar for a larger data grid space
  • -
-
-
- ) -} - -export const TabsUpdateCallout = () => { - const editor = useEditorType() - const appSnap = useAppStateSnapshot() - - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() - const isTabsEnabled = isTableEditorTabsEnabled || isSQLEditorTabsEnabled - - const [tableEditorTabsPreviewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS, - '' - ) - const [sqlEditorTabsPreviewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS, - '' - ) - - const [tabsInterfaceAcknowledge, setTabsInterfaceAcknowledge] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.TABS_INTERFACE_ACKNOWLEDGED, - false - ) - const isDefaultOptedInTabs = - isTabsEnabled && tableEditorTabsPreviewState === '' && sqlEditorTabsPreviewState === '' - - if (!isDefaultOptedInTabs || tabsInterfaceAcknowledge) return null - - return ( - - - - NEW - - Tabs Interface for Editors - - - The Table and SQL Editors now feature tabs for improved navigation and organization! Check - out our{' '} - { - appSnap.setSelectedFeaturePreview( - editor === 'table' - ? LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS - : LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS - ) - appSnap.setShowFeaturePreviewModal(true) - }} - > - feature previews - {' '} - for more information. - - -
- - } - className="absolute top-2 right-2 px-1" - onClick={() => setTabsInterfaceAcknowledge(true)} - tooltip={{ content: { side: 'bottom', text: 'Dismiss' } }} - /> - - ) -} - -export const TabsUpdateTooltip = () => { - const editor = useEditorType() - const appSnap = useAppStateSnapshot() - - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() - const isTabsEnabled = isTableEditorTabsEnabled || isSQLEditorTabsEnabled - - const [tableEditorTabsPreviewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS, - '' - ) - const [sqlEditorTabsPreviewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS, - '' - ) - const [tabsInterfaceAcknowledge, setTabsInterfaceAcknowledge] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.TABS_INTERFACE_ACKNOWLEDGED, - false - ) - - const isDefaultOptedInTabs = - isTabsEnabled && tableEditorTabsPreviewState === '' && sqlEditorTabsPreviewState === '' - - if (!isDefaultOptedInTabs || tabsInterfaceAcknowledge) return null - - return ( - - -
- - NEW - -
-
- -
-

Tabs Interface for Editors

-

- The Table and SQL Editors now feature tabs for improved navigation and organization! - Check out our{' '} - { - appSnap.setSelectedFeaturePreview( - editor === 'table' - ? LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS - : LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS - ) - appSnap.setShowFeaturePreviewModal(true) - }} - > - feature previews - {' '} - for more information. -

-
- -
-
- ) -} diff --git a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx index f87aa4aa0c6..22b473f6d9f 100644 --- a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx +++ b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' import { toast } from 'sonner' -import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useIsMFAEnabled, useParams } from 'common' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectsQuery } from 'data/projects/projects-query' import useLatest from 'hooks/misc/useLatest' @@ -18,6 +18,7 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { const isLoggedIn = useIsLoggedIn() const snap = useAppStateSnapshot() + const isUserMFAEnabled = useIsMFAEnabled() const organization = useSelectedOrganization() @@ -122,7 +123,17 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { }, [isSuccessStorage, ref]) useEffect(() => { - if (organization) setLastVisitedOrganization(organization.slug) + if (organization) { + setLastVisitedOrganization(organization.slug) + + if ( + organization.organization_requires_mfa && + !isUserMFAEnabled && + router.pathname !== '/org/[slug]' + ) { + router.push(`/org/${organization.slug}`) + } + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [organization]) diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 495ab339596..63ee824ffb8 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -119,7 +119,7 @@ export const UsersV2 = () => { } ) - const { data: countData } = useUsersCountQuery({ + const { data: countData, refetch: refetchCount } = useUsersCountQuery({ projectRef, connectionString: project?.connectionString, keywords: filterKeywords, @@ -437,7 +437,10 @@ export const UsersV2 = () => { icon={} type="default" loading={isRefetching && !isFetchingNextPage} - onClick={() => refetch()} + onClick={() => { + refetch() + refetchCount() + }} > Refresh diff --git a/apps/studio/components/interfaces/Billing/Billing.constants.ts b/apps/studio/components/interfaces/Billing/Billing.constants.ts index c4a48de2d74..3ccc2dd1e59 100644 --- a/apps/studio/components/interfaces/Billing/Billing.constants.ts +++ b/apps/studio/components/interfaces/Billing/Billing.constants.ts @@ -1,18 +1,44 @@ export const USAGE_APPROACHING_THRESHOLD = 0.8 export const CANCELLATION_REASONS = [ - 'Pricing', - "My project isn't getting traction", - 'Poor customer service', - 'Missing feature', - "I didn't see the value", - "Supabase didn't meet my needs", - 'Dashboard is too complicated', - 'Postgres is too complicated', - 'Problem not solved', - 'Too many bugs/issues', - 'I decided to use something else', - 'My work has finished/discontinued', - 'I’m migrating to/starting a new project', - 'None of the above', + { + value: 'I was just exploring, or it was a hobby/student project.', + }, + { + value: 'I was not satisfied with the customer support I received.', + label: 'Could you tell us more about your experience with our support team?', + }, + { + value: 'Supabase is missing a specific feature I need.', + label: 'What specific feature(s) are we missing?', + }, + { + value: 'I found it difficult to use or build with.', + label: 'What specific parts of Supabase did you find difficult or frustrating?', + }, + { + value: 'Performance or reliability insufficient.', + label: + 'Could you tell us more about the specific issues you encountered (e.g., UI bugs, API latency, downtime)?', + }, + { + value: 'My project was cancelled or put on hold.', + }, + { + value: 'Too expensive', + label: 'We appreciate your perspective on our pricing, what aspects of the cost felt too high?', + }, + { + value: 'The pricing is unpredictable and hard to budget for.', + label: + 'Which aspects of our pricing model made it difficult for you to predict your monthly costs?', + }, + { + value: 'My company went out of business or was acquired.', + }, + { + value: 'I lost trust in the company or its future direction.', + label: + 'Building and maintaining your trust is our highest priority, could you please share the specific event or reason that led to this loss of trust?', + }, ] diff --git a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx index 7c75bfae4e7..d24f02b8036 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx @@ -11,6 +11,7 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { STRIPE_PUBLIC_KEY } from 'lib/constants' import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' import AddPaymentMethodForm from './AddPaymentMethodForm' +import { getStripeElementsAppearanceOptions } from './Payment.utils' interface AddNewPaymentMethodModalProps { visible: boolean @@ -90,7 +91,7 @@ const AddNewPaymentMethodModal = ({ const options = { clientSecret: intent ? intent.client_secret : '', - appearance: { theme: resolvedTheme?.includes('dark') ? 'night' : 'flat', labels: 'floating' }, + appearance: getStripeElementsAppearanceOptions(resolvedTheme), } as any const onLocalCancel = () => { diff --git a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx index 7f184986165..cb470dcb3e5 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx @@ -115,7 +115,12 @@ const AddPaymentMethodForm = ({ - + {showSetDefaultCheckbox && (
{ + return { + theme: (resolvedTheme?.includes('dark') ? 'night' : 'flat') as 'night' | 'flat', + variables: { + fontSizeBase: '14px', + colorBackground: resolvedTheme?.includes('dark') + ? 'hsl(0deg 0% 14.1%)' + : 'hsl(0deg 0% 95.3%)', + fontFamily: + 'var(--font-custom, Circular, custom-font, Helvetica Neue, Helvetica, Arial, sans-serif)', + spacingUnit: '4px', + borderRadius: '.375rem', + gridRowSpacing: '4px', + }, + rules: { + '.Label': { + // Hide labels - it is obvious enough what the fields are for + fontSize: '0', + }, + '.TermsText': { + fontSize: '12px', + }, + }, + } +} diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx index 5b568e36437..dc32d28afbd 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx @@ -7,11 +7,9 @@ export const PaymentConfirmation = ({ paymentIntentSecret, onPaymentIntentConfirm, onLoadingChange, - paymentMethodId, onError, }: { paymentIntentSecret: string - paymentMethodId: string onPaymentIntentConfirm: (response: PaymentIntentResult) => void onLoadingChange: (loading: boolean) => void onError?: (error: Error) => void @@ -22,7 +20,7 @@ export const PaymentConfirmation = ({ if (stripe && paymentIntentSecret) { onLoadingChange(true) stripe! - .confirmCardPayment(paymentIntentSecret, { payment_method: paymentMethodId }) + .confirmCardPayment(paymentIntentSecret) .then((res) => { onPaymentIntentConfirm(res) onLoadingChange(false) diff --git a/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts b/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts index 2d8af813fde..ece737507d6 100644 --- a/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts +++ b/apps/studio/components/interfaces/Billing/Subscription/Subscription.utils.ts @@ -1,4 +1,4 @@ -import type { OrgSubscription, ProjectSelectedAddon } from 'data/subscriptions/types' +import type { OrgSubscription, PlanId, ProjectSelectedAddon } from 'data/subscriptions/types' import { IS_PLATFORM } from 'lib/constants' export const getAddons = (selectedAddons: ProjectSelectedAddon[]) => { @@ -32,3 +32,43 @@ export const billingPartnerLabel = (billingPartner?: string) => { return billingPartner } } + +type PlanChangeType = 'upgrade' | 'downgrade' | 'none' + +export const getPlanChangeType = ( + fromPlan: PlanId | undefined, + toPlan: PlanId | undefined +): PlanChangeType => { + const planChangeTypes: Record> = { + free: { + free: 'none', + pro: 'upgrade', + team: 'upgrade', + enterprise: 'upgrade', + }, + pro: { + free: 'downgrade', + pro: 'none', + team: 'upgrade', + enterprise: 'upgrade', + }, + team: { + free: 'downgrade', + pro: 'downgrade', + team: 'none', + enterprise: 'upgrade', + }, + enterprise: { + free: 'downgrade', + pro: 'downgrade', + team: 'downgrade', + enterprise: 'none', + }, + } + + if (!fromPlan || !toPlan) { + return 'none' + } + + return planChangeTypes[fromPlan]?.[toPlan] ?? 'none' +} diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index fed8da19382..86dd533b9e2 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -281,7 +281,7 @@ export const CreateBranchModal = ({ visible, onClose }: CreateBranchModalProps)

- Each preview branch costs $0.32 per day + Each preview branch costs $0.01344 per hour

- +
{isLoading && ( diff --git a/apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx b/apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx index e9bf493098a..11d5ab7443a 100644 --- a/apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/androidkotlin/supabasekt/content.tsx @@ -30,7 +30,7 @@ data class TodoItem(val id: Int, val name: String) {` val supabase = createSupabaseClient( supabaseUrl = "${projectKeys.apiUrl ?? 'your-project-url'}", - supabaseKey = "${projectKeys.anonKey ?? 'your-anon-key'}" + supabaseKey = "${projectKeys.publishableKey ?? ''}" ) { install(Postgrest) } diff --git a/apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx index e143f57c1cd..fe4ac30f1e3 100644 --- a/apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/astro/supabasejs/content.tsx @@ -21,7 +21,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_KEY=${projectKeys.anonKey ?? 'your-anon-key'} +SUPABASE_KEY=${projectKeys.publishableKey ?? projectKeys.anonKey ?? 'your-anon-key'} `} diff --git a/apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx index a9b22da5633..fd1815f163b 100644 --- a/apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/exporeactnative/supabasejs/content.tsx @@ -21,7 +21,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` EXPO_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -EXPO_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} +EXPO_PUBLIC_SUPABASE_KEY=${projectKeys.publishableKey ?? ''} `} @@ -31,17 +31,18 @@ EXPO_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} {` import 'react-native-url-polyfill/auto' import AsyncStorage from '@react-native-async-storage/async-storage' -import { createClient } from '@supabase/supabase-js' +import { createClient, processLock } from '@supabase/supabase-js' export const supabase = createClient( - process.env.EXPO_PUBLIC_SUPABASE_URL || "", - process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || "", + process.env.EXPO_PUBLIC_SUPABASE_URL!, + process.env.EXPO_PUBLIC_SUPABASE_KEY!, { auth: { storage: AsyncStorage, autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, + lock: processLock, }, }) `} diff --git a/apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx b/apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx index d0cf74eb53a..d0cb4c475df 100644 --- a/apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/flutter/supabaseflutter/content.tsx @@ -24,7 +24,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; Future main() async { await Supabase.initialize( url: '${projectKeys.apiUrl ?? 'your-project-url'}', - anonKey: '${projectKeys.anonKey ?? 'your-anon-key'}', + anonKey: '${projectKeys.publishableKey ?? ''}', ); runApp(MyApp()); } diff --git a/apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx index 98a9f34dfb1..ecb2e2e90be 100644 --- a/apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/ionicangular/supabasejs/content.tsx @@ -24,7 +24,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` export const environment = { supabaseUrl: '${projectKeys.apiUrl ?? 'your-project-url'}', - supabaseKey: '${projectKeys.anonKey ?? 'your-anon-key'}', + supabaseKey: '${projectKeys.publishableKey ?? ''}', }; `} diff --git a/apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx index 4cee515aadf..ac86c1a17cc 100644 --- a/apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/ionicreact/supabasejs/content.tsx @@ -21,7 +21,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` REACT_APP_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -REACT_APP_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} +REACT_APP_SUPABASE_KEY=${projectKeys.publishableKey ?? ''} `} @@ -32,7 +32,7 @@ REACT_APP_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} import { createClient } from '@supabase/supabase-js' const supabaseUrl = process.env.REACT_APP_SUPABASE_URL -const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY +const supabaseKey = process.env.REACT_APP_SUPABASE_KEY export const supabase = createClient(supabaseUrl, supabaseAnonKey) `} diff --git a/apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx index 0a99eaa0ac6..8108b197f09 100644 --- a/apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/nextjs/app/supabasejs/content.tsx @@ -21,10 +21,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -58,10 +62,13 @@ export default async function Page() { import { createServerClient, type CookieOptions } from "@supabase/ssr"; import { cookies } from "next/headers"; +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseKey = process.env.${projectKeys?.publishableKey ? 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'NEXT_PUBLIC_SUPABASE_ANON_KEY'}; + export const createClient = (cookieStore: ReturnType) => { return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + supabaseUrl!, + supabaseKey!, { cookies: { getAll() { @@ -88,10 +95,13 @@ export const createClient = (cookieStore: ReturnType) => { {` import { createBrowserClient } from "@supabase/ssr"; +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseKey = process.env.${projectKeys?.publishableKey ? 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'NEXT_PUBLIC_SUPABASE_ANON_KEY'}; + export const createClient = () => createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + supabaseUrl!, + supabaseKey!, ); `} @@ -103,6 +113,9 @@ export const createClient = () => import { createServerClient, type CookieOptions } from "@supabase/ssr"; import { type NextRequest, NextResponse } from "next/server"; +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseKey = process.env.${projectKeys?.publishableKey ? 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'NEXT_PUBLIC_SUPABASE_ANON_KEY'}; + export const createClient = (request: NextRequest) => { // Create an unmodified response let supabaseResponse = NextResponse.next({ @@ -112,8 +125,8 @@ export const createClient = (request: NextRequest) => { }); const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + supabaseUrl!, + supabaseKey!, { cookies: { getAll() { @@ -134,7 +147,6 @@ export const createClient = (request: NextRequest) => { return supabaseResponse }; - `} diff --git a/apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx index a74132d4983..92a3ea50e64 100644 --- a/apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/nextjs/pages/supabasejs/content.tsx @@ -19,10 +19,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `NEXT_PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -32,7 +36,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} import { createClient } from "@supabase/supabase-js"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; -const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; +const supabaseKey = process.env.${projectKeys?.publishableKey ? 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'NEXT_PUBLIC_SUPABASE_ANON_KEY'}; export const supabase = createClient(supabaseUrl, supabaseKey); `} diff --git a/apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx index ed49f7d0e55..be56beed13b 100644 --- a/apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/nuxt/supabasejs/content.tsx @@ -21,7 +21,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_KEY=${projectKeys.anonKey ?? 'your-anon-key'} +SUPABASE_KEY=${projectKeys.publishableKey ?? projectKeys.anonKey ?? 'your-anon-key'} `} diff --git a/apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx index e960582f408..3a70ecfd3d9 100644 --- a/apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/react/create-react-app/supabasejs/content.tsx @@ -19,10 +19,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -REACT_APP_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -REACT_APP_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `REACT_APP_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `REACT_APP_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `REACT_APP_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -32,7 +36,7 @@ REACT_APP_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} import { createClient } from "@supabase/supabase-js"; const supabaseUrl = process.env.REACT_APP_SUPABASE_URL; -const supabaseKey = process.env.REACT_APP_SUPABASE_ANON_KEY; +const supabaseKey = process.env.${projectKeys.publishableKey ? 'REACT_APP_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'REACT_APP_SUPABASE_ANON_KEY'}; export const supabase = createClient(supabaseUrl, supabaseKey); `} diff --git a/apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx index 1c334849084..a28865fb223 100644 --- a/apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/react/vite/supabasejs/content.tsx @@ -19,10 +19,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -VITE_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -VITE_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `VITE_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `VITE_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -32,7 +36,8 @@ VITE_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} import { createClient } from '@supabase/supabase-js'; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; -const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY; +const supabaseKey = import.meta.env.${projectKeys.publishableKey ? 'VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'VITE_SUPABASE_ANON_KEY'}; + const supabase = createClient(supabaseUrl, supabaseKey); export default supabase diff --git a/apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx index 5be7b60910c..fe3f9cd88d3 100644 --- a/apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/refine/supabasejs/content.tsx @@ -19,10 +19,12 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + `SUPABASE_KEY=${projectKeys?.publishableKey ?? projectKeys?.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -32,7 +34,7 @@ SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} import { createClient } from "@refinedev/supabase"; const SUPABASE_URL = process.env.SUPABASE_URL; -const SUPABASE_KEY = process.env.SUPABASE_KEY +const SUPABASE_KEY = process.env.SUPABASE_KEY; export const supabaseClient = createClient(SUPABASE_URL, SUPABASE_KEY, { db: { diff --git a/apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx index 5e33bcbc7a5..cff2d98ecc4 100644 --- a/apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/remix/supabasejs/content.tsx @@ -19,10 +19,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -35,9 +39,12 @@ export function createClient(request: Request) { const cookies = parse(request.headers.get("Cookie") ?? ""); const headers = new Headers(); + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseKey = process.env.${projectKeys.publishableKey ? 'SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'SUPABASE_ANON_KEY'}; + return createServerClient( - process.env.SUPABASE_URL!, - process.env.SUPABASE_ANON_KEY!, + supabaseUrl!, + supabaseKey!, { cookies: { get(key) { diff --git a/apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx index 3fb9adc2cc6..08bfbc7b8a1 100644 --- a/apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/solidjs/supabasejs/content.tsx @@ -19,10 +19,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -32,9 +36,9 @@ SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} import { createClient } from "@supabase/supabase-js"; const supabaseUrl = process.env.SUPABASE_URL; -const supabaseKey = process.env.SUPABASE_ANON_KEY; +const supabaseKey = process.env.${projectKeys.publishableKey ? 'SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'SUPABASE_ANON_KEY'}; -export const supabase = createClient(supabaseUrl, supabaseKey); +export const supabase = createClient(supabaseUrl!, supabaseKey!); `} diff --git a/apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx index f369de33918..d7dd5da02de 100644 --- a/apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/sveltekit/supabasejs/content.tsx @@ -20,10 +20,14 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { - {` -PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} - `} + {[ + '', + `PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}`, + projectKeys?.publishableKey + ? `PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${projectKeys.publishableKey}` + : `PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}`, + '', + ].join('\n')} @@ -31,10 +35,10 @@ PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} {` import { createClient } from "@supabase/supabase-js"; -import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from "$env/static/public" +import { PUBLIC_SUPABASE_URL, ${projectKeys.publishableKey ? 'PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'PUBLIC_SUPABASE_ANON_KEY'} } from "$env/static/public" const supabaseUrl = PUBLIC_SUPABASE_URL; -const supabaseKey = PUBLIC_SUPABASE_ANON_KEY; +const supabaseKey = ${projectKeys.publishableKey ? 'PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY' : 'PUBLIC_SUPABASE_ANON_KEY'}; export const supabase = createClient(supabaseUrl, supabaseKey); `} diff --git a/apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx b/apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx index 87ad9d3a345..a0f637e2e4a 100644 --- a/apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/swift/supabaseswift/content.tsx @@ -25,7 +25,7 @@ import Supabase let supabase = SupabaseClient( supabaseURL: URL(string: "${projectKeys.apiUrl ?? 'your-project-url'}")!, - supabaseKey: "${projectKeys.anonKey ?? 'your-anon-key'}" + supabaseKey: "${projectKeys.publishableKey ?? ''}" ) `} diff --git a/apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx b/apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx index ec4e1e0491c..3d5b6d91cce 100644 --- a/apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Connect/content/vuejs/supabasejs/content.tsx @@ -21,7 +21,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_KEY=${projectKeys.anonKey ?? 'your-anon-key'} +SUPABASE_KEY=${projectKeys.publishableKey ?? projectKeys.anonKey ?? 'your-anon-key'} `} diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index d32e9aaae95..fd966494bdf 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -134,6 +134,7 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => { useEffect(() => { if (visible) { + setFocusedEditor(false) form.reset({ name: func?.name ?? '', schema: func?.schema ?? 'public', 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/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index e7c02e26881..d76a1526aa0 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -56,6 +56,7 @@ import { DiskCountdownRadial } from './ui/DiskCountdownRadial' import { DiskType, RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3 } from './ui/DiskManagement.constants' import { NoticeBar } from './ui/NoticeBar' import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection' +import { useIsAwsCloudProvider, useIsAwsK8sCloudProvider } from 'hooks/misc/useSelectedProject' export function DiskManagementForm() { // isLoading is used to avoid a useCheckPermissions() race condition @@ -69,7 +70,8 @@ export function DiskManagementForm() { (warning) => warning.project === project?.ref ) const isReadOnlyMode = projectResourceWarnings?.is_readonly_mode_enabled - const isFlyArchitecture = project?.cloud_provider === 'FLY' + const isAws = useIsAwsCloudProvider() + const isAwsK8s = useIsAwsK8sCloudProvider() /** * Permissions @@ -118,27 +120,24 @@ export function DiskManagementForm() { setRefetchInterval(2000) } }, - enabled: project != null && !isFlyArchitecture, + enabled: project != null && isAws, } ) const { isSuccess: isAddonsSuccess } = useProjectAddonsQuery({ projectRef }) const { isWithinCooldownWindow, isSuccess: isCooldownSuccess } = useRemainingDurationForDiskAttributeUpdate({ projectRef, - enabled: project != null && !isFlyArchitecture, + enabled: project != null && isAws, }) const { data: diskUtil, isSuccess: isDiskUtilizationSuccess } = useDiskUtilizationQuery( { projectRef, }, - { enabled: project != null && !isFlyArchitecture } + { enabled: project != null && isAws } ) const { data: diskAutoscaleConfig, isSuccess: isDiskAutoscaleConfigSuccess } = - useDiskAutoscaleCustomConfigQuery( - { projectRef }, - { enabled: project != null && !isFlyArchitecture } - ) + useDiskAutoscaleCustomConfigQuery({ projectRef }, { enabled: project != null && isAws }) /** * Handle default values @@ -200,7 +199,7 @@ export function DiskManagementForm() { isPlanUpgradeRequired || isWithinCooldownWindow || !canUpdateDiskConfiguration || - isFlyArchitecture + !isAws const disableComputeInputs = isPlanUpgradeRequired const isDirty = !!Object.keys(form.formState.dirtyFields).length @@ -344,15 +343,17 @@ export function DiskManagementForm() { - {!isFlyArchitecture && ( + {isAws && ( <>
diff --git a/apps/studio/components/interfaces/Docs/LangSelector.tsx b/apps/studio/components/interfaces/Docs/LangSelector.tsx index b523ded9d53..341071c2b7e 100644 --- a/apps/studio/components/interfaces/Docs/LangSelector.tsx +++ b/apps/studio/components/interfaces/Docs/LangSelector.tsx @@ -1,15 +1,18 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { Key } from 'lucide-react' +import { useMemo } from 'react' import { useParams } from 'common' import type { showApiKey } from 'components/interfaces/Docs/Docs.types' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { Button, DropdownMenu, DropdownMenuContent, - DropdownMenuItem, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from 'ui' @@ -17,25 +20,30 @@ const DEFAULT_KEY = { name: 'hide', key: 'SUPABASE_KEY' } interface LangSelectorProps { selectedLang: string - showApiKey: showApiKey + selectedApiKey: showApiKey setSelectedLang: (selectedLang: string) => void - setShowApiKey: (showApiKey: showApiKey) => void + setSelectedApiKey: (showApiKey: showApiKey) => void } const LangSelector = ({ selectedLang, - showApiKey, + selectedApiKey, setSelectedLang, - setShowApiKey, + setSelectedApiKey, }: LangSelectorProps) => { const { ref: projectRef } = useParams() - const canReadServiceKey = useCheckPermissions( - PermissionAction.READ, - 'service_api_keys.service_role_key' - ) - const { data: settings } = useProjectSettingsV2Query({ projectRef }) - const { anonKey: anonApiKey, serviceKey: serviceApiKey } = getAPIKeys(settings) + const { data: apiKeys = [], isLoading: isLoadingAPIKeys } = useAPIKeysQuery({ + projectRef, + reveal: false, + }) + + const legacyKeys = useMemo(() => apiKeys.filter(({ type }) => type === 'legacy'), [apiKeys]) + const publishableKeys = useMemo( + () => apiKeys.filter(({ type }) => type === 'publishable'), + [apiKeys] + ) + const secretKeys = useMemo(() => apiKeys.filter(({ type }) => type === 'secret'), [apiKeys]) return (
@@ -62,48 +70,93 @@ const LangSelector = ({ > Bash - {selectedLang == 'bash' && ( -
+ {selectedLang == 'bash' && !isLoadingAPIKeys && apiKeys && apiKeys.length > 0 && ( +
- Project API key : + Project API key:
- + - <> - setShowApiKey(DEFAULT_KEY)}> - hide - - {anonApiKey && ( - - setShowApiKey({ - key: anonApiKey.api_key ?? '-', - name: 'anon (public)', - }) - } - > -

anon (public)

-
+ + setSelectedApiKey(DEFAULT_KEY)} + > + Hide keys + + + {publishableKeys.length > 0 && ( + <> + + Publishable keys + {publishableKeys.map((key) => { + const value = key.api_key + return ( + + setSelectedApiKey({ + name: `Publishable key: ${key.name}`, + key: value, + }) + } + > + {key.name} + + ) + })} + )} - {canReadServiceKey && ( - - setShowApiKey({ - key: serviceApiKey?.api_key ?? '-', - name: 'service_role (secret)', - }) - } - > -

service_role (secret)

-
+ + {secretKeys.length > 0 && ( + <> + + Secret keys + {secretKeys.map((key) => { + const value = key.prefix + '...' + return ( + + setSelectedApiKey({ name: `Secret key: ${key.name}`, key: value }) + } + > + {key.name} + + ) + })} + )} - + + + + + JWT-based legacy keys + {legacyKeys.map((key) => { + const value = key.api_key + return ( + + setSelectedApiKey({ name: `Legacy key: ${key.name}`, key: value }) + } + > + {key.name} + + ) + })} + +
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx index d7f58f0d76e..7c65503db22 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -15,7 +15,10 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from 'lib/constants' import { prettifyJSON } from 'lib/helpers' import { getRoleImpersonationJWT } from 'lib/role-impersonation' -import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' +import { + RoleImpersonationStateContextProvider, + useGetImpersonatedRoleState, +} from 'state/role-impersonation-state' import { Badge, Button, @@ -159,7 +162,7 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester // Validate that the body is valid JSON try { - JSON.parse(values.body) + JSON.parse(JSON.stringify(values.body)) } catch (e) { form.setError('body', { message: 'Must be a valid JSON string' }) return @@ -416,7 +419,12 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester
- + {/* [Alaister]: We're using a fresh context here as edge functions don't allow impersonating users. */} + + + +
+
+ + + +
+
+ +
+
+

Why use a Standby key?

+

+ Create Standby keys ahead of time which can then be promoted to 'In use' at any + time. +

+ +
+
+
+
+
*/} + + + + + Start using new JWT signing keys + + + +

+ Your project today uses a legacy symmetric JWT secret to create JWTs. To be able to + use an asymmetric JWT signing key you first have to migrate it to the new system. This + change does not cause any downtime on your project. +

+
+ + + +
+
+ + + + + + + + + + + Rotate Key + + + + + {standbyKey ? ( + <> + The standby key ({algorithmLabels[standbyKey.algorithm]}) will be promoted to 'In + use'. This will: +
    +
  • Change the current standby key to 'In use'
  • +
  • Move the current 'In use' key to 'Previously used'
  • +
  • Move any 'Previously used' key to 'Revoked'
  • +
+ + ) : ( + <> + Since there is no standby key, you need to choose an algorithm for the new key: +
+ setNewKeyAlgorithm(value)} + > + + + + + HS256 (Symmetric) + ES256 (ECC) + RS256 (RSA) + EdDSA (Ed25519) + + +

+ {algorithmDescriptions[newKeyAlgorithm]} +

+
+ + )} +
+
+ + + +
+
+ + + + + Confirm key rotation + + Review the key rotation process below. Ensure your application's components have + already picked up and are trusting your standby key to avoid downtime. + + + + +
+ {standbyKey ? ( +
+ + + STANDBY KEY + + {algorithmLabels[standbyKey!.algorithm]} + + + + + + CURRENTLY USED + +
+ ) : ( +
+ + + New Key + + {algorithmLabels[newKeyAlgorithm]} + + + + + + CURRENTLY USED + +
+ )} + +
+ + + CURRENTLY USED + + {inUseKey?.algorithm && algorithmLabels[inUseKey.algorithm]} + + + + + + PREVIOUS KEY + +
+
+
+ + + + +
+
+ + + + + Key Details + + + + {selectedKey && ( + <> +
+
+ +

Public Key (PEM format)

+
+
+                    {typeof selectedKey.public_jwk === 'string'
+                      ? selectedKey.public_jwk
+                      : JSON.stringify(selectedKey.public_jwk ?? '', null, 2)}
+                  
+
+
+

JWKS URL

+
+                    {`${window.location.origin}/jwt/v1/jwks.json`}
+                  
+
+ + )} +
+
+
+ + {selectedKey && + selectedKey.status === 'previously_used' && + (legacyKey?.id !== selectedKey.id || !(legacyAPIKeysStatus?.enabled ?? false)) && ( + handleRevokeKey(selectedKey.id)} + onCancel={resetDialog} + title={`Revoke ${selectedKey.id}`} + confirmString={selectedKey.id} + confirmLabel="Yes, revoke this signing key" + confirmPlaceholder="Type the ID of the key to confirm" + variant="destructive" + alert={{ + title: 'This key will no longer be trusted!', + description: + 'By revoking a signing key, all applications trusting it will no longer do so. If there are JWTs (access tokens) that are valid at the time of revocation, they will no longer be trusted, causing users with such JWTs to be signed out.', + }} + /> + )} + + {selectedKey && + selectedKey.status === 'previously_used' && + legacyKey?.id === selectedKey.id && + (legacyAPIKeysStatus?.enabled ?? true) && ( + resetDialog()}> + + + Disable JWT-based legacy API keys first + + + It's not possible to revoke the legacy JWT secret unless you have already disabled + JWT-based legacy API keys. This is because revoking the JWT secret invalidates the + JWT-based legacy API keys. + + + OK + + + + )} + + {selectedKey && selectedKey.status === 'revoked' && ( + handleDeleteKey(selectedKey.id)} + onCancel={resetDialog} + title={`Permanently delete ${selectedKey.id}`} + confirmString={selectedKey.id} + confirmLabel="Yes, permanently delete this key" + confirmPlaceholder="Type the ID of the key to confirm" + variant="destructive" + alert={{ + title: 'This key will be permanently deleted.', + description: + 'The private key and all information about this key will be permanently deleted from our records. This action cannot be undone.', + }} + /> + )} + + ) +} diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx new file mode 100644 index 00000000000..e8395ed57b0 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx @@ -0,0 +1,147 @@ +import { motion } from 'framer-motion' +import { CircleArrowDown, Eye, Key, MoreVertical, ShieldOff, Timer } from 'lucide-react' + +import { components } from 'api-types' +import { JWTSigningKey } from 'data/jwt-signing-keys/jwt-signing-keys-query' +import { + Badge, + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + TableCell, + TableRow, +} from 'ui' +import { AlgorithmHoverCard } from '../algorithm-hover-card' +import { statusColors, statusLabels } from '../jwt.constants' + +interface SigningKeyRowProps { + signingKey: components['schemas']['SigningKeyResponse'] + setSelectedKey: (key: JWTSigningKey | null) => void + setShownDialog: ( + dialog: + | 'legacy' + | 'create' + | 'rotate' + | 'confirm-rotate' + | 'key-details' + | 'revoke' + | 'delete' + | null + ) => void + handlePreviouslyUsedKey: (keyId: string) => void + legacyKey?: components['schemas']['SigningKeyResponse'] | null +} + +const MotionTableRow = motion(TableRow) + +export const SigningKeyRow = ({ + signingKey, + setSelectedKey, + setShownDialog, + handlePreviouslyUsedKey, + legacyKey, +}: SigningKeyRowProps) => ( + + +
+ + {signingKey.status === 'standby' ? : } + {statusLabels[signingKey.status]} + +
+
+ +
+ + {signingKey.id} + + +
+
+ + + + + + + + + + + ) +} diff --git a/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx b/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx new file mode 100644 index 00000000000..d0460153ee9 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx @@ -0,0 +1,28 @@ +import { FeatureBanner } from 'components/ui/FeatureBanner' +import { Import } from 'lucide-react' +import { Button } from 'ui' + +export const StartUsingJwtSigningKeysBanner = ({ + onClick, + isLoading, +}: { + onClick: () => void + isLoading: boolean +}) => { + return ( + +
+

Start using JWT signing keys

+

+ Right now your project is using the legacy JWT secret. To start taking advantage of the + new JWT signing keys, migrate your project's secret to the new set up. +

+
+ +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx index 8af7d72ade4..778dd9933ed 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx @@ -91,7 +91,7 @@ const BillingEmail = () => { useEffect(() => { if (billingCustomer) { form.reset({ - billingEmail: billing_email, + billingEmail: billing_email ?? '', additionalBillingEmails: billingCustomer.additional_emails ?? [], }) } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.constants.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.constants.ts deleted file mode 100644 index d2f5238e5f4..00000000000 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const CANCELLATION_REASONS = [ - 'Pricing', - "My project isn't getting traction", - 'Poor customer service', - 'Missing feature', - "I didn't see the value", - "Supabase didn't meet my needs", - 'Dashboard is too complicated', - 'Postgres is too complicated', - 'Problem not solved', - 'Too many bugs/issues', - 'I decided to use something else', - 'My work has finished/discontinued', - 'I’m migrating to/starting a new project', - 'None of the above', -] diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx index 445b2c2ba7b..6a942301803 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx @@ -82,7 +82,7 @@ export const BillingSettings = () => { )} - {isBillingCreditsEnabledOnProfileLevel && isNotOrgWithPartnerBilling && ( + {isBillingCreditsEnabledOnProfileLevel && ( <> diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index a4954c689e5..b33f047760d 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -39,6 +39,7 @@ import { import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import PaymentMethodSelection from './Subscription/PaymentMethodSelection' import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' +import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -149,7 +150,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { const options = useMemo(() => { return { clientSecret: paymentIntentSecret, - appearance: { theme: resolvedTheme?.includes('dark') ? 'night' : 'flat', labels: 'floating' }, + appearance: getStripeElementsAppearanceOptions(resolvedTheme), } as any }, [paymentIntentSecret, resolvedTheme]) @@ -251,8 +252,10 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { name="paymentMethod" render={() => ( form.setValue('paymentMethod', pm)} selectedPaymentMethod={form.getValues('paymentMethod')} + readOnly={executingTopUp || paymentConfirmationLoading} /> )} /> @@ -322,7 +325,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { paymentIntentConfirmed(paymentIntentConfirmation) } onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} - paymentMethodId={form.getValues().paymentMethod} /> )} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx new file mode 100644 index 00000000000..9070143191f --- /dev/null +++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx @@ -0,0 +1,54 @@ +/** + * 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. + */ + +import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import { forwardRef, useImperativeHandle } from 'react' +import { toast } from 'sonner' + +const NewPaymentMethodElement = forwardRef( + ( + { + email, + readOnly, + }: { + email?: string | null | undefined + readOnly: boolean + }, + ref + ) => { + const stripe = useStripe() + const elements = useElements() + + const createPaymentMethod = async () => { + if (!stripe || !elements) return + await elements.submit() + + // 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 + } + + useImperativeHandle(ref, () => ({ + createPaymentMethod, + })) + + return ( + + ) + } +) + +NewPaymentMethodElement.displayName = 'NewPaymentMethodElement' + +export { NewPaymentMethodElement } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx index 02d6159a65d..eb2cb8e44be 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx @@ -7,11 +7,15 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' import { CriticalIcon, WarningIcon } from 'ui' import { PricingMetric } from 'data/analytics/org-daily-stats-query' +import { usePathname } from 'next/navigation' export const Restriction = () => { const org = useSelectedOrganization() const { data: usage, isSuccess: isSuccessOrgUsage } = useOrgUsageQuery({ orgSlug: org?.slug }) + const pathname = usePathname() + const isUsagePage = pathname?.endsWith('/usage') + const hasExceededAnyLimits = Boolean( usage?.usages.find( (metric) => @@ -69,6 +73,11 @@ export const Restriction = () => { {org.plan.id === 'free' ? 'Upgrade plan' : 'Change spend cap'} + {!isUsagePage && ( + + )} + + {!isUsagePage && ( + + )} + + {!isUsagePage && ( + + )} + {!isUsagePage && ( + + )} - - - +
+
+ {shuffledReasons.map((option) => { + const active = selectedReason[0] === option.value + return ( + + ) + })} +
+
+ + setMessage(event.target.value)} + rows={3} + /> +
+ {hasProjectsWithComputeDowngrade && ( + + This is due to changes in compute instances from the downgrade. Affected projects + include {projectsWithComputeDowngrade.map((project) => project.name).join(', ')}. + + )} - - +
+ +
+

+ The unused amount for the remaining time of your billing cycle will be refunded as credits +

+ +
+ + + + +
+
+ ) } - -export default ExitSurveyModal diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx index 0555c36687d..3754491fbd1 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx @@ -1,5 +1,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useEffect, useState } from 'react' +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' import { toast } from 'sonner' import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal' @@ -7,25 +15,51 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { BASE_PATH } from 'lib/constants' +import { BASE_PATH, STRIPE_PUBLIC_KEY } from 'lib/constants' import { getURL } from 'lib/helpers' import { AlertCircle, CreditCard, Loader, Plus } from 'lucide-react' import { Listbox } from 'ui' +import HCaptcha from '@hcaptcha/react-hcaptcha' +import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' +import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation' +import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation' +import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js' +import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' +import { useTheme } from 'next-themes' +import { Elements } from '@stripe/react-stripe-js' +import { NewPaymentMethodElement } from '../PaymentMethods/NewPaymentMethodElement' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' + +const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) export interface PaymentMethodSelectionProps { selectedPaymentMethod?: string onSelectPaymentMethod: (id: string) => void layout?: 'vertical' | 'horizontal' + createPaymentMethodInline: boolean + readOnly: boolean } -const PaymentMethodSelection = ({ - selectedPaymentMethod, - onSelectPaymentMethod, - layout = 'vertical', -}: PaymentMethodSelectionProps) => { +const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( + { + selectedPaymentMethod, + onSelectPaymentMethod, + layout = 'vertical', + createPaymentMethodInline = false, + readOnly, + }: PaymentMethodSelectionProps, + ref +) { const selectedOrganization = useSelectedOrganization() const slug = selectedOrganization?.slug const [showAddNewPaymentMethodModal, setShowAddNewPaymentMethodModal] = useState(false) + const captchaLoaded = useIsHCaptchaLoaded() + const [captchaToken, setCaptchaToken] = useState(null) + const [captchaRef, setCaptchaRef] = useState(null) + const [setupIntent, setSetupIntent] = useState(undefined) + const { resolvedTheme } = useTheme() + const paymentRef = useRef<{ createPaymentMethod: () => Promise }>(null) + const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState(null) const { data: paymentMethods, @@ -33,11 +67,77 @@ const PaymentMethodSelection = ({ refetch: refetchPaymentMethods, } = useOrganizationPaymentMethodsQuery({ slug }) + const captchaRefCallback = useCallback((node: any) => { + setCaptchaRef(node) + }, []) + + const { mutate: initSetupIntent, isLoading: setupIntentLoading } = + useOrganizationPaymentMethodSetupIntent({ + onSuccess: (intent) => { + setSetupIntent(intent) + }, + onError: (error) => { + toast.error(`Failed to setup intent: ${error.message}`) + }, + }) + + useEffect(() => { + if (paymentMethods?.data && paymentMethods.data.length === 0 && setupNewPaymentMethod == null) { + setSetupNewPaymentMethod(true) + } + }, [paymentMethods]) + + useEffect(() => { + const loadSetupIntent = async (hcaptchaToken: string | undefined) => { + const slug = selectedOrganization?.slug + if (!slug) return console.error('Slug is required') + if (!hcaptchaToken) return console.error('HCaptcha token required') + + setSetupIntent(undefined) + initSetupIntent({ slug: slug!, hcaptchaToken }) + } + + const loadPaymentForm = async () => { + if (setupNewPaymentMethod && createPaymentMethodInline && captchaRef && captchaLoaded) { + let token = captchaToken + + try { + if (!token) { + const captchaResponse = await captchaRef.execute({ async: true }) + token = captchaResponse?.response ?? null + } + } catch (error) { + return + } + + await loadSetupIntent(token ?? undefined) + resetCaptcha() + } + } + + loadPaymentForm() + }, [createPaymentMethodInline, captchaRef, captchaLoaded, setupNewPaymentMethod]) + + const resetCaptcha = () => { + setCaptchaToken(null) + captchaRef?.resetCaptcha() + } + const canUpdatePaymentMethods = useCheckPermissions( PermissionAction.BILLING_WRITE, 'stripe.payment_methods' ) + const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo( + () => + ({ + clientSecret: setupIntent ? setupIntent.client_secret! : '', + appearance: getStripeElementsAppearanceOptions(resolvedTheme), + paymentMethodCreation: 'manual', + }) as const, + [setupIntent, resolvedTheme] + ) + useEffect(() => { if (paymentMethods?.data && paymentMethods.data.length > 0) { const selectedPaymentMethodExists = paymentMethods.data.some( @@ -55,15 +155,49 @@ const PaymentMethodSelection = ({ } }, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod]) + // If createPaymentMethod already exists, use it. Otherwise, define it here. + const createPaymentMethod = async () => { + if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) { + return paymentRef.current?.createPaymentMethod() + } else { + return { id: selectedPaymentMethod } + } + } + + useImperativeHandle(ref, () => ({ + createPaymentMethod, + })) + return ( <> + { + // [Joshen] This is to ensure that hCaptcha popup remains clickable + if (document !== undefined) document.body.classList.add('!pointer-events-auto') + }} + onClose={() => { + setSetupIntent(undefined) + if (document !== undefined) document.body.classList.remove('!pointer-events-auto') + }} + onVerify={(token) => { + setCaptchaToken(token) + if (document !== undefined) document.body.classList.remove('!pointer-events-auto') + }} + onExpire={() => { + setCaptchaToken(null) + }} + /> +
{isLoading ? (

Retrieving payment methods

- ) : paymentMethods?.data.length === 0 ? ( + ) : paymentMethods?.data.length === 0 && !createPaymentMethodInline ? (
@@ -74,7 +208,13 @@ const PaymentMethodSelection = ({ type="default" disabled={!canUpdatePaymentMethods} icon={} - onClick={() => setShowAddNewPaymentMethodModal(true)} + onClick={() => { + if (createPaymentMethodInline) { + setSetupNewPaymentMethod(true) + } else { + setShowAddNewPaymentMethodModal(true) + } + }} htmlType="button" tooltip={{ content: { @@ -93,7 +233,7 @@ const PaymentMethodSelection = ({ Add new
- ) : ( + ) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? ( setShowAddNewPaymentMethodModal(true)} + onClick={() => { + if (createPaymentMethodInline) { + setSetupNewPaymentMethod(true) + } else { + setShowAddNewPaymentMethodModal(true) + } + }} >

@@ -134,6 +280,27 @@ const PaymentMethodSelection = ({

+ ) : null} + + {stripePromise && setupIntent && ( + + + + )} + + {setupIntentLoading && ( +
+ +
+ + +
+ +
)}
@@ -158,6 +325,8 @@ const PaymentMethodSelection = ({ /> ) -} +}) + +PaymentMethodSelection.displayName = 'PaymentMethodSelection' export default PaymentMethodSelection diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index 40f7fd83c70..a91fa6368eb 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -5,7 +5,9 @@ import { useRouter } from 'next/router' import { useEffect, useRef, useState } from 'react' import { StudioPricingSidePanelOpenedEvent } from 'common/telemetry-constants' +import { getPlanChangeType } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import PartnerManagedResource from 'components/ui/PartnerManagedResource' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' import { useOrganizationBillingSubscriptionPreview } from 'data/organizations/organization-billing-subscription-preview' @@ -23,11 +25,10 @@ import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' import { Button, SidePanel, cn } from 'ui' import DowngradeModal from './DowngradeModal' import { EnterpriseCard } from './EnterpriseCard' -import ExitSurveyModal from './ExitSurveyModal' +import { ExitSurveyModal } from './ExitSurveyModal' import MembersExceedLimitModal from './MembersExceedLimitModal' import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog' import UpgradeSurveyModal from './UpgradeModal' -import PartnerManagedResource from 'components/ui/PartnerManagedResource' const PlanUpdateSidePanel = () => { const router = useRouter() @@ -70,7 +71,6 @@ const PlanUpdateSidePanel = () => { const { data: plans, isLoading: isLoadingPlans } = useOrgPlansQuery({ orgSlug: slug }) const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery({ slug }) - const billingViaPartner = subscription?.billing_via_partner === true const billingPartner = subscription?.billing_partner const { @@ -84,7 +84,6 @@ const PlanUpdateSidePanel = () => { const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 && orgProjects.filter((it) => it.status !== 'INACTIVE' && it.status !== 'GOING_DOWN').length > 0 - const subscriptionPlanMeta = subscriptionsPlans.find((tier) => tier.id === selectedTier) useEffect(() => { if (visible) { @@ -161,7 +160,8 @@ const PlanUpdateSidePanel = () => { {subscriptionsPlans.map((plan) => { const planMeta = availablePlans.find((p) => p.id === plan.id.split('tier_')[1]) const price = planMeta?.price ?? 0 - const isDowngradeOption = planMeta?.change_type === 'downgrade' + const isDowngradeOption = + getPlanChangeType(subscription?.plan.id, plan?.planId) === 'downgrade' const isCurrentPlan = planMeta?.id === subscription?.plan?.id const features = pickFeatures(plan, billingPartner) const footer = pickFooter(plan, billingPartner) @@ -299,7 +299,6 @@ const PlanUpdateSidePanel = () => { setSelectedTier(undefined)} onConfirm={onConfirmDowngrade} @@ -308,16 +307,12 @@ const PlanUpdateSidePanel = () => { setSelectedTier(undefined)} - subscriptionPlanMeta={subscriptionPlanMeta} planMeta={planMeta} subscriptionPreviewError={subscriptionPreviewError} subscriptionPreviewIsLoading={subscriptionPreviewIsLoading} subscriptionPreviewInitialized={subscriptionPreviewInitialized} subscriptionPreview={subscriptionPreview} - billingViaPartner={billingViaPartner} - billingPartner={billingPartner} subscription={subscription} projects={orgProjects} currentPlanMeta={{ diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index 0bee353c27b..dbebba34731 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -1,11 +1,14 @@ import { useQueryClient } from '@tanstack/react-query' import { Check, InfoIcon } from 'lucide-react' import Link from 'next/link' -import { useEffect, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import tweets from 'shared-data/tweets' import { toast } from 'sonner' -import { billingPartnerLabel } from 'components/interfaces/Billing/Subscription/Subscription.utils' +import { + billingPartnerLabel, + getPlanChangeType, +} from 'components/interfaces/Billing/Subscription/Subscription.utils' import AlertError from 'components/ui/AlertError' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { organizationKeys } from 'data/organizations/keys' @@ -14,13 +17,23 @@ import { ProjectInfo } from 'data/projects/projects-query' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' import { SubscriptionTier } from 'data/subscriptions/types' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { PRICING_TIER_PRODUCT_IDS, PROJECT_STATUS } from 'lib/constants' +import { PRICING_TIER_PRODUCT_IDS, PROJECT_STATUS, STRIPE_PUBLIC_KEY } from 'lib/constants' import { formatCurrency } from 'lib/helpers' import { Badge, Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui' import { Admonition } from 'ui-patterns' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { BillingCustomerDataExistingOrgDialog } from '../BillingCustomerData/BillingCustomerDataExistingOrgDialog' import PaymentMethodSelection from './PaymentMethodSelection' +import { useConfirmPendingSubscriptionChangeMutation } from 'data/subscriptions/org-subscription-confirm-pending-change' +import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' +import { Elements } from '@stripe/react-stripe-js' +import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js' +import { useTheme } from 'next-themes' +import { PaymentIntentResult } from '@stripe/stripe-js' +import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' +import { plans as subscriptionsPlans } from 'shared-data/plans' + +const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) const getRandomTweet = () => { const filteredTweets = tweets.filter((it) => it.text.length < 180) @@ -30,7 +43,7 @@ const getRandomTweet = () => { const PLAN_HEADINGS = { tier_pro: - 'the Pro plan and create unlimited projects, daily backups and premium support when you need it', + 'the Pro plan to unlock unlimited projects, daily backups, and email support whenever you need it', tier_team: 'the Team plan for SOC2, SSO, priority support and greater data and log retention', default: 'to a new plan', } as const @@ -49,15 +62,11 @@ type DowngradePlanHeadingKey = keyof typeof DOWNGRADE_PLAN_HEADINGS interface Props { selectedTier: 'tier_free' | 'tier_pro' | 'tier_team' | undefined onClose: () => void - subscriptionPlanMeta: any planMeta: any subscriptionPreviewError: any subscriptionPreviewIsLoading: boolean subscriptionPreviewInitialized: boolean subscriptionPreview: OrganizationBillingSubscriptionPreviewResponse | undefined - billingViaPartner: boolean - billingPartner?: string - selectedOrganization: any subscription: any currentPlanMeta: any projects: ProjectInfo[] @@ -66,38 +75,62 @@ interface Props { export const SubscriptionPlanUpdateDialog = ({ selectedTier, onClose, - subscriptionPlanMeta, planMeta, subscriptionPreviewError, subscriptionPreviewIsLoading, subscriptionPreviewInitialized, subscriptionPreview, - billingViaPartner, - billingPartner, - selectedOrganization, subscription, currentPlanMeta, projects, }: Props) => { - const queryClient = useQueryClient() - const { slug } = useSelectedOrganization() ?? {} + const { resolvedTheme } = useTheme() + const selectedOrganization = useSelectedOrganization() const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() - const [testimonialTweet, setTestimonialTweet] = useState(getRandomTweet()) + const [paymentIntentSecret, setPaymentIntentSecret] = useState(null) + const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) + const paymentMethodSelection = useRef<{ + createPaymentMethod: () => Promise + }>(null) - useEffect(() => { - if (selectedTier !== undefined && selectedTier !== 'tier_free') { - setTestimonialTweet(getRandomTweet()) - } - }, [selectedTier]) + const billingViaPartner = subscription?.billing_via_partner === true + const billingPartner = subscription?.billing_partner + + const stripeOptionsConfirm = useMemo(() => { + return { + clientSecret: paymentIntentSecret, + appearance: getStripeElementsAppearanceOptions(resolvedTheme), + } as StripeElementsOptions + }, [paymentIntentSecret, resolvedTheme]) + + const testimonialTweet = useMemo(() => getRandomTweet(), []) + + const changeType = useMemo(() => { + return getPlanChangeType(subscription?.plan?.id, planMeta?.id) + }, [planMeta, subscription]) + + const subscriptionPlanMeta = useMemo( + () => subscriptionsPlans.find((tier) => tier.id === selectedTier), + [selectedTier] + ) + + const onSuccessfulPlanChange = () => { + toast.success( + `Successfully ${changeType === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!` + ) + onClose() + window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) + } const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation( { - onSuccess: () => { - toast.success( - `Successfully ${planMeta?.change_type === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!` - ) - onClose() - window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }) + onSuccess: (data) => { + if (data.pending_payment_intent_secret) { + setPaymentIntentSecret(data.pending_payment_intent_secret) + return + } + + onSuccessfulPlanChange() }, onError: (error) => { toast.error(`Unable to update subscription: ${error.message}`) @@ -105,25 +138,47 @@ export const SubscriptionPlanUpdateDialog = ({ } ) + const { mutate: confirmPendingSubscriptionChange, isLoading: isConfirming } = + useConfirmPendingSubscriptionChangeMutation({ + onSuccess: () => { + onSuccessfulPlanChange() + }, + onError: (error) => { + toast.error(`Unable to update subscription: ${error.message}`) + }, + }) + + const paymentIntentConfirmed = async (paymentIntentConfirmation: PaymentIntentResult) => { + // Reset payment intent secret to ensure another attempt works as expected + setPaymentIntentSecret('') + + if (paymentIntentConfirmation.paymentIntent?.status === 'succeeded') { + await confirmPendingSubscriptionChange({ + slug: selectedOrganization?.slug, + payment_intent_id: paymentIntentConfirmation.paymentIntent.id, + }) + } else { + setPaymentConfirmationLoading(false) + // 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.`) + } + } + const onUpdateSubscription = async () => { - if (!slug) return console.error('org slug is required') + if (!selectedOrganization?.slug) return console.error('org slug is required') if (!selectedTier) return console.error('Selected plan is required') - if (!selectedPaymentMethod && subscription?.payment_method_type !== 'invoice') { - return toast.error('Please select a payment method') + + const paymentMethod = await paymentMethodSelection.current?.createPaymentMethod() + if (paymentMethod) { + setSelectedPaymentMethod(paymentMethod.id) } - if (selectedPaymentMethod) { - queryClient.setQueriesData(organizationKeys.paymentMethods(slug), (prev: any) => { - if (!prev) return prev - return { - ...prev, - defaultPaymentMethodId: selectedPaymentMethod, - data: prev.data.map((pm: any) => ({ - ...pm, - is_default: pm.id === selectedPaymentMethod, - })), - } - }) + if ( + !paymentMethod && + subscription?.payment_method_type !== 'invoice' && + changeType === 'upgrade' + ) { + return } // If the user is downgrading from team, should have spend cap disabled by default @@ -132,7 +187,11 @@ export const SubscriptionPlanUpdateDialog = ({ ? (PRICING_TIER_PRODUCT_IDS.PAYG as SubscriptionTier) : selectedTier - updateOrgSubscription({ slug, tier, paymentMethod: selectedPaymentMethod }) + updateOrgSubscription({ + slug: selectedOrganization?.slug, + tier, + paymentMethod: paymentMethod?.id, + }) } const features = subscriptionPlanMeta?.features?.[0]?.features || [] @@ -143,11 +202,11 @@ export const SubscriptionPlanUpdateDialog = ({ // Features that will be lost when downgrading const featuresToLose = - planMeta?.change_type === 'downgrade' + changeType === 'downgrade' ? currentPlanFeatures.filter((feature: string | [string, ...any[]]) => { const featureStr = typeof feature === 'string' ? feature : feature[0] // Check if this feature exists in the new plan - return !topFeatures.some((newFeature: string | [string, ...any[]]) => { + return !topFeatures.some((newFeature: string | string[]) => { const newFeatureStr = typeof newFeature === 'string' ? newFeature : newFeature[0] return newFeatureStr === featureStr }) @@ -165,7 +224,7 @@ export const SubscriptionPlanUpdateDialog = ({ const proratedCredit = currentPlanMonthlyPrice * remainingRatio // Calculate new plan cost - const newPlanCost = subscriptionPlanMeta?.priceMonthly ?? 0 + const newPlanCost = Number(subscriptionPlanMeta?.priceMonthly) ?? 0 const customerBalance = ((subscription?.customer_balance ?? 0) / 100) * -1 @@ -176,22 +235,26 @@ export const SubscriptionPlanUpdateDialog = ({ { + // Do not allow closing mid-change + if (isUpdating || paymentConfirmationLoading || isConfirming) { + return + } if (!open) onClose() }} > event.preventDefault()} size="xlarge" - className="p-0" + className="p-0 overflow-y-auto max-h-[1000px]" > -
+
{/* Left Column */} -
+

- {planMeta?.change_type === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '} + {changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '} {selectedOrganization?.name} to{' '} - {planMeta?.change_type === 'downgrade' + {changeType === 'downgrade' ? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default'] : PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}

@@ -256,7 +319,7 @@ export const SubscriptionPlanUpdateDialog = ({ <> {' '} @@ -483,16 +546,22 @@ export const SubscriptionPlanUpdateDialog = ({ )}
-
- {!billingViaPartner ? ( +
+ {!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
+ setSelectedPaymentMethod(pm)} + createPaymentMethodInline={true} + readOnly={paymentConfirmationLoading || isConfirming || isUpdating} />
- ) : ( + )} + + {billingViaPartner && (

This organization is billed through our partner{' '} @@ -518,40 +587,42 @@ export const SubscriptionPlanUpdateDialog = ({ (it) => it.status === PROJECT_STATUS.ACTIVE_HEALTHY || it.status === PROJECT_STATUS.COMING_UP - ).length === 0 && ( -

- - This organization has no active projects. Did you select the correct - organization? - -
- )} + ).length === 0 && + subscriptionPreview?.plan_change_type !== 'downgrade' && ( +
+ + This organization has no active projects. Did you select the correct + organization? + +
+ )}
{/* Right Column */} -
- {planMeta?.change_type === 'downgrade' +
+ {changeType === 'downgrade' ? featuresToLose.length > 0 && (

Features you'll lose

- Please review this carefully before downgrading. + Please review carefully before downgrading.

{featuresToLose.map((feature: string | [string, ...any[]]) => ( @@ -575,7 +646,7 @@ export const SubscriptionPlanUpdateDialog = ({

Upgrade features

- {topFeatures.map((feature: string | [string, ...any[]]) => ( + {topFeatures.map((feature: string | string[]) => (
)} - {planMeta?.change_type !== 'downgrade' && ( + {changeType !== 'downgrade' && (
{testimonialTweet.text} @@ -604,6 +675,18 @@ export const SubscriptionPlanUpdateDialog = ({ )}
+ + {stripePromise && paymentIntentSecret && ( + + + paymentIntentConfirmed(paymentIntentConfirmation) + } + onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} + /> + + )}
) diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx new file mode 100644 index 00000000000..7d890a0fb09 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx @@ -0,0 +1,132 @@ +import { ReactNode } from 'react' +import { Control } from 'react-hook-form' + +import { AIOptInFormValues } from 'hooks/forms/useAIOptInForm' +import { useFlag } from 'hooks/ui/useFlag' +import { FormField_Shadcn_, RadioGroup_Shadcn_, RadioGroupItem_Shadcn_ } from 'ui' +import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { OptInToOpenAIToggle } from './OptInToOpenAIToggle' + +interface AIOptInLevelSelectorProps { + control: Control + disabled?: boolean + label?: ReactNode + layout?: 'horizontal' | 'vertical' | 'flex-row-reverse' +} + +const AI_OPT_IN_LEVELS_OLD = [ + { + value: 'disabled', + title: 'Disabled', + description: + 'You do not consent to sharing any database information with Supabase AI and understand that responses will be generic and not tailored to your database', + }, + { + value: 'schema', + title: 'Send anonymous metadata', + description: + 'You consent to sending anonymous data to Supabase AI, which can improve the answers it shows you.', + }, +] + +const AI_OPT_IN_LEVELS = [ + { + value: 'disabled', + title: 'Disabled', + description: + 'You do not consent to sharing any database information with Amazon Bedrock and understand that responses will be generic and not tailored to your database', + }, + { + value: 'schema', + title: 'Schema Only', + description: + 'You consent to sharing your database’s schema metadata (such as table and column names, data types, and relationships—but not actual database data) with Amazon Bedrock', + }, + { + value: 'schema_and_log', + title: 'Schema & Logs', + description: + 'You consent to sharing your schema and logs (which may contain PII/database data) with Amazon Bedrock for better results', + }, + { + value: 'schema_and_log_and_data', + title: 'Schema, Logs & Database Data', + description: + 'You consent to give Amazon Bedrock full access to run database read only queries and analyze results for optimal results', + }, +] + +export const AIOptInLevelSelector = ({ + control, + disabled, + label, + layout = 'vertical', +}: AIOptInLevelSelectorProps) => { + const newOrgAiOptIn = useFlag('newOrgAiOptIn') + const useBedrockAssistant = useFlag('useBedrockAssistant') + + const optInLevels = useBedrockAssistant ? AI_OPT_IN_LEVELS : AI_OPT_IN_LEVELS_OLD + + return ( + + {useBedrockAssistant && ( + <> + {!newOrgAiOptIn && ( + + )} +

+ Supabase AI can provide more relevant answers if you choose to share different + levels of data. This feature is powered by Amazon Bedrock which does not store or + log your prompts and completions, nor does it use them to train AWS models or + distribute them to third parties. This is an organization-wide setting, so please + select the level of data you are comfortable sharing. +

+ + )} + + + } + > +
+ ( + + {optInLevels.map((item) => ( +
+ + +
+ ))} +
+ )} + /> +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx new file mode 100644 index 00000000000..2838f7df444 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx @@ -0,0 +1,50 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useEffect } from 'react' + +import { FormActions } from 'components/ui/Forms/FormActions' +import { useAIOptInForm } from 'hooks/forms/useAIOptInForm' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useFlag } from 'hooks/ui/useFlag' +import { Card, CardContent, CardFooter, Form_Shadcn_ } from 'ui' +import { AIOptInLevelSelector } from './AIOptInLevelSelector' + +export const DataPrivacyForm = () => { + const newOrgAiOptIn = useFlag('newOrgAiOptIn') + const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm() + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + + const permissionsHelperText = !canUpdateOrganization + ? "You need additional permissions to manage this organization's settings" + : undefined + + useEffect(() => { + form.reset({ aiOptInLevel: currentOptInLevel }) + }, [currentOptInLevel, form]) + + return ( + +
+ + + + + + form.reset()} + helper={permissionsHelperText} + disabled={!canUpdateOrganization} + /> + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx index d256ecfc4f2..2f28ffd5781 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx @@ -1,139 +1,38 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useQueryClient } from '@tanstack/react-query' -import { useEffect } from 'react' -import { toast } from 'sonner' - -import { useParams } from 'common' import { NoProjectsOnPaidOrgInfo } from 'components/interfaces/Billing/NoProjectsOnPaidOrgInfo' -import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold' -import { FormActions } from 'components/ui/Forms/FormActions' -import { FormPanel } from 'components/ui/Forms/FormPanel' -import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' -import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { + ScaffoldContainer, + ScaffoldSection, + ScaffoldSectionTitle, +} from 'components/layouts/Scaffold' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { OPT_IN_TAGS } from 'lib/constants' -import { Form, Input, Toggle } from 'ui' -import OptInToOpenAIToggle from './OptInToOpenAIToggle' import OrganizationDeletePanel from './OrganizationDeletePanel' +import { DataPrivacyForm } from './DataPrivacyForm' +import { OrganizationDetailsForm } from './OrganizationDetailsForm' + const GeneralSettings = () => { - const { slug } = useParams() - const queryClient = useQueryClient() - const selectedOrganization = useSelectedOrganization() - const { name } = selectedOrganization ?? {} - - const formId = 'org-general-settings' - const isOptedIntoAi = useOrgOptedIntoAi() - const initialValues = { name: name ?? '', isOptedIntoAi } - const organizationDeletionEnabled = useIsFeatureEnabled('organizations:delete') - const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') const canDeleteOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') - const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() - - const onUpdateOrganization = async (values: any, { resetForm }: any) => { - if (!canUpdateOrganization) { - return toast.error('You do not have the required permissions to update this organization') - } - - if (!slug) return console.error('Slug is required') - - const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] - const updatedOptInTags = - values.isOptedIntoAi && !existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) - ? existingOptInTags.concat([OPT_IN_TAGS.AI_SQL]) - : !values.isOptedIntoAi && existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) - ? existingOptInTags.filter((x) => x !== OPT_IN_TAGS.AI_SQL) - : existingOptInTags - - updateOrganization( - { slug, name: values.name, opt_in_tags: updatedOptInTags }, - { - onSuccess: () => { - resetForm({ values, initialValues: values }) - invalidateOrganizationsQuery(queryClient) - toast.success('Successfully saved settings') - }, - } - ) - } return ( - - + + -
- {({ handleReset, values, initialValues, resetForm }: any) => { - const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) + + Organization Details + + - // [Alaister] although this "technically" is breaking the rules of React hooks - // it won't error because the hooks are always rendered in the same order - // eslint-disable-next-line react-hooks/rules-of-hooks - useEffect(() => { - const values = { name: name ?? '', isOptedIntoAi } - resetForm({ values, initialValues: values }) - }, [selectedOrganization?.slug]) - - return ( - - - - } - > - General settings}> - - - -
- - - -
-
-
-
- ) - }} -
+ + Data Privacy + + {organizationDeletionEnabled && canDeleteOrganization && } -
+ ) } diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx index e360e000d20..becd7b76488 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx @@ -1,71 +1,111 @@ -import Link from 'next/link' -import { cn, Collapsible_Shadcn_, CollapsibleTrigger_Shadcn_, CollapsibleContent_Shadcn_ } from 'ui' -import { useState } from 'react' -import { ChevronRight } from 'lucide-react' +import { InlineLink } from 'components/ui/InlineLink' +import { useFlag } from 'hooks/ui/useFlag' -interface OptInToOpenAIToggleProps { - className?: string -} -export default function OptInToOpenAIToggle({ className }: OptInToOpenAIToggleProps) { - const [open, setOpen] = useState(false) +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogSection, + DialogTitle, + DialogTrigger, +} from 'ui' + +export const OptInToOpenAIToggle = () => { + const useBedrockAssistant = useFlag('useBedrockAssistant') return ( - - -
- -

- Important information regarding opting in -

-
-
- -
-

- Supabase AI is a chatbot support tool powered by OpenAI. Supabase will share the query - you submit and information about the databases you manage through Supabase with OpenAI, - L.L.C. and its affiliates in order to provide the Supabase AI tool. -

-

- OpenAI will only access information about the structure of your databases, such as table - names, column and row headings. OpenAI will not access the contents of the database - itself. -

-

- OpenAI uses this information to generate responses to your query, and does not retain or - use the information to train its algorithms or otherwise improve its products and - services. -

-

- If you have your own individual account on Supabase, we will use any personal - information collected through [Supabase AI] to provide you with the [Supabase AI] tool. - If you are in the UK, EEA or Switzerland, the processing of this personal information is - necessary for the performance of a contract between you and us. -

-

- Supabase collects information about the queries you submit through Supabase AI and the - responses you receive to assess the performance of the Supabase AI tool and improve our - services. If you are in the UK, EEA or Switzerland, the processing is necessary for our - legitimate interests, namely informing our product development and improvement. -

-

- For more information about how we use personal information, please see our{' '} - - privacy policy - - . -

-
-
-
+ + + + + + + Data Privacy and Supabase AI + + + {useBedrockAssistant ? ( + <> +

+ Supabase AI utilizes Amazon Bedrock ("Bedrock"), a service designed with a strong + focus on data privacy and security. +

+ +

+ Amazon Bedrock does not store or log your prompts and completions. This data is not + used to train any AWS models and is not distributed to third parties or model + providers. Model providers do not have access to Amazon Bedrock logs or customer + prompts and completions. +

+ +

+ By default, no information is shared with Bedrock unless you explicitly provide + consent. With your permission, Supabase may share customer-generated prompts, + database schema, database data, and project logs with Bedrock. This information is + used solely to generate responses to your queries and is not retained by Bedrock or + used to train their foundation models. +

+ +

+ If you are a HIPAA Covered Entity, please note that Bedrock is HIPAA eligible, and + Supabase has a Business Associate Agreement in place covering this use. +

+ +

+ For more detailed information about how we collect and use your data, see our{' '} + Privacy Policy. You can + choose which types of information you consent to share by selecting from the options + in the AI settings. +

+ + ) : ( + <> +

+ Supabase AI is a chatbot support tool powered by OpenAI. Supabase will share the + query you submit and information about the databases you manage through Supabase + with OpenAI, L.L.C. and its affiliates in order to provide the Supabase AI tool. +

+ +

+ OpenAI will only access information about the structure of your databases, such as + table names, column and row headings. OpenAI will not access the contents of the + database itself. +

+ +

+ OpenAI uses this information to generate responses to your query, and does not + retain or use the information to train its algorithms or otherwise improve its + products and services. +

+ +

+ If you have your own individual account on Supabase, we will use any personal + information collected through [Supabase AI] to provide you with the [Supabase AI] + tool. If you are in the UK, EEA or Switzerland, the processing of this personal + information is necessary for the performance of a contract between you and us. +

+ +

+ Supabase collects information about the queries you submit through Supabase AI and + the responses you receive to assess the performance of the Supabase AI tool and + improve our services. If you are in the UK, EEA or Switzerland, the processing is + necessary for our legitimate interests, namely informing our product development and + improvement. +

+ +

+ For more information about how we use personal information, please see our{' '} + privacy policy. +

+ + )} +
+
+
) } diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx index d0cd4ba4e73..dc878869f72 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx @@ -1,4 +1,4 @@ -import Panel from 'components/ui/Panel' +import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' import PartnerManagedResource from 'components/ui/PartnerManagedResource' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { Admonition } from 'ui-patterns' @@ -8,30 +8,28 @@ const OrganizationDeletePanel = () => { const selectedOrganization = useSelectedOrganization() return ( - DANGER ZONE

}> - - {selectedOrganization?.managed_by !== 'vercel-marketplace' ? ( - - - - ) : ( - - )} - -
+ + Danger Zone + {selectedOrganization?.managed_by !== 'vercel-marketplace' ? ( + + + + ) : ( + + )} + ) } diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx new file mode 100644 index 00000000000..b264df89b00 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx @@ -0,0 +1,131 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' + +import { useParams } from 'common' +import CopyButton from 'components/ui/CopyButton' +import { FormActions } from 'components/ui/Forms/FormActions' +import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import type { ResponseError } from 'types' +import { + Card, + CardContent, + CardFooter, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_ as Input, + PrePostTab, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +const OrgDetailsSchema = z.object({ + name: z.string().min(1, 'Organization name is required'), +}) + +export const OrganizationDetailsForm = () => { + const { slug } = useParams() + const queryClient = useQueryClient() + const selectedOrganization = useSelectedOrganization() + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + + const { mutate: updateOrganization, isLoading: isUpdatingDetails } = + useOrganizationUpdateMutation() + + const orgDetailsForm = useForm>({ + resolver: zodResolver(OrgDetailsSchema), + defaultValues: { name: selectedOrganization?.name ?? '' }, + }) + + const onUpdateOrganizationDetails = async (values: z.infer) => { + if (!canUpdateOrganization) { + return toast.error('You do not have the required permissions to update this organization') + } + if (!slug) return console.error('Slug is required') + + updateOrganization( + { slug, name: values.name }, + { + onSuccess: () => { + invalidateOrganizationsQuery(queryClient) + toast.success('Successfully updated organization name') + }, + onError: (error: ResponseError) => { + toast.error(`Failed to update organization name: ${error.message}`) + }, + } + ) + } + + const permissionsHelperText = !canUpdateOrganization + ? "You need additional permissions to manage this organization's settings" + : undefined + + useEffect(() => { + if (selectedOrganization && !isUpdatingDetails) { + orgDetailsForm.reset({ name: selectedOrganization.name ?? '' }) + } + }, [selectedOrganization, orgDetailsForm, isUpdatingDetails]) + + return ( + +
+ + + ( + + + + + + )} + /> + + + + + } + > + + + + + + orgDetailsForm.reset()} + helper={permissionsHelperText} + disabled={!canUpdateOrganization} + /> + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index 8d52683c54e..a1c811a09ad 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -1,11 +1,11 @@ -import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import { Elements } from '@stripe/react-stripe-js' import type { PaymentIntentResult, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js' import _ from 'lodash' import { ExternalLink, HelpCircle } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { parseAsString, useQueryStates } from 'nuqs' -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { z } from 'zod' @@ -19,7 +19,6 @@ import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { PRICING_TIER_LABELS_ORG, STRIPE_PUBLIC_KEY } from 'lib/constants' import { Button, - Input, Input_Shadcn_, Label_Shadcn_, Select_Shadcn_, @@ -35,13 +34,14 @@ 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 { useConfirmPendingSubscriptionCreateMutation } from 'data/subscriptions/org-subscription-confirm-pending-create' 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' +import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' +import { NewPaymentMethodElement } from '../BillingSettings/PaymentMethods/NewPaymentMethodElement' const ORG_KIND_TYPES = { PERSONAL: 'Personal', @@ -119,13 +119,8 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr () => ({ clientSecret: setupIntent ? setupIntent.client_secret! : '', - appearance: { - theme: resolvedTheme?.includes('dark') ? 'night' : 'flat', - labels: 'floating', - }, - ...(setupIntent?.pending_subscription_flow_enabled_for_creation === true - ? { paymentMethodCreation: 'manual' } - : {}), + appearance: getStripeElementsAppearanceOptions(resolvedTheme), + paymentMethodCreation: 'manual', }) as const, [setupIntent, resolvedTheme] ) @@ -185,13 +180,13 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr } }, onError: (data) => { - toast.error(`Failed to create organization: ${data.message}`) + toast.error(data.message, { duration: 10_000 }) resetPaymentMethod() setNewOrgLoading(false) }, }) - const { mutate: confirmPendingSubscriptionChange } = useConfirmPendingSubscriptionChangeMutation({ + const { mutate: confirmPendingSubscriptionChange } = useConfirmPendingSubscriptionCreateMutation({ onSuccess: (data) => { if (data && 'slug' in data) { onOrganizationCreated({ slug: data.slug }) @@ -212,7 +207,9 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr }) } 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.`) + toast.error(`Could not confirm payment. Please try again or use a different card.`, { + duration: 10_000, + }) resetPaymentMethod() setNewOrgLoading(false) } @@ -240,7 +237,7 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr const stripeOptionsConfirm = useMemo(() => { return { clientSecret: paymentIntentSecret, - appearance: { theme: resolvedTheme?.includes('dark') ? 'night' : 'flat', labels: 'floating' }, + appearance: getStripeElementsAppearanceOptions(resolvedTheme), } as StripeElementsOptions }, [paymentIntentSecret, resolvedTheme]) @@ -551,12 +548,13 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr {setupIntent && formState.plan !== 'FREE' && ( - + + + )} @@ -642,9 +640,8 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr paymentIntentConfirmed(paymentIntentConfirmation) } onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} - paymentMethodId={paymentMethod.id} onError={(err) => { - toast.error(`Failed to confirm payment: ${err.message}`) + toast.error(err.message, { duration: 10_000 }) setNewOrgLoading(false) resetPaymentMethod() }} @@ -656,65 +653,3 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr } 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/NewProject/NotOrganizationOwnerWarning.tsx b/apps/studio/components/interfaces/Organization/NewProject/NotOrganizationOwnerWarning.tsx index 5a2cf43acc0..a74dcafae74 100644 --- a/apps/studio/components/interfaces/Organization/NewProject/NotOrganizationOwnerWarning.tsx +++ b/apps/studio/components/interfaces/Organization/NewProject/NotOrganizationOwnerWarning.tsx @@ -1,19 +1,30 @@ import InformationBox from 'components/ui/InformationBox' import { AlertCircle } from 'lucide-react' +interface NotOrganizationOwnerWarningProps { + slug?: string +} + // [Joshen] This can just use NoPermission component i think -const NotOrganizationOwnerWarning = () => { +const NotOrganizationOwnerWarning = ({ slug }: NotOrganizationOwnerWarningProps) => { return (
} + icon={} defaultVisibility={true} hideCollapse title="You do not have permission to create a project" description={

- Contact your organization owner or administrator to create a new project. + {slug ? ( + <> + Contact the owner or administrator to create a new project in the{' '} + {slug} organization. + + ) : ( + <>Contact the owner or administrator to create a new project. + )}

} diff --git a/apps/studio/components/interfaces/Organization/OrgNotFound.tsx b/apps/studio/components/interfaces/Organization/OrgNotFound.tsx new file mode 100644 index 00000000000..673c8e116c0 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/OrgNotFound.tsx @@ -0,0 +1,50 @@ +import AlertError from 'components/ui/AlertError' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { Skeleton } from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import { OrganizationCard } from './OrganizationCard' + +export const OrgNotFound = ({ slug }: { slug?: string }) => { + const { + data: organizations, + isSuccess: isOrganizationsSuccess, + isLoading: isOrganizationsLoading, + isError: isOrganizationsError, + error: organizationsError, + } = useOrganizationsQuery() + + return ( + <> + + The selected organization does not exist or you don't have permission to access it.{' '} + {slug ? ( + <> + Contact the owner or administrator to create a new project in the {slug}{' '} + organization. + + ) : ( + <>Contact the owner or administrator to create a new project. + )} + + +

Select an organization to create your new project from

+ +
+ {isOrganizationsLoading && ( + <> + + + + + )} + {isOrganizationsError && ( + + )} + {isOrganizationsSuccess && + organizations?.map((org) => ( + + ))} +
+ + ) +} diff --git a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx new file mode 100644 index 00000000000..e636c1e34ba --- /dev/null +++ b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx @@ -0,0 +1,58 @@ +import { Boxes, Lock } from 'lucide-react' +import Link from 'next/link' + +import { useIsMFAEnabled } from 'common' +import { ActionCard } from 'components/ui/ActionCard' +import { useProjectsQuery } from 'data/projects/projects-query' +import { Organization } from 'types' +import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +export const OrganizationCard = ({ + organization, + href, +}: { + organization: Organization + href?: string +}) => { + const isUserMFAEnabled = useIsMFAEnabled() + const { data: allProjects = [] } = useProjectsQuery() + + const numProjects = allProjects.filter((x) => x.organization_slug === organization.slug).length + const isMfaRequired = organization.organization_requires_mfa + + return ( + + div]:w-full [&>div]:items-center')} + icon={} + title={organization.name} + description={ +
+
+ {organization.plan.name} Plan + {numProjects > 0 && ( + <> + • + + {numProjects} project{numProjects > 1 ? 's' : ''} + + + )} +
+ {isMfaRequired && ( + + + + + + MFA enforced + + + )} +
+ } + /> + + ) +} diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 378d18de2ea..ddbbc6cac15 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -8,17 +8,15 @@ import { toast } from 'sonner' import * as z from 'zod' import { useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import InformationBox from 'components/ui/InformationBox' import { useOrganizationCreateInvitationMutation } from 'data/organization-members/organization-invitation-create-mutation' import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query' import { useProjectsQuery } from 'data/projects/projects-query' import { useHasAccessToProjectLevelPermissions } from 'data/subscriptions/org-subscription-query' -import { - doPermissionsCheck, - useCheckPermissions, - useGetPermissions, -} from 'hooks/misc/useCheckPermissions' +import { doPermissionsCheck, useGetPermissions } from 'hooks/misc/useCheckPermissions' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useProfile } from 'lib/profile' import { @@ -50,9 +48,6 @@ import { SelectTrigger_Shadcn_, Select_Shadcn_, Switch, - Tooltip, - TooltipContent, - TooltipTrigger, cn, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -64,6 +59,10 @@ export const InviteMemberButton = () => { const organization = useSelectedOrganization() const { permissions: permissions } = useGetPermissions() + const { organizationMembersCreate: organizationMembersCreationEnabled } = useIsFeatureEnabled([ + 'organization_members:create', + ]) + const [isOpen, setIsOpen] = useState(false) const [projectDropdownOpen, setProjectDropdownOpen] = useState(false) @@ -75,10 +74,6 @@ export const InviteMemberButton = () => { const orgProjects = (projects ?? []) .filter((project) => project.organization_id === organization?.id) .sort((a, b) => a.name.localeCompare(b.name)) - const canReadSubscriptions = useCheckPermissions( - PermissionAction.BILLING_READ, - 'stripe.subscriptions' - ) const currentPlan = organization?.plan const hasAccessToProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string) @@ -96,6 +91,7 @@ export const InviteMemberButton = () => { const canInviteMembers = hasOrgRole && + rolesAddable.length > 0 && orgScopedRoles.some(({ id: role_id }) => doPermissionsCheck( permissions, @@ -182,22 +178,24 @@ export const InviteMemberButton = () => { return ( - - - - - {!canInviteMembers && ( - - You need additional permissions to invite a member to this organization - - )} - + setIsOpen(true)} + tooltip={{ + content: { + side: 'bottom', + text: !organizationMembersCreationEnabled + ? 'Inviting members is currently disabled' + : !canInviteMembers + ? 'You need additional permissions to invite a member to this organization' + : undefined, + }, + }} + > + Invite member + diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx index fc3683b754a..51d711b12a1 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx @@ -29,7 +29,7 @@ import { } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { LeaveTeamButton } from './LeaveTeamButton' -import { hasMultipleOwners, useGetRolesManagementPermissions } from './TeamSettings.utils' +import { useGetRolesManagementPermissions } from './TeamSettings.utils' import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel' interface MemberActionsProps { @@ -50,10 +50,6 @@ export const MemberActions = ({ member }: MemberActionsProps) => { const { data: allRoles } = useOrganizationRolesV2Query({ slug }) 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 ?? [] const isPendingInviteAcceptance = !!member.invited_id @@ -151,25 +147,6 @@ export const MemberActions = ({ member }: MemberActionsProps) => { ) } - if (!canRemoveMember || (isPendingInviteAcceptance && !canResendInvite && !canRevokeInvite)) { - return ( -
- } - tooltip={{ - content: { - side: 'bottom', - text: 'You need additional permissions to manage this team member', - }, - }} - /> -
- ) - } - if (memberIsUser) { return (
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index 24f831c75be..711deddee3e 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -1,7 +1,6 @@ import { Search } from 'lucide-react' import { useState } from 'react' -import { useParams } from 'common' import { ScaffoldActionsContainer, ScaffoldActionsGroup, @@ -10,40 +9,13 @@ import { ScaffoldSectionContent, ScaffoldTitle, } from 'components/layouts/Scaffold' -import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' -import { usePermissionsQuery } from 'data/permissions/permissions-query' -import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useProfile } from 'lib/profile' import { Input } from 'ui-patterns/DataInputs/Input' import { InviteMemberButton } from './InviteMemberButton' import MembersView from './MembersView' -import { useGetRolesManagementPermissions } from './TeamSettings.utils' export const TeamSettings = () => { - const { organizationMembersCreate: organizationMembersCreationEnabled } = useIsFeatureEnabled([ - 'organization_members:create', - ]) - - const { slug } = useParams() - const { profile } = useProfile() - const selectedOrganization = useSelectedOrganization() - - const { data: permissions } = usePermissionsQuery() - const { data: rolesData } = useOrganizationRolesV2Query({ slug }) - - const roles = rolesData?.org_scoped_roles ?? [] - - const { rolesAddable } = useGetRolesManagementPermissions( - selectedOrganization?.slug, - roles, - permissions ?? [] - ) - const [searchString, setSearchString] = useState('') - const canAddMembers = rolesAddable.length > 0 - return ( Team @@ -60,10 +32,7 @@ export const TeamSettings = () => { placeholder="Filter members" /> - {organizationMembersCreationEnabled && - canAddMembers && - profile !== undefined && - selectedOrganization !== undefined && } + diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts index 07d460f1bce..9d1b56009d0 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts @@ -4,7 +4,7 @@ import { AWS_REGIONS, FLY_REGIONS } from 'shared-data' export function getAvailableRegions(cloudProvider: CloudProvider): Region { switch (cloudProvider) { case 'AWS': - case 'AWS_NEW': + case 'AWS_K8S': return AWS_REGIONS case 'FLY': return FLY_REGIONS diff --git a/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx b/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx index d44f1d7fc24..120428fe922 100644 --- a/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx @@ -2,6 +2,7 @@ import { useChat } from 'ai/react' import { useEffect, useState } from 'react' import { Markdown } from 'components/interfaces/Markdown' +import { onErrorChat } from 'components/ui/AIAssistantPanel/AIAssistant.utils' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { BASE_PATH } from 'lib/constants' import { AiIconAnimation, Button, Label_Shadcn_, Textarea } from 'ui' @@ -44,6 +45,7 @@ export const SchemaGenerator = ({ api: `${BASE_PATH}/api/ai/onboarding/design`, id: 'schema-generator', maxSteps: 7, + onError: onErrorChat, onFinish: () => { setInput('') }, diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx index 8a4cc98cef7..87c29b77990 100644 --- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx +++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx @@ -29,8 +29,10 @@ import { FormField_Shadcn_, FormMessage_Shadcn_, Input_Shadcn_, + Switch, } from 'ui' import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' const formId = 'realtime-configuration-form' @@ -70,16 +72,14 @@ export const RealtimeSettings = () => { // max_channels_per_client: z.coerce.number().min(1).max(10000), // max_joins_per_second: z.coerce.number().min(1).max(5000), - // [Filipe] This field is temporarily hidden from the UI - // allow_public: z.boolean(), + allow_public: z.boolean(), }) const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: { ...REALTIME_DEFAULT_CONFIG, - // [Filipe] This field is temporarily hidden from the UI - // allow_public: !REALTIME_DEFAULT_CONFIG.private_only, + allow_public: !REALTIME_DEFAULT_CONFIG.private_only, }, }) @@ -87,8 +87,7 @@ export const RealtimeSettings = () => { if (!projectRef) return console.error('Project ref is required') updateRealtimeConfig({ ref: projectRef, - // [Filipe] This field is temporarily hidden from the UI - // private_only: !data.allow_public, + private_only: !data.allow_public, connection_pool: data.connection_pool, max_concurrent_users: data.max_concurrent_users, }) @@ -97,10 +96,9 @@ export const RealtimeSettings = () => { useEffect(() => { // [Joshen] Temp typed with any - API typing marks all the properties as nullable, // but checked with Filipe that they're not supposed to - // [Filipe] This field is temporarily hidden from the UI - // if (data) form.reset({ ...data, allow_public: !data.private_only } as any) - - if (data) form.reset({ ...data } as any) + if (isSuccess) { + form.reset({ ...data, allow_public: !data.private_only } as any) + } }, [isSuccess]) return ( @@ -111,10 +109,7 @@ export const RealtimeSettings = () => { ) : ( - {/* - [Filipe] We're hidding this field until we implement a 'kill all sockets` on change to be triggered in realtime server - */} - {/* + { )} /> - */} +
)} -

+

{label}

{actions}
diff --git a/apps/studio/components/interfaces/Reports/ReportChart.tsx b/apps/studio/components/interfaces/Reports/ReportChart.tsx new file mode 100644 index 00000000000..8cfe89816f0 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/ReportChart.tsx @@ -0,0 +1,58 @@ +/** + * ReportChart + * + * A wrapper component that uses the useChartData hook to fetch data for a chart + * and then passes the data and loading state to the ComposedChartHandler. + * + * This component acts as a bridge between the data-fetching logic and the + * presentational chart component. + */ +import ComposedChartHandler from 'components/ui/Charts/ComposedChartHandler' +import { useChartData } from 'hooks/useChartData' +import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' +import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' + +const ReportChart = ({ + chart, + startDate, + endDate, + interval, + updateDateRange, +}: { + chart: any + startDate: string + endDate: string + interval: string + updateDateRange: UpdateDateRange +}) => { + const { + data, + isLoading: isLoading, + chartAttributes, + highlightedValue, + } = useChartData({ + attributes: chart.attributes, + startDate, + endDate, + interval, + data: undefined, + highlightedValue: + chart.id === 'client-connections' || chart.id === 'pgbouncer-connections' + ? true + : chart.showMaxValue, + }) + + return ( + 0 ? chartAttributes : chart.attributes) as MultiAttribute[] + } + data={data} + isLoading={isLoading} + highlightedValue={highlightedValue as any} + updateDateRange={updateDateRange} + /> + ) +} +export default ReportChart diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx index 318f03e3546..adbe74201f6 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx @@ -12,6 +12,7 @@ export interface RoleImpersonationPopoverProps { serviceRoleLabel?: string variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both' align?: 'center' | 'start' | 'end' + disallowAuthenticatedOption?: boolean } const RoleImpersonationPopover = ({ @@ -19,6 +20,7 @@ const RoleImpersonationPopover = ({ serviceRoleLabel, variant = 'regular', align = 'end', + disallowAuthenticatedOption = false, }: RoleImpersonationPopoverProps) => { const state = useRoleImpersonationStateSnapshot() @@ -64,7 +66,10 @@ const RoleImpersonationPopover = ({ side="bottom" align={align} > - + ) diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx index 17b121074ca..fa591e1e8a0 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx @@ -10,11 +10,13 @@ import UserImpersonationSelector from './UserImpersonationSelector' export interface RoleImpersonationSelectorProps { serviceRoleLabel?: string padded?: boolean + disallowAuthenticatedOption?: boolean } const RoleImpersonationSelector = ({ serviceRoleLabel, padded = true, + disallowAuthenticatedOption = false, }: RoleImpersonationSelectorProps) => { const state = useRoleImpersonationStateSnapshot() @@ -81,21 +83,25 @@ const RoleImpersonationSelector = ({ icon={} /> - } - /> + {!disallowAuthenticatedOption && ( + } + /> + )} {selectedOption === 'service_role' && (

- The default Postgres/superuser role. This has admin privileges. + The default Postgres/superuser role. + {disallowAuthenticatedOption ?
: ' '} + This has admin privileges.
It will bypass Row Level Security (RLS) policies.

@@ -103,16 +109,21 @@ const RoleImpersonationSelector = ({ {selectedOption === 'anon' && (

- For "anonymous access". This is the role which the API (PostgREST) will use when a user + For "anonymous access". + {disallowAuthenticatedOption ?
: ' '} + This is the role which the API (PostgREST)
- is not logged in. It will respect Row Level Security (RLS) policies. + will use when a user is not logged in. + {disallowAuthenticatedOption ?
: ' '} + It will respect Row Level Security (RLS) policies.

)} {selectedOption === 'authenticated' && (

- For "authenticated access". This is the role which the API (PostgREST) will use when -
a user is logged in. It will respect Row Level Security (RLS) policies. + For "authenticated access". This is the role which the API (PostgREST) +
+ will use when a user is logged in. It will respect Row Level Security (RLS) policies.

)}
diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index 2f062081911..6b16ccc1aa3 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { getContentById } from 'data/content/content-id-query' import { UpsertContentPayload, @@ -11,12 +12,11 @@ import { import { Snippet } from 'data/content/sql-folders-query' import type { SqlSnippet } from 'data/content/sql-snippets-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useFlag } from 'hooks/ui/useFlag' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { AiIconAnimation, Button, Form, Input, Modal } from 'ui' -import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' export interface RenameQueryModalProps { @@ -34,6 +34,7 @@ const RenameQueryModal = ({ }: RenameQueryModalProps) => { const { ref } = useParams() const organization = useSelectedOrganization() + const useBedrockAssistant = useFlag('useBedrockAssistant') const snapV2 = useSqlEditorV2StateSnapshot() const tabsSnap = useTabsStateSnapshot() @@ -42,7 +43,6 @@ const RenameQueryModal = ({ { enabled: visible } ) const isSQLSnippet = snippet.type === 'sql' - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: ref }) // Customers on HIPAA plans should not have access to Supabase AI @@ -66,12 +66,12 @@ const RenameQueryModal = ({ const generateTitle = async () => { if ('content' in snippet && isSQLSnippet) { - titleSql({ sql: snippet.content.sql }) + titleSql({ sql: snippet.content.sql, useBedrockAssistant }) } else { try { const { content } = await getContentById({ projectRef: ref, id: snippet.id }) if ('sql' in content) { - titleSql({ sql: content.sql }) + titleSql({ sql: content.sql, useBedrockAssistant }) } } catch (error) { toast.error('Unable to generate title based on query contents') @@ -112,10 +112,8 @@ const RenameQueryModal = ({ snapV2.renameSnippet({ id, name: nameInput, description: descriptionInput }) - if (isSQLEditorTabsEnabled && ref) { - const tabId = createTabId('sql', { id }) - tabsSnap.updateTab(tabId, { label: nameInput }) - } + const tabId = createTabId('sql', { id }) + tabsSnap.updateTab(tabId, { label: nameInput }) toast.success('Successfully renamed snippet!') if (onComplete) onComplete() diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 054174f05f3..c8f472df446 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -19,11 +19,12 @@ import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { isError } from 'data/utils/error-check' -import { useOrgOptedIntoAiAndHippaProject } from 'hooks/misc/useOrgOptedIntoAi' +import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { useFlag } from 'hooks/ui/useFlag' +import { BASE_PATH } from 'lib/constants' import { formatSql } from 'lib/formatSql' import { detectOS, uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' @@ -51,7 +52,6 @@ import { TooltipTrigger, cn, } from 'ui' -import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' import { RunQueryWarningModal } from './RunQueryWarningModal' import { @@ -81,6 +81,7 @@ export const SQLEditor = () => { const os = detectOS() const router = useRouter() const { ref, id: urlId } = useParams() + const useBedrockAssistant = useFlag('useBedrockAssistant') const { profile } = useProfile() const project = useSelectedProject() @@ -92,10 +93,8 @@ export const SQLEditor = () => { const snapV2 = useSqlEditorV2StateSnapshot() const getImpersonatedRoleState = useGetImpersonatedRoleState() const databaseSelectorState = useDatabaseSelectorStateSnapshot() - const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject() + const { includeSchemaMetadata, isHipaaProjectDisallowed } = useOrgAiOptInLevel() const [selectedSchemas] = useSchemasForAi(project?.ref!) - const includeSchemaMetadata = (isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const { sourceSqlDiff, @@ -210,17 +209,15 @@ export const SQLEditor = () => { const setAiTitle = useCallback( async (id: string, sql: string) => { try { - const { title: name } = await generateSqlTitle({ sql }) + const { title: name } = await generateSqlTitle({ sql, useBedrockAssistant }) snapV2.renameSnippet({ id, name }) - if (isSQLEditorTabsEnabled && ref) { - const tabId = createTabId('sql', { id }) - tabs.updateTab(tabId, { label: name }) - } + const tabId = createTabId('sql', { id }) + tabs.updateTab(tabId, { label: name }) } catch (error) { // [Joshen] No error handler required as this happens in the background and not necessary to ping the user } }, - [generateSqlTitle, snapV2] + [generateSqlTitle, useBedrockAssistant, snapV2] ) const prettifyQuery = useCallback(async () => { @@ -464,7 +461,9 @@ export const SQLEditor = () => { completion, isLoading: isCompletionLoading, } = useCompletion({ - api: `${BASE_PATH}/api/ai/sql/complete`, + api: useBedrockAssistant + ? `${BASE_PATH}/api/ai/sql/complete-v2` + : `${BASE_PATH}/api/ai/sql/complete`, body: { projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx b/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx index 869b3a48efe..bf00d51029c 100644 --- a/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx +++ b/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx @@ -19,6 +19,7 @@ export const ApiKeysMoved = () => { { name: 'Infrastructure', key: 'infrastructure', url: '#' }, { name: 'Integrations', key: 'integrations', url: '#' }, { name: 'API Keys', key: 'api-keys', url: '#', label: 'NEW' }, + { name: 'JWT Keys', key: 'jwt-keys', url: '#', label: 'NEW' }, { name: 'Add Ons', key: 'addons', url: '#' }, { name: 'Vault', @@ -37,14 +38,19 @@ export const ApiKeysMoved = () => { return (
-
-

API keys have moved

+
+

API keys and JWT settings have moved

- They can now be found in the new API Keys page + They can now be found in their own respective pages.

- - - +
+ + + + + + +
{/* Menu illustration using the actual ProductMenu component */} diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx index f6d0086fca5..0b1ed99ab3a 100644 --- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -1,4 +1,3 @@ -import { JwtSecretUpdateError, JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import { useQueryClient } from '@tanstack/react-query' import { AlertCircle } from 'lucide-react' import { useEffect, useRef } from 'react' @@ -8,9 +7,7 @@ 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' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query' @@ -18,10 +15,8 @@ import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' 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 JWTSettings from './JWTSettings' -import { PostgrestConfig } from './PostgrestConfig' import { ApiKeysMoved } from './ApiKeysMoved' +import { PostgrestConfig } from './PostgrestConfig' const ServiceList = () => { const client = useQueryClient() @@ -33,15 +28,6 @@ const ServiceList = () => { const { data: databases, isError } = useReadReplicasQuery({ projectRef }) const { data: loadBalancers } = useLoadBalancersQuery({ projectRef }) - const { data } = useJwtSecretUpdatingStatusQuery({ projectRef }) - const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus - const jwtSecretUpdateError = data?.jwtSecretUpdateError - - const previousJwtSecretUpdateStatus = useRef() - const { Failed, Updated, Updating } = JwtSecretUpdateStatus - const jwtSecretUpdateErrorMessage = - JWT_SECRET_UPDATE_ERROR_MESSAGES[jwtSecretUpdateError as JwtSecretUpdateError] - // Get the API service const isCustomDomainActive = customDomainData?.customDomain?.status === 'active' const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId) @@ -55,30 +41,6 @@ const ServiceList = () => { ? loadBalancers?.[0].endpoint ?? '' : selectedDatabase?.restUrl - useEffect(() => { - if (previousJwtSecretUpdateStatus.current === Updating) { - switch (jwtSecretUpdateStatus) { - case Updated: - client.invalidateQueries(configKeys.api(projectRef)) - client.invalidateQueries(configKeys.settings(projectRef)) - client.invalidateQueries(configKeys.postgrest(projectRef)) - toast.success('Successfully updated JWT secret') - break - case Failed: - toast.error(`JWT secret update failed: ${jwtSecretUpdateErrorMessage}`) - break - } - } - - previousJwtSecretUpdateStatus.current = jwtSecretUpdateStatus - }, [jwtSecretUpdateStatus]) - - useEffect(() => { - if (source !== undefined) { - state.setSelectedDatabaseId('load-balancer') - } - }, [source]) - return (
{isLoading ? ( @@ -148,10 +110,6 @@ const ServiceList = () => { -
- -
-
diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx index 2dc1c2d1850..74dc615cf2a 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -32,7 +32,7 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import type { ProjectAddonVariantMeta } from 'data/subscriptions/types' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useIsOrioleDb, useIsOrioleDbInAws, useProjectByRef } from 'hooks/misc/useSelectedProject' +import { useIsOrioleDbInAws, useProjectByRef } from 'hooks/misc/useSelectedProject' import { useFlag } from 'hooks/ui/useFlag' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' import { BASE_PATH, INSTANCE_MICRO_SPECS, INSTANCE_NANO_SPECS } from 'lib/constants' @@ -53,7 +53,6 @@ const Addons = () => { const parentProject = useProjectByRef(selectedProject?.parent_project_ref) const isBranch = parentProject !== undefined const isProjectActive = useIsProjectActive() - const isOrioleDb = useIsOrioleDb() const isOrioleDbInAws = useIsOrioleDbInAws() const { data: settings } = useProjectSettingsV2Query({ projectRef }) diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index 16e7d30340f..af56adf36cd 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -1,4 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -11,13 +12,14 @@ import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import type { AddonVariantId } from 'data/subscriptions/types' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useIsAwsCloudProvider } from 'hooks/misc/useSelectedProject' import { formatCurrency } from 'lib/helpers' -import { ExternalLink } from 'lucide-react' import { useAddonsPagePanel } from 'state/addons-page' import { Button, Radio, SidePanel, cn } from 'ui' import { Admonition } from 'ui-patterns' const IPv4SidePanel = () => { + const isAws = useIsAwsCloudProvider() const { ref: projectRef } = useParams() const organization = useSelectedOrganization() @@ -86,7 +88,7 @@ const IPv4SidePanel = () => { onCancel={closePanel} onConfirm={onConfirm} loading={isLoading || isSubmitting} - disabled={isFreePlan || isLoading || !hasChanges || isSubmitting || !canUpdateIPv4} + disabled={isFreePlan || isLoading || !hasChanges || isSubmitting || !canUpdateIPv4 || !isAws} tooltip={ isFreePlan ? 'Unable to enable IPv4 on a Free Plan' @@ -117,6 +119,13 @@ const IPv4SidePanel = () => { database via a IPv4 address.

+ {!isAws && ( + + )} + {isPgBouncerEnabled ? ( { className="col-span-4 !p-0" name="ipv4" key={option.identifier} - disabled={isFreePlan} + disabled={isFreePlan || !isAws} checked={selectedOption === option.identifier} label={option.name} value={option.identifier} diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx index 427a7d66422..961805f9013 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import DeleteProjectModal from './DeleteProjectModal' +import { DeleteProjectModal } from './DeleteProjectModal' export interface DeleteProjectButtonProps { type?: 'danger' | 'default' diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx index bc8c94655cf..966e6d481e6 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx @@ -1,4 +1,3 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -9,13 +8,18 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send' import { useProjectDeleteMutation } from 'data/projects/project-delete-mutation' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { Input } from 'ui' import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' -const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: () => void }) => { +export const DeleteProjectModal = ({ + visible, + onClose, +}: { + visible: boolean + onClose: () => void +}) => { const router = useRouter() const { project } = useProjectContext() const organization = useSelectedOrganization() @@ -31,7 +35,25 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: ( const isFree = projectPlan === 'free' const [message, setMessage] = useState('') - const [selectedReasons, setSelectedReasons] = useState([]) + const [selectedReason, setSelectedReason] = useState([]) + + // Single select for cancellation reason + const onSelectCancellationReason = (reason: string) => { + setSelectedReason([reason]) + } + + // Helper to get label for selected reason + const getReasonLabel = (reason: string | undefined) => { + const found = CANCELLATION_REASONS.find((r) => r.value === reason) + return found?.label || 'What can we improve on?' + } + + const textareaLabel = getReasonLabel(selectedReason[0]) + + const [shuffledReasons] = useState(() => [ + ...CANCELLATION_REASONS.sort(() => Math.random() - 0.5), + { value: 'None of the above' }, + ]) const { mutate: deleteProject, isLoading: isDeleting } = useProjectDeleteMutation({ onSuccess: async () => { @@ -41,7 +63,7 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: ( orgSlug: organization?.slug, projectRef, message, - reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''), + reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''), exitAction: 'delete', }) } catch (error) { @@ -58,122 +80,102 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: ( const { mutateAsync: sendExitSurvey, isLoading: isSending } = useSendDowngradeFeedbackMutation() const isSubmitting = isDeleting || isSending - useEffect(() => { - if (visible) { - setSelectedReasons([]) - setMessage('') - } - }, [visible]) - - const canDeleteProject = useCheckPermissions(PermissionAction.UPDATE, 'projects', { - resource: { - project_id: project?.id, - }, - }) - - const onSelectCancellationReason = (reason: string) => { - const existingSelection = selectedReasons.find((x) => x === reason) - const updatedSelection = - existingSelection === undefined - ? selectedReasons.concat([reason]) - : selectedReasons.filter((x) => x !== reason) - setSelectedReasons(updatedSelection) - } - async function handleDeleteProject() { if (project === undefined) return - if (!isFree && selectedReasons.length === 0) { - return toast.error('Please select at least one reason for deleting your project') + if (!isFree && selectedReason.length === 0) { + return toast.error('Please select a reason for deleting your project') } deleteProject({ projectRef: project.ref, organizationSlug: organization?.slug }) } + useEffect(() => { + if (visible) { + setSelectedReason([]) + setMessage('') + } + }, [visible]) + return ( - <> - { - if (!isSubmitting) onClose() - }} - > - {/* + { + if (!isSubmitting) onClose() + }} + > + {/* [Joshen] This is basically ExitSurvey.tsx, ideally we have one shared component but the one in ExitSurvey has a Form wrapped around it already. Will probably need some effort to refactor but leaving that for the future. */} - {!isFree && ( - <> -
-

Help us improve.

-

- We always strive to improve Supabase as much as we can. Please let us know the - reasons you are deleting your project so that we can improve in the future. -

+ {!isFree && ( + <> +
+

+ Help us improve by sharing why you're deleting your project. +

+
+
+
+ {shuffledReasons.map((option) => { + const active = selectedReason[0] === option.value + return ( + + ) + })}
-
-
- {CANCELLATION_REASONS.map((option) => { - const active = selectedReasons.find((x) => x === option) - return ( - - ) - })} -
-
- setMessage(event.target.value)} - /> -
+
+ + setMessage(event.target.value)} + />
- - )} - - +
+ + )} + ) } - -export default DeleteProjectModal diff --git a/apps/studio/components/interfaces/Settings/General/General.tsx b/apps/studio/components/interfaces/Settings/General/General.tsx index 3675e5a8f2b..9c8aad82053 100644 --- a/apps/studio/components/interfaces/Settings/General/General.tsx +++ b/apps/studio/components/interfaces/Settings/General/General.tsx @@ -1,5 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { AlertCircle, BarChart2, ChevronRight } from 'lucide-react' +import { BarChart2 } from 'lucide-react' import Link from 'next/link' import { toast } from 'sonner' @@ -13,15 +13,11 @@ import { useProjectUpdateMutation } from 'data/projects/project-update-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useProjectByRef } from 'hooks/misc/useSelectedProject' -import { useFlag } from 'hooks/ui/useFlag' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, - CollapsibleContent_Shadcn_, - CollapsibleTrigger_Shadcn_, - Collapsible_Shadcn_, Form, Input, WarningIcon, @@ -125,7 +121,7 @@ const General = () => {
-
+

Restart project

diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx index 96c1465002a..47daf680895 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 { useIsAwsNew } from 'hooks/misc/useSelectedProject' +import { useIsAwsK8sCloudProvider } 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 isAwsNew = useIsAwsNew() + const isAwsK8s = useIsAwsK8sCloudProvider() const isFreePlan = organization?.plan.id === 'free' - const isPaidAndNotAwsNew = !isFreePlan && !isAwsNew + const isPaidAndNotAwsK8s = !isFreePlan && !isAwsK8s const { mutate: pauseProject, isLoading: isPausing } = useProjectPauseMutation({ onSuccess: (_, variables) => { @@ -53,7 +53,7 @@ const PauseProjectButton = () => { } const buttonDisabled = - isPaidAndNotAwsNew || project === undefined || isPaused || !canPauseProject || !isProjectActive + isPaidAndNotAwsK8s || project === undefined || isPaused || !canPauseProject || !isProjectActive return ( <> @@ -72,7 +72,7 @@ const PauseProjectButton = () => { ? 'You need additional permissions to pause this project' : !isProjectActive ? 'Unable to pause project as project is not active' - : isPaidAndNotAwsNew + : isPaidAndNotAwsK8s ? 'Projects on a paid plan will always be running' : undefined, }, diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx index 5f485beaebf..c3ff46c0ac5 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx @@ -187,6 +187,7 @@ const ProjectUpgradeAlert = () => { [right-sized](https://supabase.com/docs/guides/platform/upgrading#disk-sizing) with the upgrade.`} /> )} + {/* @ts-ignore */} {(data?.potential_breaking_changes ?? []).length > 0 && ( diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx index 75320e24b38..4b00ae86f5d 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx @@ -9,10 +9,12 @@ import { useIsProjectActive, useProjectContext, } from 'components/layouts/ProjectLayout/ProjectContext' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useProjectRestartMutation } from 'data/projects/project-restart-mutation' import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation' import { setProjectStatus } from 'data/projects/projects-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useIsAwsK8sCloudProvider } from 'hooks/misc/useSelectedProject' import { useFlag } from 'hooks/ui/useFlag' import { Button, @@ -20,9 +22,6 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Tooltip, - TooltipContent, - TooltipTrigger, cn, } from 'ui' import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog' @@ -32,6 +31,7 @@ const RestartServerButton = () => { const queryClient = useQueryClient() const { project } = useProjectContext() const isProjectActive = useIsProjectActive() + const isAwsK8s = useIsAwsK8sCloudProvider() const [serviceToRestart, setServiceToRestart] = useState<'project' | 'database'>() const projectRef = project?.ref ?? '' @@ -88,69 +88,68 @@ const RestartServerButton = () => { return ( <> - - -
- - {canRestartProject && isProjectActive && !projectRestartDisabled && ( - - -
-
- {((project !== undefined && (!canRestartProject || !isProjectActive)) || - projectRestartDisabled) && ( - - {projectRestartDisabled - ? 'Project restart is currently disabled' - : !canRestartProject - ? 'You need additional permissions to restart this project' - : !isProjectActive - ? 'Unable to restart project as project is not active' - : ''} - +
+ setServiceToRestart('project')} + tooltip={{ + content: { + side: 'bottom', + text: projectRestartDisabled + ? 'Project restart is currently disabled' + : !canRestartProject + ? 'You need additional permissions to restart this project' + : !isProjectActive + ? 'Unable to restart project as project is not active' + : isAwsK8s + ? 'Project restart is not supported for AWS (Revamped) projects' + : '', + }, + }} + > + Restart project + + {canRestartProject && isProjectActive && !projectRestartDisabled && ( + + +
x.organization_id === project?.organization_id ) const hasOverdueInvoices = overdueInvoices.length > 0 && isNotOnTeamOrEnterprisePlan + const isAwsK8s = useIsAwsK8sCloudProvider() // Opting for useState temporarily as Listbox doesn't seem to work with react-hook-form yet const [defaultRegion] = Object.entries(AWS_REGIONS).find( @@ -182,6 +183,7 @@ const DeployNewReplicaPanel = ({ isWalgEnabled && currentComputeAddon !== undefined && !hasOverdueInvoices && + !isAwsK8s && !isProWithSpendCapEnabled const computeAddons = @@ -261,6 +263,19 @@ const DeployNewReplicaPanel = ({ />
+ ) : isAwsK8s ? ( + + + + Read replicas are not supported for AWS (Revamped) projects + + + + Projects provisioned by other cloud providers currently will not be able to use read + replicas + + + ) : currentPgVersion < 15 ? ( diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx index 8f767224235..ca84227d92f 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx @@ -19,7 +19,6 @@ import { AlertTitle_Shadcn_, Alert_Shadcn_, Badge, - Button, Input, Tooltip, TooltipContent, @@ -27,6 +26,13 @@ import { } from 'ui' import { ProjectUpgradeAlert } from '../General/Infrastructure/ProjectUpgradeAlert' import InstanceConfiguration from './InfrastructureConfiguration/InstanceConfiguration' +import { + DatabaseExtensionsWarning, + ObjectsToBeDroppedWarning, + ReadReplicasWarning, + UnsupportedExtensionsWarning, + UserDefinedObjectsInInternalSchemasWarning, +} from './UpgradeWarnings' const InfrastructureInfo = () => { const { ref } = useParams() @@ -73,6 +79,12 @@ const InfrastructureInfo = () => { const isInactive = project?.status === 'INACTIVE' const hasReadReplicas = (databases ?? []).length > 1 + // @ts-ignore [Bobbie] to be removed after 2025-06-30 prod deploy + const hasExtensionDependentObjects = (data?.extension_dependent_objects ?? []).length > 0 + const hasObjectsToBeDropped = (data?.objects_to_be_dropped ?? []).length > 0 + const hasUnsupportedExtensions = (data?.unsupported_extensions || []).length > 0 + const hasObjectsInternalSchema = (data?.user_defined_objects_in_internal_schemas || []).length > 0 + return ( <> @@ -181,62 +193,38 @@ const InfrastructureInfo = () => { )} - {data?.eligible && !hasReadReplicas && } - {data.eligible && hasReadReplicas && ( - - - A new version of Postgres is available for your project - - - You will need to remove all read replicas prior to upgrading your Postgres - version to the latest available ({latestPgVersion}). - - - )} - {!data?.eligible && (data?.extension_dependent_objects || []).length > 0 && ( - - - A new version of Postgres is available - - -
-

- You'll need to remove the following extensions before upgrading: -

+ {data.eligible ? ( + hasReadReplicas ? ( + + ) : ( + + ) + ) : null} -
    - {(data?.extension_dependent_objects || []).map((obj) => ( -
  • - {obj} -
  • - ))} -
-
-

- {projectUpgradeEligibilityData?.potential_breaking_changes?.includes( - 'pg17_upgrade_unsupported_extensions' - ) - ? 'These extensions are not supported in newer versions of Supabase Postgres. If you are not using them, it is safe to remove them.' - : 'Check the docs for which ones might need to be removed.'} -

- - -
-
- )} + {!data.eligible ? ( + hasExtensionDependentObjects ? ( + + ) : hasObjectsToBeDropped ? ( + + ) : hasUnsupportedExtensions ? ( + + ) : hasObjectsInternalSchema ? ( + + ) : null + ) : null} )} diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx new file mode 100644 index 00000000000..4f4e9d8679a --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx @@ -0,0 +1,185 @@ +import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui' + +export const ReadReplicasWarning = ({ latestPgVersion }: { latestPgVersion: string }) => { + return ( + + + A new version of Postgres is available for your project + + + You will need to remove all read replicas prior to upgrading your Postgres version to the + latest available ({latestPgVersion}). + + + ) +} + +export const DatabaseExtensionsWarning = ({ + extensions, + potentialBreakingChanges, +}: { + extensions: string[] + potentialBreakingChanges?: string[] +}) => { + return ( + + A new version of Postgres is available + +
+

You'll need to remove the following extensions before upgrading:

+ +
    + {extensions.map((obj) => ( +
  • + {obj} +
  • + ))} +
+
+

+ {potentialBreakingChanges?.includes('pg17_upgrade_unsupported_extensions') + ? 'These extensions are not supported in newer versions of Supabase Postgres. If you are not using them, it is safe to remove them.' + : 'Check the docs for which ones might need to be removed.'} +

+
+ + + ) +} + +export const ObjectsToBeDroppedWarning = ({ + objectsToBeDropped, +}: { + objectsToBeDropped: string[] +}) => { + return ( + + A new version of Postgres is available + +
+

You'll need to remove the following objects before upgrading:

+ +
    + {objectsToBeDropped.map((obj) => ( +
  • + {obj} +
  • + ))} +
+
+

Check the docs for which objects need to be removed.

+ +
+
+ ) +} + +export const UnsupportedExtensionsWarning = ({ + unsupportedExtensions, +}: { + unsupportedExtensions: string[] +}) => { + return ( + + A new version of Postgres is available + +
+

You'll need to remove the following extensions before upgrading:

+ +
    + {unsupportedExtensions.map((obj: string) => ( +
  • + {obj} +
  • + ))} +
+
+

+ These extensions are not supported in newer versions of Supabase Postgres. If you are not + using them, it is safe to remove them. +

+ +
+
+ ) +} + +export const UserDefinedObjectsInInternalSchemasWarning = ({ objects }: { objects: string[] }) => { + return ( + + A new version of Postgres is available + +
+

+ You'll need to move these objects out of auth/realtime/storage schemas before upgrading: +

+ +
    + {objects.map((obj: string) => ( +
  • + {obj} +
  • + ))} +
+
+

+ These schemas are Supabase-managed and creating custom objects in them is no longer + supported. Check the changelog to see how to move them to your own schemas. +

+ +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/ErrorCodeDialog.tsx b/apps/studio/components/interfaces/Settings/Logs/ErrorCodeDialog.tsx new file mode 100644 index 00000000000..a14b40e82e6 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/ErrorCodeDialog.tsx @@ -0,0 +1,111 @@ +import { + Alert_Shadcn_, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Badge, + Button_Shadcn_, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from 'ui' +import { useErrorCodesQuery } from 'data/content-api/docs-error-codes-query' +import { type ErrorCodeQueryQuery, Service } from 'data/graphql/graphql' +import { AlertTriangle } from 'lucide-react' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' + +interface ErrorCodeDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + errorCode: string + service?: Service +} + +export const ErrorCodeDialog = ({ + open, + onOpenChange, + errorCode, + service, +}: ErrorCodeDialogProps) => { + const { data, isLoading, isSuccess, refetch } = useErrorCodesQuery( + { code: errorCode, service }, + { enabled: open } + ) + + return ( + + + + + Help for error code {errorCode} + + + {isLoading && } + {isSuccess && } + {!isLoading && !isSuccess && } + + + + + ) +} + +const LoadingState = () => ( + <> + + + +) + +const SuccessState = ({ data }: { data: ErrorCodeQueryQuery | undefined }) => { + const errors = data?.errors?.nodes?.filter((error) => !!error.message) + if (!errors || errors.length === 0) { + return <>No information found for this error code. + } + + return ( + <> +

Possible explanations for this error:

+
+ {errors.map((error) => ( + + ))} +
+ + ) +} + +const ErrorExplanation = ({ + code, + service, + message, +}: { + code: string + service: Service + message?: string | null +}) => { + if (!message) return null + + return ( + <> + {service} +

{message}

+ + ) +} + +const ErrorState = ({ refetch }: { refetch?: () => void }) => ( + + + Lookup failed + +

Failed to look up error code help info

+ {refetch && ( + + Try again + + )} +
+
+) diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx index 9627a48d96c..ffa1e699e22 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelection.tsx @@ -47,6 +47,7 @@ const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectio const status = log?.metadata?.[0]?.response?.[0]?.status_code const method = log?.metadata?.[0]?.request?.[0]?.method const path = log?.metadata?.[0]?.request?.[0]?.path + const search = log?.metadata?.[0]?.request?.[0]?.search const user_agent = log?.metadata?.[0]?.request?.[0]?.headers[0].user_agent const error_code = log?.metadata?.[0]?.response?.[0]?.headers?.[0]?.x_sb_error_code const apikey = jwtAPIKey(log?.metadata) ?? apiKey(log?.metadata) @@ -59,6 +60,7 @@ const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectio status, method, path, + search, user_agent, timestamp, event_message, diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx index 5da4f516bee..fd4de31277c 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx @@ -1,3 +1,4 @@ +import { Service } from 'data/graphql/graphql' import { useLogsUrlState } from 'hooks/analytics/useLogsUrlState' import { useEffect, useState } from 'react' import { toast } from 'sonner' @@ -12,13 +13,14 @@ import { Separator, } from 'ui' import { TimestampInfo } from 'ui-patterns' +import { ErrorCodeDialog } from '../ErrorCodeDialog' import type { LogSearchCallback, PreviewLogData } from '../Logs.types' import { ResponseCodeFormatter } from '../LogsFormatters' const LogRowCodeBlock = ({ value, className }: { value: string; className?: string }) => (
@@ -32,12 +34,18 @@ const PropertyRow = ({
   keyName,
   value,
   dataTestId,
+  path,
 }: {
   keyName: string
   value: any
   dataTestId?: string
+  path?: string
 }) => {
   const { setSearch } = useLogsUrlState()
+  const [showErrorInfo, setShowErrorInfo] = useState(false)
+
+  const service = path?.startsWith('/auth/') ? Service.Auth : undefined
+
   const handleSearch: LogSearchCallback = async (event: string, { query }: { query?: string }) => {
     setSearch(query || '')
   }
@@ -47,8 +55,18 @@ const PropertyRow = ({
   const isObject = typeof value === 'object' && value !== null
   const isStatus = keyName === 'status' || keyName === 'status_code'
   const isMethod = keyName === 'method'
-  const isPath = keyName === 'path'
+  const isSearch = keyName === 'search'
   const isUserAgent = keyName === 'user_agent'
+  const isEventMessage = keyName === 'event_message'
+  const isPath = keyName === 'path'
+
+  function getSearchPairs() {
+    if (isSearch && typeof value === 'string') {
+      const str = value.startsWith('?') ? value.slice(1) : value
+      return str.split('&').filter(Boolean)
+    }
+    return []
+  }
 
   const storageKey = `log-viewer-expanded-${keyName}`
   const [isExpanded, setIsExpanded] = useState(() => {
@@ -76,7 +94,7 @@ const PropertyRow = ({
     }, 1000)
   }
 
-  if (isObject) {
+  if (isObject || isEventMessage) {
     return (
       <>
         
@@ -86,17 +104,20 @@ const PropertyRow = ({ className={cn('px-2.5', { 'max-h-[80px]': !isExpanded, 'max-h-[400px]': isExpanded, + 'py-2': isEventMessage, })} value={value} /> - + {!isEventMessage && ( + + )}
@@ -105,76 +126,108 @@ const PropertyRow = ({ } return ( - - -
-
-

- {keyName} -

+ <> + + +
- {isExpanded ? ( - - ) : isTimestamp ? ( - - ) : isStatus ? ( -
- -
- ) : isMethod ? ( -
- -
- ) : ( -
{JSON.stringify(value)}
- )} +

+ {keyName} +

+
+ {isExpanded ? ( + + ) : isTimestamp ? ( + + ) : isStatus ? ( +
+ +
+ ) : isMethod ? ( +
+ +
+ ) : ( +
{value}
+ )} +
-
- - - Copy {keyName} - {!isObject && ( - { - setIsExpanded(!isExpanded) - }} - > - {isExpanded ? 'Collapse' : 'Expand'} value - - )} - {(isPath || isMethod || isUserAgent || isStatus) && ( - { - handleSearch('search-input-change', { query: value }) - }} - > - Search by {keyName} - - )} - - - + + + {keyName === 'error_code' && ( + { + setShowErrorInfo(true) + }} + > + More information + + )} + Copy {keyName} + {!isObject && ( + { + setIsExpanded(!isExpanded) + }} + > + {isExpanded ? 'Collapse' : 'Expand'} value + + )} + {(isMethod || isUserAgent || isStatus || isPath) && ( + { + handleSearch('search-input-change', { query: value }) + }} + > + Search by {keyName} + + )} + {isSearch + ? getSearchPairs().map((pair) => ( + { + handleSearch('search-input-change', { query: pair }) + }} + > + Search by {pair} + + )) + : null} + + + + {keyName === 'error_code' && ( + + )} + ) } const DefaultPreviewSelectionRenderer = ({ log }: { log: PreviewLogData }) => { const { timestamp, event_message, metadata, id, status, ...rest } = log + const path = typeof log.path === 'string' ? log.path : undefined const log_file = log?.metadata?.[0]?.log_file return ( @@ -187,7 +240,7 @@ const DefaultPreviewSelectionRenderer = ({ log }: { log: PreviewLogData }) => { )} {Object.entries(rest).map(([key, value]) => { - return + return })} {log?.event_message && ( diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts index 06ee5653348..ef3597a06fb 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts @@ -20,7 +20,6 @@ export interface LogsWarning { linkText?: string } export interface LogsEndpointParams { - project: string // project ref iso_timestamp_start?: string iso_timestamp_end?: string sql?: string diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index ad68e1a781a..d1f9fbc2273 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -4,12 +4,12 @@ import { get, isEqual } from 'lodash' import uniqBy from 'lodash/uniqBy' import { useEffect } from 'react' +import { IS_PLATFORM } from 'common' import BackwardIterator from 'components/ui/CodeEditor/Providers/BackwardIterator' import type { PlanId } from 'data/subscriptions/types' import logConstants from 'shared-data/logConstants' import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants' import type { Filters, LogData, LogsEndpointParams } from './Logs.types' -import { IS_PLATFORM } from 'common' /** * Convert a micro timestamp from number/string to iso timestamp @@ -145,7 +145,7 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters, limit: n if (IS_PLATFORM === false) { return ` -- local dev edge_logs query -select id, edge_logs.timestamp, event_message, request.method, request.path, response.status_code +select id, edge_logs.timestamp, event_message, request.method, request.path, request.search, response.status_code from edge_logs ${joins} ${where} @@ -153,7 +153,7 @@ ${orderBy} limit ${limit}; ` } - return `select id, identifier, timestamp, event_message, request.method, request.path, response.status_code + return `select id, identifier, timestamp, event_message, request.method, request.path, request.search, response.status_code from ${table} ${joins} ${where} diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index afc27de6f8c..bb7625b410a 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { ComponentProps, ComponentPropsWithoutRef, FC, ReactNode, useEffect } from 'react' -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useIsMFAEnabled, useParams } from 'common' import { generateOtherRoutes, generateProductRoutes, @@ -18,6 +18,7 @@ import { useHideSidebar } from 'hooks/misc/useHideSidebar' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLints } from 'hooks/misc/useLints' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useFlag } from 'hooks/ui/useFlag' import { Home } from 'icons' import { useAppStateSnapshot } from 'state/app-state' @@ -41,10 +42,7 @@ import { Sidebar as SidebarPrimitive, useSidebar, } from 'ui' -import { - useIsAPIDocsSidePanelEnabled, - useIsSQLEditorTabsEnabled, -} from './App/FeaturePreview/FeaturePreviewContext' +import { useIsAPIDocsSidePanelEnabled } from './App/FeaturePreview/FeaturePreviewContext' export const ICON_SIZE = 32 export const ICON_STROKE_WIDTH = 1.5 @@ -158,10 +156,12 @@ export function SideBarNavLink({ route, active, onClick, + disabled, ...props }: { route: any active?: boolean + disabled?: boolean onClick?: () => void } & ComponentPropsWithoutRef) { const [sidebarBehaviour] = useLocalStorageQuery( @@ -170,6 +170,7 @@ export function SideBarNavLink({ ) const buttonProps = { + disabled, tooltip: sidebarBehaviour === 'closed' ? route.label : '', isActive: active, className: cn('text-sm', sidebarBehaviour === 'open' ? '!px-2' : ''), @@ -188,7 +189,7 @@ export function SideBarNavLink({ return ( - {route.link ? ( + {route.link && !disabled ? ( {content} @@ -223,7 +224,7 @@ const ProjectLinks = () => { const { securityLints, errorLints } = useLints() const showWarehouse = useFlag('warehouse') - const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() + const showUnifiedLogs = useFlag('unifiedLogs') const activeRoute = router.pathname.split('/')[3] @@ -239,16 +240,14 @@ const ProjectLinks = () => { 'realtime:all', ]) - const toolRoutes = generateToolRoutes(ref, project, { - sqlEditorTabs: isSqlEditorTabsEnabled, - }) + const toolRoutes = generateToolRoutes(ref, project) const productRoutes = generateProductRoutes(ref, project, { auth: authEnabled, edgeFunctions: edgeFunctionsEnabled, storage: storageEnabled, realtime: realtimeEnabled, }) - const otherRoutes = generateOtherRoutes(ref, project) + const otherRoutes = generateOtherRoutes(ref, project, { unifiedLogs: showUnifiedLogs }) const settingsRoutes = generateSettingsRoutes(ref, project) return ( @@ -351,6 +350,10 @@ const OrganizationLinks = () => { const router = useRouter() const { slug } = useParams() + const org = useSelectedOrganization() + const isUserMFAEnabled = useIsMFAEnabled() + const disableAccessMfa = org?.organization_requires_mfa && !isUserMFAEnabled + const activeRoute = router.pathname.split('/')[3] const navMenuItems = [ @@ -398,6 +401,7 @@ const OrganizationLinks = () => { {navMenuItems.map((item, i) => ( {lastSignIn === type && ( - - -
-
-
- - Last used - + + Last used + )}
{ const [passwordHidden, setPasswordHidden] = useState(true) const [captchaToken, setCaptchaToken] = useState(null) + const [searchParams] = useQueryStates({ + auth_id: parseAsString.withDefault(''), + token: parseAsString.withDefault(''), + }) + const { mutate: signup, isLoading: isSigningUp } = useSignUpMutation({ onSuccess: () => { toast.success(`Signed up successfully!`) @@ -47,15 +53,21 @@ const SignUpForm = () => { token = captchaResponse?.response ?? null } + const isInsideOAuthFlow = !!searchParams.auth_id + const redirectUrlBase = `${ + process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' + ? location.origin + : process.env.NEXT_PUBLIC_SITE_URL + }${BASE_PATH}` + const redirectTo = isInsideOAuthFlow + ? `${redirectUrlBase}/authorize?auth_id=${searchParams.auth_id}${searchParams.token && `&token=${searchParams.token}`}` + : `${redirectUrlBase}/sign-in` + signup({ email, password, hcaptchaToken: token ?? null, - redirectTo: `${ - process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' - ? location.origin - : process.env.NEXT_PUBLIC_SITE_URL - }${BASE_PATH}/sign-in`, + redirectTo, }) } diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.Alerts.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.Alerts.tsx deleted file mode 100644 index 479c8855f5d..00000000000 --- a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.Alerts.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { AlertTriangle, Info } from 'lucide-react' - -import { useAppStateSnapshot } from 'state/app-state' -import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button_Shadcn_ } from 'ui' - -export function IncludeSchemaAlert() { - const { setShowAiSettingsModal } = useAppStateSnapshot() - - return ( - - - - Project metadata (tables, columns, and data types) is being shared with OpenAI - - - setShowAiSettingsModal(true)} - > - Change this configuration - - - - ) -} - -export type AiMetadataSkipReason = 'forbidden' | 'no_project' - -export function ExcludeSchemaAlert({ - metadataSkipReason, -}: { - metadataSkipReason: AiMetadataSkipReason | undefined -}) { - const { setShowAiSettingsModal } = useAppStateSnapshot() - - return ( - - - - Project metadata (tables, columns, and data types) is not being shared with OpenAI - - - {metadataSkipReason === 'no_project' ? ( - 'Switch to a project to change this setting' - ) : ( - setShowAiSettingsModal(true)} - > - Change this configuration - - )} - - - ) -} diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.Commands.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.Commands.tsx deleted file mode 100644 index cf30643e27a..00000000000 --- a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.Commands.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useIsLoggedIn } from 'common' -import { useAppStateSnapshot } from 'state/app-state' -import { AiIconAnimation } from 'ui' -import { BadgeExperimental, useRegisterCommands, useSetQuery } from 'ui-patterns/CommandMenu' -import { orderCommandSectionsByPriority } from '../App/CommandMenu/ordering' -import { COMMAND_MENU_SECTIONS } from '../App/CommandMenu/CommandMenu.utils' - -export function useGenerateSqlCommand() { - const isLoggedIn = useIsLoggedIn() - - const { setShowGenerateSqlModal } = useAppStateSnapshot() - const setQuery = useSetQuery() - - useRegisterCommands( - COMMAND_MENU_SECTIONS.QUERY, - [ - { - id: 'generate-sql-ai', - name: 'Run SQL with Supabase AI', - action: () => { - setShowGenerateSqlModal(true) - setQuery('') - }, - icon: () => , - badge: () => , - }, - ], - { - enabled: isLoggedIn, - orderSection: orderCommandSectionsByPriority, - sectionMeta: { priority: 2 }, - } - ) -} diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.tsx deleted file mode 100644 index 137a11f4dd6..00000000000 --- a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import dynamic from 'next/dynamic' -import { memo } from 'react' - -import { useCommandMenuInitiated } from 'ui-patterns/CommandMenu' - -const LazyGenerateSql = dynamic(() => import('./SqlGeneratorImpl'), { ssr: false }) - -export const GenerateSql = memo(() => { - const isInitiated = useCommandMenuInitiated() - return isInitiated && -}) -GenerateSql.displayName = 'GenerateSql' diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.utils.ts b/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.utils.ts deleted file mode 100644 index 4ca094a049c..00000000000 --- a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.utils.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { stripIndent } from 'common-tags' - -type QueryCategory = { - category: string - queries: string[] -} - -type SampleQueries = QueryCategory[] - -export const SAMPLE_QUERIES: SampleQueries = [ - { - category: 'Tables', - queries: [ - 'Create a table that stores a list of cities, and insert 10 rows of sample data into it', - 'Create tables (with foreign key relationships) for blog posts and comments', - 'Create tables for employees, reviewers, and employee reviews, with columns for employee ID, reviewer ID, and review text', - ], - }, - { - category: 'Views', - queries: [ - 'Create a view that shows the total revenue for each customer', - 'Create a view that shows all orders that were placed in the last week', - 'Create a view that shows all products that are currently out of stock', - 'Create a materialized view that shows the customer_orders table the total value of orders in a month', - ], - }, - { - category: 'Indexes', - queries: [ - 'Create an index on the primary key column of my orders table', - 'Create a partial index on the orders table:', - 'Create an index on the customer_id column of the customer_orders table', - ], - }, - { - category: 'Select', - queries: [ - 'Retrieve a list of employees from the employees table who have a salary greater than $50,000', - 'Retrieve a list of all employees with the title "Manager" from the employees table', - 'Retrieve a list of all employees hired in the last 6 months', - 'Retrieve the department and average salary of each department from the employees table, group by department', - ], - }, - { - category: 'Triggers', - queries: [ - 'Create a trigger that updates the updated_at column on the orders table with the current time when the row of the orders table is updated', - 'Create a trigger to add a new user to the users table when someone new registers', - 'Create a trigger to send an email whenever there is an insert in the orders table', - 'Create a trigger to delete all orders placed by a customer when that customer is deleted from the customers table', - ], - }, - { - category: 'Row Level Security', - queries: [ - 'Create an RLS policy that grants only authenticated access to the profiles table', - 'Create an RLS policy that grants SELECT access to the sales_rep role for the customers table, but denies access to all other roles', - "Create an RLS policy that grants INSERT access access to the manager role for the employees table, but only for rows where the employee's department_id matches a list of departments that the manager is responsible for", - ], - }, - { - category: 'Functions', - queries: [ - "Create a function to add a new entry to a user's table when a new user signs up", - 'Create an a function to calculate the average price of a product in a given category', - 'Create a function to insert a new order and update the inventory for the ordered products', - 'Create a function to calculate the total sales for a product given an id', - ], - }, -] - -export function generatePrompt(prompt: string, definitions?: any) { - return stripIndent` - ${ - definitions !== undefined - ? ` - Given the following Postgres SQL tables: - ${definitions} - ` - : '' - } - - Generate a Postgres SQL query based on the following natural language prompt. - - Only output valid SQL - all explanations must be SQL comments - - SQL comments should be short - - Your very last output should be "\`\`\`" - - For primary keys, always use "integer primary key generated always as identity" - - Natural language prompt: - ${prompt} - - Postgres SQL query (markdown SQL only): - `.trim() -} - -/** - * Formats a string for use as a title. - * - * Removes punctuation and capitalizes each word - */ -export function formatTitle(value: string) { - let words = value.replace(/\.$/, '').replace(/['"]/g, '').split(' ') - words = words.map((word) => { - // Don't capitalize code - if (/[._\(\)]+/.test(word)) { - return word - } - return word.charAt(0).toUpperCase() + word.slice(1) - }) - return words.join(' ') -} diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGeneratorImpl.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlGeneratorImpl.tsx deleted file mode 100644 index d7a802c8a32..00000000000 --- a/apps/studio/components/interfaces/SqlGenerator/SqlGeneratorImpl.tsx +++ /dev/null @@ -1,413 +0,0 @@ -import { AlertTriangle, User } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useEffectOnce } from 'react-use' - -import { IS_PLATFORM } from 'common' -import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' -import { useOrgOptedIntoAiAndHippaProject } from 'hooks/misc/useOrgOptedIntoAi' -import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { formatSql } from 'lib/formatSql' -import { useAppStateSnapshot } from 'state/app-state' -import { - AiIconAnimation, - Button, - CodeBlock, - CommandItem_Shadcn_, - CommandList_Shadcn_, - Command_Shadcn_, - Input_Shadcn_, - Modal, - StatusIcon, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, - Tabs_Shadcn_, - cn, -} from 'ui' -import { - BadgeExperimental, - generateCommandClassNames, - useHistoryKeys, - useQuery, - useSetQuery, -} from 'ui-patterns/CommandMenu' -import type { UseAiChatOptions } from 'ui-patterns/CommandMenu/prepackaged/ai' -import { - AiWarning, - MessageRole, - MessageStatus, - useAiChat, -} from 'ui-patterns/CommandMenu/prepackaged/ai' -import type { AiMetadataSkipReason } from './SqlGenerator.Alerts' -import { ExcludeSchemaAlert, IncludeSchemaAlert } from './SqlGenerator.Alerts' -import { SAMPLE_QUERIES, generatePrompt } from './SqlGenerator.utils' -import { SQLOutputActions } from './SqlOutputActions' - -function useSchemaMetadataForAi() { - const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject() - const project = useSelectedProject() - - const [schemas] = useSchemasForAi(project?.ref!) - const includeMetadata = - ((isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM) && - schemas.length > 0 && - !!project - - const metadataSkipReason: AiMetadataSkipReason = !project ? 'no_project' : 'forbidden' - - const { data } = useEntityDefinitionsQuery( - { - schemas: schemas, - projectRef: project?.ref, - connectionString: project?.connectionString, - }, - { enabled: includeMetadata } - ) - - const api = useMemo( - () => - includeMetadata - ? { - includeMetadata, - data, - } - : { includeMetadata, metadataSkipReason }, - [includeMetadata, data, metadataSkipReason] - ) - - return api -} - -function useAiSqlGeneration() { - const { showGenerateSqlModal } = useAppStateSnapshot() - - const schemaMetadata = useSchemaMetadataForAi() - const definitions = useMemo( - () => (schemaMetadata.data ?? []).map((def) => def.sql.trim()).join('\n\n'), - [schemaMetadata] - ) - - const query = useQuery() - const setQuery = useSetQuery() - const [isLoading, setIsLoading] = useState(false) - - const messageTemplate = useCallback>( - (message) => generatePrompt(message, schemaMetadata.includeMetadata ? definitions : undefined), - [schemaMetadata, definitions] - ) - const { submit, reset, messages, isResponding, hasError } = useAiChat({ - messageTemplate, - setIsLoading, - }) - - useHistoryKeys({ - enable: showGenerateSqlModal && !isResponding, - stack: messages.filter(({ role }) => role === MessageRole.User).map(({ content }) => content), - }) - - const handleSubmit = useCallback( - (message: string) => { - setQuery('') - submit(message) - }, - [setQuery, submit] - ) - - const handleReset = useCallback(() => { - setQuery('') - reset() - }, [setQuery, reset]) - - useEffectOnce(() => { - if (query) handleSubmit(query) - }) - - return { - query, - setQuery, - isLoading, - isResponding, - hasError, - messages, - handleSubmit, - handleReset, - usesMetadata: schemaMetadata.includeMetadata, - metadataSkipReason: schemaMetadata.metadataSkipReason, - } -} - -export default function SqlGeneratorImpl() { - const { showGenerateSqlModal, setShowGenerateSqlModal } = useAppStateSnapshot() - const inputRef = useRef(null) - - const timeoutHandle = useRef>() - useEffect(() => () => clearTimeout(timeoutHandle.current), []) - - const { - query, - setQuery, - isLoading, - isResponding, - hasError, - messages, - handleSubmit, - handleReset, - usesMetadata, - metadataSkipReason, - } = useAiSqlGeneration() - - return ( - - Generate SQL - - - } - hideClose - hideFooter - visible={showGenerateSqlModal} - onOpenAutoFocus={() => { - timeoutHandle.current = setTimeout(() => inputRef.current?.focus()) - }} - onCancel={() => { - if (messages.length > 0) { - handleReset() - timeoutHandle.current = setTimeout(() => inputRef.current?.focus()) - } else { - setShowGenerateSqlModal(false) - } - }} - className="w-11/12 !max-w-3xl h-4/5 max-h-[800px] grid-rows-[min-content,_1fr] bg-overlay" - > - -
- {messages.length > 0 && } - {messages.length === 0 && !hasError && ( - - )} - {hasError && } -
-
- {usesMetadata ? ( - - ) : ( - - )} - { - if (!isLoading || !isResponding) { - setQuery(e.target.value) - } - }} - onKeyDown={(e) => { - switch (e.key) { - case 'Enter': - if (!query || isLoading || isResponding) { - return - } - return handleSubmit(query) - default: - return - } - }} - /> -
-
-
- ) -} - -function Messages({ - messages, - handleReset, -}: { - messages: ReturnType['messages'] - handleReset: () => void -}) { - const X_PADDING = 'px-4' - - const UserAvatar = useCallback(() => { - return ( -
- -
- ) - }, []) - - return messages.map((message, i) => { - switch (message.role) { - case MessageRole.User: - return ( -
- -
{message.content}
-
- ) - case MessageRole.Assistant: - const unformattedAnswer = message.content - .replace(/```sql/g, '') - .replace(/```.*/gs, '') - .replace(/-- End of SQL query\.*/g, '') - .trim() - - const answer = - message.status === MessageStatus.Complete - ? formatSql(unformattedAnswer) - : unformattedAnswer - const cantHelp = answer.replace(/^-- /, '') === "Sorry, I don't know how to help with that." - - return ( -
-
- - {message.status === MessageStatus.Pending ? ( -
- ) : cantHelp ? ( -
- -

- Sorry, I don't know how to help with that. -

- -
- ) : ( -
- - {answer} - - - {message.status === MessageStatus.Complete && ( - - )} -
- )} -
-
- ) - } - }) -} - -function EmptyState({ - query, - handleSubmit, -}: { - query: string - handleSubmit: (query: string) => void -}) { - const [activeTab, setActiveTab] = useState(SAMPLE_QUERIES[0].category) - - useEffect(() => { - function handleSwitchTab(event: KeyboardEvent) { - if (query || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) { - return - } - - const activeSampleIndex = SAMPLE_QUERIES.findIndex( - (samples) => samples.category === activeTab - )! - const nextIndex = - event.key === 'ArrowRight' - ? Math.min(activeSampleIndex + 1, SAMPLE_QUERIES.length - 1) - : Math.max(0, activeSampleIndex - 1) - setActiveTab(SAMPLE_QUERIES[nextIndex].category) - } - - document.addEventListener('keydown', handleSwitchTab) - return () => document.removeEventListener('keydown', handleSwitchTab) - }, [query, activeTab]) - - return ( - <> -

- Describe what you need and Supabase AI will try to generate the relevant SQL statements -

-

- Here are some example prompts to try out: -

-
- setActiveTab(value)} - className="focus-visible:ring-0" - > - - {SAMPLE_QUERIES.map((samples) => ( - {samples.category} - ))} - - - {SAMPLE_QUERIES.map((samples) => ( - - {samples.queries.map((sampleQuery) => ( - { - if (!query) { - handleSubmit(sampleQuery) - } - }} - > - - {sampleQuery} - - ))} - - ))} - - - - ) -} - -function ErrorMessage({ handleReset }: { handleReset: () => void }) { - return ( -
- -

- Sorry, looks like Supabase AI is having a hard time! -

-

Please try again in a bit.

- -
- ) -} diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlOutputActions.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlOutputActions.tsx deleted file mode 100644 index 7a280bd6ae4..00000000000 --- a/apps/studio/components/interfaces/SqlGenerator/SqlOutputActions.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { codeBlock, stripIndent } from 'common-tags' -import { Check, Clipboard, Save } from 'lucide-react' -import { useCallback, useEffect, useState } from 'react' -import CopyToClipboard from 'react-copy-to-clipboard' -import { toast } from 'sonner' - -import { useParams } from 'common' -import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { uuidv4 } from 'lib/helpers' -import { useProfile } from 'lib/profile' -import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' -import { Button, cn } from 'ui' -import type { Message } from 'ui-patterns/CommandMenu/prepackaged/ai' -import { MessageRole, MessageStatus, queryAi } from 'ui-patterns/CommandMenu/prepackaged/ai' -import { formatTitle } from './SqlGenerator.utils' - -const useSaveGeneratedSql = () => { - const { ref } = useParams() - const { profile } = useProfile() - const selectedProject = useSelectedProject() - const snapV2 = useSqlEditorV2StateSnapshot() - const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'sql', owner_id: profile?.id }, - subject: { id: profile?.id }, - }) - - const saveGeneratedSql = useCallback( - (answer: string, title: string) => { - if (!ref) return console.error('Project ref is required') - if (!profile) return console.error('Profile is required') - if (!selectedProject) return console.error('Project is required') - - if (!canCreateSQLSnippet) { - toast('Unable to save query as you do not have sufficient permissions for this project') - return - } - - // Remove markdown syntax from returned answer - answer = answer.replace(/`/g, '').replace(/sql\n/g, '').trim() - - const formattedSql = codeBlock` - -- Note: This query was generated via Supabase AI, please verify the correctness of the - -- SQL snippet before running it against your database as we are not able to guarantee it - -- will do exactly what you requested the AI. - ${answer} - ` - - try { - const snippet = createSqlSnippetSkeletonV2({ - id: uuidv4(), - name: title || 'Generated query', - sql: formattedSql, - owner_id: profile.id, - project_id: selectedProject.id, - }) - snapV2.addSnippet({ projectRef: ref, snippet }) - toast.success(`Successfully saved snippet!`) - } catch (error: any) { - toast.error(`Failed to create new query: ${error.message}`) - } - }, - [canCreateSQLSnippet, profile?.id, ref, selectedProject?.id, snapV2] - ) - - return saveGeneratedSql -} - -export interface SQLOutputActionsProps { - answer: string - messages: Message[] - className?: string -} - -export function SQLOutputActions({ answer, messages, className }: SQLOutputActionsProps) { - const { ref } = useParams() - const saveGeneratedSql = useSaveGeneratedSql() - - const [showCopied, setShowCopied] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const [isSaved, setIsSaved] = useState(false) - - const onSelectSaveSnippet = async () => { - setIsSaving(true) - - let suggestedTitle - try { - suggestedTitle = await queryAi( - [ - ...messages, - { - role: MessageRole.User, - content: stripIndent` - Generate a title for the above SQL snippet following all of these rules: - - The title is only for the last SQL snippet - - Focus on the main purposes of this snippet - - Use as few words as possible - - Title should be nouns, not verbs - - Do not include word articles (eg. a, the, for, of) - - Do not use words like "SQL" or "snippet" or "title" - - Do not output markdown, quotes, etc - - Do not be too verbose - `, - status: MessageStatus.Complete, - }, - ], - 10000 - ) - } catch (error) { - suggestedTitle = '' - } - - const formattedTitle = formatTitle(suggestedTitle) - await saveGeneratedSql(answer, formattedTitle) - setIsSaved(true) - setIsSaving(false) - } - - useEffect(() => { - if (!showCopied) return - const timer = setTimeout(() => setShowCopied(false), 2000) - return () => clearTimeout(timer) - }, [showCopied]) - - useEffect(() => { - if (!isSaved) return - const timer = setTimeout(() => setIsSaved(false), 2000) - return () => clearTimeout(timer) - }, [isSaved]) - - return ( -
- - - - {ref !== undefined && ( - - )} -
- ) -} diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index f0c41e6e951..552e59f16e8 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -13,7 +13,8 @@ import { import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation' import { IS_PLATFORM } from 'lib/constants' -import { Alert, Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui' +import { Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui' +import { Admonition } from 'ui-patterns' export interface CreateBucketModalProps { visible: boolean @@ -115,29 +116,36 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { onSubmit={onSubmit} > {({ values }: { values: any }) => { + const isPublicBucket = values.public + return ( <> - + -
+
- {values.public && ( - + {isPublicBucket && ( +

Users can read objects in public buckets without any authorization.

@@ -145,7 +153,7 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { Row level security (RLS) policies are still required for other operations such as object uploads and deletes.

-
+ )}
diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx index 53815b39e63..aff15a75ef7 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx @@ -3,16 +3,18 @@ import { ChevronDown } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { Alert, Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui' +import { Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui' import { StorageSizeUnits } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.constants' import { convertFromBytes, convertToBytes, } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils' +import { InlineLink } from 'components/ui/InlineLink' import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useBucketUpdateMutation } from 'data/storage/bucket-update-mutation' import { IS_PLATFORM } from 'lib/constants' +import { Admonition } from 'ui-patterns' import type { StorageBucket } from './Storage.types' export interface EditBucketModalProps { @@ -84,6 +86,10 @@ const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => >
{({ values, resetForm }: { values: any; resetForm: any }) => { + const isChangingBucketVisibility = bucket?.public !== values.public + const isMakingBucketPrivate = bucket?.public && !values.public + const isMakingBucketPublic = !bucket?.public && values.public + // [Alaister] although this "technically" is breaking the rules of React hooks // it won't error because the hooks are always rendered in the same order // eslint-disable-next-line react-hooks/rules-of-hooks @@ -106,45 +112,56 @@ const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => return ( <> - + -
+
- {bucket?.public !== values.public && ( - -

- {!bucket?.public && values.public - ? `This will make all objects in the bucket "${bucket?.name}" public` - : bucket?.public && !values.public - ? `All objects in "${bucket?.name}" will be made private and will only be accessible via signed URLs or downloaded with the right authorisation headers` +

+ {isMakingBucketPublic + ? `This will make all objects in your bucket publicly accessible.` + : isMakingBucketPrivate + ? `All objects in your bucket will be private and only accessible via signed URLs, or downloaded with the right authorisation headers.` : ''}

-
+ {isMakingBucketPrivate && ( +

+ Assets cached in the CDN may still be publicly accessible. You can + consider{' '} + + purging the cache + {' '} + or moving your assets to a new bucket. +

+ )} + )}
diff --git a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx deleted file mode 100644 index 0fbf052477c..00000000000 --- a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' - -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' -import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useLocalStorage } from 'hooks/misc/useLocalStorage' -import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' -import { useTableEditorStateSnapshot } from 'state/table-editor' - -export interface EmptyStateProps {} - -const EmptyState = ({}: EmptyStateProps) => { - const snap = useTableEditorStateSnapshot() - const { selectedSchema } = useQuerySchemaState() - const isProtectedSchema = PROTECTED_SCHEMAS.includes(selectedSchema) - const canCreateTables = - useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') && !isProtectedSchema - - const [sort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>( - 'table-editor-sort', - 'alphabetical' - ) - - const { project } = useProjectContext() - const { data } = useEntityTypesQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - schemas: [selectedSchema], - sort, - }) - - const totalCount = data?.pages?.[0].data.count ?? 0 - - return ( -
- {totalCount === 0 ? ( - -

- There are no tables available in this schema. -

-
- ) : ( -
- -

- Select a table from the navigation panel on the left to view its data - {canCreateTables && ', or create a new one.'} -

-
-
- )} -
- ) -} - -export default EmptyState diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 2bfb205ed15..debbd89ef68 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -5,7 +5,6 @@ import { useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useDatabasePublicationCreateMutation } from 'data/database-publications/database-publications-create-mutation' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' @@ -21,6 +20,7 @@ import { tableRowKeys } from 'data/table-rows/keys' import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation' import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation' import { tableKeys } from 'data/tables/keys' +import { RetrieveTableResult } from 'data/tables/table-retrieve-query' import { getTables } from 'data/tables/tables-query' import { useUrlState } from 'hooks/ui/useUrlState' import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' @@ -50,7 +50,6 @@ import { import SpreadsheetImport from './SpreadsheetImport/SpreadsheetImport' import TableEditor from './TableEditor/TableEditor' import type { ImportContent } from './TableEditor/TableEditor.types' -import { RetrieveTableResult } from 'data/tables/table-retrieve-query' export interface SidePanelEditorProps { editable?: boolean @@ -71,7 +70,6 @@ const SidePanelEditor = ({ const { ref } = useParams() const snap = useTableEditorStateSnapshot() const tabsSnap = useTabsStateSnapshot() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] }) const queryClient = useQueryClient() @@ -493,7 +491,7 @@ const SidePanelEditor = ({ `Table ${table.name} has been updated but there were some errors. Please check these errors separately.` ) } else { - if (isTableEditorTabsEnabled && ref && payload.name) { + if (ref && payload.name) { // [Joshen] Only table entities can be updated via the dashboard const tabId = createTabId(ENTITY_TYPE.TABLE, { id: selectedTable.id }) tabsSnap.updateTab(tabId, { label: payload.name }) diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 08ec744a22f..a29a158fc05 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -1,5 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { isUndefined } from 'lodash' +import Link from 'next/link' import { useRouter } from 'next/router' import { useCallback } from 'react' @@ -18,13 +18,11 @@ import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' -import Link from 'next/link' import { useAppStateSnapshot } from 'state/app-state' import { TableEditorTableStateContextProvider } from 'state/table-editor-table' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { Button } from 'ui' import { Admonition, GenericSkeletonLoader } from 'ui-patterns' -import { useIsTableEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import DeleteConfirmationDialogs from './DeleteConfirmationDialogs' import SidePanelEditor from './SidePanelEditor/SidePanelEditor' import TableDefinition from './TableDefinition' @@ -44,7 +42,6 @@ export const TableGridEditor = ({ const { ref: projectRef, id } = useParams() const tabs = useTabsStateSnapshot() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const { selectedSchema } = useQuerySchemaState() useLoadTableEditorStateFromLocalStorageIntoUrl({ @@ -65,9 +62,9 @@ export const TableGridEditor = ({ connectionString: project?.connectionString, }) - const onClearDashboardHistory = () => { + const onClearDashboardHistory = useCallback(() => { if (projectRef) appSnap.setDashboardHistory(projectRef, 'editor', undefined) - } + }, [appSnap, projectRef]) const onTableCreated = useCallback( (table: { id: number }) => { @@ -78,19 +75,12 @@ export const TableGridEditor = ({ const onTableDeleted = useCallback(async () => { // For simplicity for now, we just open the first table within the same schema - if (isTableEditorTabsEnabled && selectedTable) { + if (selectedTable) { // Close tab const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id }) tabs.handleTabClose({ id: tabId, router, editor: 'table', onClearDashboardHistory }) - } else { - const tables = await getTables(selectedSchema) - if (tables.length > 0) { - router.push(`/project/${projectRef}/editor/${tables[0].id}`) - } else { - router.push(`/project/${projectRef}/editor`) - } } - }, [getTables, isTableEditorTabsEnabled, projectRef, router, selectedSchema]) + }, [onClearDashboardHistory, router, selectedTable, tabs]) // NOTE: DO NOT PUT HOOKS AFTER THIS LINE if (isLoadingSelectedTable || !projectRef) { @@ -105,7 +95,7 @@ export const TableGridEditor = ({ ) } - if (isUndefined(selectedTable)) { + if (!selectedTable) { return (
@@ -114,45 +104,41 @@ export const TableGridEditor = ({ title={`Unable to find your table with ID ${id}`} description="This table doesn't exist in your database" > - {isTableEditorTabsEnabled && ( - <> - {!!tabId ? ( - - ) : openTabs.length > 0 ? ( - - ) : ( - - )} - + {!!tabId ? ( + + ) : openTabs.length > 0 ? ( + + ) : ( + )}
diff --git a/apps/studio/components/interfaces/TableGridEditor/index.ts b/apps/studio/components/interfaces/TableGridEditor/index.ts index 50e1839b986..52936211e7e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/index.ts +++ b/apps/studio/components/interfaces/TableGridEditor/index.ts @@ -1,2 +1 @@ -export { default as EmptyState } from './EmptyState' export { default as SidePanelEditor } from './SidePanelEditor/SidePanelEditor' diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx new file mode 100644 index 00000000000..2eeaf8a7bff --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx @@ -0,0 +1,90 @@ +import { + createParser, + parseAsArrayOf, + parseAsBoolean, + parseAsInteger, + parseAsString, + parseAsStringLiteral, + parseAsTimestamp, +} from 'nuqs' + +import { + ARRAY_DELIMITER, + LEVELS, + RANGE_DELIMITER, + SLIDER_DELIMITER, + SORT_DELIMITER, +} from 'components/ui/DataTable/DataTable.constants' +import { ChartConfig } from 'ui' +import { TooltipLabel } from './components/TooltipLabel' + +export const CHART_CONFIG = { + success: { + label: , + color: 'hsl(var(--foreground-muted))', + }, + warning: { + label: , + color: 'hsl(var(--warning-default))', + }, + error: { + label: , + color: 'hsl(var(--destructive-default))', + }, +} satisfies ChartConfig + +export const REGIONS = ['ams', 'fra', 'gru', 'hkg', 'iad', 'syd'] as const +export const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] as const +export const LOG_TYPES = [ + 'auth', + 'edge', + 'edge_function', + 'function_events', + 'postgres', + 'postgres_upgrade', + 'postgrest', + 'storage', + 'supavisor', +] as const + +const parseAsSort = createParser({ + parse(queryValue) { + const [id, desc] = queryValue.split(SORT_DELIMITER) + if (!id && !desc) return null + return { id, desc: desc === 'desc' } + }, + serialize(value) { + return `${value.id}.${value.desc ? 'desc' : 'asc'}` + }, +}) + +export const SEARCH_PARAMS_PARSER = { + // CUSTOM FILTERS + level: parseAsArrayOf(parseAsStringLiteral(LEVELS), ARRAY_DELIMITER), + log_type: parseAsArrayOf(parseAsString, ARRAY_DELIMITER), + latency: parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.dns': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.connection': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.tls': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.ttfb': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.transfer': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + status: parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + regions: parseAsArrayOf(parseAsStringLiteral(REGIONS), ARRAY_DELIMITER), + method: parseAsArrayOf(parseAsStringLiteral(METHODS), ARRAY_DELIMITER), + host: parseAsString, + pathname: parseAsString, + date: parseAsArrayOf(parseAsTimestamp, RANGE_DELIMITER), + + // REQUIRED FOR SORTING & PAGINATION + sort: parseAsSort, + size: parseAsInteger.withDefault(40), + start: parseAsInteger.withDefault(0), + + // REQUIRED FOR INFINITE SCROLLING (Live Mode and Load More) + direction: parseAsStringLiteral(['prev', 'next']).withDefault('next'), + cursor: parseAsTimestamp.withDefault(new Date()), + live: parseAsBoolean.withDefault(false), + + // REQUIRED FOR SELECTION + id: parseAsString, +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx new file mode 100644 index 00000000000..48cea7695ce --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx @@ -0,0 +1,185 @@ +import { format } from 'date-fns' +import { User } from 'lucide-react' + +import { LEVELS } from 'components/ui/DataTable/DataTable.constants' +import { DataTableFilterField, Option } from 'components/ui/DataTable/DataTable.types' +import { getLevelColor, getStatusColor } from 'components/ui/DataTable/DataTable.utils' +import { cn } from 'ui' +import { LOG_TYPES, METHODS } from './UnifiedLogs.constants' +import { ColumnSchema } from './UnifiedLogs.schema' +import { LogsMeta, SheetField } from './UnifiedLogs.types' +import { getLevelLabel } from './UnifiedLogs.utils' + +// instead of filterFields, maybe just 'fields' with a filterDisabled prop? +// that way, we could have 'message' or 'headers' field with label and value as well as type! +export const filterFields = [ + { + label: 'Time Range', + value: 'date', + type: 'timerange', + defaultOpen: true, + commandDisabled: true, + }, + { + label: 'Level', + value: 'level', + type: 'checkbox', + defaultOpen: true, + options: LEVELS.map((level) => ({ label: level, value: level })), + component: (props: Option) => { + // TODO: type `Option` with `options` values via Generics + const value = props.value as (typeof LEVELS)[number] + return ( +
+ + {props.label} + +
+
+ {getLevelLabel(value)} +
+
+ ) + }, + }, + { + label: 'Log Type', + value: 'log_type', + type: 'checkbox', + defaultOpen: true, + options: LOG_TYPES.map((type) => ({ label: type, value: type })), + component: (props: Option) => { + return ( +
+ + {props.label.replace('_', ' ')} + + {/* [Joshen] Temporarily hiding, this feels excessive */} + {/* {props.value} */} +
+ ) + }, + }, + { + label: 'Host', + value: 'host', + type: 'input', + }, + { + label: 'Pathname', + value: 'pathname', + type: 'input', + }, + { + label: 'Auth User', + value: 'auth_user', + type: 'input', + }, + { + label: 'Status Code', + value: 'status', + type: 'checkbox', + options: [ + { label: '200', value: 200 }, + { label: '400', value: 400 }, + { label: '404', value: 404 }, + { label: '500', value: 500 }, + ], // REMINDER: this is a placeholder to set the type in the client.tsx + component: (props: Option) => { + if (typeof props.value === 'boolean') return null + if (typeof props.value === 'undefined') return null + if (typeof props.value === 'string') return null + return ( + {props.value} + ) + }, + }, + { + label: 'Method', + value: 'method', + type: 'checkbox', + options: METHODS.map((region) => ({ label: region, value: region })), + component: (props: Option) => { + return {props.value} + }, + }, +] satisfies DataTableFilterField[] + +export const sheetFields = [ + { + id: 'id', + label: 'Request ID', + type: 'readonly', + skeletonClassName: 'w-64', + }, + { + id: 'date', + label: 'Date', + type: 'timerange', + component: (props) => { + const date = new Date(props.date) + const month = format(date, 'LLL') + const day = format(date, 'dd') + const year = format(date, 'y') + const time = format(date, 'HH:mm:ss') + + return ( +
+ {month} + · + {day} + · + {year} + · + {time} +
+ ) + }, + skeletonClassName: 'w-36', + }, + { + id: 'auth_user', + label: 'Auth User', + type: 'readonly', + condition: (props) => Boolean(props.auth_user), + component: (props) => ( +
+ + {props.auth_user} +
+ ), + skeletonClassName: 'w-56', + }, + { + id: 'status', + label: 'Status', + type: 'checkbox', + component: (props) => { + return ( + {props.status} + ) + }, + skeletonClassName: 'w-12', + }, + { + id: 'method', + label: 'Method', + type: 'checkbox', + component: (props) => { + return {props.method} + }, + skeletonClassName: 'w-10', + }, + { + id: 'host', + label: 'Host', + type: 'input', + skeletonClassName: 'w-24', + }, + { + id: 'pathname', + label: 'Pathname', + type: 'input', + skeletonClassName: 'w-56', + }, +] satisfies SheetField[] diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts new file mode 100644 index 00000000000..1661458a027 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts @@ -0,0 +1,44 @@ +import { useQueryState } from 'nuqs' +import { useEffect, useMemo, useRef } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' + +export const useResetFocus = () => { + useHotKey(() => { + // FIXME: some dedicated div[tabindex="0"] do not auto-unblur (e.g. the DataTableFilterResetButton) + // REMINDER: we cannot just document.activeElement?.blur(); as the next tab will focus the next element in line, + // which is not what we want. We want to reset entirely. + document.body.setAttribute('tabindex', '0') + document.body.focus() + document.body.removeAttribute('tabindex') + }, '.') +} + +export const useLiveMode = (data: TData[]) => { + const [live] = useQueryState('live', SEARCH_PARAMS_PARSER.live) + // REMINDER: used to capture the live mode on timestamp + const liveTimestamp = useRef(live ? new Date().getTime() : undefined) + + useEffect(() => { + if (live) liveTimestamp.current = new Date().getTime() + else liveTimestamp.current = undefined + }, [live]) + + const anchorRow = useMemo(() => { + if (!live) return undefined + + const item = data.find((item) => { + // return first item that is there if not liveTimestamp + if (!liveTimestamp.current) return true + // return first item that is after the liveTimestamp + if (item.date.getTime() > liveTimestamp.current) return false + return true + // return first item if no liveTimestamp + }) + + return item + }, [live, data]) + + return { row: anchorRow, timestamp: liveTimestamp.current } +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts new file mode 100644 index 00000000000..45decf308f8 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -0,0 +1,569 @@ +import dayjs from 'dayjs' + +import { QuerySearchParamsType, SearchParamsType } from './UnifiedLogs.types' + +// Pagination and control parameters +const PAGINATION_PARAMS = ['sort', 'start', 'size', 'uuid', 'cursor', 'direction', 'live'] as const + +// Special filter parameters that need custom handling +const SPECIAL_FILTER_PARAMS = ['date'] as const + +// Combined list of all parameters to exclude from standard filtering +const EXCLUDED_QUERY_PARAMS = [...PAGINATION_PARAMS, ...SPECIAL_FILTER_PARAMS] as const +const BASE_CONDITIONS_EXCLUDED_PARAMS = [...PAGINATION_PARAMS, 'date', 'level'] as const + +/** + * Builds query conditions from search parameters and returns WHERE clause + * @param search SearchParamsType object containing query parameters + * @returns Object with whereConditions array and formatted WHERE clause + */ +const buildQueryConditions = (search: QuerySearchParamsType) => { + const whereConditions: string[] = [] + + // Process all search parameters for filtering + Object.entries(search).forEach(([key, value]) => { + // Skip pagination/control parameters + if (EXCLUDED_QUERY_PARAMS.includes(key as any)) { + return + } + + // Handle array filters (IN clause) + if (Array.isArray(value) && value.length > 0) { + whereConditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(',')})`) + return + } + + // Handle scalar values + if (value !== null && value !== undefined) { + if (['host', 'pathname'].includes(key)) { + whereConditions.push(`${key} LIKE '%${value}%'`) + } else { + whereConditions.push(`${key} = '${value}'`) + } + } + }) + + // Create final WHERE clause + const finalWhere = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '' + + return { whereConditions, finalWhere } +} + +/** + * Builds level-specific condition for different log types + */ +const buildLevelConditions = (logType: string, levelFilter: string[]) => { + const conditions = [] + + switch (logType) { + case 'edge': + if (levelFilter.includes('success')) + conditions.push('edge_logs_response.status_code BETWEEN 200 AND 299') + if (levelFilter.includes('warning')) + conditions.push('edge_logs_response.status_code BETWEEN 400 AND 499') + if (levelFilter.includes('error')) conditions.push('edge_logs_response.status_code >= 500') + break + case 'postgres': + if (levelFilter.includes('success')) conditions.push("pgl_parsed.error_severity = 'LOG'") + if (levelFilter.includes('warning')) conditions.push("pgl_parsed.error_severity = 'WARNING'") + if (levelFilter.includes('error')) conditions.push("pgl_parsed.error_severity = 'ERROR'") + break + case 'edge function': + if (levelFilter.includes('success')) + conditions.push('fel_response.status_code BETWEEN 200 AND 299') + if (levelFilter.includes('warning')) + conditions.push('fel_response.status_code BETWEEN 400 AND 499') + if (levelFilter.includes('error')) conditions.push('fel_response.status_code >= 500') + break + case 'auth': + if (levelFilter.includes('success')) + conditions.push('el_in_al_response.status_code BETWEEN 200 AND 299') + if (levelFilter.includes('warning')) + conditions.push('el_in_al_response.status_code BETWEEN 400 AND 499') + if (levelFilter.includes('error')) conditions.push('el_in_al_response.status_code >= 500') + break + case 'supavisor': + if (levelFilter.includes('success')) + conditions.push("LOWER(svl_metadata.level) NOT IN ('error', 'warn', 'warning')") + if (levelFilter.includes('warning')) + conditions.push( + "(LOWER(svl_metadata.level) = 'warn' OR LOWER(svl_metadata.level) = 'warning')" + ) + if (levelFilter.includes('error')) conditions.push("LOWER(svl_metadata.level) = 'error'") + break + } + + return conditions +} + +/** + * Creates WHERE clause for a specific log type including level filtering + */ +const createFilterWhereClause = ( + logType: string, + levelFilter: string[], + baseConditions: string[] +) => { + const hasLevelFilter = levelFilter.length > 0 + + let where = '' + + if (hasLevelFilter) { + const levelConditions = buildLevelConditions(logType, levelFilter) + + if (levelConditions.length > 0) { + if (baseConditions.length > 0) { + where = `WHERE (${levelConditions.join(' OR ')}) AND ${baseConditions.join(' AND ')}` + } else { + where = `WHERE (${levelConditions.join(' OR ')})` + } + } else if (baseConditions.length > 0) { + where = `WHERE ${baseConditions.join(' AND ')}` + } + } else if (baseConditions.length > 0) { + where = `WHERE ${baseConditions.join(' AND ')}` + } + + // Special case for auth logs + if (logType === 'auth') { + if (where) { + where = where.replace('WHERE', 'WHERE al_metadata.request_id is not null AND') + } else { + where = 'WHERE al_metadata.request_id is not null' + } + } + + return where +} + +/** + * Builds base conditions array from search params + */ +const buildBaseConditions = (search: SearchParamsType): string[] => { + const baseConditions: string[] = [] + + Object.entries(search).forEach(([key, value]) => { + // Skip pagination/control parameters, date and level (handled separately) + if (BASE_CONDITIONS_EXCLUDED_PARAMS.includes(key as any)) { + return + } + + // Handle array filters (IN clause) + if (Array.isArray(value) && value.length > 0) { + baseConditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(', ')})`) + } + // Handle scalar values + else if (value !== null && value !== undefined) { + baseConditions.push(`${key} = '${value}'`) + } + }) + + return baseConditions +} + +/** + * Calculates how much the chart start datetime should be offset given the current datetime filter params + * and determines the appropriate bucketing level (minute, hour, day) + * Ported from the older implementation (apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts) + */ +const calculateChartBucketing = (search: SearchParamsType | Record): string => { + // Extract start and end times from the date array if available + const dateRange = search.date || [] + + // Handle timestamps that could be in various formats + const convertToMillis = (timestamp: any) => { + if (!timestamp) return null + // If timestamp is a Date object + if (timestamp instanceof Date) return timestamp.getTime() + + // If timestamp is a string that needs parsing + if (typeof timestamp === 'string') return dayjs(timestamp).valueOf() + + // If timestamp is already a number (unix timestamp) + // Check if microseconds (16 digits) and convert to milliseconds + if (typeof timestamp === 'number') { + const str = timestamp.toString() + if (str.length >= 16) return Math.floor(timestamp / 1000) + return timestamp + } + + return null + } + + let startMillis = convertToMillis(dateRange[0]) + let endMillis = convertToMillis(dateRange[1]) + + // Default values if not set + if (!startMillis) startMillis = dayjs().subtract(1, 'hour').valueOf() + if (!endMillis) endMillis = dayjs().valueOf() + + const startTime = dayjs(startMillis) + const endTime = dayjs(endMillis) + + let truncationLevel = 'MINUTE' + + const minuteDiff = endTime.diff(startTime, 'minute') + const hourDiff = endTime.diff(startTime, 'hour') + const dayDiff = endTime.diff(startTime, 'day') + + console.log(`Time difference: ${minuteDiff} minutes, ${hourDiff} hours, ${dayDiff} days`) + + // Adjust bucketing based on time range + if (dayDiff >= 2) { + truncationLevel = 'DAY' + } else if (hourDiff >= 12) { + truncationLevel = 'HOUR' + } else { + truncationLevel = 'MINUTE' + } + + return truncationLevel +} + +/** + * Edge logs query fragment + * + * excludes `/rest/` in the path + */ +const getEdgeLogsQuery = () => { + return ` + select + id, + el.timestamp as timestamp, + 'edge' as log_type, + CAST(edge_logs_response.status_code AS STRING) as status, + CASE + WHEN edge_logs_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN edge_logs_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN edge_logs_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + edge_logs_request.path as pathname, + edge_logs_request.host as host, + null as event_message, + edge_logs_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + null as log_count, + null as logs + from edge_logs as el + cross join unnest(metadata) as edge_logs_metadata + cross join unnest(edge_logs_metadata.request) as edge_logs_request + cross join unnest(edge_logs_metadata.response) as edge_logs_response + left join unnest(edge_logs_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + + -- ONLY include logs where the path does not include /rest/ + WHERE edge_logs_request.path NOT LIKE '%/rest/%' + + ` +} + +// Postgrest logs + +// WHERE pathname includes `/rest/` +const getPostgrestLogsQuery = () => { + return ` + select + id, + el.timestamp as timestamp, + 'postgrest' as log_type, + CAST(edge_logs_response.status_code AS STRING) as status, + CASE + WHEN edge_logs_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN edge_logs_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN edge_logs_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + edge_logs_request.path as pathname, + edge_logs_request.host as host, + null as event_message, + edge_logs_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + null as log_count, + null as logs + from edge_logs as el + cross join unnest(metadata) as edge_logs_metadata + cross join unnest(edge_logs_metadata.request) as edge_logs_request + cross join unnest(edge_logs_metadata.response) as edge_logs_response + left join unnest(edge_logs_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + + -- ONLY include logs where the path includes /rest/ + WHERE edge_logs_request.path LIKE '%/rest/%' + ` +} + +/** + * Postgres logs query fragment + */ +const getPostgresLogsQuery = () => { + return ` + select + id, + pgl.timestamp as timestamp, + 'postgres' as log_type, + pgl_parsed.sql_state_code as status, + CASE + WHEN pgl_parsed.error_severity = 'LOG' THEN 'success' + WHEN pgl_parsed.error_severity = 'WARNING' THEN 'warning' + WHEN pgl_parsed.error_severity = 'ERROR' THEN 'error' + ELSE null + END as level, + null as pathname, + null as host, + event_message as event_message, + null as method, + 'api_role' as api_role, + null as auth_user, + null as log_count, + null as logs + from postgres_logs as pgl + cross join unnest(pgl.metadata) as pgl_metadata + cross join unnest(pgl_metadata.parsed) as pgl_parsed + ` +} + +/** + * Edge function logs query fragment + */ +const getEdgeFunctionLogsQuery = () => { + return ` + select + id, + fel.timestamp as timestamp, + 'edge function' as log_type, + CAST(fel_response.status_code AS STRING) as status, + CASE + WHEN fel_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN fel_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN fel_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + fel_request.url as pathname, + fel_request.host as host, + COALESCE(function_logs_agg.last_event_message, '') as event_message, + fel_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + function_logs_agg.function_log_count as log_count, + function_logs_agg.logs as logs + from function_edge_logs as fel + cross join unnest(metadata) as fel_metadata + cross join unnest(fel_metadata.response) as fel_response + cross join unnest(fel_metadata.request) as fel_request + left join unnest(fel_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + left join ( + SELECT + fl_metadata.execution_id, + COUNT(fl.id) as function_log_count, + ANY_VALUE(fl.event_message) as last_event_message, + ARRAY_AGG(STRUCT(fl.id, fl.timestamp, fl.event_message, fl_metadata.level, fl_metadata.event_type)) as logs + FROM function_logs as fl + CROSS JOIN UNNEST(fl.metadata) as fl_metadata + WHERE fl_metadata.execution_id IS NOT NULL + GROUP BY fl_metadata.execution_id + ) as function_logs_agg on fel_metadata.execution_id = function_logs_agg.execution_id + ` +} + +/** + * Auth logs query fragment + */ +const getAuthLogsQuery = () => { + return ` + select + al.id as id, + el_in_al.timestamp as timestamp, + 'auth' as log_type, + CAST(el_in_al_response.status_code AS STRING) as status, + CASE + WHEN el_in_al_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN el_in_al_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN el_in_al_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + el_in_al_request.path as pathname, + el_in_al_request.host as host, + null as event_message, + el_in_al_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + null as log_count, + null as logs + from auth_logs as al + cross join unnest(metadata) as al_metadata + left join ( + edge_logs as el_in_al + cross join unnest (metadata) as el_in_al_metadata + cross join unnest (el_in_al_metadata.response) as el_in_al_response + cross join unnest (el_in_al_response.headers) as el_in_al_response_headers + cross join unnest (el_in_al_metadata.request) as el_in_al_request + left join unnest(el_in_al_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + ) + on al_metadata.request_id = el_in_al_response_headers.cf_ray + WHERE al_metadata.request_id is not null + ` +} + +/** + * Supavisor logs query fragment + */ +const getSupavisorLogsQuery = () => { + return ` + select + id, + svl.timestamp as timestamp, + 'supavisor' as log_type, + 'undefined' as status, + CASE + WHEN LOWER(svl_metadata.level) = 'error' THEN 'error' + WHEN LOWER(svl_metadata.level) = 'warn' OR LOWER(svl_metadata.level) = 'warning' THEN 'warning' + ELSE 'success' + END as level, + null as pathname, + null as host, + null as event_message, + null as method, + 'api_role' as api_role, + null as auth_user, + null as log_count, + null as logs + from supavisor_logs as svl + cross join unnest(metadata) as svl_metadata + ` +} + +/** + * Combine all log sources to create the unified logs CTE + */ +const getUnifiedLogsCTE = () => { + return ` +WITH unified_logs AS ( + ${getEdgeLogsQuery()} + union all + ${getPostgrestLogsQuery()} + union all + ${getPostgresLogsQuery()} + union all + ${getEdgeFunctionLogsQuery()} + union all + ${getAuthLogsQuery()} + union all + ${getSupavisorLogsQuery()} +) + ` +} + +/** + * Unified logs SQL query + */ +export const getUnifiedLogsQuery = (search: QuerySearchParamsType): string => { + // Use the buildQueryConditions helper + const { finalWhere } = buildQueryConditions(search) + + // The unified SQL query with UNION ALL statements + const sql = ` +${getUnifiedLogsCTE()} +SELECT + id, + timestamp, + log_type, + status, + level, + pathname, + host, + event_message, + method, + api_role, + auth_user, + log_count, + logs +FROM unified_logs +${finalWhere} +` + + return sql +} + +/** + * Get a count query for the total logs within the timeframe + * Also returns facets for all filter dimensions + */ +export const getLogsCountQuery = (search: QuerySearchParamsType): string => { + const { finalWhere } = buildQueryConditions(search) + const methodWhere = + finalWhere.length > 0 ? `${finalWhere} AND method is NOT NULL` : 'WHERE method IS NOT NULL' + + // Create a count query using the same unified logs CTE + const sql = ` +${getUnifiedLogsCTE()} +-- Get total count +SELECT 'total' as dimension, 'all' as value, COUNT(*) as count +FROM unified_logs +${finalWhere} + +UNION ALL + +-- Get counts by level +SELECT 'level' as dimension, level as value, COUNT(*) as count +FROM unified_logs +${finalWhere} +GROUP BY level + +UNION ALL + +-- Get counts by log_type +SELECT 'log_type' as dimension, log_type as value, COUNT(*) as count +FROM unified_logs +${finalWhere} +GROUP BY log_type + +UNION ALL + +-- Get counts by method +SELECT 'method' as dimension, method as value, COUNT(*) as count +FROM unified_logs +${methodWhere} +GROUP BY method +` + + return sql +} + +/** + * Enhanced logs chart query with dynamic bucketing based on time range + * Incorporates dynamic bucketing from the older implementation + */ +export const getLogsChartQuery = (search: QuerySearchParamsType): string => { + // Use the buildQueryConditions helper + const { finalWhere } = buildQueryConditions(search) + + // Determine appropriate bucketing level based on time range + const truncationLevel = calculateChartBucketing(search) + + return ` +${getUnifiedLogsCTE()} +SELECT + TIMESTAMP_TRUNC(timestamp, ${truncationLevel}) as time_bucket, + COUNTIF(level = 'success') as success, + COUNTIF(level = 'warning') as warning, + COUNTIF(level = 'error') as error, + COUNT(*) as total_per_bucket +FROM unified_logs +${finalWhere} +GROUP BY time_bucket +ORDER BY time_bucket ASC +` +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts new file mode 100644 index 00000000000..a6037e1270b --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' + +import { + ARRAY_DELIMITER, + LEVELS, + RANGE_DELIMITER, +} from 'components/ui/DataTable/DataTable.constants' +import { LOG_TYPES, METHODS, REGIONS } from './UnifiedLogs.constants' + +export const columnSchema = z.object({ + id: z.string(), + log_type: z.enum(LOG_TYPES), + method: z.enum(METHODS), + host: z.string(), + pathname: z.string(), + level: z.enum(LEVELS), + status: z.number(), + date: z.date(), + timestamp: z.number(), + event_message: z.string().optional(), + log_count: z.number().optional(), // used to count function logs for a given execution_id + logs: z.array(z.any()).optional(), // array of function logs + auth_user: z.string().optional(), +}) + +export type ColumnSchema = z.infer + +export const columnFilterSchema = z.object({ + level: z + .string() + .transform((val) => val.split(ARRAY_DELIMITER)) + .pipe(z.enum(LEVELS).array()) + .optional(), + method: z + .string() + .transform((val) => val.split(ARRAY_DELIMITER)) + .pipe(z.enum(METHODS).array()) + .optional(), + host: z.string().optional(), + pathname: z.string().optional(), + status: z + .string() + .transform((val) => val.split(ARRAY_DELIMITER)) + .pipe(z.coerce.number().array()) + .optional(), + regions: z + .string() + .transform((val) => val.split(ARRAY_DELIMITER)) + .pipe(z.enum(REGIONS).array()) + .optional(), + date: z + .string() + .transform((val) => val.split(RANGE_DELIMITER).map(Number)) + .pipe(z.coerce.date().array()) + .optional(), + auth_user: z.string().optional(), +}) + +export type ColumnFilterSchema = z.infer + +export const facetMetadataSchema = z.object({ + rows: z.array(z.object({ value: z.any(), total: z.number() })), + total: z.number(), + min: z.number().optional(), + max: z.number().optional(), +}) + +export type FacetMetadataSchema = z.infer + +export type BaseChartSchema = { timestamp: number; [key: string]: number } + +export const timelineChartSchema = z.object({ + timestamp: z.number(), // UNIX + ...LEVELS.reduce( + (acc, level) => ({ + ...acc, + [level]: z.number().default(0), + }), + {} as Record<(typeof LEVELS)[number], z.ZodNumber> + ), + // REMINDER: make sure to have the `timestamp` field in the object +}) satisfies z.ZodType + +export type TimelineChartSchema = z.infer diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx new file mode 100644 index 00000000000..f4bd6be13d1 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -0,0 +1,414 @@ +import { + ColumnFiltersState, + getCoreRowModel, + getFacetedRowModel, + getFilteredRowModel, + getSortedRowModel, + getFacetedMinMaxValues as getTTableFacetedMinMaxValues, + getFacetedUniqueValues as getTTableFacetedUniqueValues, + Row, + RowSelectionState, + SortingState, + useReactTable, + VisibilityState, +} from '@tanstack/react-table' +import { useQueryStates } from 'nuqs' +import { useEffect, useMemo, useState } from 'react' + +import { useParams } from 'common' +import { arrSome, inDateRange } from 'components/ui/DataTable/DataTable.utils' +import { DataTableFilterCommand } from 'components/ui/DataTable/DataTableFilters/DataTableFilterCommand' +import { DataTableHeaderLayout } from 'components/ui/DataTable/DataTableHeaderLayout' +import { DataTableInfinite } from 'components/ui/DataTable/DataTableInfinite' +import { DataTableSheetDetails } from 'components/ui/DataTable/DataTableSheetDetails' +import { DataTableSideBarLayout } from 'components/ui/DataTable/DataTableSideBarLayout' +import { DataTableToolbar } from 'components/ui/DataTable/DataTableToolbar' +import { FilterSideBar } from 'components/ui/DataTable/FilterSideBar' +import { LiveButton } from 'components/ui/DataTable/LiveButton' +import { LiveRow } from 'components/ui/DataTable/LiveRow' +import { DataTableProvider } from 'components/ui/DataTable/providers/DataTableProvider' +import { TimelineChart } from 'components/ui/DataTable/TimelineChart' +import { useUnifiedLogsChartQuery } from 'data/logs/unified-logs-chart-query' +import { useUnifiedLogsCountQuery } from 'data/logs/unified-logs-count-query' +import { useUnifiedLogsInfiniteQuery } from 'data/logs/unified-logs-infinite-query' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { + ChartConfig, + cn, + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + Separator, + Tabs_Shadcn_ as Tabs, + TabsContent_Shadcn_ as TabsContent, + TabsList_Shadcn_ as TabsList, + TabsTrigger_Shadcn_ as TabsTrigger, +} from 'ui' +import { RefreshButton } from '../../ui/DataTable/RefreshButton' +import { UNIFIED_LOGS_COLUMNS } from './components/Columns' +import { MemoizedDataTableSheetContent } from './components/DataTableSheetContent' +import { FunctionLogsTab } from './components/FunctionLogsTab' +import { CHART_CONFIG, SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' +import { filterFields as defaultFilterFields, sheetFields } from './UnifiedLogs.fields' +import { useLiveMode, useResetFocus } from './UnifiedLogs.hooks' +import { QuerySearchParamsType } from './UnifiedLogs.types' +import { getFacetedUniqueValues, getLevelRowClassName, logEventBus } from './UnifiedLogs.utils' + +export const UnifiedLogs = () => { + useResetFocus() + + const { ref: projectRef } = useParams() + const [search, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER) + + const { sort, start, size, id, cursor, direction, live, ...filter } = search + const defaultColumnSorting = sort ? [sort] : [] + const defaultColumnVisibility = { uuid: false } + const defaultRowSelection = search.id ? { [search.id]: true } : {} + const defaultColumnFilters = Object.entries(filter) + .map(([key, value]) => ({ id: key, value })) + .filter(({ value }) => value ?? undefined) + + const [topBarHeight, setTopBarHeight] = useState(0) + const [activeTab, setActiveTab] = useState('details') + const [sorting, setSorting] = useState(defaultColumnSorting) + const [columnFilters, setColumnFilters] = useState(defaultColumnFilters) + const [rowSelection, setRowSelection] = useState(defaultRowSelection) + + const [columnVisibility, setColumnVisibility] = useLocalStorageQuery( + 'data-table-visibility', + defaultColumnVisibility + ) + const [columnOrder, setColumnOrder] = useLocalStorageQuery( + 'data-table-column-order', + [] + ) + + // Create a stable query key object by removing nulls/undefined, id, and live + // Mainly to prevent the react queries from unnecessarily re-fetching + const searchParameters = Object.entries(search).reduce( + (acc, [key, value]) => { + if (!['id', 'live'].includes(key) && value !== null && value !== undefined) { + acc[key] = value + } + return acc + }, + {} as Record + ) as QuerySearchParamsType + + const { + data: unifiedLogsData, + isLoading, + isFetching, + hasNextPage, + refetch: refetchLogs, + fetchNextPage, + fetchPreviousPage, + } = useUnifiedLogsInfiniteQuery({ projectRef, search: searchParameters }) + const { + data: counts, + isLoading: isLoadingCounts, + isFetching: isFetchingCounts, + refetch: refetchCounts, + } = useUnifiedLogsCountQuery({ + projectRef, + search: searchParameters, + }) + const { + data: unifiedLogsChart = [], + isFetching: isFetchingCharts, + refetch: refetchCharts, + } = useUnifiedLogsChartQuery({ + projectRef, + search: searchParameters, + }) + + const refetchAllData = () => { + refetchLogs() + refetchCounts() + refetchCharts() + } + + const isRefetchingData = isFetching || isFetchingCounts || isFetchingCharts + + const rawFlatData = useMemo(() => { + return unifiedLogsData?.pages?.flatMap((page) => page.data ?? []) ?? [] + }, [unifiedLogsData?.pages]) + // [Joshen] Refer to unified-logs-infinite-query on why the need to deupe + const flatData = useMemo(() => { + return rawFlatData.filter((value, idx) => { + return idx === rawFlatData.findIndex((x) => x.id === value.id) + }) + }, [rawFlatData]) + const liveMode = useLiveMode(flatData) + + const totalDBRowCount = counts?.totalRowCount + const filterDBRowCount = flatData.length + + const facets = counts?.facets + const totalFetched = flatData?.length + + // Create a filtered version of the chart config based on selected levels + const filteredChartConfig = useMemo(() => { + const levelFilter = search.level || ['success', 'warning', 'error'] + return Object.fromEntries( + Object.entries(CHART_CONFIG).filter(([key]) => levelFilter.includes(key as any)) + ) as ChartConfig + }, [search.level]) + + const getRowClassName = ( + row: Row + ) => { + const rowTimestamp = row.original.timestamp + const isPast = rowTimestamp <= (liveMode.timestamp || -1) + const levelClassName = getLevelRowClassName(row.original.level as any) + return cn(levelClassName, isPast ? 'opacity-50' : 'opacity-100', 'h-[30px]') + } + + const table = useReactTable({ + data: flatData, + columns: UNIFIED_LOGS_COLUMNS, + state: { + columnFilters, + sorting, + columnVisibility, + rowSelection, + columnOrder, + }, + enableMultiRowSelection: false, + columnResizeMode: 'onChange', + filterFns: { inDateRange, arrSome }, + meta: { getRowClassName }, + getRowId: (row) => row.id, + onColumnVisibilityChange: setColumnVisibility, + onColumnFiltersChange: setColumnFilters, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnOrderChange: setColumnOrder, + getSortedRowModel: getSortedRowModel(), + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getTTableFacetedUniqueValues(), + getFacetedMinMaxValues: getTTableFacetedMinMaxValues(), + }) + + const selectedRow = useMemo(() => { + if ((isLoading || isFetching) && !flatData.length) return + const selectedRowKey = Object.keys(rowSelection)?.[0] + return table.getCoreRowModel().flatRows.find((row) => row.id === selectedRowKey) + }, [rowSelection, flatData]) + + const selectedRowKey = Object.keys(rowSelection)?.[0] + + // REMINDER: this is currently needed for the cmdk search + // TODO: auto search via API when the user changes the filter instead of hardcoded + const filterFields = useMemo(() => { + return defaultFilterFields.map((field) => { + const facetsField = facets?.[field.value] + if (!facetsField) return field + if (field.options && field.options.length > 0) return field + + // REMINDER: if no options are set, we need to set them via the API + const options = facetsField.rows.map(({ value }) => ({ label: `${value}`, value })) + + if (field.type === ('slider' as any)) { + return { + ...(field as any), + min: facetsField.min ?? (field as any).min, + max: facetsField.max ?? (field as any).max, + options, + } + } + + return { ...field, options } + }) + }, [facets]) + + useEffect(() => { + const columnFiltersWithNullable = filterFields.map((field) => { + const filterValue = columnFilters.find((filter) => filter.id === field.value) + if (!filterValue) return { id: field.value, value: null } + return { id: field.value, value: filterValue.value } + }) + + const search = columnFiltersWithNullable.reduce( + (prev, curr) => { + // Add to search parameters + prev[curr.id as string] = curr.value + return prev + }, + {} as Record + ) + + setSearch(search) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnFilters]) + + useEffect(() => { + setSearch({ sort: sorting?.[0] || null }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sorting]) + + useEffect(() => { + if (isLoading || isFetching) return + if (Object.keys(rowSelection)?.length && !selectedRow) { + setSearch({ id: null }) + setRowSelection({}) + } else { + setSearch({ id: Object.keys(rowSelection)?.[0] || null }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rowSelection, selectedRow, isLoading, isFetching]) + + // Set up event listener for trace tab selection + useEffect(() => { + const unsubscribe = logEventBus.on('selectTraceTab', (rowId) => { + setRowSelection({ [rowId]: true }) + setActiveTab('trace') + }) + return () => { + unsubscribe() + } + }, [setRowSelection]) + + return ( + + + +
+ + + [ + , + fetchPreviousPage ? ( + + ) : null, + ]} + /> + + + + + + + + + + { + if (!liveMode.timestamp) return null + if (props?.row?.original.id !== liveMode?.row?.id) return null + return + }} + setColumnOrder={setColumnOrder} + setColumnVisibility={setColumnVisibility} + searchParamsParser={SEARCH_PARAMS_PARSER} + /> + + {selectedRow?.original?.logs && selectedRow?.original?.logs?.length > 0 && ( + <> + + +
+
+

+ Function Logs ( + {selectedRow?.original?.logs && + Array.isArray(selectedRow?.original?.logs) + ? selectedRow?.original?.logs?.length + : 0} + ) +

+
+
+ +
+
+
+ + )} +
+
+ + {selectedRowKey && ( + <> + + +
+ + + + Log Details + + + + + + + +
+
+ + )} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts new file mode 100644 index 00000000000..22dc02b9552 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts @@ -0,0 +1,57 @@ +import { type inferParserType } from 'nuqs' + +import { LOG_TYPES, SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' + +type Percentile = 50 | 75 | 90 | 95 | 99 + +export type LogType = (typeof LOG_TYPES)[number] + +export type UnifiedLogSchema = { + id: string + timestamp: Date + log_type: LogType + code: string + level: string + path: string | null + event_message: string + method: string + api_role: string + auth_user: string | null +} + +export type LogsMeta = { + currentPercentiles: Record +} + +export type PageParam = { cursor: number; direction: 'next' | 'prev' } + +export type SearchParamsType = inferParserType +export type QuerySearchParamsType = Omit + +export type SearchParams = { + [key: string]: string | string[] | undefined +} + +/** ----------------------------------------- */ + +export type SheetField> = { + id: keyof TData + label: string + // FIXME: rethink that! I dont think we need this as there is no input type + // REMINDER: readonly if we only want to copy the value (e.g. uuid) + // TODO: we might have some values that are not in the data but can be computed + type: 'readonly' | 'input' | 'checkbox' | 'slider' | 'timerange' + component?: ( + // REMINDER: this is used to pass additional data like the `InfiniteQueryMeta` + props: TData & { + metadata?: { + totalRows: number + filterRows: number + totalRowsFetched: number + } & TMeta + } + ) => JSX.Element | null | string + condition?: (props: TData) => boolean + className?: string + skeletonClassName?: string +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts new file mode 100644 index 00000000000..a32831a39a8 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts @@ -0,0 +1,75 @@ +import { type Table as TTable } from '@tanstack/react-table' + +import { LEVELS } from 'components/ui/DataTable/DataTable.constants' +import { cn } from 'ui' +import { FacetMetadataSchema } from './UnifiedLogs.schema' + +export const logEventBus = { + listeners: new Map void>>(), + + on(event: 'selectTraceTab', callback: (rowId: string) => void) { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()) + } + this.listeners.get(event)?.add(callback) + return () => this.listeners.get(event)?.delete(callback) + }, + + emit(event: 'selectTraceTab', rowId: string) { + this.listeners.get(event)?.forEach((callback) => callback(rowId)) + }, +} + +export const getFacetedUniqueValues = (facets?: Record) => { + return (table: TTable, columnId: string) => { + return new Map(facets?.[columnId]?.rows?.map(({ value, total }) => [value, total]) || []) + } +} + +export const getFacetedMinMaxValues = (facets?: Record) => { + return (table: TTable, columnId: string) => { + const min = facets?.[columnId]?.min + const max = facets?.[columnId]?.max + if (typeof min === 'number' && typeof max === 'number') return [min, max] + if (typeof min === 'number') return [min, min] + if (typeof max === 'number') return [max, max] + return undefined + } +} + +export const getLevelLabel = (value: (typeof LEVELS)[number]): string => { + switch (value) { + case 'success': + return '2xx' + case 'warning': + return '4xx' + case 'error': + return '5xx' + default: + return 'Unknown' + } +} + +export function getLevelRowClassName(value: (typeof LEVELS)[number]): string { + switch (value) { + case 'success': + return '' + case 'warning': + return cn( + 'bg-warning/5 hover:bg-warning/10 data-[state=selected]:bg-warning/20 focus-visible:bg-warning/10', + 'dark:bg-warning/10 dark:hover:bg-warning/20 dark:data-[state=selected]:bg-warning/30 dark:focus-visible:bg-warning/20' + ) + case 'error': + return cn( + 'bg-destructive/5 hover:bg-destructive/10 data-[state=selected]:bg-destructive/20 focus-visible:bg-destructive/10', + 'dark:bg-error/10 dark:hover:bg-destructive/20 dark:data-[state=selected]:bg-destructive/30 dark:focus-visible:bg-destructive/20' + ) + case 'info': + return cn( + 'bg-info/5 hover:bg-info/10 data-[state=selected]:bg-info/20 focus-visible:bg-info/10', + 'dark:bg-info/10 dark:hover:bg-info/20 dark:data-[state=selected]:bg-info/30 dark:focus-visible:bg-info/20' + ) + default: + return '' + } +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx new file mode 100644 index 00000000000..0d9f329dd92 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx @@ -0,0 +1,37 @@ +import { User } from 'lucide-react' + +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from 'ui/src/components/shadcn/ui/hover-card' + +interface AuthUserHoverCardProps { + authUser: string +} + +export const AuthUserHoverCard = ({ authUser }: AuthUserHoverCardProps) => { + if (!authUser) return null + + return ( + + + + + +
+
+ +

User Details

+
+
+
+ ID/Email: {authUser} +
+ {/* Additional user details can be added here if available */} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx new file mode 100644 index 00000000000..49334f1b61b --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx @@ -0,0 +1,193 @@ +import { ColumnDef } from '@tanstack/react-table' + +import { DataTableColumnHeader } from 'components/ui/DataTable/DataTableColumn/DataTableColumnHeader' +import { DataTableColumnLevelIndicator } from 'components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator' +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { ColumnFilterSchema, ColumnSchema } from '../UnifiedLogs.schema' +import { AuthUserHoverCard } from './AuthUserHoverCard' +import { HoverCardTimestamp } from './HoverCardTimestamp' +import { LogTypeIcon } from './LogTypeIcon' +import { TextWithTooltip } from './TextWithTooltip' + +export const UNIFIED_LOGS_COLUMNS: ColumnDef[] = [ + { + accessorKey: 'level', + header: '', + cell: ({ row }) => { + const level = row.getValue('level') + return + }, + enableHiding: false, + enableResizing: false, + filterFn: (row, columnId, filterValue) => true, + size: 48, + minSize: 48, + maxSize: 48, + meta: { + headerClassName: + 'w-[--header-level-size] max-w-[--header-level-size] min-w-[--header-level-size]', + cellClassName: 'w-[--col-level-size] max-w-[--col-level-size] min-w-[--col-level-size]', + }, + }, + { + accessorKey: 'date', + header: ({ column }) => , + cell: ({ row }) => { + const date = new Date(row.getValue('date')) + return + }, + filterFn: (row, columnId, filterValue) => true, + enableResizing: false, + size: 130, + minSize: 130, + maxSize: 130, + meta: { + headerClassName: + 'w-[--header-date-size] max-w-[--header-date-size] min-w-[--header-date-size]', + cellClassName: + 'font-mono w-[--col-date-size] max-w-[--col-date-size] min-w-[--col-date-size]', + }, + }, + { + accessorKey: 'log_type', + header: '', + cell: ({ row }) => { + const logType = row.getValue('log_type') + return ( +
+ +
+ ) + }, + enableHiding: false, + filterFn: (row, columnId, filterValue) => true, + size: 40, + minSize: 40, + maxSize: 40, + enableResizing: false, + meta: { + headerClassName: + 'w-[--header-log_type-size] max-w-[--header-log_type-size] min-w-[--header-log_type-size]', + cellClassName: + 'text-right px-0 relative justify-end items-center font-mono w-[--col-log_type-size] max-w-[--col-log_type-size] min-w-[--col-log_type-size]', + }, + }, + { + // TODO: make it a type of MethodSchema! + accessorKey: 'method', + header: 'Method', + filterFn: 'arrIncludesSome', + cell: ({ row }) => { + const value = row.getValue('method') + return {value} + }, + enableResizing: false, + size: 70, + minSize: 70, + meta: { + cellClassName: + 'font-mono text-muted-foreground w-[--col-method-size] max-w-[--col-method-size] min-w-[--col-method-size]', + headerClassName: + 'w-[--header-method-size] max-w-[--header-method-size] min-w-[--header-method-size]', + }, + }, + { + accessorKey: 'status', + header: '', + cell: ({ row }) => { + const value = row.getValue('status') + return ( +
+ {row.original.auth_user && ( +
+ +
+ )} + ('level')} + /> +
+ ) + }, + filterFn: (row, columnId, filterValue) => true, + enableResizing: false, + size: 50, + minSize: 50, + meta: { + headerClassName: + 'w-[--header-status-size] max-w-[--header-status-size] min-w-[--header-status-size]', + cellClassName: + 'font-mono w-[--col-status-size] max-w-[--col-status-size] min-w-[--col-status-size]', + }, + }, + + { + accessorKey: 'host', + header: 'Host', + cell: ({ row }) => { + const value = row.getValue('host') + return + }, + size: 220, + minSize: 220, + meta: { + cellClassName: 'font-mono w-[--col-host-size] max-w-[--col-host-size]', + headerClassName: 'min-w-[--header-host-size] w-[--header-host-size]', + }, + }, + { + accessorKey: 'pathname', + header: 'Pathname', + cell: ({ row }) => { + const value = row.getValue('pathname') ?? '' + return + }, + size: 320, + minSize: 320, + meta: { + cellClassName: 'font-mono w-[--col-pathname-size] max-w-[--col-pathname-size]', + headerClassName: 'min-w-[--header-pathname-size] w-[--header-pathname-size]', + }, + }, + { + accessorKey: 'event_message', + header: ({ column }) => , + cell: ({ row }) => { + const value = row.getValue('event_message') + const logCount = row.original.log_count + + return ( +
+ {logCount && ( + + + + {logCount} + + + + {logCount} {logCount === 1 ? 'log' : 'logs'} for this execution + + + )} + {value && ( + + + + )} +
+ ) + }, + enableResizing: true, + size: 200, + minSize: 200, + meta: { + headerClassName: + 'w-[--header-event_message-size] max-w-[--header-event_message-size] min-w-[--header-event_message-size]', + cellClassName: + 'font-mono w-[--col-event_message-size] max-w-[--col-event_message-size] min-w-[--col-event_message-size]', + }, + }, +] diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx new file mode 100644 index 00000000000..ff08c51e681 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx @@ -0,0 +1,105 @@ +import { Table } from '@tanstack/react-table' +import { HTMLAttributes, memo } from 'react' + +import { DataTableFilterField } from 'components/ui/DataTable/DataTable.types' +import { DataTableSheetRowAction } from 'components/ui/DataTable/DataTableSheetRowAction' +import { cn, Skeleton } from 'ui' +import { SheetField } from '../UnifiedLogs.types' + +interface SheetDetailsContentSkeletonProps { + fields: SheetField[] +} + +const SheetDetailsContentSkeleton = ({ + fields, +}: SheetDetailsContentSkeletonProps) => { + return ( +
+ {fields.map((field) => ( +
+
{field.label}
+
+ +
+
+ ))} +
+ ) +} + +interface DataTableSheetContentProps extends HTMLAttributes { + data?: TData + table: Table + fields: SheetField[] + filterFields: DataTableFilterField[] + metadata?: TMeta & { + totalRows: number + filterRows: number + totalRowsFetched: number + } +} + +export function DataTableSheetContent({ + data, + table, + className, + fields, + filterFields, + metadata, + ...props +}: DataTableSheetContentProps) { + if (!data) return + + return ( +
+ {fields.map((field) => { + if (field.condition && !field.condition(data)) return null + + const Component = field.component + const value = String(data[field.id]) + + return ( +
+ {field.type === 'readonly' ? ( +
+
{field.label}
+
+ {Component ? : value} +
+
+ ) : ( + +
{field.label}
+
+ {Component ? : value} +
+
+ )} +
+ ) + })} +
+ ) +} + +export const MemoizedDataTableSheetContent = memo(DataTableSheetContent, (prev, next) => { + // REMINDER: only check if data is the same, rest is useless + return prev.data === next.data +}) as typeof DataTableSheetContent diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx new file mode 100644 index 00000000000..a9998ffe56b --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx @@ -0,0 +1,53 @@ +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { HoverCardTimestamp } from './HoverCardTimestamp' + +interface FunctionLogEntry { + id: string + timestamp: string + event_message: string + level: string + event_type: string +} + +interface FunctionLogsTabProps { + logs?: FunctionLogEntry[] +} + +export const FunctionLogsTab = ({ logs = [] }: FunctionLogsTabProps) => { + if (!logs || logs.length === 0) { + return ( +
+

No function logs found

+
+ ) + } + + return ( +
+
+ {logs.map((log) => { + const date = new Date(Number(log.timestamp) / 1000) + + return ( +
+
+
+ + + +
+
+ {log.event_message} +
+
+
+ ) + })} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx new file mode 100644 index 00000000000..337fe574ed6 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx @@ -0,0 +1,14 @@ +import { TimestampInfo } from 'ui-patterns' + +interface HoverCardTimestampProps { + date: Date + className?: string +} + +export function HoverCardTimestamp({ date, className }: HoverCardTimestampProps) { + return ( +
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx new file mode 100644 index 00000000000..5f9d067407a --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx @@ -0,0 +1,65 @@ +import { BookHeart, Box, Cpu, Database, Globe } from 'lucide-react' + +import { Auth, EdgeFunctions, Storage } from 'icons' +import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { type LOG_TYPES } from '../UnifiedLogs.constants' + +interface LogTypeIconProps { + type: (typeof LOG_TYPES)[number] + size?: number + strokeWidth?: number + className?: string +} + +export const LogTypeIcon = ({ + type, + size = 16, + strokeWidth = 1.5, + className, +}: LogTypeIconProps) => { + const iconMap = { + edge: () => , + postgrest: () => , + auth: () => , + edge_function: () => ( + + ), + postgres: () => , + function_events: () => ( + + ), + supavisor: () => , + postgres_upgrade: () => , + storage: () => , + + // cron: () => , + } + + const IconComponent = + iconMap[type] || (() => ) + + return ( + + + + + +
{type}
+
+
+ ) +} + +export const LogTypeIconWithText = ({ + type, + size = 16, + strokeWidth = 1.5, + className, +}: LogTypeIconProps) => { + return ( +
+ + {type} +
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx new file mode 100644 index 00000000000..fae42e933ac --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from 'react' +import { cn, Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger } from 'ui' + +interface TextWithTooltipProps { + text: string | number + className?: string +} + +export function TextWithTooltip({ text, className }: TextWithTooltipProps) { + const [isTruncated, setIsTruncated] = useState(false) + const textRef = useRef(null) + + useEffect(() => { + const checkTruncation = () => { + if (textRef.current) { + const { scrollWidth, clientWidth } = textRef.current + setIsTruncated(scrollWidth > clientWidth) + } + } + + const resizeObserver = new ResizeObserver(() => { + checkTruncation() + }) + + if (textRef.current) { + resizeObserver.observe(textRef.current) + } + + checkTruncation() + + return () => { + resizeObserver.disconnect() + } + }, []) + + return ( + + + +
+ {text} +
+
+ + {text} + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx new file mode 100644 index 00000000000..7745d22b864 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx @@ -0,0 +1,10 @@ +import { getLevelLabel } from '../UnifiedLogs.utils' + +export const TooltipLabel = ({ level }: { level: 'success' | 'warning' | 'error' }) => { + return ( +
+
{level}
+
{getLevelLabel(level)}
+
+ ) +} diff --git a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx index a677ab116e7..8b9b80ffef2 100644 --- a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx @@ -29,7 +29,7 @@ export const AssistantButton = () => { }, }} > - + ) } diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx index d0f59229663..2a94ed94a1f 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx @@ -365,7 +365,7 @@ export const EnableBranchingModal = () => {

- Preview branches are billed $0.32 per day + Preview branches are billed $0.01344 per hour

This cost will continue for as long as the branch has not been removed. diff --git a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx index b6131c9910d..e0a4b5955d4 100644 --- a/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/OrganizationDropdown.tsx @@ -45,12 +45,22 @@ export const OrganizationDropdown = () => { return ( <> - + - - {orgName} + - {selectedOrganization?.plan.name} + {!!selectedOrganization && ( + {selectedOrganization?.plan.name} + )} @@ -71,6 +81,7 @@ export const OrganizationDropdown = () => { const href = !!routeSlug ? router.pathname.replace('[slug]', org.slug) : `/org/${org.slug}` + return ( { + const { ref: projectRef } = useParams() + + const navigationItems = [ + { + label: 'JWT Secret', + href: `/project/${projectRef}/settings/jwt`, + id: 'legacy-jwt-keys', + }, + { + label: 'JWT Signing Keys (Coming Soon)', + href: `/project/${projectRef}/settings/jwt/signing-keys`, + id: 'signing-keys', + }, + ] + + return ( + + + {children} + + + ) +} + +export default JWTKeysLayout diff --git a/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx index 7296bb61f6f..2edee7ba163 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx @@ -2,13 +2,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' +import { LOCAL_STORAGE_KEYS } from 'common' import NoPermission from 'components/ui/NoPermission' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { withAuth } from 'hooks/misc/withAuth' import ProjectLayout from '../ProjectLayout/ProjectLayout' import { LogsSidebarMenuV2 } from './LogsSidebarMenuV2' -import { LOCAL_STORAGE_KEYS } from 'common' interface LogsLayoutProps { title?: string diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index 993484f7197..f47831e49e3 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -80,22 +80,27 @@ export function SidebarCollapsible({ } export function LogsSidebarMenuV2() { + const router = useRouter() + const { ref } = useParams() as { ref: string } + const warehouseEnabled = useFlag('warehouse') + const [searchText, setSearchText] = useState('') const [createCollectionOpen, setCreateCollectionOpen] = useState(false) const canCreateCollection = useCheckPermissions(PermissionAction.ANALYTICS_WRITE, 'logflare') - const router = useRouter() - const { ref } = useParams() as { ref: string } + const { data: tenantData } = useWarehouseTenantQuery({ projectRef: ref }) + const { projectAuthAll: authEnabled, projectStorageAll: storageEnabled, realtimeAll: realtimeEnabled, } = useIsFeatureEnabled(['project_storage:all', 'project_auth:all', 'realtime:all']) - const warehouseEnabled = useFlag('warehouse') + const { data: whCollections, isLoading: whCollectionsLoading } = useWarehouseCollectionsQuery( { projectRef: ref }, { enabled: IS_PLATFORM && warehouseEnabled && !!tenantData } ) + const { plan: orgPlan, isLoading: isOrgPlanLoading } = useCurrentOrgPlan() const isFreePlan = !isOrgPlanLoading && orgPlan?.id === 'free' diff --git a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx index d0044b2823b..c8f9726cc15 100644 --- a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx @@ -1,50 +1,39 @@ import { useQueryClient } from '@tanstack/react-query' +import { ArrowRight, Loader2 } from 'lucide-react' +import Link from 'next/link' + +import { useParams } from 'common' import ClientLibrary from 'components/interfaces/Home/ClientLibrary' import ExampleProject from 'components/interfaces/Home/ExampleProject' import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' -import Link from 'next/link' -import { useEffect, useRef } from 'react' - -import { useParams } from 'common' import { DisplayApiSettings, DisplayConfigSettings } from 'components/ui/ProjectSettings' import { invalidateProjectDetailsQuery } from 'data/projects/project-detail-query' +import { useProjectStatusQuery } from 'data/projects/project-status-query' import { invalidateProjectsQuery } from 'data/projects/projects-query' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { getWithTimeout } from 'lib/common/fetch' -import { API_URL, PROJECT_STATUS } from 'lib/constants' -import { ArrowRight, Loader2 } from 'lucide-react' +import { PROJECT_STATUS } from 'lib/constants' import { Badge, Button } from 'ui' const BuildingState = () => { const { ref } = useParams() const project = useSelectedProject() const queryClient = useQueryClient() - const checkServerInterval = useRef() - // TODO: move to react-query - async function checkServer() { - if (!project) return - - const projectStatus = await getWithTimeout(`${API_URL}/projects/${project.ref}/status`, { - timeout: 2000, - }) - if (projectStatus && !projectStatus.error) { - const { status } = projectStatus - if (status === PROJECT_STATUS.ACTIVE_HEALTHY) { - clearInterval(checkServerInterval.current) - if (ref) await invalidateProjectDetailsQuery(queryClient, ref) - await invalidateProjectsQuery(queryClient) - } + useProjectStatusQuery( + { projectRef: ref }, + { + enabled: project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY, + refetchInterval: (res) => { + return res?.status === PROJECT_STATUS.ACTIVE_HEALTHY ? false : 4000 + }, + onSuccess: async (res) => { + if (res.status === PROJECT_STATUS.ACTIVE_HEALTHY) { + if (ref) invalidateProjectDetailsQuery(queryClient, ref) + invalidateProjectsQuery(queryClient) + } + }, } - } - - useEffect(() => { - // check server status every 4s - checkServerInterval.current = window.setInterval(checkServer, 4000) - return () => { - clearInterval(checkServerInterval.current) - } - }, []) + ) if (project === undefined) return null @@ -122,8 +111,8 @@ const BuildingState = () => {

-
- +
+
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx index d8903ee448c..e610bb3a4e1 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx @@ -14,14 +14,12 @@ import { useProjectsQuery } from 'data/projects/projects-query' import { useNotificationsStateSnapshot } from 'state/notifications' import { Button, - CriticalIcon, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_, - WarningIcon, cn, } from 'ui' import NotificationRow from './NotificationRow' @@ -103,41 +101,33 @@ export const NotificationsPopoverV2 = () => { text: 'Notifications', }, }} - type={hasNewNotifications ? 'outline' : 'text'} - className={cn( - 'rounded-none h-[30px] w-[32px]', - // !hasCritical || !hasWarning || !hasNewNotifications ? 'w-[26px]' : '', - 'group', - hasNewNotifications ? 'rounded-full px-1.5' : 'px-1', - hasCritical - ? 'border-destructive-500 hover:border-destructive-600 hover:bg-destructive-300' - : hasWarning - ? 'border-warning-500 hover:border-warning-600 hover:bg-warning-300' - : '' - )} + type="text" + className={cn('rounded-none h-[30px] w-[32px] group relative')} icon={ - hasCritical ? ( - - ) : hasWarning ? ( - - ) : hasNewNotifications ? ( -
+ 9 ? 'px-0.5 w-auto' : 'w-4' + '!h-[18px] !w-[18px] text-foreground-light group-hover:text-foreground' )} - > -

{summary?.unread_count}

-
- ) : null - } - iconRight={ - + /> + {hasCritical && ( +
+
+
+ )} + {hasWarning && !hasCritical && ( +
+
+
+ )} + {!!hasNewNotifications && !hasCritical && !hasWarning && ( +
+
+
+ )} +
} /> diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 96df83e1bb9..41a6434e197 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -1,4 +1,4 @@ -import { Blocks, FileText, Lightbulb, List, Settings } from 'lucide-react' +import { Blocks, FileText, Lightbulb, List, Logs, Settings } from 'lucide-react' import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar' import { generateAuthMenu } from 'components/layouts/AuthLayout/AuthLayout.utils' @@ -19,12 +19,7 @@ import { } from 'icons' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' -export const generateToolRoutes = ( - ref?: string, - project?: Project, - features?: { sqlEditorTabs: boolean } -): Route[] => { - const { sqlEditorTabs } = features ?? {} +export const generateToolRoutes = (ref?: string, project?: Project, features?: {}): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP const buildingUrl = `/project/${ref}` @@ -42,8 +37,7 @@ export const generateToolRoutes = ( icon: , link: !IS_PLATFORM ? `/project/${ref}/sql/1` - : ref && - (isProjectBuilding ? buildingUrl : `/project/${ref}/sql${!!sqlEditorTabs ? '' : '/new'}`), + : ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/sql`), }, ] } @@ -122,10 +116,16 @@ export const generateProductRoutes = ( ] } -export const generateOtherRoutes = (ref?: string, project?: Project): Route[] => { +export const generateOtherRoutes = ( + ref?: string, + project?: Project, + features?: { unifiedLogs?: boolean } +): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP const buildingUrl = `/project/${ref}` + const showUnifiedLogs = features?.unifiedLogs ?? false + return [ { key: 'advisors', @@ -149,6 +149,16 @@ export const generateOtherRoutes = (ref?: string, project?: Project): Route[] => icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/logs`), }, + ...(showUnifiedLogs + ? [ + { + key: 'unified-logs', + label: 'Unified Logs', + icon: , + link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/unified-logs`), + }, + ] + : []), { key: 'api', label: 'API Docs', diff --git a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx index 9e8e36faf38..63caa017d88 100644 --- a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx @@ -1,20 +1,16 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Download, MoreVertical, Trash } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { useParams } from 'common' -import DeleteProjectModal from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' +import { DeleteProjectModal } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import { useDownloadableBackupQuery } from 'data/database/backup-query' -import { - Button, - CriticalIcon, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from 'ui' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Button, CriticalIcon, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' import { useProjectContext } from './ProjectContext' const PauseFailedState = () => { @@ -22,6 +18,10 @@ const PauseFailedState = () => { const { project } = useProjectContext() const [visible, setVisible] = useState(false) + const canDeleteProject = useCheckPermissions(PermissionAction.UPDATE, 'projects', { + resource: { project_id: project?.id }, + }) + const { data } = useDownloadableBackupQuery({ projectRef: ref }) const backups = data?.backups ?? [] @@ -90,9 +90,18 @@ const PauseFailedState = () => {
diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 3c887f9ac36..4368aab294b 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -4,13 +4,8 @@ import { useRouter } from 'next/router' import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect, useState } from 'react' import { useParams } from 'common' -import { - useIsSQLEditorTabsEnabled, - useIsTableEditorTabsEnabled, -} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs' import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant' -import AISettingsModal from 'components/ui/AISettingsModal' import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel' import { Loading } from 'components/ui/Loading' import { ResourceExhaustionWarningBanner } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner' @@ -101,14 +96,8 @@ const ProjectLayout = forwardRef
- {productMenu} diff --git a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx index 6ba94a4833b..bf20923f157 100644 --- a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx @@ -1,20 +1,16 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Download, MoreVertical, Trash } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { useParams } from 'common' -import DeleteProjectModal from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' +import { DeleteProjectModal } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import { useDownloadableBackupQuery } from 'data/database/backup-query' -import { - Button, - CriticalIcon, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from 'ui' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Button, CriticalIcon, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' import { useProjectContext } from './ProjectContext' const RestoreFailedState = () => { @@ -22,6 +18,10 @@ const RestoreFailedState = () => { const { project } = useProjectContext() const [visible, setVisible] = useState(false) + const canDeleteProject = useCheckPermissions(PermissionAction.UPDATE, 'projects', { + resource: { project_id: project?.id }, + }) + const { data } = useDownloadableBackupQuery({ projectRef: ref }) const backups = data?.backups ?? [] @@ -90,9 +90,18 @@ const RestoreFailedState = () => {
diff --git a/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx b/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx index 6cbae550a21..f6a4517eb23 100644 --- a/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx @@ -1,16 +1,15 @@ import { useQueryClient } from '@tanstack/react-query' import { CheckCircle, Download, Loader } from 'lucide-react' import Link from 'next/link' -import { useEffect, useRef, useState } from 'react' +import { useState } from 'react' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useBackupDownloadMutation } from 'data/database/backup-download-mutation' import { useDownloadableBackupQuery } from 'data/database/backup-query' -import { projectKeys } from 'data/projects/keys' import { invalidateProjectDetailsQuery } from 'data/projects/project-detail-query' -import { getWithTimeout } from 'lib/common/fetch' -import { API_URL, PROJECT_STATUS } from 'lib/constants' +import { useProjectStatusQuery } from 'data/projects/project-status-query' +import { PROJECT_STATUS } from 'lib/constants' import { Button } from 'ui' import { useProjectContext } from './ProjectContext' @@ -18,7 +17,6 @@ const RestoringState = () => { const { ref } = useParams() const queryClient = useQueryClient() const { project } = useProjectContext() - const checkServerInterval = useRef() const [loading, setLoading] = useState(false) const [isCompleted, setIsCompleted] = useState(false) @@ -27,6 +25,23 @@ const RestoringState = () => { const backups = data?.backups ?? [] const logicalBackups = backups.filter((b) => !b.isPhysicalBackup) + useProjectStatusQuery( + { projectRef: ref }, + { + enabled: project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY, + refetchInterval: (res) => { + return res?.status === PROJECT_STATUS.ACTIVE_HEALTHY ? false : 4000 + }, + onSuccess: async (res) => { + if (res.status === PROJECT_STATUS.ACTIVE_HEALTHY) { + setIsCompleted(true) + } else { + if (ref) invalidateProjectDetailsQuery(queryClient, ref) + } + }, + } + ) + const { mutate: downloadBackup, isLoading: isDownloading } = useBackupDownloadMutation({ onSuccess: (res) => { const { fileUrl } = res @@ -47,23 +62,6 @@ const RestoringState = () => { downloadBackup({ ref, backup: logicalBackups[0] }) } - async function checkServer() { - if (!project) return - - const projectStatus = await getWithTimeout(`${API_URL}/projects/${project.ref}/status`, { - timeout: 2000, - }) - if (projectStatus && !projectStatus.error) { - const { status } = projectStatus - if (status === PROJECT_STATUS.ACTIVE_HEALTHY) { - clearInterval(checkServerInterval.current) - setIsCompleted(true) - } else { - queryClient.invalidateQueries(projectKeys.detail(ref)) - } - } - } - const onConfirm = async () => { if (!project) return console.error('Project is required') @@ -71,11 +69,6 @@ const RestoringState = () => { if (ref) await invalidateProjectDetailsQuery(queryClient, ref) } - useEffect(() => { - checkServerInterval.current = window.setInterval(checkServer, 4000) - return () => clearInterval(checkServerInterval.current) - }, []) - return (
diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx index 6f847292c24..f7f7138e956 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx @@ -64,6 +64,13 @@ export const generateSettingsMenu = ( items: [], label: 'NEW', }, + { + name: 'JWT Keys', + key: 'jwt', + url: `/project/${ref}/settings/jwt`, + items: [], + label: 'NEW', + }, ] : []), { diff --git a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx index 7e54b211189..aaea88ae059 100644 --- a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx +++ b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx @@ -18,6 +18,7 @@ import { useProfile } from 'lib/profile' import { Menu, cn } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { ReportMenuItem } from './ReportMenuItem' +import { useFlag } from 'hooks/ui/useFlag' const ReportsMenu = () => { const router = useRouter() @@ -25,6 +26,7 @@ const ReportsMenu = () => { const { ref, id } = useParams() const pageKey = (id || router.pathname.split('/')[4]) as string const storageEnabled = useIsFeatureEnabled('project_storage:all') + const authEnabled = useFlag('authreportv2') const canCreateCustomReport = useCheckPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'report', owner_id: profile?.id }, @@ -97,16 +99,15 @@ const ReportsMenu = () => { key: 'api-overview', url: `/project/${ref}/reports/api-overview`, }, - ...(storageEnabled + ...(authEnabled ? [ { - name: 'Storage', - key: 'storage', - url: `/project/${ref}/reports/storage`, + name: 'Auth', + key: 'auth', + url: `/project/${ref}/reports/auth`, }, ] : []), - { name: 'Database', key: 'database', @@ -117,6 +118,15 @@ const ReportsMenu = () => { key: 'functions', url: `/project/${ref}/reports/edge-functions`, }, + ...(storageEnabled + ? [ + { + name: 'Storage', + key: 'storage', + url: `/project/${ref}/reports/storage`, + }, + ] + : []), ], }, ] diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 4dd9bd7ba7c..55b8dee51f7 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -1,5 +1,9 @@ +import { Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react' +import { useRouter } from 'next/router' +import { useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' + import { LOCAL_STORAGE_KEYS, useParams } from 'common' -import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import DownloadSnippetModal from 'components/interfaces/SQLEditor/DownloadSnippetModal' import { MoveQueryModal } from 'components/interfaces/SQLEditor/MoveQueryModal' import RenameQueryModal from 'components/interfaces/SQLEditor/RenameQueryModal' @@ -19,10 +23,6 @@ import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' import uuidv4 from 'lib/uuid' -import { Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react' -import { useRouter } from 'next/router' -import { useEffect, useMemo, useState } from 'react' -import { toast } from 'sonner' import { SnippetWithContent, useSnippetFolders, @@ -59,7 +59,6 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const snapV2 = useSqlEditorV2StateSnapshot() const tabs = useTabsStateSnapshot() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const [sectionVisibility, setSectionVisibility] = useLocalStorage( LOCAL_STORAGE_KEYS.SQL_EDITOR_SECTION_STATE(projectRef ?? ''), @@ -293,12 +292,10 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({ onSuccess: (data) => { - if (isSQLEditorTabsEnabled) { - // Update Tabs state - currently unknown how to differentiate between sql and non-sql content - // so we're just deleting all tabs for with matching IDs - const tabIds = data.map((id) => createTabId('sql', { id })) - tabs.removeTabs(tabIds) - } + // Update Tabs state - currently unknown how to differentiate between sql and non-sql content + // so we're just deleting all tabs for with matching IDs + const tabIds = data.map((id) => createTabId('sql', { id })) + tabs.removeTabs(tabIds) }, onError: (error, data) => { if (error.message.includes('Contents not found')) { @@ -541,10 +538,10 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const sqlEditorTabsCleanup = useSqlEditorTabsCleanup() useEffect(() => { - if (isSuccess && isSQLEditorTabsEnabled) { + if (isSuccess) { sqlEditorTabsCleanup({ snippets: allSnippetsInView as any }) } - }, [allSnippetsInView, isSQLEditorTabsEnabled, isSuccess, sqlEditorTabsCleanup]) + }, [allSnippetsInView, isSuccess, sqlEditorTabsCleanup]) return ( <> @@ -572,15 +569,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { data={projectSnippetsTreeState} aria-label="project-level-snippets" nodeRenderer={({ element, ...props }) => { - const isOpened = - isSQLEditorTabsEnabled && - Object.values(tabs.tabsMap).some( - (tab) => tab.metadata?.sqlId === element.metadata?.id - ) + const isOpened = Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === element.metadata?.id + ) const tabId = createTabId('sql', { id: element?.metadata?.id as unknown as Snippet['id'], }) - const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId + const isPreview = tabs.previewTabId === tabId const isActive = !isPreview && element.metadata?.id === id const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id) @@ -656,15 +651,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { data={favoritesTreeState} aria-label="favorite-snippets" nodeRenderer={({ element, ...props }) => { - const isOpened = - isSQLEditorTabsEnabled && - Object.values(tabs.tabsMap).some( - (tab) => tab.metadata?.sqlId === element.metadata?.id - ) + const isOpened = Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === element.metadata?.id + ) const tabId = createTabId('sql', { id: element?.metadata?.id as unknown as Snippet['id'], }) - const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId + const isPreview = tabs.previewTabId === tabId const isActive = !isPreview && element.metadata?.id === id const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id) @@ -746,15 +739,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { }} expandedIds={expandedFolderIds} nodeRenderer={({ element, ...props }) => { - const isOpened = - isSQLEditorTabsEnabled && - Object.values(tabs.tabsMap).some( - (tab) => tab.metadata?.sqlId === element.metadata?.id - ) + const isOpened = Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === element.metadata?.id + ) const tabId = createTabId('sql', { id: element?.metadata?.id as unknown as Snippet['id'], }) - const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId + const isPreview = tabs.previewTabId === tabId const isActive = !isPreview && element.metadata?.id === id const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id) diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index 895eaee2bb2..3633fdf5b74 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -1,7 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { IS_PLATFORM } from 'common' import { useParams } from 'common/hooks/useParams' -import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useSQLSnippetFolderContentsQuery } from 'data/content/sql-folder-contents-query' import { Snippet } from 'data/content/sql-folders-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -83,7 +82,6 @@ export const SQLEditorTreeViewItem = ({ const { profile } = useProfile() const { className, onClick } = getNodeProps() const snapV2 = useSqlEditorV2StateSnapshot() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const isOwner = profile?.id === element?.metadata.owner_id const isSharedSnippet = element.metadata.visibility === 'project' @@ -262,13 +260,12 @@ export const SQLEditorTreeViewItem = ({ {}} onFocusCapture={(e) => e.stopPropagation()} > Open in new tab diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index d1e8269143b..62cea948bb6 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -10,7 +10,6 @@ import { MAX_EXPORT_ROW_COUNT_MESSAGE, } from 'components/grid/components/header/Header' import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' -import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { formatTableRowsToSQL, getEntityLintDetails, @@ -70,11 +69,9 @@ const EntityListItem: ItemRenderer = ({ const snap = useTableEditorStateSnapshot() const { selectedSchema } = useQuerySchemaState() - // For tabs preview flag logic - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const tabId = createTabId(entity.type, { id: entity.id }) const tabs = useTabsStateSnapshot() - const isPreview = isTableEditorTabsEnabled ? tabs.previewTabId === tabId : false + const isPreview = tabs.previewTabId === tabId const isOpened = Object.values(tabs.tabsMap).some((tab) => tab.metadata?.tableId === entity.id) const isActive = Number(id) === entity.id diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index a306730b114..84bd7f4f689 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -5,7 +5,6 @@ import { useEffect, useMemo, useState } from 'react' import { useParams } from 'common' import { useBreakpoint } from 'common/hooks/useBreakpoint' -import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { ProtectedSchemaModal } from 'components/interfaces/Database/ProtectedSchemaWarning' import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton' import AlertError from 'components/ui/AlertError' @@ -45,11 +44,10 @@ import EntityListItem from './EntityListItem' import { TableMenuEmptyState } from './TableMenuEmptyState' const TableEditorMenu = () => { - const { ref, id: _id } = useParams() + const { id: _id } = useParams() const id = _id ? Number(_id) : undefined const snap = useTableEditorStateSnapshot() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const isMobile = useBreakpoint() const [showModal, setShowModal] = useState(false) @@ -118,10 +116,10 @@ const TableEditorMenu = () => { const tableEditorTabsCleanUp = useTableEditorTabsCleanUp() useEffect(() => { // Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session - if (isTableEditorTabsEnabled && entityTypes && !searchText) { + if (entityTypes && !searchText) { tableEditorTabsCleanUp({ schemas: [selectedSchema], entities: entityTypes }) } - }, [entityTypes, isTableEditorTabsEnabled, searchText, selectedSchema, tableEditorTabsCleanUp]) + }, [entityTypes, searchText, selectedSchema, tableEditorTabsCleanUp]) return ( <> diff --git a/apps/studio/components/layouts/Tabs/NewTab.tsx b/apps/studio/components/layouts/Tabs/NewTab.tsx index ba199aab480..8afcfe3bd4d 100644 --- a/apps/studio/components/layouts/Tabs/NewTab.tsx +++ b/apps/studio/components/layouts/Tabs/NewTab.tsx @@ -6,7 +6,6 @@ import { useRouter } from 'next/router' import { toast } from 'sonner' import { useParams } from 'common' -import { TabsUpdateCallout } from 'components/interfaces/App/FeaturePreview/TableEditorTabs' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' @@ -114,7 +113,6 @@ export function NewTab() { return (
-
{actions.map((item, i) => ( diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index 66fb4b9b715..847a3c11d45 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -12,7 +12,6 @@ import { Plus, X } from 'lucide-react' import { useRouter } from 'next/router' import { useParams } from 'common' -import { TabsUpdateTooltip } from 'components/interfaces/App/FeaturePreview/TableEditorTabs' import { useAppStateSnapshot } from 'state/app-state' import { editorEntityTypes, useTabsStateSnapshot, type Tab } from 'state/tabs' import { @@ -173,43 +172,41 @@ export const EditorTabs = () => { {/* Non-draggable new tab */} {hasNewTab && ( - <> - + +
+ New +
+ { + e.preventDefault() + e.stopPropagation() + }} + className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer" + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onPointerDown={(e) => { + e.preventDefault() + e.stopPropagation() + handleClose('new') + }} > - -
- New -
- { - e.preventDefault() - e.stopPropagation() - }} - className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer" - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onPointerDown={(e) => { - e.preventDefault() - e.stopPropagation() - handleClose('new') - }} - > - - {' '} -
- - + + {' '} +
+ )} @@ -235,8 +232,6 @@ export const EditorTabs = () => {
- - diff --git a/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx b/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx new file mode 100644 index 00000000000..27ba57f0ce0 --- /dev/null +++ b/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren } from 'react' + +import ProjectLayout from '../ProjectLayout/ProjectLayout' + +const UnifiedLogsLayout = ({ children }: PropsWithChildren) => { + return {children} +} + +export default UnifiedLogsLayout diff --git a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx index eafb111c3b0..9f0da404d7f 100644 --- a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx +++ b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx @@ -2,10 +2,6 @@ import { usePathname } from 'next/navigation' import { ComponentProps, ReactNode } from 'react' import { useParams } from 'common' -import { - useIsSQLEditorTabsEnabled, - useIsTableEditorTabsEnabled, -} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useTabsStateSnapshot } from 'state/tabs' import { cn } from 'ui' import { ProjectLayoutWithAuth } from '../ProjectLayout/ProjectLayout' @@ -26,12 +22,6 @@ export const EditorBaseLayout = ({ children, title, product, ...props }: Explore const editor = useEditorType() const tabs = useTabsStateSnapshot() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() - - const tableEditorTabsEnabled = editor === 'table' && isTableEditorTabsEnabled - const sqlEditorTabsEnabled = editor === 'sql' && isSQLEditorTabsEnabled - const hasNoOpenTabs = editor === 'table' ? tabs.openTabs.filter((x) => !x.startsWith('sql')).length === 0 : false const hideTabs = @@ -40,16 +30,14 @@ export const EditorBaseLayout = ({ children, title, product, ...props }: Explore return (
- {tableEditorTabsEnabled || sqlEditorTabsEnabled ? ( -
- {hideTabs ? : } -
- ) : null} +
+ {hideTabs ? : } +
{children}
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..a268b1aaeff 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx @@ -63,38 +63,30 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => { const currentFolderIdx = openedFolders.length - 1 const currentFolder = openedFolders[currentFolderIdx] - if (itemSearchString) { - if (!currentFolder) { - // At root of bucket - await fetchFolderContents({ - folderId: bucket.id, - folderName: bucket.name, - index: -1, - searchString: itemSearchString, - }) - } else { - await fetchFolderContents({ - folderId: currentFolder.id, - folderName: currentFolder.name, - index: currentFolderIdx, - searchString: itemSearchString, - }) - } - } else { - if (!currentFolder) { - // At root of bucket - await fetchFolderContents({ folderId: bucket.id, folderName: bucket.name, index: -1 }) - } else { - await fetchFolderContents({ - folderId: currentFolder.id, - folderName: currentFolder.name, - index: currentFolderIdx, - }) - } - } + const folderId = !currentFolder ? bucket.id : currentFolder.id + const folderName = !currentFolder ? bucket.name : currentFolder.name + const index = !currentFolder ? -1 : currentFolderIdx + + await fetchFolderContents({ + bucketId: bucket.id, + folderId, + folderName, + index, + searchString: itemSearchString, + }) } 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, + searchString: itemSearchString, + }) + } } } 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/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 1ebea56eb35..640b511c37c 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -1,28 +1,24 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import type { Message as MessageType } from 'ai/react' import { useChat } from 'ai/react' import { AnimatePresence, motion } from 'framer-motion' -import { last } from 'lodash' import { ArrowDown, FileText, Info, RefreshCw, X } from 'lucide-react' import { useRouter } from 'next/router' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { toast } from 'sonner' +import { LOCAL_STORAGE_KEYS } from 'common' import { useParams, useSearchParamsShallow } from 'common/hooks' import { Markdown } from 'components/interfaces/Markdown' -import OptInToOpenAIToggle from 'components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query' import { constructHeaders } from 'data/fetchers' -import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' import { useTablesQuery } from 'data/tables/tables-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useOrgOptedIntoAiAndHippaProject } from 'hooks/misc/useOrgOptedIntoAi' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useFlag } from 'hooks/ui/useFlag' -import { BASE_PATH, IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' +import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import uuidv4 from 'lib/uuid' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' @@ -36,15 +32,16 @@ import { TooltipTrigger, } from 'ui' import { Admonition, AssistantChatForm, GenericSkeletonLoader } from 'ui-patterns' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { ButtonTooltip } from '../ButtonTooltip' -import DotGrid from '../DotGrid' +import { DotGrid } from '../DotGrid' +import { ErrorBoundary } from '../ErrorBoundary' +import { onErrorChat } from './AIAssistant.utils' import { AIAssistantChatSelector } from './AIAssistantChatSelector' -import AIOnboarding from './AIOnboarding' -import CollapsibleCodeBlock from './CollapsibleCodeBlock' +import { AIOnboarding } from './AIOnboarding' +import { AIOptInModal } from './AIOptInModal' +import { CollapsibleCodeBlock } from './CollapsibleCodeBlock' import { Message } from './Message' import { useAutoScroll } from './hooks' -import { ErrorBoundary } from '../ErrorBoundary' const MemoizedMessage = memo( ({ @@ -68,8 +65,7 @@ const MemoizedMessage = memo( { const router = useRouter() const project = useSelectedProject() - const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject() const selectedOrganization = useSelectedOrganization() const { ref, id: entityId } = useParams() const searchParams = useSearchParamsShallow() - const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM + const newOrgAiOptIn = useFlag('newOrgAiOptIn') const disablePrompts = useFlag('disableAssistantPrompts') + const useBedrockAssistant = useFlag('useBedrockAssistant') const { snippets } = useSqlEditorV2StateSnapshot() const snap = useAiAssistantStateSnapshot() + const [updatedOptInSinceMCP] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN, + false + ) + const inputRef = useRef(null) const { ref: scrollContainerRef, isSticky, scrollToEnd } = useAutoScroll() + const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel() + const showMetadataWarning = + IS_PLATFORM && + !!selectedOrganization && + ((!useBedrockAssistant && aiOptInLevel === 'disabled') || + (useBedrockAssistant && (aiOptInLevel === 'disabled' || aiOptInLevel === 'schema'))) + // Add a ref to store the last user message const lastUserMessageRef = useRef(null) @@ -129,11 +137,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const { mutate: sendEvent } = useSendEventMutation() - const handleError = useCallback((error: Error) => { - const errorMessage = JSON.parse(error.message).message - toast.error(errorMessage) - }, []) - // Handle completion of the assistant's response const handleChatFinish = useCallback((message: MessageType) => { // If we have a user message stored in the ref, save both messages @@ -156,25 +159,43 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { setMessages, } = useChat({ id: snap.activeChatId, - api: `${BASE_PATH}/api/ai/sql/generate-v3`, + api: useBedrockAssistant + ? `${BASE_PATH}/api/ai/sql/generate-v4` + : `${BASE_PATH}/api/ai/sql/generate-v3`, maxSteps: 5, // [Alaister] typecast is needed here because valtio returns readonly arrays // and useChat expects a mutable array initialMessages: snap.activeChat?.messages as unknown as MessageType[] | undefined, - body: { - includeSchemaMetadata, - projectRef: project?.ref, - connectionString: project?.connectionString, - schema: currentSchema, - table: currentTable?.name, + experimental_prepareRequestBody: ({ messages }) => { + // [Joshen] Specifically limiting the chat history that get's sent to reduce the + // size of the context that goes into the model. This should always be an odd number + // as much as possible so that the first message is always the user's + const MAX_CHAT_HISTORY = 5 + + return JSON.stringify({ + messages: messages.slice(-MAX_CHAT_HISTORY), + aiOptInLevel, + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: currentSchema, + table: currentTable?.name, + includeSchemaMetadata: !useBedrockAssistant + ? !IS_PLATFORM || aiOptInLevel !== 'disabled' + : undefined, + }) }, - onError: handleError, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = await constructHeaders() + const existingHeaders = new Headers(init?.headers) + for (const [key, value] of headers.entries()) { + existingHeaders.set(key, value) + } + return fetch(input, { ...init, headers: existingHeaders }) + }, + onError: onErrorChat, onFinish: handleChatFinish, }) - const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') - const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() - const updateMessage = useCallback( ({ messageId, @@ -207,24 +228,14 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const hasMessages = chatMessages.length > 0 - const sendMessageToAssistant = async (content: string) => { - const payload = { role: 'user', createdAt: new Date(), content } as MessageType - const headerData = await constructHeaders() + const sendMessageToAssistant = (content: string) => { + const payload = { role: 'user', createdAt: new Date(), content, id: uuidv4() } as MessageType snap.clearSqlSnippets() // Store the user message in the ref before appending lastUserMessageRef.current = payload - const authorizationHeader = headerData.get('Authorization') - - append( - payload, - authorizationHeader - ? { - headers: { Authorization: authorizationHeader }, - } - : undefined - ) + append(payload) setValue('') @@ -247,30 +258,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { } } - const confirmOptInToShareSchemaData = async () => { - if (!canUpdateOrganization) { - return toast.error('You do not have the required permissions to update this organization') - } - - if (!selectedOrganization?.slug) return console.error('Organization slug is required') - - const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] - - const updatedOptInTags = existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) - ? existingOptInTags - : [...existingOptInTags, OPT_IN_TAGS.AI_SQL] - - updateOrganization( - { slug: selectedOrganization?.slug, opt_in_tags: updatedOptInTags }, - { - onSuccess: () => { - toast.success('Successfully opted-in') - setIsConfirmOptInModalOpen(false) - }, - } - ) - } - const handleClearMessages = () => { snap.clearMessages() setMessages([]) @@ -336,9 +323,14 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { The Assistant is in Alpha and your prompts might be rate limited.{' '} - {includeSchemaMetadata - ? 'Project metadata is being shared to improve Assistant responses.' - : 'Project metadata is not being shared. Opt in to improve Assistant responses.'} + {aiOptInLevel === 'schema_and_log_and_data' && + 'Schema, logs, and query data are being shared to improve Assistant responses.'} + {aiOptInLevel === 'schema_and_log' && + 'Schema and logs are being shared to improve Assistant responses.'} + {aiOptInLevel === 'schema' && + 'Only schema metadata is being shared to improve Assistant responses.'} + {aiOptInLevel === 'disabled' && + 'Project metadata is not being shared. Opt in to improve Assistant responses.'}
@@ -375,16 +367,30 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
- {!includeSchemaMetadata && selectedOrganization && ( + {showMetadataWarning && ( {!isHipaaProjectDisallowed && ( + + + + + + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx b/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx index 0a60ef1055f..92e83becffb 100644 --- a/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx @@ -1,12 +1,13 @@ import { ChevronDown, ChevronUp, X } from 'lucide-react' import { useState } from 'react' + import { Button, CodeBlock, CodeBlockProps, cn } from 'ui' interface CollapsibleCodeBlockProps extends CodeBlockProps { onRemove?: () => void } -const CollapsibleCodeBlock = ({ onRemove, ...props }: CollapsibleCodeBlockProps) => { +export const CollapsibleCodeBlock = ({ onRemove, ...props }: CollapsibleCodeBlockProps) => { const [isExpanded, setIsExpanded] = useState(false) const codeString = (props.value || props.children) as string @@ -57,5 +58,3 @@ const CollapsibleCodeBlock = ({ onRemove, ...props }: CollapsibleCodeBlockProps)
) } - -export default CollapsibleCodeBlock diff --git a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx new file mode 100644 index 00000000000..8d1a94ec670 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx @@ -0,0 +1,138 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Message } from 'ai/react' +import { useRouter } from 'next/router' +import { DragEvent, PropsWithChildren, useMemo, useState } from 'react' + +import { useParams } from 'common' +import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useProfile } from 'lib/profile' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { Badge } from 'ui' +import { DEFAULT_CHART_CONFIG, QueryBlock } from '../QueryBlock/QueryBlock' +import { identifyQueryType } from './AIAssistant.utils' +import { findResultForManualId } from './Message.utils' + +interface DisplayBlockRendererProps { + messageId: string + toolCallId: string + manualId?: string + initialArgs: { + sql: string + label?: string + view?: 'table' | 'chart' + xAxis?: string + yAxis?: string + runQuery?: boolean + } + messageParts: Readonly | undefined + isLoading: boolean + onResults: (args: { messageId: string; resultId?: string; results: any[] }) => void +} + +export const DisplayBlockRenderer = ({ + messageId, + toolCallId, + manualId, + initialArgs, + messageParts, + isLoading, + onResults, +}: PropsWithChildren) => { + const router = useRouter() + const { ref } = useParams() + const { profile } = useProfile() + const org = useSelectedOrganization() + const snap = useAiAssistantStateSnapshot() + + const { mutate: sendEvent } = useSendEventMutation() + const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, + }) + + const [chartConfig, setChartConfig] = useState(() => ({ + ...DEFAULT_CHART_CONFIG, + view: initialArgs.view === 'chart' ? 'chart' : 'table', + xKey: initialArgs.xAxis ?? '', + yKey: initialArgs.yAxis ?? '', + })) + + const isChart = initialArgs.view === 'chart' + const resultId = manualId || toolCallId + const liveResultData = useMemo( + () => (manualId ? findResultForManualId(messageParts, manualId) : undefined), + [messageParts, manualId] + ) + const cachedResults = useMemo( + () => snap.getCachedSQLResults({ messageId, snippetId: resultId }), + [snap, messageId, resultId] + ) + const displayData = liveResultData ?? cachedResults + const isDraggableToReports = canCreateSQLSnippet && router.pathname.endsWith('/reports/[id]') + const label = initialArgs.label || 'SQL Results' + const sqlQuery = initialArgs.sql + + const handleRunQuery = (queryType: 'select' | 'mutation') => { + sendEvent({ + action: 'assistant_suggestion_run_query_clicked', + properties: { + queryType, + ...(queryType === 'mutation' ? { category: identifyQueryType(sqlQuery) ?? 'unknown' } : {}), + }, + groups: { + project: ref ?? 'Unknown', + organization: org?.slug ?? 'Unknown', + }, + }) + } + + const handleUpdateChartConfig = ({ + chartConfig: updatedValues, + }: { + chartConfig: Partial + }) => { + setChartConfig((prev) => ({ ...prev, ...updatedValues })) + } + + const handleDragStart = (e: DragEvent) => { + e.dataTransfer.setData( + 'application/json', + JSON.stringify({ label, sql: sqlQuery, config: chartConfig }) + ) + } + + return ( +
+ + + NEW + +

Drag to add this chart into your custom report

+
+ ) : undefined + } + onResults={(results) => onResults({ messageId, resultId, results })} + onRunQuery={handleRunQuery} + onUpdateChartConfig={handleUpdateChartConfig} + onDragStart={handleDragStart} + /> +
+ ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx index 8398c86c170..771626f60ea 100644 --- a/apps/studio/components/ui/AIAssistantPanel/Message.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx @@ -1,11 +1,21 @@ +import { Message as VercelMessage } from 'ai/react' import { User } from 'lucide-react' -import { createContext, PropsWithChildren, useMemo } from 'react' +import { createContext, PropsWithChildren, ReactNode, useMemo } from 'react' import ReactMarkdown from 'react-markdown' import { Components } from 'react-markdown/lib/ast-to-react' import remarkGfm from 'remark-gfm' -import { AiIconAnimation, cn, markdownComponents, WarningIcon } from 'ui' -import { Heading3, InlineCode, Link, ListItem, MarkdownPre, OrderedList } from './MessageMarkdown' +import { cn, markdownComponents, WarningIcon } from 'ui' +import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock' +import { DisplayBlockRenderer } from './DisplayBlockRenderer' +import { + Heading3, + Hyperlink, + InlineCode, + ListItem, + MarkdownPre, + OrderedList, +} from './MessageMarkdown' interface MessageContextType { isLoading: boolean @@ -18,16 +28,16 @@ const baseMarkdownComponents: Partial = { li: ListItem, h3: Heading3, code: InlineCode, - a: Link, + a: Hyperlink, + img: ({ src }) => [Image: {src}], } interface MessageProps { id: string - role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool' - content?: string + message: VercelMessage isLoading: boolean readOnly?: boolean - action?: React.ReactNode + action?: ReactNode variant?: 'default' | 'warning' onResults: ({ messageId, @@ -42,15 +52,13 @@ interface MessageProps { export const Message = function Message({ id, - role, - content, + message, isLoading, readOnly, action = null, variant = 'default', onResults, }: PropsWithChildren) { - const isUser = role === 'user' const allMarkdownComponents: Partial = useMemo( () => ({ ...markdownComponents, @@ -61,16 +69,26 @@ export const Message = function Message({ ), }), - [] + [id, onResults] ) - if (!content) return null + if (!message) { + console.error(`Message component received undefined message prop for id: ${id}`) + return null + } + + const { role, content, parts } = message + const isUser = role === 'user' + + const shouldUsePartsRendering = parts && parts.length > 0 + + const hasTextContent = content && content.trim().length > 0 return (
- {isUser ? ( + {isUser && (
- ) : ( - )} - - {content} - + +
+ {shouldUsePartsRendering ? ( + (() => { + const shownLoadingTools = new Set() + return parts.map( + (part: NonNullable[number], index: number) => { + switch (part.type) { + case 'text': + return ( + li]:pl-4 [&_ol>li]:my-0 [&_li>p]:mt-0 space-y-5 [&>*>code]:text-xs [&>*>*>code]:text-xs [&_li]:space-y-4', + isUser && 'text-foreground font-semibold' + )} + remarkPlugins={[remarkGfm]} + components={allMarkdownComponents} + > + {part.text} + + ) + + case 'tool-invocation': { + const { toolCallId, toolName, args, state } = part.toolInvocation + if (state === 'call' || state === 'partial-call') { + if (shownLoadingTools.has(toolName)) { + // Already shown loading for this toolName in this step + return null + } + shownLoadingTools.add(toolName) + return ( +
+ {`Calling ${toolName}...`} +
+ ) + } + // Only render the result UI for known tools when state is 'result' + switch (toolName) { + case 'display_query': { + return ( + + ) + } + case 'display_edge_function': { + return ( +
+ +
+ ) + } + default: + // For unknown tools, just show nothing for result + return null + } + } + case 'reasoning': + case 'source': + case 'file': + return null + default: + return null + } + } + ) + })() + ) : hasTextContent ? ( + + {content} + + ) : ( + Assistant is thinking... + )} +
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts new file mode 100644 index 00000000000..d8e94c8f983 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts @@ -0,0 +1,78 @@ +import { Message } from 'ai/react' + +type MessagePart = NonNullable[number] + +const extractDataFromSafetyMessage = (text: string): string | null => { + const openingTags = [...text.matchAll(//gi)] + if (openingTags.length < 2) return null + + const closingTagMatch = text.match(/<\/untrusted-data-[a-z0-9-]+>/i) + if (!closingTagMatch) return null + + const secondOpeningEnd = openingTags[1].index! + openingTags[1][0].length + const closingStart = text.indexOf(closingTagMatch[0]) + const content = text.substring(secondOpeningEnd, closingStart) + + return content.replace(/\\n/g, '').replace(/\\"/g, '"').replace(/\n/g, '').trim() +} + +// Helper function to find result data directly from parts array +export const findResultForManualId = ( + parts: Readonly | undefined, + manualId: string +): any[] | undefined => { + if (!parts) return undefined + + const invocationPart = parts.find( + (part: MessagePart) => + part.type === 'tool-invocation' && + 'toolInvocation' in part && + part.toolInvocation.state === 'result' && + 'result' in part.toolInvocation && + part.toolInvocation.result?.manualToolCallId === manualId + ) + + if ( + invocationPart && + 'toolInvocation' in invocationPart && + 'result' in invocationPart.toolInvocation && + invocationPart.toolInvocation.result?.content?.[0]?.text + ) { + try { + const rawText = invocationPart.toolInvocation.result.content[0].text + + const extractedData = extractDataFromSafetyMessage(rawText) || rawText + + let parsedData = JSON.parse(extractedData.trim()) + return Array.isArray(parsedData) ? parsedData : undefined + } catch (error) { + console.error('Failed to parse tool invocation result data for manualId:', manualId, error) + return undefined + } + } + return undefined +} + +// [Joshen] From https://github.com/remarkjs/react-markdown/blob/fda7fa560bec901a6103e195f9b1979dab543b17/lib/index.js#L425 +export function defaultUrlTransform(value: string) { + const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i + const colon = value.indexOf(':') + const questionMark = value.indexOf('?') + const numberSign = value.indexOf('#') + const slash = value.indexOf('/') + + if ( + // If there is no protocol, it’s relative. + colon === -1 || + // If the first colon is after a `?`, `#`, or `/`, it’s not a protocol. + (slash !== -1 && colon > slash) || + (questionMark !== -1 && colon > questionMark) || + (numberSign !== -1 && colon > numberSign) || + // It is a protocol, it should be allowed. + safeProtocol.test(value.slice(0, colon)) + ) { + return value + } + + return '' +} diff --git a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx index 9b482072b50..8575b46fd3a 100644 --- a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx @@ -1,3 +1,4 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useRouter } from 'next/router' import { DragEvent, @@ -10,23 +11,38 @@ import { useRef, } from 'react' -import { PermissionAction } from '@supabase/shared-types/out/constants' import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' +import Link from 'next/link' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { Dashboards } from 'types' -import { Badge, cn, CodeBlock, CodeBlockLang } from 'ui' +import { + Badge, + Button, + cn, + CodeBlock, + CodeBlockLang, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogTitle, + DialogTrigger, +} from 'ui' import { DebouncedComponent } from '../DebouncedComponent' import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock' import { QueryBlock } from '../QueryBlock/QueryBlock' import { AssistantSnippetProps } from './AIAssistant.types' import { identifyQueryType } from './AIAssistant.utils' -import CollapsibleCodeBlock from './CollapsibleCodeBlock' +import { CollapsibleCodeBlock } from './CollapsibleCodeBlock' import { MessageContext } from './Message' +import { defaultUrlTransform } from './Message.utils' export const OrderedList = memo(({ children }: { children: ReactNode }) => (
    {children}
@@ -50,17 +66,63 @@ export const InlineCode = memo( ) InlineCode.displayName = 'InlineCode' -export const Link = memo(({ href, children }: { href?: string; children: ReactNode }) => ( - - {children} - -)) -Link.displayName = 'Link' +export const Hyperlink = memo(({ href, children }: { href?: string; children: ReactNode }) => { + const isExternalURL = !href?.startsWith('https://supabase.com/dashboard') + const safeUrl = defaultUrlTransform(href ?? '') + const isSafeUrl = safeUrl.length > 0 + + if (!isSafeUrl) { + return {children} + } + + return ( + + + + {children} + + + + + Verify the link before navigating + + + +

+ This link will take you to the following URL: +

+

{safeUrl}

+

Are you sure you want to head there?

+
+ + + + + + + + + +
+
+ ) +}) +Hyperlink.displayName = 'Hyperlink' const MemoizedQueryBlock = memo( ({ @@ -243,7 +305,7 @@ export const MarkdownPre = ({ } return ( -
+
{language === 'edge' ? ( { - const snap = useAppStateSnapshot() - const selectedOrganization = useSelectedOrganization() - const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject() - const selectedProject = useSelectedProject() - - const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(selectedProject?.ref!) - - const includeSchemaMetadata = (isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM - - return ( - snap.setShowAiSettingsModal(false)} - > - -
-

Schemas metadata to be shared with OpenAI

- 0 - ? `${selectedSchemas.length} schema${ - selectedSchemas.length > 1 ? 's' : '' - } selected` - : 'No schemas selected' - } - disabled={(IS_PLATFORM && !isOptedInToAI) || isHipaaProjectDisallowed} - selectedSchemas={selectedSchemas} - onSelectSchemas={setSelectedSchemas} - /> -

- Metadata includes table names, column names and their corresponding data types in the - request. This will generate queries that are more relevant to your project. -

-
- {IS_PLATFORM && (!isOptedInToAI || isHipaaProjectDisallowed) && selectedOrganization && ( - - - - {isHipaaProjectDisallowed - ? 'Sending data to OpenAI is disabled for HIPAA projects' - : 'Your organization does not allow sending anonymous data to OpenAI'} - - - This option is only available if your organization has opted-in to sending anonymous - data to OpenAI and non-HIPAA projects. You may configure your opt-in preferences - through your organization's settings. - - - - - - )} -
-
- ) -} - -export default AISettingsModal diff --git a/apps/studio/components/ui/ActionCard.tsx b/apps/studio/components/ui/ActionCard.tsx index 5f600070348..549e51a2ad7 100644 --- a/apps/studio/components/ui/ActionCard.tsx +++ b/apps/studio/components/ui/ActionCard.tsx @@ -1,10 +1,11 @@ +import { JSX, ReactNode } from 'react' import { Badge, Card, cn } from 'ui' export const ActionCard = (card: { icon: JSX.Element title: string bgColor?: string - description?: string + description?: string | ReactNode isBeta?: boolean className?: string onClick?: () => void @@ -28,9 +29,13 @@ export const ActionCard = (card: { > {card.icon}
-
+

{card.title}

-
{card.description}
+ {typeof card.description === 'string' ? ( +
{card.description}
+ ) : ( + card.description + )}
diff --git a/apps/studio/components/ui/Charts/ChartHeader.tsx b/apps/studio/components/ui/Charts/ChartHeader.tsx index 8b2fceca792..e15ee1d9eed 100644 --- a/apps/studio/components/ui/Charts/ChartHeader.tsx +++ b/apps/studio/components/ui/Charts/ChartHeader.tsx @@ -39,13 +39,20 @@ const ChartHeader = ({ {title} {docsUrl && ( - - - + + + + + + + + Read docs + + )}
) diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index 8a57a190b78..a1d501fd655 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -29,8 +29,8 @@ import { import { CommonChartProps, Datum } from './Charts.types' import { numberFormatter, useChartSize } from './Charts.utils' import { calculateTotalChartAggregate, CustomLabel, CustomTooltip } from './ComposedChart.utils' -import { MultiAttribute } from './ComposedChartHandler' import NoDataPlaceholder from './NoDataPlaceholder' +import { MultiAttribute } from './ComposedChart.utils' import { ChartHighlight } from './useChartHighlight' import { formatBytes } from 'lib/helpers' @@ -54,6 +54,7 @@ export interface ComposedChartProps extends CommonChartProps { chartStyle?: string onChartStyleChange?: (style: string) => void updateDateRange: any + titleTooltip?: string hideYAxis?: boolean hideHighlightedValue?: boolean syncId?: string @@ -110,6 +111,22 @@ export default function ComposedChart({ const { Container } = useChartSize(size) + const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value)) + + const formatTimestamp = (ts: unknown) => { + if (typeof ts !== 'number' && typeof ts !== 'string') { + return '' + } + + // Timestamps from auth logs can be in microseconds + if (typeof ts === 'number' && ts > 1e14) { + return day(ts / 1000).format(customDateFormat) + } + + // dayjs can handle ISO strings and millisecond numbers + return day(ts).format(customDateFormat) + } + // Default props const _XAxisProps = XAxisProps || { interval: data.length - 2, @@ -123,18 +140,19 @@ export default function ComposedChart({ width: 0, } - const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value)) - function getHeaderLabel() { if (!xAxisIsDate) { if (!focusDataIndex) return highlightedLabel - return data[focusDataIndex]?.timestamp + return data[focusDataIndex]?.[xAxisKey] } return ( (focusDataIndex !== null && data && data[focusDataIndex] !== undefined && - day(data[focusDataIndex].timestamp).format(customDateFormat)) || + (() => { + const ts = data[focusDataIndex][xAxisKey] + return formatTimestamp(ts) + })()) || highlightedLabel ) } @@ -145,13 +163,45 @@ export default function ComposedChart({ color: CHART_COLORS.REFERENCE_LINE, } + const chartData = + data && !!data[0] + ? Object.entries(data[0]) + ?.map(([key, value]) => ({ + name: key, + value: value, + })) + .filter( + (att) => + att.name !== 'timestamp' && + att.name !== 'period_start' && + att.name !== maxAttribute?.attribute && + attributes.some((attr) => attr.attribute === att.name && attr.enabled !== false) + ) + .map((att, index) => { + const attribute = attributes.find((attr) => attr.attribute === att.name) + return { + ...att, + color: attribute?.color + ? resolvedTheme?.includes('dark') + ? attribute.color.dark + : attribute.color.light + : STACKED_CHART_COLORS[index % STACKED_CHART_COLORS.length], + } + }) + : [] + const lastDataPoint = !!data[data.length - 1] ? Object.entries(data[data.length - 1]) .map(([key, value]) => ({ dataKey: key, value: value as number, })) - .filter((entry) => entry.dataKey !== 'timestamp') + .filter( + (entry) => + entry.dataKey !== 'timestamp' && + entry.dataKey !== 'period_start' && + attributes.some((attr) => attr.attribute === entry.dataKey && attr.enabled !== false) + ) : undefined const referenceLines = attributes.filter((attribute) => attribute?.provider === 'reference-line') @@ -180,22 +230,6 @@ export default function ComposedChart({ chartHighlight?.coordinates.right && chartHighlight?.coordinates.left !== chartHighlight?.coordinates.right - const chartData = - data && !!data[0] - ? Object.entries(data[0]) - ?.map(([key, value], index) => ({ - name: key, - value: value, - color: STACKED_CHART_COLORS[index - (1 % STACKED_CHART_COLORS.length)], - })) - .filter( - (att) => - att.name !== 'timestamp' && - att.name !== maxAttribute?.attribute && - !referenceLines.map((a) => a.attribute).includes(att.name) - ) - : [] - const stackedAttributes = chartData.filter((att) => !att.name.includes('max')) const isPercentage = format === '%' const isRamChart = chartData?.some((att: any) => att.name.toLowerCase().includes('ram_')) @@ -384,6 +418,7 @@ export default function ComposedChart({ y={line.value} strokeWidth={1} {...line} + color={line.color?.dark} strokeDasharray={line.strokeDasharray ?? '3 3'} label={undefined} > @@ -415,13 +450,11 @@ export default function ComposedChart({ className="text-foreground-lighter -mt-9 flex items-center justify-between text-xs" style={{ marginLeft: YAxisProps?.width }} > - - {xAxisIsDate ? day(data[0]?.timestamp).format(customDateFormat) : data[0]?.timestamp} - + {xAxisIsDate ? formatTimestamp(data[0]?.[xAxisKey]) : data[0]?.[xAxisKey]} {xAxisIsDate - ? day(data[data?.length - 1]?.timestamp).format(customDateFormat) - : data[data?.length - 1]?.timestamp} + ? formatTimestamp(data[data.length - 1]?.[xAxisKey]) + : data[data.length - 1]?.[xAxisKey]}
)} diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx index 16578ee99da..831f4b6153a 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx @@ -5,9 +5,75 @@ import { useState } from 'react' import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { CHART_COLORS, DateTimeFormats } from './Charts.constants' import { numberFormatter } from './Charts.utils' -import { MultiAttribute } from './ComposedChartHandler' import { formatBytes } from 'lib/helpers' +export interface ReportAttributes { + id?: string + label: string + attributes?: (MultiAttribute | false)[] + defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine' + hide?: boolean + hideChartType?: boolean + format?: string + className?: string + showTooltip?: boolean + showLegend?: boolean + showTotal?: boolean + showMaxValue?: boolean + valuePrecision?: number + docsUrl?: string + syncId?: string + showGrid?: boolean + YAxisProps?: { + width?: number + tickFormatter?: (value: any) => string + } + hideHighlightedValue?: boolean +} + +export type Provider = 'infra-monitoring' | 'daily-stats' | 'mock' | 'reference-line' | 'logs' + +export type MultiAttribute = { + attribute: string + provider: Provider + label?: string + color?: { + light: string + dark: string + } + statusCode?: string + grantType?: string + providerType?: string + stackId?: string + format?: string + description?: string + docsLink?: string + isMaxValue?: boolean + type?: 'line' | 'area-bar' + omitFromTotal?: boolean + tooltip?: string + customValue?: number + /** + * Manipulate the value of the attribute before it is displayed on the chart. + * @param value - The value of the attribute. + * @returns The manipulated value. + */ + manipulateValue?: (value: number) => number + /** + * Create a virtual attribute by combining values from other attributes. + * Expression should use attribute names and basic math operators (+, -, *, /). + * Example: 'disk_fs_used - pg_database_size - disk_fs_used_wal' + */ + combine?: string + id?: string + value?: number + isReferenceLine?: boolean + strokeDasharray?: string + className?: string + hide?: boolean + enabled?: boolean +} + interface CustomIconProps { color: string } @@ -78,9 +144,7 @@ const CustomTooltip = ({ const isDiskSpaceChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('disk_space_') ) - const isDBSizeChart = payload?.some((p: any) => - p.dataKey.toLowerCase().includes('pg_database_size') - ) + const isDBSizeChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('disk_fs_')) const isNetworkChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('network_')) const shouldFormatBytes = isRamChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart @@ -110,7 +174,7 @@ const CustomTooltip = ({ return (
{getIcon(entry.color, isMax)} - + {attribute?.label || entry.name} @@ -120,7 +184,7 @@ const CustomTooltip = ({ {isPercentage ? '%' : ''} {/* Show percentage if max value is set */} - {!!maxValueData && !isMax && ( + {!!maxValueData && !isMax && !isPercentage && ( ({percentage}%) )} @@ -137,9 +201,12 @@ const CustomTooltip = ({ >

{dayjs(timestamp).format(DateTimeFormats.FULL_SECONDS)}

- {payload.reverse().map((entry: any, index: number) => ( - - ))} + {payload + .reverse() + .filter((entry: any) => entry.value !== 0) + .map((entry: any, index: number) => ( + + ))} {active && showTotal && (
Total @@ -154,6 +221,7 @@ const CustomTooltip = ({ {isPercentage ? '%' : ''} {maxValueAttribute && + !isPercentage && !isNaN((total as number) / maxValueData?.value) && isFinite((total as number) / maxValueData?.value) && ( @@ -212,7 +280,7 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom {getIcon(entry.name, entry.color)} diff --git a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx index 1bcbc388043..448f56de645 100644 --- a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx +++ b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx @@ -1,5 +1,11 @@ -import React, { PropsWithChildren, useState, useMemo, useEffect, useRef } from 'react' -import { useRouter } from 'next/router' +/** + * ComposedChartHandler + * + * A presentational component for rendering charts. + * It is responsible only for rendering the chart UI based on the data and loading state passed to it as props. + * All the complex data fetching logic has been moved to the useChartData hook. + */ +import React, { PropsWithChildren, useState, useEffect, useRef } from 'react' import { Loader2 } from 'lucide-react' import { cn, WarningIcon } from 'ui' @@ -11,34 +17,12 @@ import { InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query' import { useInfraMonitoringQueries } from 'data/analytics/infra-monitoring-queries' import { ProjectDailyStatsAttribute } from 'data/analytics/project-daily-stats-query' import { useProjectDailyStatsQueries } from 'data/analytics/project-daily-stats-queries' -import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { useChartHighlight } from './useChartHighlight' +import { getMockDataForAttribute } from 'data/reports/auth-charts' import type { ChartData } from './Charts.types' import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' - -type Provider = 'infra-monitoring' | 'daily-stats' | 'reference-line' - -export type MultiAttribute = { - attribute: string - provider: Provider - label?: string - color?: string - stackId?: string - format?: 'percent' | 'number' - description?: string - docsLink?: string - isMaxValue?: boolean - type?: 'line' | 'area-bar' - omitFromTotal?: boolean - tooltip?: string - customValue?: number - id?: string - value?: number - isReferenceLine?: boolean - strokeDasharray?: string - className?: string -} +import type { MultiAttribute } from './ComposedChart.utils' interface ComposedChartHandlerProps { id?: string @@ -62,6 +46,7 @@ interface ComposedChartHandlerProps { updateDateRange?: UpdateDateRange valuePrecision?: number isVisible?: boolean + titleTooltip?: string docsUrl?: string } @@ -113,9 +98,6 @@ const LazyChartWrapper = ({ children }: PropsWithChildren) => { const ComposedChartHandler = ({ label, attributes, - startDate, - endDate, - interval, customDateFormat, children = null, defaultChartStyle = 'bar', @@ -131,121 +113,14 @@ const ComposedChartHandler = ({ showTotal, updateDateRange, valuePrecision, - isVisible = true, + titleTooltip, id, ...otherProps }: PropsWithChildren) => { - const router = useRouter() - const { ref } = router.query - - const state = useDatabaseSelectorStateSnapshot() const [chartStyle, setChartStyle] = useState(defaultChartStyle) const chartHighlight = useChartHighlight() - const databaseIdentifier = state.selectedDatabaseId - - // Use the custom hook at the top level of the component - const attributeQueries = useAttributeQueries( - attributes, - ref, - startDate, - endDate, - interval as AnalyticsInterval, - databaseIdentifier, - data, - isVisible - ) - - // Combine all the data into a single dataset - const combinedData = useMemo(() => { - if (data) return data - - const isLoading = attributeQueries.some((query: any) => query.isLoading) - if (isLoading) return undefined - - const hasError = attributeQueries.some((query: any) => !query.data) - if (hasError) return undefined - - // Get all unique timestamps from all datasets - const timestamps = new Set() - attributeQueries.forEach((query: any) => { - query.data?.data?.forEach((point: any) => { - if (point?.period_start) { - timestamps.add(point.period_start) - } - }) - }) - - const referenceLineQueries = attributeQueries.filter( - (_, index) => attributes[index].provider === 'reference-line' - ) - - // Combine data points for each timestamp - const combined = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { timestamp } - - // Add regular attributes - attributes.forEach((attr, index) => { - if (!attr) return - // Handle custom value attributes (like disk size) - if (attr.customValue !== undefined) { - point[attr.attribute] = attr.customValue - return - } - - // Skip reference line attributes here, we'll add them below - if (attr.provider === 'reference-line') return - - const queryData = attributeQueries[index]?.data?.data - const matchingPoint = queryData?.find((p: any) => p.period_start === timestamp) - point[attr.attribute] = matchingPoint?.[attr.attribute] ?? 0 - }) - - // Add reference line values for each timestamp - referenceLineQueries.forEach((query: any) => { - const attr = query.data.attribute - const value = query.data.total - point[attr] = value - }) - - return point as DataPoint - }) - - return combined as DataPoint[] - }, [data, attributeQueries, attributes]) - - const loading = isLoading || attributeQueries.some((query: any) => query.isLoading) - - // Calculate highlighted value based on the first attribute's data - const _highlightedValue = useMemo(() => { - if (highlightedValue !== undefined) return highlightedValue - - const firstAttr = attributes[0] - const firstQuery = attributeQueries[0] - const firstData = firstQuery?.data - - if (!firstData) return undefined - - const shouldHighlightMaxValue = - firstAttr.provider === 'daily-stats' && - !firstAttr.attribute.includes('ingress') && - !firstAttr.attribute.includes('egress') && - 'maximum' in firstData - - const shouldHighlightTotalGroupedValue = 'totalGrouped' in firstData - - return shouldHighlightMaxValue - ? firstData.maximum - : firstAttr.provider === 'daily-stats' - ? firstData.total - : shouldHighlightTotalGroupedValue - ? firstData.totalGrouped?.[firstAttr.attribute as keyof typeof firstData.totalGrouped] - : (firstData.data[firstData.data.length - 1] as any)?.[firstAttr.attribute] - }, [highlightedValue, attributes, attributeQueries]) - - if (loading) { + if (isLoading) { return ( @@ -284,11 +159,11 @@ const ComposedChartHandler = ({
{children}
@@ -308,7 +184,7 @@ const ComposedChartHandler = ({ ) } -const useAttributeQueries = ( +export const useAttributeQueries = ( attributes: MultiAttribute[], ref: string | string[] | undefined, startDate: string, @@ -318,16 +194,15 @@ const useAttributeQueries = ( data: ChartData | undefined, isVisible: boolean ) => { - const infraAttributes = attributes - .filter((attr) => attr?.provider === 'infra-monitoring') - .map((attr) => attr.attribute as InfraMonitoringAttribute) - const dailyStatsAttributes = attributes - .filter((attr) => attr?.provider === 'daily-stats') - .map((attr) => attr.attribute as ProjectDailyStatsAttribute) - const referenceLines = attributes.filter((attr) => attr?.provider === 'reference-line') + const projectRef = typeof ref === 'string' ? ref : Array.isArray(ref) ? ref[0] : '' + + const infraAttributes = attributes.filter((attr) => attr.provider === 'infra-monitoring') + const dailyStatsAttributes = attributes.filter((attr) => attr.provider === 'daily-stats') + const mockAttributes = attributes.filter((attr) => attr.provider === 'mock') + const referenceLineAttributes = attributes.filter((attr) => attr.provider === 'reference-line') const infraQueries = useInfraMonitoringQueries( - infraAttributes, + infraAttributes.map((attr) => attr.attribute as InfraMonitoringAttribute), ref, startDate, endDate, @@ -337,7 +212,7 @@ const useAttributeQueries = ( isVisible ) const dailyStatsQueries = useProjectDailyStatsQueries( - dailyStatsAttributes, + dailyStatsAttributes.map((attr) => attr.attribute as ProjectDailyStatsAttribute), ref, startDate, endDate, @@ -347,23 +222,48 @@ const useAttributeQueries = ( isVisible ) - const referenceLineQueries = referenceLines.map((line) => { - let value = line.value || 0 - - return { - data: { - data: [], // Will be populated in combinedData - attribute: line.attribute, - total: value, - maximum: value, - totalGrouped: { [line.attribute]: value }, - }, - isLoading: false, - isError: false, - } - }) - - return [...infraQueries, ...dailyStatsQueries, ...referenceLineQueries] + let infraIdx = 0 + let dailyStatsIdx = 0 + return attributes + .filter((attr) => attr.provider !== 'logs') + .map((attr) => { + if (attr.provider === 'infra-monitoring') { + return { + ...infraQueries[infraIdx++], + data: { ...infraQueries[infraIdx - 1]?.data, provider: 'infra-monitoring' }, + } + } else if (attr.provider === 'daily-stats') { + return { + ...dailyStatsQueries[dailyStatsIdx++], + data: { ...dailyStatsQueries[dailyStatsIdx - 1]?.data, provider: 'daily-stats' }, + } + } else if (attr.provider === 'mock') { + const mockData = getMockDataForAttribute(attr.attribute) + return { + isLoading: false, + data: { ...mockData, provider: 'mock', attribute: attr.attribute }, + } + } else if (attr.provider === 'reference-line') { + let value = attr.value || 0 + return { + data: { + data: [], + attribute: attr.attribute, + total: value, + maximum: value, + totalGrouped: { [attr.attribute]: value }, + provider: 'reference-line', + }, + isLoading: false, + isError: false, + } + } else { + return { + isLoading: false, + data: undefined, + } + } + }) } export default function LazyComposedChartHandler(props: ComposedChartHandlerProps) { diff --git a/apps/studio/components/ui/CopyButton.tsx b/apps/studio/components/ui/CopyButton.tsx index 9ac56ba9df6..a45df0d6180 100644 --- a/apps/studio/components/ui/CopyButton.tsx +++ b/apps/studio/components/ui/CopyButton.tsx @@ -7,7 +7,6 @@ type CopyButtonBaseProps = { iconOnly?: boolean copyLabel?: string copiedLabel?: string - onCopy?: () => Promise | string } type CopyButtonWithText = CopyButtonBaseProps & { diff --git a/apps/studio/components/ui/DataTable/DataTable.constants.ts b/apps/studio/components/ui/DataTable/DataTable.constants.ts new file mode 100644 index 00000000000..da947462782 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTable.constants.ts @@ -0,0 +1,50 @@ +import { addDays, addHours, endOfDay, startOfDay } from 'date-fns' + +import { DatePreset } from './DataTable.types' + +export const ARRAY_DELIMITER = ',' +export const SLIDER_DELIMITER = '-' +export const SPACE_DELIMITER = '_' +export const RANGE_DELIMITER = '-' +export const SORT_DELIMITER = '.' + +export const LEVELS = ['success', 'warning', 'error', 'info'] as const + +export const presets = [ + { + label: 'Today', + from: startOfDay(new Date()), + to: endOfDay(new Date()), + shortcut: 'd', // day + }, + { + label: 'Yesterday', + from: startOfDay(addDays(new Date(), -1)), + to: endOfDay(addDays(new Date(), -1)), + shortcut: 'y', + }, + { + label: 'Last hour', + from: addHours(new Date(), -1), + to: new Date(), + shortcut: 'h', + }, + { + label: 'Last 7 days', + from: startOfDay(addDays(new Date(), -7)), + to: endOfDay(new Date()), + shortcut: 'w', + }, + { + label: 'Last 14 days', + from: startOfDay(addDays(new Date(), -14)), + to: endOfDay(new Date()), + shortcut: 'b', // bi-weekly + }, + { + label: 'Last 30 days', + from: startOfDay(addDays(new Date(), -30)), + to: endOfDay(new Date()), + shortcut: 'm', + }, +] satisfies DatePreset[] diff --git a/apps/studio/components/ui/DataTable/DataTable.types.ts b/apps/studio/components/ui/DataTable/DataTable.types.ts new file mode 100644 index 00000000000..9ee5cada849 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTable.types.ts @@ -0,0 +1,61 @@ +// TODO: we could type the value(!) especially when using enums +export type Option = { + label: string + value: string | boolean | number | undefined +} + +export type DatePreset = { + label: string + from: Date + to: Date + shortcut: string +} + +export type Input = { + type: 'input' + options?: Option[] +} + +export type Checkbox = { + type: 'checkbox' + component?: (props: Option) => JSX.Element | null + options?: Option[] +} + +export type Slider = { + type: 'slider' + min: number + max: number + // if options is undefined, we will provide all the steps between min and max + options?: Option[] +} + +export type Timerange = { + type: 'timerange' + options?: Option[] // required for TS + presets?: DatePreset[] +} + +export type Base = { + label: string + value: keyof TData + /** + * Defines if the accordion in the filter bar is open by default + */ + defaultOpen?: boolean + /** + * Defines if the command input is disabled for this field + */ + commandDisabled?: boolean +} + +export type DataTableCheckboxFilterField = Base & Checkbox +export type DataTableSliderFilterField = Base & Slider +export type DataTableInputFilterField = Base & Input +export type DataTableTimerangeFilterField = Base & Timerange + +export type DataTableFilterField = + | DataTableCheckboxFilterField + | DataTableSliderFilterField + | DataTableInputFilterField + | DataTableTimerangeFilterField diff --git a/apps/studio/components/ui/DataTable/DataTable.utils.ts b/apps/studio/components/ui/DataTable/DataTable.utils.ts new file mode 100644 index 00000000000..12997306dab --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTable.utils.ts @@ -0,0 +1,129 @@ +import { FilterFn } from '@tanstack/react-table' +import { isAfter, isBefore, isSameDay } from 'date-fns' + +import { LEVELS } from './DataTable.constants' + +export function formatCompactNumber(value: number) { + if (value >= 100 && value < 1000) { + return value.toString() // Keep the number as is if it's in the hundreds + } else if (value >= 1000 && value < 1000000) { + return (value / 1000).toFixed(1) + 'k' // Convert to 'k' for thousands + } else if (value >= 1000000) { + return (value / 1000000).toFixed(1) + 'M' // Convert to 'M' for millions + } else { + return value.toString() // Optionally handle numbers less than 100 if needed + } +} + +export function isArrayOfNumbers(arr: any): arr is number[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => typeof item === 'number') +} + +export function isArrayOfDates(arr: any): arr is Date[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => item instanceof Date) +} + +export function isArrayOfStrings(arr: any): arr is string[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => typeof item === 'string') +} + +export function isArrayOfBooleans(arr: any): arr is boolean[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => typeof item === 'boolean') +} + +export const inDateRange: FilterFn = (row, columnId, value) => { + const date = new Date(row.getValue(columnId)) + const [start, end] = value as Date[] + + if (isNaN(date.getTime())) return false + + // if no end date, check if it's the same day + if (!end) return isSameDay(date, start) + + return isAfter(date, start) && isBefore(date, end) +} + +inDateRange.autoRemove = (val: any) => !Array.isArray(val) || !val.length || !isArrayOfDates(val) + +export const arrSome: FilterFn = (row, columnId, filterValue) => { + if (!Array.isArray(filterValue)) return false + return filterValue.some((val) => row.getValue(columnId) === val) +} + +arrSome.autoRemove = (val: any) => !Array.isArray(val) || !val?.length + +export function getLevelColor( + value: (typeof LEVELS)[number] +): Record<'text' | 'bg' | 'border', string> { + switch (value) { + case 'success': + return { + text: 'text-muted', + bg: 'bg-muted', + border: 'border-muted', + } + case 'warning': + return { + text: 'text-warning', + bg: 'bg-warning', + border: 'border-warning', + } + case 'error': + return { + text: 'text-destructive', + bg: 'bg-destructive', + border: 'border-destructive', + } + case 'info': + default: + return { + text: 'text-info', + bg: 'bg-info', + border: 'border-info', + } + } +} + +export function getStatusColor(value?: number | string): Record<'text' | 'bg' | 'border', string> { + switch (value) { + case '1': + case 'info': + return { + text: 'text-blue-500', + bg: '', + border: 'border-blue-200 dark:border-blue-800', + } + case '2': + case 'success': + return { + text: 'text-foreground-lighter', + bg: '', + border: 'border-green-200 dark:border-green-800', + } + case '4': + case 'warning': + case 'redirect': + return { + text: 'text-warning-600 dark:text-warning', + bg: 'bg-warning-300 dark:bg-waning-200', + border: 'border border-warning-400/50 dark:border-warning-400/50', + } + case '5': + case 'error': + return { + text: 'text-destructive', + bg: 'bg-destructive-300 dark:bg-destructive-300/50', + border: 'border border-destructive-400/50 dark:border-destructive-400/50', + } + default: + return { + text: 'text-foreground-lighter', + bg: '', + border: '', + } + } +} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx new file mode 100644 index 00000000000..7bd07547b55 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx @@ -0,0 +1,54 @@ +import { type Column } from '@tanstack/react-table' +import { ChevronDown, ChevronUp } from 'lucide-react' + +import { Button, cn, type ButtonProps } from 'ui' + +interface DataTableColumnHeaderProps extends ButtonProps { + column: Column + title: string +} + +export const DataTableColumnHeader = ({ + column, + title, + className, + ...props +}: DataTableColumnHeaderProps) => { + if (!column.getCanSort()) { + return
{title}
+ } + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx new file mode 100644 index 00000000000..c0038b7c3dd --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx @@ -0,0 +1,17 @@ +import { cn } from 'ui' +import { LEVELS } from '../DataTable.constants' +import { getLevelColor } from '../DataTable.utils' + +export const DataTableColumnLevelIndicator = ({ + value, + className, +}: { + value: (typeof LEVELS)[number] + className?: string +}) => { + return ( +
+
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx new file mode 100644 index 00000000000..175e3a477a8 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx @@ -0,0 +1,35 @@ +import { Minus } from 'lucide-react' + +import { cn } from 'ui' +import { getStatusColor } from '../DataTable.utils' + +export const DataTableColumnStatusCode = ({ + value, + level, + className, +}: { + value?: number | string + level?: string + className?: string +}) => { + const colors = getStatusColor(level) + if (!value) { + return + } + + return ( +
+
+ {value} +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx new file mode 100644 index 00000000000..94cca02970c --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx @@ -0,0 +1,120 @@ +import { Search } from 'lucide-react' +import { useState } from 'react' + +import { Checkbox_Shadcn_ as Checkbox, cn, Label_Shadcn_ as Label, Skeleton } from 'ui' +import type { DataTableCheckboxFilterField } from '../DataTable.types' +import { formatCompactNumber } from '../DataTable.utils' +import { InputWithAddons } from '../primitives/InputWithAddons' +import { useDataTable } from '../providers/DataTableProvider' + +export function DataTableFilterCheckbox({ + value: _value, + options, + component, +}: DataTableCheckboxFilterField) { + const value = _value as string + const [inputValue, setInputValue] = useState('') + const { table, columnFilters, isLoading, isLoadingCounts, getFacetedUniqueValues } = + useDataTable() + + const column = table.getColumn(value) + // REMINDER: avoid using column?.getFilterValue() + const filterValue = columnFilters.find((i) => i.id === value)?.value + const facetedValue = getFacetedUniqueValues?.(table, value) || column?.getFacetedUniqueValues() + + const Component = component + + // filter out the options based on the input value + const filterOptions = options?.filter( + (option) => inputValue === '' || option.label.toLowerCase().includes(inputValue.toLowerCase()) + ) + + // CHECK: it could be filterValue or searchValue + const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : [] + + // REMINDER: if no options are defined, while fetching data, we should show a skeleton + if (isLoading && !filterOptions?.length) + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ + +
+ ))} +
+ ) + + if (!filterOptions?.length) return null + + return ( +
+ {options && options.length > 4 ? ( + } + containerClassName="h-9 rounded" + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + /> + ) : null} + {/* FIXME: due to the added max-h and overflow-y-auto, the hover state and border is laying on top of the scroll bar */} +
+ {filterOptions + // TODO: we shoudn't sort the options here, instead filterOptions should be sorted by default + // .sort((a, b) => a.label.localeCompare(b.label)) + .map((option, index) => { + const checked = filters.includes(option.value) + + return ( +
+ { + const newValue = checked + ? [...(filters || []), option.value] + : filters?.filter((value) => option.value !== value) + column?.setFilterValue(newValue?.length ? newValue : undefined) + }} + /> + +
+ ) + })} +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx new file mode 100644 index 00000000000..066f881d895 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx @@ -0,0 +1,404 @@ +import { formatDistanceToNow } from 'date-fns' +import { LoaderCircle, Search, X } from 'lucide-react' +import { ParserBuilder } from 'nuqs' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { + cn, + Command_Shadcn_ as Command, + CommandEmpty_Shadcn_ as CommandEmpty, + CommandGroup_Shadcn_ as CommandGroup, + CommandInput_Shadcn_ as CommandInput, + CommandItem_Shadcn_ as CommandItem, + CommandList_Shadcn_ as CommandList, + CommandSeparator_Shadcn_ as CommandSeparator, + Separator, +} from 'ui' +import type { DataTableFilterField } from '../DataTable.types' +import { formatCompactNumber } from '../DataTable.utils' +import { Kbd } from '../primitives/Kbd' +import { useDataTable } from '../providers/DataTableProvider' +import { + columnFiltersParser, + getFieldOptions, + getFilterValue, + getWordByCaretPosition, + replaceInputByFieldType, +} from './DataTableFilters.utils' + +// FIXME: there is an issue on cmdk if I wanna only set a single slider value... + +interface DataTableFilterCommandProps { + // TODO: maybe use generics for the parser + searchParamsParser: Record> + placeholder?: string +} + +export function DataTableFilterCommand({ + searchParamsParser, + placeholder = 'Search data table...', +}: DataTableFilterCommandProps) { + const { table, isLoading, filterFields: _filterFields, getFacetedUniqueValues } = useDataTable() + const columnFilters = table.getState().columnFilters + const inputRef = useRef(null) + const [open, setOpen] = useState(false) + const [currentWord, setCurrentWord] = useState('') + const filterFields = useMemo( + () => _filterFields?.filter((i) => !i.commandDisabled), + [_filterFields] + ) + const columnParser = useMemo( + () => columnFiltersParser({ searchParamsParser, filterFields }), + [searchParamsParser, filterFields] + ) + const [inputValue, setInputValue] = useState(columnParser.serialize(columnFilters)) + const [lastSearches, setLastSearches] = useLocalStorage< + { + search: string + timestamp: number + }[] + >('data-table-command', []) + + const trimmedInputValue = inputValue.trim() + + // [Joshen] Temporarily disabling as this conflicts with our current CMD K behaviour + // useHotKey(() => setOpen((open) => !open), 'k') + + useEffect(() => { + // TODO: we could check for ARRAY_DELIMITER or SLIDER_DELIMITER to auto-set filter when typing + if (currentWord !== '' && open) return + // reset + if (currentWord !== '' && !open) setCurrentWord('') + // avoid recursion + if (trimmedInputValue === '' && !open) return + + const searchParams = columnParser.parse(inputValue) + + const currentFilters = table.getState().columnFilters + const currentEnabledFilters = currentFilters.filter((filter) => { + const field = _filterFields?.find((field) => field.value === filter.id) + return !field?.commandDisabled + }) + const currentDisabledFilters = currentFilters.filter((filter) => { + const field = _filterFields?.find((field) => field.value === filter.id) + return field?.commandDisabled + }) + + const commandDisabledFilterKeys = currentDisabledFilters.reduce( + (prev, curr) => { + prev[curr.id] = curr.value + return prev + }, + {} as Record + ) + + for (const key of Object.keys(searchParams)) { + const value = searchParams[key as keyof typeof searchParams] + table.getColumn(key)?.setFilterValue(value) + } + const currentFiltersToReset = currentEnabledFilters.filter((filter) => { + return !(filter.id in searchParams) + }) + for (const filter of currentFiltersToReset) { + table.getColumn(filter.id)?.setFilterValue(undefined) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputValue, open, currentWord]) + + useEffect(() => { + // REMINDER: only update the input value if the command is closed (avoids jumps while open) + if (!open) { + setInputValue(columnParser.serialize(columnFilters)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnFilters, filterFields, open]) + + useEffect(() => { + if (open) { + inputRef?.current?.focus() + } + }, [open]) + + return ( +
+ + + div]:border-none bg', + open ? 'visible' : 'hidden' + )} + filter={(value, search, keywords) => + getFilterValue({ value, search, keywords, currentWord }) + } + > + { + if (e.key === 'Escape') inputRef?.current?.blur() + }} + onBlur={() => { + setOpen(false) + // FIXME: doesnt reflect the jumps + // FIXME: will save non-existing searches + // TODO: extract into function + const search = inputValue.trim() + if (!search) return + const timestamp = Date.now() + const searchIndex = lastSearches.findIndex((item) => item.search === search) + if (searchIndex !== -1) { + lastSearches[searchIndex].timestamp = timestamp + setLastSearches(lastSearches) + return + } + setLastSearches([...lastSearches, { search, timestamp }]) + return + }} + onInput={(e) => { + const caretPosition = e.currentTarget?.selectionStart || -1 + const value = e.currentTarget?.value || '' + const word = getWordByCaretPosition({ value, caretPosition }) + setCurrentWord(word) + }} + placeholder={placeholder} + className="text-foreground" + /> +
+
+ {/* default height is 300px but in case of more, we'd like to tease the user */} + + + {filterFields.map((field) => { + if (typeof field.value !== 'string') return null + if (inputValue.includes(`${field.value}:`)) return null + // TBD: should we handle this in the component? + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value) => { + setInputValue((prev) => { + if (currentWord.trim() === '') { + const input = `${prev}${value}` + return `${input}:` + } + // lots of cheat + const isStarting = currentWord === prev + const prefix = isStarting ? '' : ' ' + const input = prev.replace(`${prefix}${currentWord}`, `${prefix}${value}`) + return `${input}:` + }) + setCurrentWord(`${value}:`) + }} + className="group" + > + {field.value} + + + ) + })} + + + + {filterFields?.map((field) => { + if (typeof field.value !== 'string') return null + if (!currentWord.includes(`${field.value}:`)) return null + + const column = table.getColumn(field.value) + const facetedValue = + getFacetedUniqueValues?.(table, field.value) || column?.getFacetedUniqueValues() + + const options = getFieldOptions({ field }) + + return options.map((optionValue) => { + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value) => { + setInputValue((prev) => + replaceInputByFieldType({ + prev, + currentWord, + optionValue, + value, + field, + }) + ) + setCurrentWord('') + }} + > + {`${optionValue}`} + {facetedValue?.has(optionValue) ? ( + + {formatCompactNumber(facetedValue.get(optionValue) || 0)} + + ) : null} + + ) + }) + })} + + + + {lastSearches + ?.sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 5) + .map((item) => { + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value) => { + const search = value.replace('suggestion:', '') + setInputValue(`${search} `) + setCurrentWord('') + }} + className="group" + > + {item.search} + + {formatDistanceToNow(item.timestamp, { + addSuffix: true, + })} + + + + ) + })} + + No results found. + +
+
+ + Use ↑ ↓ to navigate + + + Enter to query + + + Esc to close + + + + Union: regions:a,b + + + Range: p95:59-340 + +
+ {lastSearches.length ? ( + + ) : null} +
+
+
+
+
+ ) +} + +function CommandItemSuggestions({ field }: { field: DataTableFilterField }) { + const { table, getFacetedMinMaxValues, getFacetedUniqueValues } = useDataTable() + const value = field.value as string + switch (field.type) { + case 'checkbox': { + return ( + + {getFacetedUniqueValues + ? Array.from(getFacetedUniqueValues(table, value)?.keys() || []) + .map((value) => `[${value}]`) + .join(' ') + : field.options?.map(({ value }) => `[${value}]`).join(' ')} + + ) + } + case 'slider': { + const [min, max] = getFacetedMinMaxValues?.(table, value) || [field.min, field.max] + return ( + + [{min} - {max}] + + ) + } + case 'input': { + return ( + + [{`${String(field.value)}`} input] + + ) + } + default: { + return null + } + } +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx new file mode 100644 index 00000000000..c5030af0153 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx @@ -0,0 +1,74 @@ +import { + Accordion_Shadcn_ as Accordion, + AccordionContent_Shadcn_ as AccordionContent, + AccordionItem_Shadcn_ as AccordionItem, + AccordionTrigger_Shadcn_ as AccordionTrigger, +} from 'ui' + +import { DataTableFilterCheckbox } from './DataTableFilterCheckbox' +import { DataTableFilterInput } from './DataTableFilterInput' +import { DataTableFilterResetButton } from './DataTableFilterResetButton' +import { DataTableFilterSlider } from './DataTableFilterSlider' +import { DataTableFilterTimerange } from './DataTableFilterTimerange' + +import { useDataTable } from '../providers/DataTableProvider' + +// FIXME: use @container (especially for the slider element) to restructure elements + +// TODO: only pass the columns to generate the filters! +// https://tanstack.com/table/v8/docs/framework/react/examples/filters + +export function DataTableFilterControls() { + const { filterFields } = useDataTable() + return ( + defaultOpen) + ?.map(({ value }) => value as string)} + > + {filterFields?.map((field) => { + const value = field.value as string + return ( + + +
+
+

{field.label}

+ {value !== field.label.toLowerCase() && !field.commandDisabled ? ( +

+ {value} +

+ ) : null} +
+ +
+
+ + {/* REMINDER: avoid the focus state to be cut due to overflow-hidden */} + {/* REMINDER: need to move within here because of accordion height animation */} +
+ {(() => { + switch (field.type) { + case 'checkbox': { + return + } + case 'slider': { + return + } + case 'input': { + return + } + case 'timerange': { + return + } + } + })()} +
+
+
+ ) + })} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx new file mode 100644 index 00000000000..835d77cc54e --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx @@ -0,0 +1,75 @@ +import { VisuallyHidden } from '@radix-ui/react-visually-hidden' +import { FilterIcon } from 'lucide-react' +import { useRef } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { + Button, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from 'ui' +import { useMediaQuery } from '../hooks/useMediaQuery' +import { Kbd } from '../primitives/Kbd' +import { DataTableFilterControls } from './DataTableFilterControls' + +export function DataTableFilterControlsDrawer() { + const triggerButtonRef = useRef(null) + const isMobile = useMediaQuery('(max-width: 640px)') + + useHotKey(() => { + triggerButtonRef.current?.click() + }, 'b') + + return ( + + + + + + + + + +

+ Toggle controls with{' '} + + ⌘ + B + +

+
+
+
+ + + + Filters + Adjust your table filters + + +
+ +
+ + + + + +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx new file mode 100644 index 00000000000..a41cc5d9656 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx @@ -0,0 +1,52 @@ +import { Search } from 'lucide-react' +import { useEffect, useState } from 'react' + +import { Label_Shadcn_ as Label } from 'ui' +import type { DataTableInputFilterField } from '../DataTable.types' +import { useDebounce } from '../hooks/useDebounce' +import { InputWithAddons } from '../primitives/InputWithAddons' +import { useDataTable } from '../providers/DataTableProvider' + +function getFilter(filterValue: unknown) { + return typeof filterValue === 'string' ? filterValue : null +} + +export function DataTableFilterInput({ value: _value }: DataTableInputFilterField) { + const value = _value as string + const { table, columnFilters } = useDataTable() + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + const filters = getFilter(filterValue) + const [input, setInput] = useState(filters) + + const debouncedInput = useDebounce(input, 500) + + useEffect(() => { + const newValue = debouncedInput?.trim() === '' ? null : debouncedInput + if (debouncedInput === null) return + column?.setFilterValue(newValue) + }, [debouncedInput]) + + useEffect(() => { + if (debouncedInput?.trim() !== filters) { + setInput(filters) + } + }, [filters]) + + return ( +
+ + } + containerClassName="h-9 rounded" + name={value} + id={value} + value={input || ''} + onChange={(e) => setInput(e.target.value)} + /> +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx new file mode 100644 index 00000000000..2279eb48b17 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx @@ -0,0 +1,35 @@ +import { X } from 'lucide-react' + +import { Button } from 'ui' +import type { DataTableFilterField } from '../DataTable.types' +import { useDataTable } from '../providers/DataTableProvider' + +export function DataTableFilterResetButton({ value: _value }: DataTableFilterField) { + const { columnFilters, table } = useDataTable() + const value = _value as string + const column = table.getColumn(value) + const filterValue = columnFilters.find((f) => f.id === value)?.value + const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : [] + + if (filters.length === 0) return null + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx new file mode 100644 index 00000000000..8882c6fe91d --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' + +import { Label_Shadcn_ as Label } from 'ui' +import type { DataTableSliderFilterField } from '../DataTable.types' +import { isArrayOfNumbers } from '../DataTable.utils' +import { useDebounce } from '../hooks/useDebounce' +import { InputWithAddons } from '../primitives/InputWithAddons' +import { Slider } from '../primitives/Slider' +import { useDataTable } from '../providers/DataTableProvider' + +function getFilter(filterValue: unknown) { + return typeof filterValue === 'number' + ? [filterValue, filterValue] + : Array.isArray(filterValue) && isArrayOfNumbers(filterValue) + ? filterValue.length === 1 + ? [filterValue[0], filterValue[0]] + : filterValue + : null +} + +// TODO: discuss if we even need the `defaultMin` and `defaultMax` +export function DataTableFilterSlider({ + value: _value, + min: defaultMin, + max: defaultMax, +}: DataTableSliderFilterField) { + const value = _value as string + const { table, columnFilters, getFacetedMinMaxValues } = useDataTable() + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + const filters = getFilter(filterValue) + const [input, setInput] = useState(filters) + const [min, max] = getFacetedMinMaxValues?.(table, value) || + column?.getFacetedMinMaxValues() || [defaultMin, defaultMax] + + const debouncedInput = useDebounce(input, 500) + + useEffect(() => { + if (debouncedInput?.length === 2) { + column?.setFilterValue(debouncedInput) + } + }, [debouncedInput]) + + useEffect(() => { + if (debouncedInput?.length !== 2) { + } else if (!filters) { + setInput(null) + } else if (debouncedInput[0] !== filters[0] || debouncedInput[1] !== filters[1]) { + setInput(filters) + } + }, [filters]) + + return ( +
+
+
+ + setInput((prev) => [Number(e.target.value), prev?.[1] || max])} + /> +
+
+ + setInput((prev) => [prev?.[0] || min, Number(e.target.value)])} + /> +
+
+ setInput(values)} + /> +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx new file mode 100644 index 00000000000..9981ed31e61 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx @@ -0,0 +1,39 @@ +import { useMemo } from 'react' +import type { DateRange } from 'react-day-picker' + +import type { DataTableTimerangeFilterField } from '../DataTable.types' +import { isArrayOfDates } from '../DataTable.utils' +import { DatePickerWithRange } from '../DatePickerWithRange' +import { useDataTable } from '../providers/DataTableProvider' + +export function DataTableFilterTimerange({ + value: _value, + presets, +}: DataTableTimerangeFilterField) { + const value = _value as string + const { table, columnFilters } = useDataTable() + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + + const date: DateRange | undefined = useMemo( + () => + filterValue instanceof Date + ? { from: filterValue, to: undefined } + : Array.isArray(filterValue) && isArrayOfDates(filterValue) + ? { from: filterValue?.[0], to: filterValue?.[1] } + : undefined, + [filterValue] + ) + + const setDate = (date: DateRange | undefined) => { + if (!date) return // TODO: remove from search params if columnFilter is removed + if (date.from && !date.to) { + column?.setFilterValue([date.from]) + } + if (date.to && date.from) { + column?.setFilterValue([date.from, date.to]) + } + } + + return +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts new file mode 100644 index 00000000000..c65b6106b07 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts @@ -0,0 +1,259 @@ +import { ColumnFiltersState } from '@tanstack/react-table' +import { ParserBuilder } from 'nuqs' + +import { ARRAY_DELIMITER, RANGE_DELIMITER, SLIDER_DELIMITER } from '../DataTable.constants' +import type { DataTableFilterField } from '../DataTable.types' +import { isArrayOfDates } from '../DataTable.utils' + +/** + * Extracts the word from the given string at the specified caret position. + */ +export function getWordByCaretPosition({ + value, + caretPosition, +}: { + value: string + caretPosition: number +}) { + let start = caretPosition + let end = caretPosition + + while (start > 0 && value[start - 1] !== ' ') start-- + while (end < value.length && value[end] !== ' ') end++ + + const word = value.substring(start, end) + return word +} + +export function replaceInputByFieldType({ + prev, + currentWord, + optionValue, + value, + field, +}: { + prev: string + currentWord: string + optionValue?: string | number | boolean | undefined // FIXME: use DataTableFilterField["options"][number]; + value: string + field: DataTableFilterField +}) { + switch (field.type) { + case 'checkbox': { + if (currentWord.includes(ARRAY_DELIMITER)) { + const words = currentWord.split(ARRAY_DELIMITER) + words[words.length - 1] = `${optionValue}` + const input = prev.replace(currentWord, words.join(ARRAY_DELIMITER)) + return `${input.trim()} ` + } + } + case 'slider': { + if (currentWord.includes(SLIDER_DELIMITER)) { + const words = currentWord.split(SLIDER_DELIMITER) + words[words.length - 1] = `${optionValue}` + const input = prev.replace(currentWord, words.join(SLIDER_DELIMITER)) + return `${input.trim()} ` + } + } + case 'timerange': { + if (currentWord.includes(RANGE_DELIMITER)) { + const words = currentWord.split(RANGE_DELIMITER) + words[words.length - 1] = `${optionValue}` + const input = prev.replace(currentWord, words.join(RANGE_DELIMITER)) + return `${input.trim()} ` + } + } + default: { + const input = prev.replace(currentWord, value) + return `${input.trim()} ` + } + } +} + +export function getFieldOptions({ field }: { field: DataTableFilterField }) { + switch (field.type) { + case 'slider': { + return field.options?.length + ? field.options + .map(({ value }) => value) + .sort((a, b) => Number(a) - Number(b)) + .filter(notEmpty) + : Array.from({ length: field.max - field.min + 1 }, (_, i) => field.min + i) || [] + } + default: { + return field.options?.map(({ value }) => value).filter(notEmpty) || [] + } + } +} + +export function getFilterValue({ + value, + search, + currentWord, +}: { + value: string + search: string + keywords?: string[] | undefined + currentWord: string +}): number { + /** + * @example value "suggestion:public:true regions,ams,gru,fra" + */ + if (value.startsWith('suggestion:')) { + const rawValue = value.toLowerCase().replace('suggestion:', '') + if (rawValue.includes(search)) return 1 + return 0 + } + + /** */ + if (value.toLowerCase().includes(currentWord.toLowerCase())) return 1 + + /** + * @example checkbox [filter, query] = ["regions", "ams,gru,fra"] + * @example slider [filter, query] = ["p95", "0-3000"] + * @example input [filter, query] = ["name", "api"] + */ + const [filter, query] = currentWord.toLowerCase().split(':') + if (query && value.startsWith(`${filter}:`)) { + if (query.includes(ARRAY_DELIMITER)) { + /** + * array of n elements + * @example queries = ["ams", "gru", "fra"] + */ + const queries = query.split(ARRAY_DELIMITER) + const rawValue = value.toLowerCase().replace(`${filter}:`, '') + if (queries.some((item, i) => item === rawValue && i !== queries.length - 1)) return 0 + if (queries.some((item) => rawValue.includes(item))) return 1 + } + if (query.includes(SLIDER_DELIMITER)) { + /** + * range between 2 elements + * @example queries = ["0", "3000"] + */ + const queries = query.split(SLIDER_DELIMITER) + const rawValue = value.toLowerCase().replace(`${filter}:`, '') + + const rawValueAsNumber = Number.parseInt(rawValue) + const queryAsNumber = Number.parseInt(queries[0]) + + if (queryAsNumber < rawValueAsNumber) { + if (rawValue.includes(queries[1])) return 1 + return 0 + } + return 0 + } + const rawValue = value.toLowerCase().replace(`${filter}:`, '') + if (rawValue.includes(query)) return 1 + } + return 0 +} + +export function getFieldValueByType({ + field, + value, +}: { + field?: DataTableFilterField + value: unknown +}) { + if (!field) return null + + switch (field.type) { + case 'slider': { + if (Array.isArray(value)) { + return value.join(SLIDER_DELIMITER) + } + return value + } + case 'checkbox': { + if (Array.isArray(value)) { + return value.join(ARRAY_DELIMITER) + } + // REMINER: inversed logic + if (typeof value === 'string') { + return value.split(ARRAY_DELIMITER) + } + return value + } + case 'timerange': { + if (Array.isArray(value)) { + if (isArrayOfDates(value)) { + return value.map((date) => date.getTime()).join(RANGE_DELIMITER) + } + return value.join(RANGE_DELIMITER) + } + if (value instanceof Date) { + return value.getTime() + } + return value + } + default: { + return value + } + } +} + +export function notEmpty(value: TValue | null | undefined): value is TValue { + return value !== null && value !== undefined +} + +export function columnFiltersParser({ + searchParamsParser, + filterFields, +}: { + searchParamsParser: Record> + filterFields: DataTableFilterField[] +}) { + return { + parse: (inputValue: string) => { + // Use regex to properly extract field:value pairs + // This properly handles spaces within values + const filterPairs: Record = {} + + // This regex properly extracts field:value pairs where: + // 1. Values can contain spaces (both normal spaces and URL-encoded + characters) + // 2. It finds word characters followed by a colon as the field name + // 3. It then captures everything (including spaces and + characters) until + // it finds another field:value pattern or the end of string + // This solves the issue with values like "edge function" or "edge+function" in URLs + const regex = /(\w+):([^]*?)(?=\s+\w+:|$)/g + let match + + console.log('DataTableFilterCommand parsing input:', inputValue) + + while ((match = regex.exec(inputValue)) !== null) { + const [_, fieldName, fieldValue] = match + if (fieldName && fieldValue) { + filterPairs[fieldName] = fieldValue.trim() + console.log(`Parsed filter pair: ${fieldName} = ${fieldValue.trim()}`) + } + } + + const searchParams = Object.entries(filterPairs).reduce( + (prev, [key, value]) => { + const parser = searchParamsParser[key] + if (!parser) return prev + + prev[key] = parser.parse(value) + return prev + }, + {} as Record + ) + + return searchParams + }, + serialize: (columnFilters: ColumnFiltersState) => { + const values = columnFilters.reduce((prev, curr) => { + const { commandDisabled } = filterFields?.find((field) => curr.id === field.value) || { + commandDisabled: true, + } // if column filter is not found, disable the command by default + const parser = searchParamsParser[curr.id] + + if (commandDisabled || !parser) return prev + + return `${prev}${curr.id}:${parser.serialize(curr.value)} ` + }, '') + + return values + }, + } +} diff --git a/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx b/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx new file mode 100644 index 00000000000..5222d38f14c --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx @@ -0,0 +1,35 @@ +import { forwardRef, PropsWithChildren, useEffect, useRef } from 'react' +import { cn } from 'ui' + +export const DataTableHeaderLayout = forwardRef< + HTMLDivElement, + PropsWithChildren<{ + setTopBarHeight: (height: number) => void + }> +>(({ setTopBarHeight, ...props }, ref) => { + const topBarRef = useRef(null) + + useEffect(() => { + const observer = new ResizeObserver(() => { + const rect = topBarRef.current?.getBoundingClientRect() + if (rect) { + setTopBarHeight(rect.height) + } + }) + + const topBar = topBarRef.current + if (!topBar) return + + observer.observe(topBar) + return () => observer.unobserve(topBar) + }, [topBarRef]) + + return ( +
+ ) +}) +DataTableHeaderLayout.displayName = 'DataTableHeaderLayout' diff --git a/apps/studio/components/ui/DataTable/DataTableInfinite.tsx b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx new file mode 100644 index 00000000000..8dec475c052 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx @@ -0,0 +1,224 @@ +import { type FetchNextPageOptions } from '@tanstack/react-query' +import type { ColumnDef, Row, Table as TTable, VisibilityState } from '@tanstack/react-table' +import { flexRender } from '@tanstack/react-table' +import { LoaderCircle } from 'lucide-react' +import { useQueryState } from 'nuqs' +import { Fragment, memo, ReactNode, UIEvent, useCallback, useRef } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, cn } from 'ui' +import { formatCompactNumber } from './DataTable.utils' +import { useDataTable } from './providers/DataTableProvider' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './Table' + +// TODO: add a possible chartGroupBy +export interface DataTableInfiniteProps { + columns: ColumnDef[] + defaultColumnVisibility?: VisibilityState + totalRows?: number + filterRows?: number + totalRowsFetched?: number + isFetching?: boolean + isLoading?: boolean + hasNextPage?: boolean + fetchNextPage: (options?: FetchNextPageOptions | undefined) => Promise + renderLiveRow?: (props?: { row: Row }) => ReactNode + setColumnOrder: (columnOrder: string[]) => void + setColumnVisibility: (columnVisibility: VisibilityState) => void + + // [Joshen] See if we can type this properly + searchParamsParser: any +} + +// [Joshen] JFYI this component is NOT virtualized and hence will struggle handling many data points +export function DataTableInfinite({ + columns, + defaultColumnVisibility = {}, + fetchNextPage, + hasNextPage, + totalRows = 0, + filterRows = 0, + totalRowsFetched = 0, + renderLiveRow, + setColumnOrder, + setColumnVisibility, + searchParamsParser, +}: DataTableInfiniteProps) { + const { table, isLoading, isFetching } = useDataTable() + const tableRef = useRef(null) + + const headerGroups = table.getHeaderGroups() + const headers = headerGroups[0].headers + const rows = table.getRowModel().rows ?? [] + + const onScroll = useCallback( + (e: UIEvent) => { + const onPageBottom = + Math.ceil(e.currentTarget.scrollTop + e.currentTarget.clientHeight) >= + e.currentTarget.scrollHeight + + if (onPageBottom && !isFetching && totalRows > totalRowsFetched) { + fetchNextPage() + } + }, + [fetchNextPage, isFetching, totalRows, totalRowsFetched] + ) + + useHotKey(() => { + setColumnOrder([]) + setColumnVisibility(defaultColumnVisibility) + }, 'u') + + return ( + + + + {headers.map((header) => { + const sort = header.column.getIsSorted() + const canResize = header.column.getCanResize() + const onResize = header.getResizeHandler() + const headerClassName = (header.column.columnDef.meta as any)?.headerClassName + + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + {canResize && ( +
header.column.resetSize()} + onMouseDown={onResize} + onTouchStart={onResize} + className={cn( + 'user-select-none absolute -right-2 top-0 z-10 flex h-full w-4 cursor-col-resize touch-none justify-center', + 'before:absolute before:inset-y-0 before:w-px before:translate-x-px before:bg-border' + )} + /> + )} + + ) + })} + + + + {rows.length ? ( + rows.map((row) => ( + // REMINDER: if we want to add arrow navigation https://github.com/TanStack/table/discussions/2752#discussioncomment-192558 + + {renderLiveRow?.({ row: row as any })} + + + )) + ) : ( + + {renderLiveRow?.()} + + + {isLoading ? 'Retrieving logs...' : 'No results'} + + + + )} + + + {hasNextPage || isFetching || isLoading ? ( +
+ +

+ Showing{' '} + + {formatCompactNumber(totalRowsFetched)} + {' '} + of {formatCompactNumber(totalRows)}{' '} + rows +

+
+ ) : ( +

+ No more data to load ( + {formatCompactNumber(filterRows)} of{' '} + {formatCompactNumber(totalRows)}{' '} + rows) +

+ )} +
+
+
+
+ ) +} + +/** + * REMINDER: this is the heaviest component in the table if lots of rows + * Some other components are rendered more often necessary, but are fixed size (not like rows that can grow in height) + * e.g. DataTableFilterControls, DataTableFilterCommand, DataTableToolbar, DataTableHeader + */ + +function DataTableRow({ + row, + table, + selected, + searchParamsParser, +}: { + row: Row + table: TTable + selected?: boolean + searchParamsParser: any +}) { + useQueryState('live', searchParamsParser.live) + const rowClassName = (table.options.meta as any)?.getRowClassName?.(row) + const cells = row.getVisibleCells() + + return ( + row.toggleSelected()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + row.toggleSelected() + } + }} + className={cn(rowClassName)} + > + {cells.map((cell) => { + const cellClassName = (cell.column.columnDef.meta as any)?.cellClassName + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + ) +} + +// [Joshen] Using MemoizedRow will cause the column visibility to break as the rows aren't getting re-rendered +const MemoizedRow = memo( + DataTableRow, + (prev, next) => prev.row.id === next.row.id && prev.selected === next.selected +) as typeof DataTableRow diff --git a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx new file mode 100644 index 00000000000..06a80799f08 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx @@ -0,0 +1,37 @@ +import { X } from 'lucide-react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { Kbd } from './primitives/Kbd' +import { useDataTable } from './providers/DataTableProvider' + +export function DataTableResetButton() { + const { table } = useDataTable() + useHotKey(table.resetColumnFilters, 'Escape') + + return ( + + + + + + +

+ Reset filters with{' '} + + ⌘ + Esc + +

+
+
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx new file mode 100644 index 00000000000..922d38bc3ee --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx @@ -0,0 +1,135 @@ +import { ChevronDown, ChevronUp, X } from 'lucide-react' +import { ReactNode, useCallback, useEffect, useMemo } from 'react' + +import { + Button, + cn, + Separator, + Skeleton, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from 'ui' +import { Kbd } from './primitives/Kbd' +import { useDataTable } from './providers/DataTableProvider' + +export interface DataTableSheetDetailsProps { + title?: ReactNode + titleClassName?: string + children?: ReactNode +} + +export function DataTableSheetDetails({ + title, + titleClassName, + children, +}: DataTableSheetDetailsProps) { + const { table, rowSelection, isLoading } = useDataTable() + + const selectedRowKey = Object.keys(rowSelection)?.[0] + + const selectedRow = useMemo(() => { + if (isLoading && !selectedRowKey) return + return table.getCoreRowModel().flatRows.find((row) => row.id === selectedRowKey) + }, [selectedRowKey, isLoading]) + + const index = table.getCoreRowModel().flatRows.findIndex((row) => row.id === selectedRow?.id) + + const nextId = useMemo(() => table.getCoreRowModel().flatRows[index + 1]?.id, [index, isLoading]) + + const prevId = useMemo(() => table.getCoreRowModel().flatRows[index - 1]?.id, [index, isLoading]) + + const onPrev = useCallback(() => { + if (prevId) table.setRowSelection({ [prevId]: true }) + }, [prevId, isLoading]) + + const onNext = useCallback(() => { + if (nextId) table.setRowSelection({ [nextId]: true }) + }, [nextId, isLoading]) + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (!selectedRowKey) return + + // REMINDER: prevent dropdown navigation inside of sheet to change row selection + const activeElement = document.activeElement + const isMenuActive = activeElement?.closest('[role="menu"]') + + if (isMenuActive) return + + if (e.key === 'ArrowUp') { + e.preventDefault() + onPrev() + } + if (e.key === 'ArrowDown') { + e.preventDefault() + onNext() + } + } + + document.addEventListener('keydown', down) + return () => document.removeEventListener('keydown', down) + }, [selectedRowKey, onNext, onPrev]) + + return ( +
+
+
+ {isLoading && !selectedRowKey ? : title} +
+
+ + + +
+
+ + {children} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx b/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx new file mode 100644 index 00000000000..d1cec2acbfe --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx @@ -0,0 +1,186 @@ +import { Table } from '@tanstack/react-table' +import { endOfDay, endOfHour, startOfDay, startOfHour } from 'date-fns' +import { + CalendarClock, + CalendarDays, + CalendarSearch, + ChevronLeft, + ChevronRight, + Copy, + Equal, + Search, +} from 'lucide-react' +import { ComponentPropsWithRef } from 'react' + +import { DataTableFilterField } from 'components/ui/DataTable/DataTable.types' +import { useCopyToClipboard } from 'hooks/ui/useCopyToClipboard' +import { + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' + +interface DataTableSheetRowActionProps> + extends ComponentPropsWithRef { + fieldValue: TFields['value'] + filterFields: TFields[] + value: string | number + table: Table +} + +export function DataTableSheetRowAction>({ + fieldValue, + filterFields, + value, + children, + className, + table, + onKeyDown, + ...props +}: DataTableSheetRowActionProps) { + const { copy, isCopied } = useCopyToClipboard() + const field = filterFields.find((field) => field.value === fieldValue) + const column = table.getColumn(fieldValue.toString()) + + if (!field || !column) return null + + function renderOptions() { + if (!field) return null + switch (field.type) { + case 'checkbox': + return ( + { + const filterValue = column?.getFilterValue() as undefined | Array + const newValue = filterValue?.includes(value) + ? filterValue + : [...(filterValue || []), value] + + column?.setFilterValue(newValue) + }} + className="flex items-center gap-2" + > + + Include + + ) + case 'input': + return ( + column?.setFilterValue(value)} + className="flex items-center gap-2" + > + + Include + + ) + case 'slider': + return ( + + column?.setFilterValue([0, value])} + className="flex items-center gap-2" + > + {/* FIXME: change icon as it is not clear */} + + Less or equal than + + column?.setFilterValue([value, 5000])} + className="flex items-center gap-2" + > + {/* FIXME: change icon as it is not clear */} + + Greater or equal than + + column?.setFilterValue([value])} + className="flex items-center gap-2" + > + + Equal to + + + ) + case 'timerange': + const date = new Date(value) + return ( + + column?.setFilterValue([date])} + className="flex items-center gap-2" + > + + Exact timestamp + + { + const start = startOfHour(date) + const end = endOfHour(date) + column?.setFilterValue([start, end]) + }} + className="flex items-center gap-2" + > + + Same hour + + { + const start = startOfDay(date) + const end = endOfDay(date) + column?.setFilterValue([start, end]) + }} + className="flex items-center gap-2" + > + + Same day + + + ) + default: + return null + } + } + + return ( + + { + if (e.key === 'ArrowDown') { + // REMINDER: default behavior is to open the dropdown menu + // But because we use it to navigate between rows, we need to prevent it + // and only use "Enter" to select the option + e.preventDefault() + } + onKeyDown?.(e) + }} + {...props} + > + {children} + {isCopied ? ( +
Value copied
+ ) : null} +
+ + {renderOptions()} + + copy(String(value), { timeout: 1000 })} + className="flex items-center gap-2" + > + + Copy value + + +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx b/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx new file mode 100644 index 00000000000..5898566d139 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx @@ -0,0 +1,52 @@ +import { CSSProperties, ReactNode, useMemo } from 'react' + +import { cn } from 'ui' +import { useDataTable } from './providers/DataTableProvider' + +interface DataTableSideBarLayoutProps { + children: ReactNode + className?: string + topBarHeight?: number +} + +export function DataTableSideBarLayout({ + children, + className, + topBarHeight = 0, +}: DataTableSideBarLayoutProps) { + const { table } = useDataTable() + + /** + * https://tanstack.com/table/v8/docs/guide/column-sizing#advanced-column-resizing-performance + * Instead of calling `column.getSize()` on every render for every header + * and especially every data cell (very expensive), + * we will calculate all column sizes at once at the root table level in a useMemo + * and pass the column sizes down as CSS variables to the element. + */ + const columnSizeVars = useMemo(() => { + const headers = table.getFlatHeaders() + const colSizes: { [key: string]: string } = {} + for (let i = 0; i < headers.length; i++) { + const header = headers[i]! + // REMINDER: replace "." with "-" to avoid invalid CSS variable name (e.g. "timing.dns" -> "timing-dns") + colSizes[`--header-${header.id.replace('.', '-')}-size`] = `${header.getSize()}px` + colSizes[`--col-${header.column.id.replace('.', '-')}-size`] = `${header.column.getSize()}px` + } + return colSizes + }, [ + // TODO: check if we need this + table.getState().columnSizingInfo, + table.getState().columnSizing, + table.getState().columnVisibility, + ]) + + return ( +
+ {children} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableToolbar.tsx b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx new file mode 100644 index 00000000000..906e89dee00 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx @@ -0,0 +1,82 @@ +import { PanelLeftClose } from 'lucide-react' +import { ReactNode, useMemo } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { formatCompactNumber } from './DataTable.utils' +import { DataTableFilterControlsDrawer } from './DataTableFilters/DataTableFilterControlsDrawer' +import { DataTableResetButton } from './DataTableResetButton' +import { DataTableViewOptions } from './DataTableViewOptions' +import { Kbd } from './primitives/Kbd' +import { useControls } from './providers/ControlsProvider' +import { useDataTable } from './providers/DataTableProvider' + +interface DataTableToolbarProps { + renderActions?: () => ReactNode +} + +export function DataTableToolbar({ renderActions }: DataTableToolbarProps) { + const { table, isLoading, columnFilters } = useDataTable() + const { open, setOpen } = useControls() + const filters = table.getState().columnFilters + + useHotKey(() => setOpen((prev) => !prev), 'b') + + const rows = useMemo( + () => ({ + total: table.getCoreRowModel().rows.length, + filtered: table.getFilteredRowModel().rows.length, + }), + [isLoading, columnFilters] + ) + + return ( +
+
+ + + + + + +

+ Toggle controls with{' '} + + ⌘ + B + +

+
+
+
+
+ +
+
+

+ {formatCompactNumber(rows.filtered)} of{' '} + {formatCompactNumber(rows.total)} row(s){' '} + filtered +

+

+ {formatCompactNumber(rows.filtered)}{' '} + row(s) +

+
+
+
+ {filters.length ? : null} + {renderActions?.()} + +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx b/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx new file mode 100644 index 00000000000..24fc9a6641a --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx @@ -0,0 +1,95 @@ +import { GripVertical, Settings2 } from 'lucide-react' +import { useMemo, useState } from 'react' + +import { + Button, + Checkbox_Shadcn_, + Command_Shadcn_ as Command, + CommandEmpty_Shadcn_ as CommandEmpty, + CommandGroup_Shadcn_ as CommandGroup, + CommandInput_Shadcn_ as CommandInput, + CommandItem_Shadcn_ as CommandItem, + CommandList_Shadcn_ as CommandList, + Popover_Shadcn_ as Popover, + PopoverContent_Shadcn_ as PopoverContent, + PopoverTrigger_Shadcn_ as PopoverTrigger, +} from 'ui' +import { Sortable, SortableDragHandle, SortableItem } from './primitives/Sortable' +import { useDataTable } from './providers/DataTableProvider' + +export function DataTableViewOptions() { + const { table, enableColumnOrdering } = useDataTable() + const [open, setOpen] = useState(false) + const [drag, setDrag] = useState(false) + const [search, setSearch] = useState('') + + const columnOrder = table.getState().columnOrder + + const sortedColumns = useMemo( + () => + table.getAllColumns().sort((a, b) => { + return columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id) + }), + [columnOrder] + ) + + return ( + + + + + +
+
+ +
+
+ +
+ + +
+ + +
+
+ + ) +} + +function DatePresets({ + selected, + onSelect, + presets, +}: { + selected: DateRange | undefined + onSelect: (date: DateRange | undefined) => void + presets: DatePreset[] +}) { + return ( +
+

Date Range

+
+ {presets.map(({ label, shortcut, from, to }) => { + const isActive = selected?.from === from && selected?.to === to + return ( + + ) + })} +
+
+ ) +} + +function DatePresetsSelect({ + selected, + onSelect, + presets, +}: { + selected: DateRange | undefined + onSelect: (date: DateRange | undefined) => void + presets: DatePreset[] +}) { + function findPreset(from?: Date, to?: Date) { + return presets.find((p) => p.from === from && p.to === to)?.shortcut + } + const [value, setValue] = useState(findPreset(selected?.from, selected?.to)) + + useEffect(() => { + const preset = findPreset(selected?.from, selected?.to) + if (preset === value) return + setValue(preset) + }, [selected, presets]) + + return ( + + ) +} + +// REMINDER: We can add min max date range validation https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#setting_maximum_and_minimum_dates_and_times +function CustomDateRange({ + selected, + onSelect, +}: { + selected: DateRange | undefined + onSelect: (date: DateRange | undefined) => void +}) { + const [dateFrom, setDateFrom] = useState(selected?.from) + const [dateTo, setDateTo] = useState(selected?.to) + const debounceDateFrom = useDebounce(dateFrom, 1000) + const debounceDateTo = useDebounce(dateTo, 1000) + + const formatDateForInput = (date: Date | undefined): string => { + if (!date) return '' + const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000) + return utcDate.toISOString().slice(0, 16) + } + + useEffect(() => { + onSelect({ from: debounceDateFrom, to: debounceDateTo }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debounceDateFrom, debounceDateTo]) + + return ( +
+

Custom Range

+
+
+ + { + const newDate = new Date(e.target.value) + if (!Number.isNaN(newDate.getTime())) { + setDateFrom(newDate) + } + }} + disabled={!selected?.from} + /> +
+
+ + { + const newDate = new Date(e.target.value) + if (!Number.isNaN(newDate.getTime())) { + setDateTo(newDate) + } + }} + disabled={!selected?.to} + /> +
+
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/FilterSideBar.tsx b/apps/studio/components/ui/DataTable/FilterSideBar.tsx new file mode 100644 index 00000000000..d5be1d62a3d --- /dev/null +++ b/apps/studio/components/ui/DataTable/FilterSideBar.tsx @@ -0,0 +1,28 @@ +import { cn } from 'ui' +import { DataTableFilterControls } from './DataTableFilters/DataTableFilterControls' +import { DataTableResetButton } from './DataTableResetButton' +import { useDataTable } from './providers/DataTableProvider' + +export function FilterSideBar() { + const { table } = useDataTable() + + return ( +
+
+
+

Filters

+
{table.getState().columnFilters.length ? : null}
+
+
+
+ +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/LiveButton.tsx b/apps/studio/components/ui/DataTable/LiveButton.tsx new file mode 100644 index 00000000000..ebb1c3f04e2 --- /dev/null +++ b/apps/studio/components/ui/DataTable/LiveButton.tsx @@ -0,0 +1,71 @@ +import type { FetchPreviousPageOptions } from '@tanstack/react-query' +import { CirclePause, CirclePlay } from 'lucide-react' +import { useQueryStates } from 'nuqs' +import { useEffect } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, cn } from 'ui' +import { useDataTable } from './providers/DataTableProvider' + +const REFRESH_INTERVAL = 10_000 + +interface LiveButtonProps { + searchParamsParser: any + fetchPreviousPage?: (options?: FetchPreviousPageOptions | undefined) => Promise +} + +export function LiveButton({ fetchPreviousPage, searchParamsParser }: LiveButtonProps) { + const [{ live, date, sort }, setSearch] = useQueryStates(searchParamsParser) + const { table } = useDataTable() + useHotKey(handleClick, 'j') + + useEffect(() => { + let timeoutId: NodeJS.Timeout + + async function fetchData() { + if (live) { + await fetchPreviousPage?.() + timeoutId = setTimeout(fetchData, REFRESH_INTERVAL) + } else { + clearTimeout(timeoutId) + } + } + + fetchData() + + return () => { + clearTimeout(timeoutId) + } + }, [live, fetchPreviousPage]) + + // REMINDER: make sure to reset live when date is set + // TODO: test properly + useEffect(() => { + if ((date || sort) && live) { + setSearch((prev) => ({ ...prev, live: null })) + } + }, [date, sort]) + + function handleClick() { + setSearch((prev) => ({ + ...prev, + live: !prev.live, + date: null, + sort: null, + })) + table.getColumn('date')?.setFilterValue(undefined) + table.resetSorting() + } + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/LiveRow.tsx b/apps/studio/components/ui/DataTable/LiveRow.tsx new file mode 100644 index 00000000000..76c63acd8be --- /dev/null +++ b/apps/studio/components/ui/DataTable/LiveRow.tsx @@ -0,0 +1,18 @@ +import { TableCell, TableRow } from 'ui' +import { DataTableColumnLevelIndicator } from './DataTableColumn/DataTableColumnLevelIndicator' + +export function LiveRow({ colSpan }: { colSpan: number }) { + return ( + + + + + + Live Mode + + + ) +} diff --git a/apps/studio/components/ui/DataTable/RefreshButton.tsx b/apps/studio/components/ui/DataTable/RefreshButton.tsx new file mode 100644 index 00000000000..2adee376572 --- /dev/null +++ b/apps/studio/components/ui/DataTable/RefreshButton.tsx @@ -0,0 +1,26 @@ +import { LoaderCircle, RefreshCcw } from 'lucide-react' +import { Button } from 'ui' + +interface RefreshButtonProps { + isLoading: boolean + onRefresh: () => void +} + +export const RefreshButton = ({ isLoading, onRefresh }: RefreshButtonProps) => { + return ( + - + {additionalOptions.length > 0 && ( diff --git a/apps/studio/components/ui/DotGrid.tsx b/apps/studio/components/ui/DotGrid.tsx index 6e0fdbdccf1..03b52f87c06 100644 --- a/apps/studio/components/ui/DotGrid.tsx +++ b/apps/studio/components/ui/DotGrid.tsx @@ -6,7 +6,7 @@ interface DotGridProps { count: number } -const DotGrid = ({ rows, columns, count }: DotGridProps) => { +export const DotGrid = ({ rows, columns, count }: DotGridProps) => { const container = { hidden: { opacity: 1 }, visible: { @@ -65,5 +65,3 @@ const DotGrid = ({ rows, columns, count }: DotGridProps) => { ) } - -export default DotGrid diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index acfb5394781..ee70fab8c8f 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -12,9 +12,10 @@ import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' import { SqlRunButton } from 'components/interfaces/SQLEditor/UtilityPanel/RunButton' import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' -import { useOrgOptedIntoAiAndHippaProject } from 'hooks/misc/useOrgOptedIntoAi' +import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { useFlag } from 'hooks/ui/useFlag' +import { BASE_PATH } from 'lib/constants' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' import { useAppStateSnapshot } from 'state/app-state' @@ -55,8 +56,8 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { const { profile } = useProfile() const snapV2 = useSqlEditorV2StateSnapshot() const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation() - const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject() - const includeSchemaMetadata = (isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM + const { includeSchemaMetadata } = useOrgAiOptInLevel() + const useBedrockAssistant = useFlag('useBedrockAssistant') const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState() @@ -236,7 +237,10 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { try { setIsSaving(true) - const { title: name } = await generateSqlTitle({ sql: currentValue }) + const { title: name } = await generateSqlTitle({ + sql: currentValue, + useBedrockAssistant, + }) const snippet = createSqlSnippetSkeletonV2({ id: uuidv4(), name, @@ -276,7 +280,11 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { language="pgsql" value={currentValue} onChange={handleChange} - aiEndpoint={`${BASE_PATH}/api/ai/sql/complete`} + aiEndpoint={ + useBedrockAssistant + ? `${BASE_PATH}/api/ai/sql/complete-v2` + : `${BASE_PATH}/api/ai/sql/complete` + } aiMetadata={{ projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/components/ui/InfoPill.tsx b/apps/studio/components/ui/InfoPill.tsx new file mode 100644 index 00000000000..be2b35973bc --- /dev/null +++ b/apps/studio/components/ui/InfoPill.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from 'ui/src/components/shadcn/ui/hover-card' +import { Badge } from 'ui/src/components/shadcn/ui/badge' +import { ExternalLink } from 'lucide-react' + +interface Link { + url: string + label: string +} + +interface InfoPillProps { + label: string + icon: React.ReactNode + title: string + description: string + links?: Link[] +} + +export const InfoPill: React.FC = ({ label, icon, title, description, links }) => { + return ( + + + + {icon} {label} + + + +
+ {React.cloneElement(icon as React.ReactElement, { + className: 'w-5 h-5', + strokeWidth: 1.5, + })} +
+
+

{title}

+

{description}

+ {links && links.length > 0 && ( +
+ {links.map((link, index) => ( + + + {link.label} + + ))} +
+ )} +
+
+
+ ) +} 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..a24bc09ef89 --- /dev/null +++ b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx @@ -0,0 +1,146 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useState } from 'react' +import { toast } from 'sonner' + +import { useParams } from 'common' +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 TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' +import Panel from '../Panel' + +export const ToggleLegacyApiKeysPanel = () => { + const { ref: projectRef } = useParams() + const [isConfirmOpen, setIsConfirmOpen] = useState(false) + + const { data: legacyAPIKeysStatusData, isSuccess: isLegacyAPIKeysStatusSuccess } = + useLegacyAPIKeysStatusQuery({ projectRef }) + + const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } = + useAsyncCheckProjectPermissions(PermissionAction.SECRETS_WRITE, '*') + + const { enabled: isLegacyKeysEnabled } = legacyAPIKeysStatusData || {} + + if (!(isLegacyAPIKeysStatusSuccess && isPermissionsSuccess)) { + return null + } + + return ( +

+ + +
+
+

+ {isLegacyKeysEnabled ? 'Disable legacy API keys' : 'Re-enabling legacy API keys'} +

+

+ {isLegacyKeysEnabled + ? 'Make sure you are no longer using your legacy API keys before proceeding.' + : 'Ensure that your RLS policies are in place prior to re-enabling legacy keys.'} +

+
+
+ 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 { ref: projectRef } = useParams() + const { enabled: isLegacyKeysEnabled } = legacyAPIKeysStatusData || {} + + const { mutate: toggleLegacyAPIKey, isLoading: isTogglingLegacyAPIKey } = + useToggleLegacyAPIKeysMutation() + + const onToggleLegacyAPIKeysEnabled = () => { + const enabled = !legacyAPIKeysStatusData?.enabled + + toggleLegacyAPIKey( + { projectRef, 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={isLegacyKeysEnabled ? 'Disable JWT-based keys' : 'Re-enable JWT-based keys'} + confirmString={isLegacyKeysEnabled ? 'disable' : 're-enable'} + confirmLabel={`Confirm to ${isLegacyKeysEnabled ? 'disable' : 're-enable'} anon and service_role`} + confirmPlaceholder={isLegacyKeysEnabled ? 'disable' : 're-enable'} + loading={isTogglingLegacyAPIKey} + variant={isLegacyKeysEnabled ? 'destructive' : 'default'} + alert={ + isLegacyKeysEnabled + ? { + title: 'Ensure legacy keys are no longer in use before disabling', + description: ( + + Disabling anon and service_role keys while they are in + use will cause downtime for your application. Ensure they are no longer in use + before proceeding. + + ), + } + : { + title: 'Publishable and secret keys are preferred', + description: ( + + Re-enabling anon and service_role keys may be + appropriate in certain cases, but using a publishable and secret key is more + secure. We recommend against re-enabling legacy API keys + + ), + } + } + /> + ) +} 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/components/ui/QueryBlock/QueryBlock.tsx b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx index 99a885bec36..afa9fc56408 100644 --- a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx +++ b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx @@ -424,7 +424,7 @@ export const QueryBlock = ({ diff --git a/apps/studio/data/ai/sql-cron-mutation.ts b/apps/studio/data/ai/sql-cron-mutation.ts new file mode 100644 index 00000000000..8257a8a9993 --- /dev/null +++ b/apps/studio/data/ai/sql-cron-mutation.ts @@ -0,0 +1,66 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { constructHeaders, fetchHandler } from 'data/fetchers' +import { BASE_PATH } from 'lib/constants' +import { ResponseError } from 'types' + +export type SqlCronGenerateResponse = string + +export type SqlCronGenerateVariables = { + prompt: string + useBedrockAssistant?: boolean +} + +export async function generateSqlCron({ prompt, useBedrockAssistant }: SqlCronGenerateVariables) { + const url = useBedrockAssistant + ? `${BASE_PATH}/api/ai/sql/cron-v2` + : `${BASE_PATH}/api/ai/sql/cron` + + const headers = await constructHeaders({ 'Content-Type': 'application/json' }) + const response = await fetchHandler(url, { + headers, + method: 'POST', + body: JSON.stringify({ prompt }), + }) + + let body: any + + try { + body = await response.json() + } catch {} + + if (!response.ok) { + throw new ResponseError(body?.message, response.status) + } + + return body as SqlCronGenerateResponse +} + +type SqlCronGenerateData = Awaited> + +export const useSqlCronGenerateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation( + (vars) => generateSqlCron(vars), + { + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to generate cron expression: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/ai/sql-title-mutation.ts b/apps/studio/data/ai/sql-title-mutation.ts index 6b6868698f4..ba28a87eeb7 100644 --- a/apps/studio/data/ai/sql-title-mutation.ts +++ b/apps/studio/data/ai/sql-title-mutation.ts @@ -12,11 +12,16 @@ export type SqlTitleGenerateResponse = { export type SqlTitleGenerateVariables = { sql: string + useBedrockAssistant?: boolean } -export async function generateSqlTitle({ sql }: SqlTitleGenerateVariables) { +export async function generateSqlTitle({ sql, useBedrockAssistant }: SqlTitleGenerateVariables) { + const url = useBedrockAssistant + ? `${BASE_PATH}/api/ai/sql/title-v2` + : `${BASE_PATH}/api/ai/sql/title` + const headers = await constructHeaders({ 'Content-Type': 'application/json' }) - const response = await fetchHandler(`${BASE_PATH}/api/ai/sql/title`, { + const response = await fetchHandler(url, { headers, method: 'POST', body: JSON.stringify({ 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/config/project-settings-v2-query.ts b/apps/studio/data/config/project-settings-v2-query.ts index b76d50f85f9..9b8d65e071d 100644 --- a/apps/studio/data/config/project-settings-v2-query.ts +++ b/apps/studio/data/config/project-settings-v2-query.ts @@ -63,6 +63,9 @@ export const useProjectSettingsV2Query = ( ) } +/** + * @deprecated Use api-keys-query instead! + */ export const getAPIKeys = (settings?: ProjectSettings) => { const anonKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'anon') const serviceKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'service_role') diff --git a/apps/studio/data/content-api/docs-error-codes-query.ts b/apps/studio/data/content-api/docs-error-codes-query.ts new file mode 100644 index 00000000000..381c655222b --- /dev/null +++ b/apps/studio/data/content-api/docs-error-codes-query.ts @@ -0,0 +1,43 @@ +import { useQuery, type UseQueryOptions } from '@tanstack/react-query' +import { graphql } from 'data/graphql' +import { executeGraphQL } from 'data/graphql/execute' +import { Service } from 'data/graphql/graphql' +import { contentApiKeys } from './keys' + +const ErrorCodeQuery = graphql(` + query ErrorCodeQuery($code: String!, $service: Service) { + errors(code: $code, service: $service) { + nodes { + code + service + message + } + } + } +`) + +interface Variables { + code: string + service?: Service +} + +async function getErrorCodeDescriptions({ code, service }: Variables, signal?: AbortSignal) { + return await executeGraphQL(ErrorCodeQuery, { variables: { code, service }, signal }) +} + +type ErrorCodeDescriptionsData = Awaited> +type ErrorCodeDescriptionsError = unknown + +export const useErrorCodesQuery = ( + variables: Variables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + return useQuery( + contentApiKeys.errorCodes(variables), + ({ signal }) => getErrorCodeDescriptions(variables, signal), + { enabled, ...options } + ) +} diff --git a/apps/studio/data/content-api/keys.ts b/apps/studio/data/content-api/keys.ts new file mode 100644 index 00000000000..80d437700a0 --- /dev/null +++ b/apps/studio/data/content-api/keys.ts @@ -0,0 +1,4 @@ +export const contentApiKeys = { + errorCodes: ({ code, service }: { code: string; service?: string }) => + ['content-api', 'error-codes', { code, service }] as const, +} diff --git a/apps/studio/data/feedback/support-ticket-send.ts b/apps/studio/data/feedback/support-ticket-send.ts index 45ed494472e..137215c244f 100644 --- a/apps/studio/data/feedback/support-ticket-send.ts +++ b/apps/studio/data/feedback/support-ticket-send.ts @@ -52,7 +52,7 @@ export async function sendSupportTicket({ }, }) - if (error) handleError(error) + if (error) handleError(error, { alwaysCapture: true }) return data } diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index a4c4ad6f4cd..9c369ee5504 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -128,8 +128,18 @@ export const { OPTIONS: options, } = client -export const handleError = (error: unknown): never => { +type HandleErrorOptions = { + alwaysCapture?: boolean +} + +export const handleError = ( + error: unknown, + options: HandleErrorOptions = { alwaysCapture: false } +): never => { if (error && typeof error === 'object') { + if (options.alwaysCapture) { + Sentry.captureException(error) + } const errorMessage = 'msg' in error && typeof error.msg === 'string' ? error.msg @@ -160,3 +170,55 @@ export const handleError = (error: unknown): never => { // up in the UI. throw new ResponseError(undefined) } + +// [Joshen] The methods below are brought over from lib/common/fetchers because we still need them +// primarily for our own endpoints in the dashboard repo. So consolidating all the fetch methods into here. + +async function handleFetchResponse(response: Response): Promise { + const contentType = response.headers.get('Content-Type') + if (contentType === 'application/octet-stream') return response as any + try { + const resTxt = await response.text() + try { + // try to parse response text as json + return JSON.parse(resTxt) + } catch (err) { + // return as text plain + return resTxt as any + } + } catch (e) { + return handleError(response) as T | ResponseError + } +} + +/** + * To be used only for dashboard API endpoints. Use `fetch` directly if calling a non dashboard API endpoint + * + * Exception for `bucket-object-download-mutation` as openapi-fetch doesn't support octet-stream responses + */ +export async function fetchPost( + url: string, + data: { [prop: string]: any }, + options?: { [prop: string]: any } +): Promise { + try { + const { headers: otherHeaders, abortSignal, ...otherOptions } = options ?? {} + const headers = await constructHeaders({ + 'Content-Type': 'application/json', + ...DEFAULT_HEADERS, + ...otherHeaders, + }) + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(data), + referrerPolicy: 'no-referrer-when-downgrade', + headers, + ...otherOptions, + signal: abortSignal, + }) + if (!response.ok) return handleError(response) + return handleFetchResponse(response) + } catch (error) { + return handleError(error) + } +} diff --git a/apps/studio/data/graphql/execute.ts b/apps/studio/data/graphql/execute.ts new file mode 100644 index 00000000000..3305e1ba5d6 --- /dev/null +++ b/apps/studio/data/graphql/execute.ts @@ -0,0 +1,36 @@ +import { handleError } from 'data/fetchers' +import type { TypedDocumentString } from './graphql' + +const CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL! + +export async function executeGraphQL( + query: TypedDocumentString, + { variables, signal }: { variables?: TVariables; signal?: AbortSignal } +) { + try { + const response = await fetch(CONTENT_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + variables, + }), + signal, + }) + + if (!response.ok) { + throw new Error('Failed network response from Content API') + } + + const { data, errors } = await response.json() + if (errors) { + throw errors + } + + return data as TResult + } catch (err) { + handleError(err) + } +} diff --git a/apps/studio/data/graphql/fragment-masking.ts b/apps/studio/data/graphql/fragment-masking.ts new file mode 100644 index 00000000000..494960811fa --- /dev/null +++ b/apps/studio/data/graphql/fragment-masking.ts @@ -0,0 +1,84 @@ +/* eslint-disable */ +import { ResultOf, DocumentTypeDecoration } from '@graphql-typed-document-node/core' +import { Incremental, TypedDocumentString } from './graphql' + +export type FragmentType> = + TDocumentType extends DocumentTypeDecoration + ? [TType] extends [{ ' $fragmentName'?: infer TKey }] + ? TKey extends string + ? { ' $fragmentRefs'?: { [key in TKey]: TType } } + : never + : never + : never + +// return non-nullable if `fragmentType` is non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> +): TType +// return nullable if `fragmentType` is undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | undefined +): TType | undefined +// return nullable if `fragmentType` is nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | null +): TType | null +// return nullable if `fragmentType` is nullable or undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | null | undefined +): TType | null | undefined +// return array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: Array>> +): Array +// return array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: Array>> | null | undefined +): Array | null | undefined +// return readonly array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> +): ReadonlyArray +// return readonly array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: ReadonlyArray>> | null | undefined +): ReadonlyArray | null | undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: + | FragmentType> + | Array>> + | ReadonlyArray>> + | null + | undefined +): TType | Array | ReadonlyArray | null | undefined { + return fragmentType as any +} + +export function makeFragmentData< + F extends DocumentTypeDecoration, + FT extends ResultOf, +>(data: FT, _fragment: F): FragmentType { + return data as FragmentType +} +export function isFragmentReady( + queryNode: TypedDocumentString, + fragmentNode: TypedDocumentString, + data: FragmentType, any>> | null | undefined +): data is FragmentType { + const deferredFields = queryNode.__meta__?.deferredFields as Record + const fragName = fragmentNode.__meta__?.fragmentName as string | undefined + + if (!deferredFields || !fragName) return true + + const fields = deferredFields[fragName] ?? [] + return fields.length > 0 && fields.every((field) => data && field in data) +} diff --git a/apps/studio/data/graphql/gql.ts b/apps/studio/data/graphql/gql.ts new file mode 100644 index 00000000000..1f4ff76f6e6 --- /dev/null +++ b/apps/studio/data/graphql/gql.ts @@ -0,0 +1,32 @@ +/* eslint-disable */ +import * as types from './graphql' + +/** + * Map of all GraphQL operations in the project. + * + * This map has several performance disadvantages: + * 1. It is not tree-shakeable, so it will include all operations in the project. + * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. + * 3. It does not support dead code elimination, so it will add unused operations. + * + * Therefore it is highly recommended to use the babel or swc plugin for production. + * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size + */ +type Documents = { + '\n query ErrorCodeQuery($code: String!, $service: Service) {\n errors(code: $code, service: $service) {\n nodes {\n code\n service\n message\n }\n }\n }\n': typeof types.ErrorCodeQueryDocument +} +const documents: Documents = { + '\n query ErrorCodeQuery($code: String!, $service: Service) {\n errors(code: $code, service: $service) {\n nodes {\n code\n service\n message\n }\n }\n }\n': + types.ErrorCodeQueryDocument, +} + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql( + source: '\n query ErrorCodeQuery($code: String!, $service: Service) {\n errors(code: $code, service: $service) {\n nodes {\n code\n service\n message\n }\n }\n }\n' +): typeof import('./graphql').ErrorCodeQueryDocument + +export function graphql(source: string) { + return (documents as any)[source] ?? {} +} diff --git a/apps/studio/data/graphql/graphql.ts b/apps/studio/data/graphql/graphql.ts new file mode 100644 index 00000000000..99d32bef6b9 --- /dev/null +++ b/apps/studio/data/graphql/graphql.ts @@ -0,0 +1,266 @@ +/* eslint-disable */ +import { DocumentTypeDecoration } from '@graphql-typed-document-node/core' +export type Maybe = T | null +export type InputMaybe = Maybe +export type Exact = { [K in keyof T]: T[K] } +export type MakeOptional = Omit & { [SubKey in K]?: Maybe } +export type MakeMaybe = Omit & { [SubKey in K]: Maybe } +export type MakeEmpty = { + [_ in K]?: never +} +export type Incremental = + | T + | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never } +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string } + String: { input: string; output: string } + Boolean: { input: boolean; output: boolean } + Int: { input: number; output: number } + Float: { input: number; output: number } +} + +/** A reference document containing a description of a Supabase CLI command */ +export type CliCommandReference = SearchResult & { + __typename?: 'CLICommandReference' + /** The content of the reference document, as text */ + content?: Maybe + /** The URL of the document */ + href?: Maybe + /** The title of the document */ + title?: Maybe +} + +/** A reference document containing a description of a function from a Supabase client library */ +export type ClientLibraryFunctionReference = SearchResult & { + __typename?: 'ClientLibraryFunctionReference' + /** The content of the reference document, as text */ + content?: Maybe + /** The URL of the document */ + href?: Maybe + /** The programming language for which the function is written */ + language: Language + /** The name of the function or method */ + methodName?: Maybe + /** The title of the document */ + title?: Maybe +} + +/** An error returned by a Supabase service */ +export type Error = { + __typename?: 'Error' + /** The unique code identifying the error. The code is stable, and can be used for string matching during error handling. */ + code: Scalars['String']['output'] + /** The HTTP status code returned with this error. */ + httpStatusCode?: Maybe + /** A human-readable message describing the error. The message is not stable, and should not be used for string matching during error handling. Use the code instead. */ + message?: Maybe + /** The Supabase service that returns this error. */ + service: Service +} + +/** A collection of Errors */ +export type ErrorCollection = { + __typename?: 'ErrorCollection' + /** A list of edges containing nodes in this collection */ + edges: Array + /** The nodes in this collection, directly accessible */ + nodes: Array + /** Pagination information */ + pageInfo: PageInfo + /** The total count of items available in this collection */ + totalCount: Scalars['Int']['output'] +} + +/** An edge in a collection of Errors */ +export type ErrorEdge = { + __typename?: 'ErrorEdge' + /** A cursor for use in pagination */ + cursor: Scalars['String']['output'] + /** The Error at the end of the edge */ + node: Error +} + +/** A document containing content from the Supabase docs. This is a guide, which might describe a concept, or explain the steps for using or implementing a feature. */ +export type Guide = SearchResult & { + __typename?: 'Guide' + /** The full content of the document, including all subsections (both those matching and not matching any query string) and possibly more content */ + content?: Maybe + /** The URL of the document */ + href?: Maybe + /** The subsections of the document. If the document is returned from a search match, only matching content chunks are returned. For the full content of the original document, use the content field in the parent Guide. */ + subsections?: Maybe + /** The title of the document */ + title?: Maybe +} + +export enum Language { + Csharp = 'CSHARP', + Dart = 'DART', + Javascript = 'JAVASCRIPT', + Kotlin = 'KOTLIN', + Python = 'PYTHON', + Swift = 'SWIFT', +} + +/** Pagination information for a collection */ +export type PageInfo = { + __typename?: 'PageInfo' + /** Cursor pointing to the end of the current page */ + endCursor?: Maybe + /** Whether there are more items after the current page */ + hasNextPage: Scalars['Boolean']['output'] + /** Whether there are more items before the current page */ + hasPreviousPage: Scalars['Boolean']['output'] + /** Cursor pointing to the start of the current page */ + startCursor?: Maybe +} + +export type RootQueryType = { + __typename?: 'RootQueryType' + /** Get the details of an error code returned from a Supabase service */ + error?: Maybe + /** Get error codes that can potentially be returned by Supabase services */ + errors?: Maybe + /** Get the GraphQL schema for this endpoint */ + schema: Scalars['String']['output'] + /** Search the Supabase docs for content matching a query string */ + searchDocs?: Maybe +} + +export type RootQueryTypeErrorArgs = { + code: Scalars['String']['input'] + service: Service +} + +export type RootQueryTypeErrorsArgs = { + after?: InputMaybe + before?: InputMaybe + code?: InputMaybe + first?: InputMaybe + last?: InputMaybe + service?: InputMaybe +} + +export type RootQueryTypeSearchDocsArgs = { + limit?: InputMaybe + query: Scalars['String']['input'] +} + +/** Document that matches a search query */ +export type SearchResult = { + /** The full content of the matching result */ + content?: Maybe + /** The URL of the matching result */ + href?: Maybe + /** The title of the matching result */ + title?: Maybe +} + +/** A collection of search results containing content from Supabase docs */ +export type SearchResultCollection = { + __typename?: 'SearchResultCollection' + /** A list of edges containing nodes in this collection */ + edges: Array + /** The nodes in this collection, directly accessible */ + nodes: Array + /** The total count of items available in this collection */ + totalCount: Scalars['Int']['output'] +} + +/** An edge in a collection of SearchResults */ +export type SearchResultEdge = { + __typename?: 'SearchResultEdge' + /** The SearchResult at the end of the edge */ + node: SearchResult +} + +export enum Service { + Auth = 'AUTH', + Realtime = 'REALTIME', + Storage = 'STORAGE', +} + +/** A content chunk taken from a larger document in the Supabase docs */ +export type Subsection = { + __typename?: 'Subsection' + /** The content of the subsection */ + content?: Maybe + /** The URL of the subsection */ + href?: Maybe + /** The title of the subsection */ + title?: Maybe +} + +/** A collection of content chunks from a larger document in the Supabase docs. */ +export type SubsectionCollection = { + __typename?: 'SubsectionCollection' + /** A list of edges containing nodes in this collection */ + edges: Array + /** The nodes in this collection, directly accessible */ + nodes: Array + /** The total count of items available in this collection */ + totalCount: Scalars['Int']['output'] +} + +/** An edge in a collection of Subsections */ +export type SubsectionEdge = { + __typename?: 'SubsectionEdge' + /** The Subsection at the end of the edge */ + node: Subsection +} + +/** A document describing how to troubleshoot an issue when using Supabase */ +export type TroubleshootingGuide = SearchResult & { + __typename?: 'TroubleshootingGuide' + /** The full content of the troubleshooting guide */ + content?: Maybe + /** The URL of the troubleshooting guide */ + href?: Maybe + /** The title of the troubleshooting guide */ + title?: Maybe +} + +export type ErrorCodeQueryQueryVariables = Exact<{ + code: Scalars['String']['input'] + service?: InputMaybe +}> + +export type ErrorCodeQueryQuery = { + __typename?: 'RootQueryType' + errors?: { + __typename?: 'ErrorCollection' + nodes: Array<{ __typename?: 'Error'; code: string; service: Service; message?: string | null }> + } | null +} + +export class TypedDocumentString + extends String + implements DocumentTypeDecoration +{ + __apiType?: DocumentTypeDecoration['__apiType'] + private value: string + public __meta__?: Record | undefined + + constructor(value: string, __meta__?: Record | undefined) { + super(value) + this.value = value + this.__meta__ = __meta__ + } + + toString(): string & DocumentTypeDecoration { + return this.value + } +} + +export const ErrorCodeQueryDocument = new TypedDocumentString(` + query ErrorCodeQuery($code: String!, $service: Service) { + errors(code: $code, service: $service) { + nodes { + code + service + message + } + } +} + `) as unknown as TypedDocumentString diff --git a/apps/studio/data/graphql/index.ts b/apps/studio/data/graphql/index.ts new file mode 100644 index 00000000000..f9bc8e591d0 --- /dev/null +++ b/apps/studio/data/graphql/index.ts @@ -0,0 +1,2 @@ +export * from './fragment-masking' +export * from './gql' diff --git a/apps/studio/data/integrations/github-branches-query.ts b/apps/studio/data/integrations/github-branches-query.ts index 18a49ece0a0..342238466c5 100644 --- a/apps/studio/data/integrations/github-branches-query.ts +++ b/apps/studio/data/integrations/github-branches-query.ts @@ -19,7 +19,7 @@ export async function getGitHubBranches( }) if (error) handleError(error) - return data as Record[] + return data } export type GitHubBranchesData = Awaited> diff --git a/apps/studio/data/integrations/integrations.types.ts b/apps/studio/data/integrations/integrations.types.ts index cc5c7f3ccce..a453f581771 100644 --- a/apps/studio/data/integrations/integrations.types.ts +++ b/apps/studio/data/integrations/integrations.types.ts @@ -259,7 +259,7 @@ export type IntegrationConnectionsCreateVariables = { export type GitHubConnectionCreateVariables = { organizationId: number - connection: components['schemas']['CreateGitHubConnectionsBody'] + connection: components['schemas']['CreateGitHubConnectionBody'] } export type EnvironmentTargets = 'production' | 'preview' | 'development' diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts new file mode 100644 index 00000000000..91ad4e4d66e --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts @@ -0,0 +1,64 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { JWTAlgorithm } from './jwt-signing-keys-query' +import { jwtSigningKeysKeys } from './keys' + +interface JWTSigningKeyCreateVariables { + projectRef?: string + algorithm: JWTAlgorithm + status: 'in_use' | 'standby' +} + +export async function createJWTSigningKey(payload: JWTSigningKeyCreateVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/v1/projects/{ref}/config/auth/signing-keys', { + params: { + path: { ref: payload.projectRef }, + }, + body: { + algorithm: payload.algorithm, + status: payload.status, + }, + }) + + if (error) handleError(error) + return data +} + +type JWTSigningKeyCreateData = Awaited> + +export const useJWTSigningKeyCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => createJWTSigningKey(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create new JWT signing key: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts new file mode 100644 index 00000000000..d39179436c9 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts @@ -0,0 +1,58 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { del, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +interface JWTSigningKeyDeleteVariables { + projectRef?: string + keyId: string +} + +export async function deleteJWTSigningKey(payload: JWTSigningKeyDeleteVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await del('/v1/projects/{ref}/config/auth/signing-keys/{id}', { + params: { + path: { ref: payload.projectRef, id: payload.keyId }, + }, + }) + + if (error) handleError(error) + return data +} + +type JWTSigningKeyDeleteData = Awaited> + +export const useJWTSigningKeyDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => deleteJWTSigningKey(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete JWT signing key: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts new file mode 100644 index 00000000000..a406194980e --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts @@ -0,0 +1,62 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, patch } from 'data/fetchers' +import type { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +interface JWTSigningKeyUpdateVariables { + projectRef?: string + keyId: string + status: 'in_use' | 'standby' | 'previously_used' | 'revoked' +} + +export async function updateJWTSigningKey(payload: JWTSigningKeyUpdateVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await patch('/v1/projects/{ref}/config/auth/signing-keys/{id}', { + params: { + path: { ref: payload.projectRef, id: payload.keyId }, + }, + body: { + status: payload.status, + }, + }) + + if (error) handleError(error) + return data +} + +type JWTSigningKeyUpdateData = Awaited> + +export const useJWTSigningKeyUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateJWTSigningKey(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update new JWT signing key: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts new file mode 100644 index 00000000000..83cf2a5addc --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts @@ -0,0 +1,43 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { components } from 'api-types' +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +export type JWTSigningKey = components['schemas']['SigningKeyResponse'] + +export type JWTAlgorithm = components['schemas']['SigningKeyResponse']['algorithm'] + +interface JWTSigningKeysVariables { + projectRef?: string +} + +async function getJWTSigningKeys({ projectRef }: JWTSigningKeysVariables, signal?: AbortSignal) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get(`/v1/projects/{ref}/config/auth/signing-keys`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) { + handleError(error) + } + + return data +} + +export type JWTSigningKeysData = Awaited> + +export const useJWTSigningKeysQuery = ( + { projectRef }: JWTSigningKeysVariables, + { enabled, ...options }: UseQueryOptions = {} +) => + useQuery( + jwtSigningKeysKeys.list(projectRef), + ({ signal }) => getJWTSigningKeys({ projectRef }, signal), + { + enabled: enabled && !!projectRef, + ...options, + } + ) diff --git a/apps/studio/data/jwt-signing-keys/keys.ts b/apps/studio/data/jwt-signing-keys/keys.ts new file mode 100644 index 00000000000..4d2c9a01a63 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/keys.ts @@ -0,0 +1,4 @@ +export const jwtSigningKeysKeys = { + list: (projectRef?: string) => ['projects', projectRef, 'jwt-signing-keys'] as const, + legacy: (projectRef?: string) => ['projects', projectRef, 'legacy-jwt-signing-key'] as const, +} diff --git a/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts new file mode 100644 index 00000000000..3ec3c15fc10 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts @@ -0,0 +1,64 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +interface LegacyJWTSigningKeyCreateVariables { + projectRef?: string +} + +export async function createLegacyJWTSigningKey(payload: LegacyJWTSigningKeyCreateVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/v1/projects/{ref}/config/auth/signing-keys/legacy', { + params: { + path: { ref: payload.projectRef }, + }, + }) + + if (error) handleError(error) + return data +} + +type LegacyJWTSigningKeyCreateData = Awaited> + +export const useLegacyJWTSigningKeyCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + LegacyJWTSigningKeyCreateData, + ResponseError, + LegacyJWTSigningKeyCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + LegacyJWTSigningKeyCreateData, + ResponseError, + LegacyJWTSigningKeyCreateVariables + >((vars) => createLegacyJWTSigningKey(vars), { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.legacy(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error( + `Failed to enable use of JWT signing keys with legacy JWT secret: ${data.message}` + ) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts new file mode 100644 index 00000000000..069b7504776 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts @@ -0,0 +1,42 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' + +import { jwtSigningKeysKeys } from './keys' + +interface LegacyJWTSigningKeyVariables { + projectRef?: string +} + +async function getLegacyJWTSigningKey( + { projectRef }: LegacyJWTSigningKeyVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get(`/v1/projects/{ref}/config/auth/signing-keys/legacy`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) { + handleError(error) + } + + return data +} + +export type LegacyJWTSigningKeyData = Awaited> + +export const useLegacyJWTSigningKeyQuery = ( + { projectRef }: LegacyJWTSigningKeyVariables, + { enabled, ...options }: UseQueryOptions = {} +) => + useQuery( + jwtSigningKeysKeys.legacy(projectRef), + ({ signal }) => getLegacyJWTSigningKey({ projectRef }, signal), + { + enabled: enabled && !!projectRef, + ...options, + } + ) diff --git a/apps/studio/data/log-drains/update-log-drain-mutation.ts b/apps/studio/data/log-drains/update-log-drain-mutation.ts index bd0e2ba7a80..14463173f58 100644 --- a/apps/studio/data/log-drains/update-log-drain-mutation.ts +++ b/apps/studio/data/log-drains/update-log-drain-mutation.ts @@ -25,6 +25,7 @@ export async function updateLogDrain(payload: LogDrainUpdateVariables) { body: { name: payload.name, description: payload.description, + type: payload.type, config: payload.config as any, }, }) diff --git a/apps/studio/data/logs/keys.ts b/apps/studio/data/logs/keys.ts new file mode 100644 index 00000000000..2a02591a244 --- /dev/null +++ b/apps/studio/data/logs/keys.ts @@ -0,0 +1,37 @@ +import { QuerySearchParamsType } from 'components/interfaces/UnifiedLogs/UnifiedLogs.types' + +export const logsKeys = { + unifiedLogsInfinite: ( + projectRef: string | undefined, + searchParams: QuerySearchParamsType | undefined + ) => + [ + 'projects', + projectRef, + 'unified-logs', + 'logs-data', + ...(searchParams ? [searchParams].filter(Boolean) : []), + ] as const, + unifiedLogsCount: ( + projectRef: string | undefined, + searchParams: QuerySearchParamsType | undefined + ) => + [ + 'projects', + projectRef, + 'unified-logs', + 'count-data', + ...(searchParams ? [searchParams].filter(Boolean) : []), + ] as const, + unifiedLogsChart: ( + projectRef: string | undefined, + searchParams: QuerySearchParamsType | undefined + ) => + [ + 'projects', + projectRef, + 'unified-logs', + 'chart-data', + ...(searchParams ? [searchParams].filter(Boolean) : []), + ] as const, +} diff --git a/apps/studio/data/logs/unified-logs-chart-query.ts b/apps/studio/data/logs/unified-logs-chart-query.ts new file mode 100644 index 00000000000..295078c2c44 --- /dev/null +++ b/apps/studio/data/logs/unified-logs-chart-query.ts @@ -0,0 +1,156 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { getLogsChartQuery } from 'components/interfaces/UnifiedLogs/UnifiedLogs.queries' +import { handleError, post } from 'data/fetchers' +import { ExecuteSqlError } from 'data/sql/execute-sql-query' +import { logsKeys } from './keys' +import { UNIFIED_LOGS_QUERY_OPTIONS, UnifiedLogsVariables } from './unified-logs-infinite-query' + +export async function getUnifiedLogsChart( + { projectRef, search }: UnifiedLogsVariables, + signal?: AbortSignal +) { + if (typeof projectRef === 'undefined') { + throw new Error('projectRef is required for getUnifiedLogsChart') + } + + // Use a default date range (last hour) if no date range is selected + let dateStart: string + let dateEnd: string + let startTime: Date + let endTime: Date + + if (search.date && search.date.length === 2) { + startTime = new Date(search.date[0]) + endTime = new Date(search.date[1]) + dateStart = startTime.toISOString() + dateEnd = endTime.toISOString() + } else { + // Default to last hour + endTime = new Date() + startTime = new Date(endTime.getTime() - 60 * 60 * 1000) + dateStart = startTime.toISOString() + dateEnd = endTime.toISOString() + } + + // Get SQL query from utility function (with dynamic bucketing) + const sql = getLogsChartQuery(search) + + const { data, error } = await post(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { path: { ref: projectRef } }, + body: { sql, iso_timestamp_start: dateStart, iso_timestamp_end: dateEnd }, + signal, + }) + + if (error) handleError(error) + + const chartData: Array<{ + timestamp: number + success: number + warning: number + error: number + }> = [] + + const dataByTimestamp = new Map< + number, + { + timestamp: number + success: number + warning: number + error: number + } + >() + + if (data?.result) { + data.result.forEach((row: any) => { + // The API returns timestamps in microseconds (needs to be converted to milliseconds for JS Date) + const microseconds = Number(row.time_bucket) + const milliseconds = Math.floor(microseconds / 1000) + + // Create chart data point + const dataPoint = { + timestamp: milliseconds, // Convert to milliseconds for the chart + success: Number(row.success) || 0, + warning: Number(row.warning) || 0, + error: Number(row.error) || 0, + } + + // Filter levels if needed + const levelFilter = search.level + if (levelFilter && levelFilter.length > 0) { + // Reset levels not in the filter + if (!levelFilter.includes('success')) dataPoint.success = 0 + if (!levelFilter.includes('warning')) dataPoint.warning = 0 + if (!levelFilter.includes('error')) dataPoint.error = 0 + } + + dataByTimestamp.set(milliseconds, dataPoint) + }) + } + + // Determine bucket size based on the truncation level in the SQL query + // We need to fill in missing data points + const startTimeMs = startTime.getTime() + const endTimeMs = endTime.getTime() + + // Calculate appropriate bucket size from the time range + const timeRangeHours = (endTimeMs - startTimeMs) / (1000 * 60 * 60) + + let bucketSizeMs: number + if (timeRangeHours > 72) { + // Day-level bucketing (for ranges > 3 days) + bucketSizeMs = 24 * 60 * 60 * 1000 + } else if (timeRangeHours > 12) { + // Hour-level bucketing (for ranges > 12 hours) + bucketSizeMs = 60 * 60 * 1000 + } else { + // Minute-level bucketing (for shorter ranges) + bucketSizeMs = 60 * 1000 + } + + // Fill in any missing buckets + for (let t = startTimeMs; t <= endTimeMs; t += bucketSizeMs) { + // Round to the nearest bucket boundary + const bucketTime = Math.floor(t / bucketSizeMs) * bucketSizeMs + + if (!dataByTimestamp.has(bucketTime)) { + // Create empty data point for this bucket + dataByTimestamp.set(bucketTime, { + timestamp: bucketTime, + success: 0, + warning: 0, + error: 0, + }) + } + } + + // Convert map to array + for (const dataPoint of dataByTimestamp.values()) { + chartData.push(dataPoint) + } + + // Sort by timestamp + chartData.sort((a, b) => a.timestamp - b.timestamp) + + return chartData +} + +export type UnifiedLogsChartData = Awaited> +export type UnifiedLogsChartError = ExecuteSqlError + +export const useUnifiedLogsChartQuery = ( + { projectRef, search }: UnifiedLogsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + logsKeys.unifiedLogsChart(projectRef, search), + ({ signal }) => getUnifiedLogsChart({ projectRef, search }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...UNIFIED_LOGS_QUERY_OPTIONS, + ...options, + } + ) diff --git a/apps/studio/data/logs/unified-logs-count-query.ts b/apps/studio/data/logs/unified-logs-count-query.ts new file mode 100644 index 00000000000..7bd0d1dca2f --- /dev/null +++ b/apps/studio/data/logs/unified-logs-count-query.ts @@ -0,0 +1,94 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { getLogsCountQuery } from 'components/interfaces/UnifiedLogs/UnifiedLogs.queries' +import { FacetMetadataSchema } from 'components/interfaces/UnifiedLogs/UnifiedLogs.schema' +import { handleError, post } from 'data/fetchers' +import { ExecuteSqlError } from 'data/sql/execute-sql-query' +import { logsKeys } from './keys' +import { + getUnifiedLogsISOStartEnd, + UNIFIED_LOGS_QUERY_OPTIONS, + UnifiedLogsVariables, +} from './unified-logs-infinite-query' + +export async function getUnifiedLogsCount( + { projectRef, search }: UnifiedLogsVariables, + signal?: AbortSignal +) { + if (typeof projectRef === 'undefined') { + throw new Error('projectRef is required for getUnifiedLogsCount') + } + + const sql = getLogsCountQuery(search) + const { isoTimestampStart, isoTimestampEnd } = getUnifiedLogsISOStartEnd(search) + + const { data, error } = await post(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { path: { ref: projectRef } }, + body: { iso_timestamp_start: isoTimestampStart, iso_timestamp_end: isoTimestampEnd, sql }, + signal, + }) + + if (error) handleError(error) + + // Process count results into facets structure + const facets: Record = {} + const countsByDimension: Record> = {} + let totalRowCount = 0 + + // Group by dimension + if (data?.result) { + data.result.forEach((row: any) => { + const dimension = row.dimension + const value = row.value + const count = Number(row.count || 0) + + // Set total count if this is the total dimension + if (dimension === 'total' && value === 'all') { + totalRowCount = count + } + + // Initialize dimension map if not exists + if (!countsByDimension[dimension]) { + countsByDimension[dimension] = new Map() + } + + // Add count to the dimension map + countsByDimension[dimension].set(value, count) + }) + } + + // Convert dimension maps to facets structure + Object.entries(countsByDimension).forEach(([dimension, countsMap]) => { + // Skip the 'total' dimension as it's not a facet + if (dimension === 'total') return + + const dimensionTotal = Array.from(countsMap.values()).reduce((sum, count) => sum + count, 0) + + facets[dimension] = { + total: dimensionTotal, + rows: Array.from(countsMap.entries()).map(([value, count]) => ({ value, total: count })), + } + }) + + return { totalRowCount, facets } +} + +export type UnifiedLogsCountData = Awaited> +export type UnifiedLogsCountError = ExecuteSqlError + +export const useUnifiedLogsCountQuery = ( + { projectRef, search }: UnifiedLogsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + logsKeys.unifiedLogsCount(projectRef, search), + ({ signal }) => getUnifiedLogsCount({ projectRef, search }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...UNIFIED_LOGS_QUERY_OPTIONS, + ...options, + } + ) diff --git a/apps/studio/data/logs/unified-logs-infinite-query.ts b/apps/studio/data/logs/unified-logs-infinite-query.ts new file mode 100644 index 00000000000..ee7a1cd677f --- /dev/null +++ b/apps/studio/data/logs/unified-logs-infinite-query.ts @@ -0,0 +1,176 @@ +import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' + +import { getUnifiedLogsQuery } from 'components/interfaces/UnifiedLogs/UnifiedLogs.queries' +import { + PageParam, + QuerySearchParamsType, +} from 'components/interfaces/UnifiedLogs/UnifiedLogs.types' +import { handleError, post } from 'data/fetchers' +import { ResponseError } from 'types' +import { logsKeys } from './keys' + +const LOGS_PAGE_LIMIT = 50 +type LogLevel = 'success' | 'warning' | 'error' + +export const UNIFIED_LOGS_QUERY_OPTIONS = { + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + refetchInterval: 0, + staleTime: 1000 * 60 * 5, // 5 minutes, +} + +export type UnifiedLogsData = any +export type UnifiedLogsError = ResponseError +export type UnifiedLogsVariables = { projectRef?: string; search: QuerySearchParamsType } + +export const getUnifiedLogsISOStartEnd = (search: QuerySearchParamsType) => { + // Extract date range from search or use default (last hour) + let isoTimestampStart: string + let isoTimestampEnd: string + + if (search.date && search.date.length === 2) { + isoTimestampStart = new Date(search.date[0]).toISOString() + isoTimestampEnd = new Date(search.date[1]).toISOString() + } else { + const now = new Date() + isoTimestampEnd = now.toISOString() + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) + isoTimestampStart = oneHourAgo.toISOString() + } + + return { isoTimestampStart, isoTimestampEnd } +} + +async function getUnifiedLogs( + { projectRef, search, pageParam }: UnifiedLogsVariables & { pageParam: PageParam }, + signal?: AbortSignal +) { + if (typeof projectRef === 'undefined') + throw new Error('projectRef is required for getUnifiedLogs') + + /** + * [Joshen] RE infinite loading pagination logic for unified logs, these all really should live in the API + * but for now we're doing these on the FE to move quickly while figuring out what data we need before we + * migrate this logic to the BE. Just thought to leave a small explanation on the logic here: + * + * We're leveraging on the log's timestamp to essentially fetch the next page + * Given that the logs are ordered descending (latest logs come first, and we're fetching older logs as we scroll down) + * Hence why the cursor is basically the last row's timestamp from the latest page + * + * iso_timestamp_start will always be the current timestamp + * iso_timestamp_end will default to the last hour for the first page, followed by the last row's timestamp from + * the previous page. + * + * However, just note that this isn't a perfect solution as there's always the edge case where by there's multiple rows + * with identical timestamps, hence why FE will need a de-duping logic (in UnifiedLogs.tsx) unless we can figure a cleaner + * solution when we move all this logic to the BE (e.g using composite columns for the cursor like timestamp + id) + * + */ + + const { isoTimestampStart, isoTimestampEnd } = getUnifiedLogsISOStartEnd(search) + const sql = `${getUnifiedLogsQuery(search)} ORDER BY timestamp DESC, id DESC LIMIT ${LOGS_PAGE_LIMIT}` + + const cursorValue = pageParam?.cursor + const cursorDirection = pageParam?.direction + + let timestampStart: string + let timestampEnd: string + + if (cursorDirection === 'prev') { + // Live mode: fetch logs newer than the cursor + timestampStart = cursorValue + ? new Date(Number(cursorValue) / 1000).toISOString() + : isoTimestampStart + timestampEnd = new Date().toISOString() + } else if (cursorDirection === 'next') { + // Regular pagination: fetch logs older than the cursor + timestampStart = isoTimestampStart + timestampEnd = cursorValue + ? new Date(Number(cursorValue) / 1000).toISOString() + : isoTimestampEnd + } else { + timestampStart = isoTimestampStart + timestampEnd = isoTimestampEnd + } + + const { data, error } = await post(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { path: { ref: projectRef } }, + body: { iso_timestamp_start: isoTimestampStart, iso_timestamp_end: timestampEnd, sql }, + signal, + }) + + if (error) handleError(error) + + const resultData = data?.result ?? [] + + const result = resultData.map((row: any) => { + // Create a date object for display purposes + const date = new Date(Number(row.timestamp) / 1000) + + return { + id: row.id, + date, + timestamp: row.timestamp, + level: row.level as LogLevel, + status: row.status || 200, + method: row.method, + host: row.host, + pathname: (row.url || '').replace(/^https?:\/\/[^\/]+/, '') || row.pathname || '', + event_message: row.event_message || row.body || '', + headers: + typeof row.headers === 'string' ? JSON.parse(row.headers || '{}') : row.headers || {}, + regions: row.region ? [row.region] : [], + log_type: row.log_type || '', + latency: row.latency || 0, + log_count: row.log_count || null, + logs: row.logs || [], + auth_user: row.auth_user || null, + } + }) + + const firstRow = result.length > 0 ? result[0] : null + const lastRow = result.length > 0 ? result[result.length - 1] : null + const hasMore = result.length >= LOGS_PAGE_LIMIT - 1 + + const nextCursor = lastRow ? lastRow.timestamp : null + // FIXED: Always provide prevCursor like DataTableDemo does + // This ensures live mode never breaks the infinite query chain + // DataTableDemo uses milliseconds, but our timestamps are in microseconds + const prevCursor = result.length > 0 ? firstRow!.timestamp : new Date().getTime() * 1000 + + return { + data: result, + nextCursor: hasMore ? nextCursor : null, + prevCursor, + } +} + +export const useUnifiedLogsInfiniteQuery = ( + { projectRef, search }: UnifiedLogsVariables, + { + enabled = true, + ...options + }: UseInfiniteQueryOptions = {} +) => { + return useInfiniteQuery( + logsKeys.unifiedLogsInfinite(projectRef, search), + ({ signal, pageParam }) => { + return getUnifiedLogs({ projectRef, search, pageParam }, signal) + }, + { + enabled: enabled && typeof projectRef !== 'undefined', + getPreviousPageParam: (firstPage) => { + if (!firstPage.prevCursor) return null + const result = { cursor: firstPage.prevCursor, direction: 'prev' } as PageParam + return result + }, + getNextPageParam(lastPage) { + if (!lastPage.nextCursor || lastPage.data.length === 0) return null + return { cursor: lastPage.nextCursor, direction: 'next' } as PageParam + }, + ...UNIFIED_LOGS_QUERY_OPTIONS, + ...options, + } + ) +} diff --git a/apps/studio/data/organizations/organization-project-claim-mutation.ts b/apps/studio/data/organizations/organization-project-claim-mutation.ts index 25b29c20ea4..16d42a31571 100644 --- a/apps/studio/data/organizations/organization-project-claim-mutation.ts +++ b/apps/studio/data/organizations/organization-project-claim-mutation.ts @@ -10,7 +10,6 @@ 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 f620074a041..169043d7fdc 100644 --- a/apps/studio/data/organizations/organization-project-claim-query.ts +++ b/apps/studio/data/organizations/organization-project-claim-query.ts @@ -51,7 +51,6 @@ 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, diff --git a/apps/studio/data/organizations/organizations-query.ts b/apps/studio/data/organizations/organizations-query.ts index f9c9e17eb0d..85f32ab775c 100644 --- a/apps/studio/data/organizations/organizations-query.ts +++ b/apps/studio/data/organizations/organizations-query.ts @@ -6,9 +6,9 @@ import { useProfile } from 'lib/profile' import type { Organization, ResponseError } from 'types' import { organizationKeys } from './keys' -export function castOrganizationResponseToOrganization( - org: components['schemas']['OrganizationResponse'] -): Organization { +export type OrganizationBase = components['schemas']['OrganizationResponse'] + +export function castOrganizationResponseToOrganization(org: OrganizationBase): Organization { return { ...org, billing_email: org.billing_email ?? 'Unknown', diff --git a/apps/studio/data/profile/profile-create-mutation.ts b/apps/studio/data/profile/profile-create-mutation.ts index f4c674300d2..0b373597a9f 100644 --- a/apps/studio/data/profile/profile-create-mutation.ts +++ b/apps/studio/data/profile/profile-create-mutation.ts @@ -1,23 +1,20 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' +import { components } from 'api-types' +import { handleError, post } from 'data/fetchers' import { organizationKeys } from 'data/organizations/keys' import { permissionKeys } from 'data/permissions/keys' -import { post } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' import type { ResponseError } from 'types' import { profileKeys } from './keys' -import type { Profile } from './types' -export type ProfileResponse = Profile +export type ProfileResponse = components['schemas']['ProfileResponse'] export async function createProfile() { - const response = await post(`${API_URL}/profile`, {}) - if (response.error) { - throw response.error - } + const { data, error } = await post('/platform/profile') - return response as ProfileResponse + if (error) handleError(error) + return data } type ProfileCreateData = Awaited> diff --git a/apps/studio/data/projects/new-project.constants.ts b/apps/studio/data/projects/new-project.constants.ts index 98240ddd5dd..58f55cf9faa 100644 --- a/apps/studio/data/projects/new-project.constants.ts +++ b/apps/studio/data/projects/new-project.constants.ts @@ -31,7 +31,7 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.01344, priceMonthly: 10, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, small: { label: 'Small', @@ -39,7 +39,7 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.0206, priceMonthly: 15, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, medium: { label: 'Medium', @@ -47,7 +47,7 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.0822, priceMonthly: 60, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, large: { label: 'Large', @@ -55,7 +55,7 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.1517, priceMonthly: 110, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, xlarge: { label: 'XL', @@ -63,7 +63,7 @@ export const instanceSizeSpecs: Record< cpu: '4-core', priceHourly: 0.2877, priceMonthly: 210, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, '2xlarge': { label: '2XL', @@ -71,7 +71,7 @@ export const instanceSizeSpecs: Record< cpu: '8-core', priceHourly: 0.562, priceMonthly: 410, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, '4xlarge': { label: '4XL', @@ -79,7 +79,7 @@ export const instanceSizeSpecs: Record< cpu: '16-core', priceHourly: 1.32, priceMonthly: 960, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], }, '8xlarge': { label: '8XL', @@ -87,7 +87,7 @@ export const instanceSizeSpecs: Record< cpu: '32-core', priceHourly: 2.562, priceMonthly: 1870, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '12xlarge': { label: '12XL', @@ -95,7 +95,7 @@ export const instanceSizeSpecs: Record< cpu: '48-core', priceHourly: 3.836, priceMonthly: 2800, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '16xlarge': { label: '16XL', @@ -103,7 +103,7 @@ export const instanceSizeSpecs: Record< cpu: '64-core', priceHourly: 5.12, priceMonthly: 3730, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '24xlarge': { label: '24XL', @@ -111,7 +111,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 9.73, priceMonthly: 7100, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '24xlarge_optimized_cpu': { label: '24XL - Optimized CPU', @@ -119,7 +119,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 8.9, priceMonthly: 6500, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '24xlarge_optimized_memory': { label: '24XL - Optimized Memory', @@ -127,7 +127,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 13.84, priceMonthly: 10100, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '24xlarge_high_memory': { label: '24XL - High Memory', @@ -135,7 +135,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 21.91, priceMonthly: 16000, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '48xlarge': { label: '48XL', @@ -143,7 +143,7 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 19.47, priceMonthly: 14200, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '48xlarge_optimized_cpu': { label: '48XL - Optimized CPU', @@ -151,7 +151,7 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 17.8, priceMonthly: 13000, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '48xlarge_optimized_memory': { label: '48XL - Optimized Memory', @@ -159,7 +159,7 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 27.68, priceMonthly: 20200, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, '48xlarge_high_memory': { label: '48XL - High Memory', @@ -167,6 +167,6 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 43.84, priceMonthly: 32000, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], }, } diff --git a/apps/studio/data/projects/project-create-mutation.ts b/apps/studio/data/projects/project-create-mutation.ts index ceaefb80bb3..1e6e4c3e313 100644 --- a/apps/studio/data/projects/project-create-mutation.ts +++ b/apps/studio/data/projects/project-create-mutation.ts @@ -21,7 +21,7 @@ const WHITELIST_ERRORS = [ export type ProjectCreateVariables = { name: string - organizationId: number + organizationSlug: string dbPass: string dbRegion: string dbSql?: string @@ -38,7 +38,7 @@ export type ProjectCreateVariables = { export async function createProject({ name, - organizationId, + organizationSlug, dbPass, dbRegion, dbSql, @@ -53,7 +53,7 @@ export async function createProject({ }: ProjectCreateVariables) { const body: components['schemas']['CreateProjectBody'] = { cloud_provider: cloudProvider, - org_id: organizationId, + organization_slug: organizationSlug, name, db_pass: dbPass, db_region: dbRegion, diff --git a/apps/studio/data/reports/auth-charts.ts b/apps/studio/data/reports/auth-charts.ts new file mode 100644 index 00000000000..7978511209c --- /dev/null +++ b/apps/studio/data/reports/auth-charts.ts @@ -0,0 +1,1197 @@ +export const getAuthReportAttributes = (isFreePlan: boolean) => [ + { + id: 'active-users', + label: 'Active Users', + valuePrecision: 0, + hide: false, + showTooltip: false, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'bar', + attributes: [ + { attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true }, + ], + }, + { + id: 'sign-in-attempts', + label: 'Sign In Attempts by Type', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'bar', + titleTooltip: 'The total number of sign in attempts by grant type.', + attributes: [ + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'Password', + grantType: 'password', + enabled: true, + }, + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'PKCE', + grantType: 'pkce', + enabled: true, + }, + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'Refresh Token', + grantType: 'refresh_token', + enabled: true, + }, + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'ID Token', + grantType: 'id_token', + enabled: true, + }, + ], + }, + { + id: 'signups', + label: 'Sign Ups', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'bar', + titleTooltip: 'The total number of sign ups.', + attributes: [ + { + attribute: 'TotalSignUps', + provider: 'logs', + label: 'Sign Ups', + enabled: true, + }, + ], + }, + { + id: 'auth-errors', + label: 'Auth Errors', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'bar', + titleTooltip: 'The total number of auth errors by status code.', + attributes: [ + { + attribute: 'ErrorsByStatus', + provider: 'logs', + label: 'Auth Errors', + }, + ], + }, + { + id: 'password-reset-requests', + label: 'Password Reset Requests', + valuePrecision: 0, + hide: false, + showTooltip: false, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'bar', + attributes: [ + { + attribute: 'PasswordResetRequests', + provider: 'logs', + label: 'Password Reset Requests', + enabled: true, + }, + ], + }, + { + id: 'sign-in-latency', + label: 'Sign In Latency', + valuePrecision: 2, + hide: true, // Jordi: Hidden until we can fix the query + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'Average latency for sign in operations by grant type.', + attributes: [ + { + attribute: 'SignInLatency', + provider: 'logs', + label: 'Password', + grantType: 'password', + enabled: true, + }, + { + attribute: 'SignInLatency', + provider: 'logs', + label: 'PKCE', + grantType: 'pkce', + enabled: true, + }, + { + attribute: 'SignInLatency', + provider: 'logs', + label: 'Refresh Token', + grantType: 'refresh_token', + enabled: true, + }, + { + attribute: 'SignInLatency', + provider: 'logs', + label: 'ID Token', + grantType: 'id_token', + enabled: true, + }, + ], + }, + { + id: 'sign-up-latency', + label: 'Sign Up Latency', + valuePrecision: 2, + hide: true, // Jordi: Hidden until we can fix the query + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'Average latency for sign up operations by provider.', + attributes: [ + { + attribute: 'SignUpLatency', + provider: 'logs', + label: 'Email', + providerType: 'email', + enabled: true, + }, + { + attribute: 'SignUpLatency', + provider: 'logs', + label: 'Google', + providerType: 'google', + enabled: true, + }, + { + attribute: 'SignUpLatency', + provider: 'logs', + label: 'GitHub', + providerType: 'github', + enabled: true, + }, + { + attribute: 'SignUpLatency', + provider: 'logs', + label: 'Apple', + providerType: 'apple', + enabled: true, + }, + ], + }, +] + +/** + * ================================================ + * Mock data below + * TODO: Remove once we have real data + * ================================================ + * */ + +// Time points for all mock data +const DEFAULT_TIME_POINTS = [ + '2025-05-05T13:47:23Z', + '2025-05-05T14:47:23Z', + '2025-05-05T15:47:23Z', + '2025-05-05T16:47:23Z', + '2025-05-05T17:47:23Z', + '2025-05-05T18:47:23Z', + '2025-05-05T19:47:23Z', + '2025-05-05T20:47:23Z', + '2025-05-05T21:47:23Z', + '2025-05-05T22:47:23Z', + '2025-05-05T23:47:23Z', + '2025-05-06T00:47:23Z', + '2025-05-06T01:47:23Z', + '2025-05-06T02:47:23Z', + '2025-05-06T03:47:23Z', + '2025-05-06T04:47:23Z', + '2025-05-06T05:47:23Z', + '2025-05-06T06:47:23Z', + '2025-05-06T07:47:23Z', + '2025-05-06T08:47:23Z', + '2025-05-06T09:47:23Z', + '2025-05-06T10:47:23Z', + '2025-05-06T11:47:23Z', + '2025-05-06T12:47:23Z', + '2025-05-06T13:47:23Z', +] + +// Helper to generate random values with some "realistic" patterns +const generatePatternedValues = ( + length: number, + base: number = 5, + variance: number = 5, + spikeProbability: number = 0.1, + spikeMultiplier: number = 5 +): number[] => { + return Array(length) + .fill(0) + .map(() => { + const hasSpike = Math.random() < spikeProbability + const baseValue = base + Math.floor(Math.random() * variance) + return hasSpike ? baseValue * spikeMultiplier : baseValue + }) +} + +export const getAuthRequestsMockData = (provider: string) => { + let values: number[] = [] + + if (provider === 'Email') { + values = [3, 4, 4, 5, 4, 3, 5, 6, 4, 3, 5, 5, 4, 3, 4, 5, 6, 7, 5, 15, 6, 9, 17, 8, 4] + } else if (provider === 'Google') { + values = [2, 1, 3, 0, 5, 2, 0, 0, 1, 2, 0, 0, 1, 2, 1, 0, 0, 0, 1, 1, 2, 4, 0, 4, 12] + } else if (provider === 'GitHub') { + values = [0, 0, 8, 0, 3, 0, 0, 1, 0, 0, 3, 0, 0, 0, 1, 0, 8, 4, 2, 0, 2, 1, 3, 6, 10] + } else { + values = Array(25).fill(0) + } + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [provider]: values[index], + })) + + return { + data, + yAxisLimit: 100, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +// Add new mock data generator for provider-specific sessions +export const getProviderSessionsMockData = (provider: string) => { + let values: number[] = [] + + // Generate higher base values for sessions compared to signups + if (provider === 'EmailSessions') { + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 15, 8, 0.1, 2) + } else if (provider === 'GoogleSessions') { + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 12, 6, 0.1, 2.5) + } else if (provider === 'GitHubSessions') { + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 10, 5, 0.1, 2.2) + } else { + // For other providers, generate lower values + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 5, 3, 0.1, 2) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [provider]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +// Add new mock data generator for provider-specific churn rates +export const getProviderChurnMockData = (provider: string) => { + let values: number[] = [] + + // Generate realistic churn rates (typically between 2-15%) + if (provider === 'EmailChurn') { + // Email typically has higher churn + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 2, 10, 0.1, 5.2).map((v) => + Math.min(v, 20) + ) + } else if (provider === 'GoogleChurn') { + // Google typically has lower churn due to account persistence + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 8, 2, 0.1, 1.3).map((v) => + Math.min(v, 15) + ) + } else if (provider === 'GitHubChurn') { + // GitHub has moderate churn + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 10, 2.5, 0.1, 1.2).map((v) => + Math.min(v, 18) + ) + } else { + // Other providers have varying churn rates + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 9, 3, 0.1, 1.4).map((v) => + Math.min(v, 20) + ) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [provider]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: 20, // Max 20% churn rate + format: '%', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +// Add new mock data generator for churn rates +export const getChurnRateMockData = (attribute: string) => { + let values: number[] = [] + + // Overall churn rate with some variance + values = generatePatternedValues(DEFAULT_TIME_POINTS.length, 0.6, 2, 0.15, 2.6) + .map((v) => Math.max(0, v - 0.25)) // Allow some values to go to 0 + .map((v) => Math.min(v, 3.2)) + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [attribute]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: 1.5, // Max 1.5% churn rate + format: '%', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +// Mock data generators for new charts +export const getActiveUsersMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + // Generate more realistic pattern for Daily Active Users + // Create 30 days of data for a better view + const thirtyDays = 30 + const thirtyDayDates = Array(thirtyDays) + .fill(0) + .map((_, i) => { + const date = new Date() + date.setDate(date.getDate() - (thirtyDays - i - 1)) + return date.toISOString() + }) + + // Base values that reflect a gradual growth trend + const baseValue = 200 + const dailyGrowth = 1.5 // Small daily growth factor + + if (metric === 'DAU') { + values = Array(thirtyDays) + .fill(0) + .map((_, i) => { + const dayOfWeek = new Date(thirtyDayDates[i]).getDay() + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6 + + // Base value with growth trend + let value = baseValue + i * dailyGrowth + + // Weekday/weekend pattern (weekends have ~20% less activity) + if (isWeekend) { + value = value * 0.8 + } + + // Add some randomness + const randomFactor = 0.9 + Math.random() * 0.2 // ±10% + value = value * randomFactor + + return Math.round(value) + }) + } else { + // For WAU and MAU (not used in this case) + values = Array(thirtyDays).fill(0) + } + + const data = thirtyDayDates.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getUserTypesMockData = (type: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (type === 'NewUsers') { + values = generatePatternedValues(length, 40, 20, 0.15, 3) + } else if (type === 'ReturningUsers') { + values = generatePatternedValues(length, 200, 50, 0.1, 1.5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [type]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getAuthSessionsMockData = (sessionType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (sessionType === 'TotalSessions') { + values = generatePatternedValues(length, 1200, 300, 0.1, 1.3) + } else if (sessionType === 'WebSessions') { + values = generatePatternedValues(length, 800, 200, 0.1, 1.4) + } else if (sessionType === 'MobileSessions') { + values = generatePatternedValues(length, 400, 150, 0.12, 1.5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [sessionType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getSessionDurationMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + // Session duration in minutes following a relatively stable pattern + const values = generatePatternedValues(length, 15, 5, 0.05, 1.5) + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +// Mock data generators for authentication flows +export const getPasswordResetMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (metric === 'PasswordResetRequests') { + values = generatePatternedValues(length, 25, 15, 0.1, 2) + } else if (metric === 'PasswordResetCompleted') { + values = generatePatternedValues(length, 18, 10, 0.1, 1.5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getEmailVerificationMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (metric === 'VerificationSent') { + values = generatePatternedValues(length, 50, 20, 0.12, 1.8) + } else if (metric === 'VerificationCompleted') { + values = generatePatternedValues(length, 35, 15, 0.1, 1.5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getVerificationRatesMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (metric === 'EmailOpenRate') { + // Email open rates between 50-80% + values = generatePatternedValues(length, 65, 15, 0.05, 1.2).map((v) => Math.min(v, 100)) + } else if (metric === 'VerificationRate') { + // Verification completion rates between 60-90% + values = generatePatternedValues(length, 75, 15, 0.05, 1.1).map((v) => Math.min(v, 100)) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: 100, // Percentage chart + format: '%', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +export const getMfaUsageMockData = (method: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (method === 'MfaSMS') { + values = generatePatternedValues(length, 15, 8, 0.1, 1.5) + } else if (method === 'MfaAuthenticator') { + values = generatePatternedValues(length, 25, 10, 0.1, 1.4) + } else if (method === 'MfaEmail') { + values = generatePatternedValues(length, 10, 5, 0.1, 1.6) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [method]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getMfaAdoptionMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + // MFA adoption between 15-35% + const values = generatePatternedValues(length, 25, 10, 0.05, 1.2).map((v) => Math.min(v, 100)) + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: 100, // Percentage chart + format: '%', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +// Mock data generators for error and security charts +export const getAuthErrorsMockData = (errorType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (errorType === 'Status403') { + // 403 Forbidden - Relatively uncommon + values = generatePatternedValues(length, 1, 1, 0.05, 3) + } else if (errorType === 'Status422') { + // 422 Unprocessable Entity - More common validation errors + values = generatePatternedValues(length, 8, 5, 0.12, 2) + } else if (errorType === 'Status429') { + // 429 Too Many Requests - Rate limiting, can be spiky + values = generatePatternedValues(length, 3, 2, 0.15, 4) + } else if (errorType === 'Status500') { + // 500 Internal Server Error - Server errors, usually low but impactful + values = generatePatternedValues(length, 1, 1, 0.08, 5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [errorType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getProviderErrorRateMockData = (provider: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + // Error rates between 0.5-8% + if (provider === 'EmailErrorRate') { + values = generatePatternedValues(length, 2.5, 2, 0.15, 2).map((v) => Math.min(v, 10)) + } else if (provider === 'GoogleErrorRate') { + values = generatePatternedValues(length, 1.2, 1, 0.1, 2.5).map((v) => Math.min(v, 10)) + } else if (provider === 'GitHubErrorRate') { + values = generatePatternedValues(length, 1.5, 1.2, 0.12, 2.2).map((v) => Math.min(v, 10)) + } else if (provider === 'FacebookErrorRate') { + values = generatePatternedValues(length, 2, 1.5, 0.1, 2).map((v) => Math.min(v, 10)) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [provider]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: 10, // Percentage chart + format: '%', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +export const getRateLimitingMockData = (eventType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (eventType === 'IPRateLimited') { + values = generatePatternedValues(length, 4, 3, 0.1, 4) + } else if (eventType === 'UserRateLimited') { + values = generatePatternedValues(length, 2, 2, 0.08, 3) + } else if (eventType === 'BruteForceAttempts') { + values = generatePatternedValues(length, 1, 1, 0.05, 5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [eventType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getAuthLatencyMockData = (operationType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (operationType === 'SignInLatency') { + values = generatePatternedValues(length, 180, 50, 0.1, 1.5) + } else if (operationType === 'SignUpLatency') { + values = generatePatternedValues(length, 250, 70, 0.1, 1.4) + } else if (operationType === 'TokenRefreshLatency') { + values = generatePatternedValues(length, 120, 40, 0.1, 1.6) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [operationType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(0)), + total, + } +} + +export const getSecurityEventsMockData = (eventType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (eventType === 'SuspiciousLogins') { + values = generatePatternedValues(length, 3, 2, 0.1, 3) + } else if (eventType === 'NewDeviceLogins') { + values = generatePatternedValues(length, 15, 8, 0.1, 1.5) + } else if (eventType === 'PasswordBreachDetections') { + values = generatePatternedValues(length, 1, 1, 0.05, 5) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [eventType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getTokenUsageMockData = (metricType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (metricType === 'TokenIssuance') { + values = generatePatternedValues(length, 100, 40, 0.1, 1.5) + } else if (metricType === 'TokenRefresh') { + values = generatePatternedValues(length, 400, 100, 0.1, 1.3) + } else if (metricType === 'TokenRevocation') { + values = generatePatternedValues(length, 30, 15, 0.1, 2) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metricType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +// Mock data generators for conversion metrics +export const getAuthFunnelMockData = (stage: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (stage === 'PageVisits') { + values = generatePatternedValues(length, 500, 200, 0.1, 1.5) + } else if (stage === 'SignupStarts') { + values = generatePatternedValues(length, 120, 50, 0.1, 1.4) + } else if (stage === 'SignupCompletes') { + values = generatePatternedValues(length, 80, 30, 0.1, 1.3) + } else if (stage === 'FirstLogins') { + values = generatePatternedValues(length, 75, 25, 0.1, 1.3) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [stage]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getConversionRatesMockData = (rateType: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (rateType === 'VisitToSignupRate') { + // Visit to signup: 20-30% + values = generatePatternedValues(length, 25, 5, 0.1, 1.2).map((v) => Math.min(v, 40)) + } else if (rateType === 'SignupCompletionRate') { + // Signup completion: 60-80% + values = generatePatternedValues(length, 70, 10, 0.1, 1.1).map((v) => Math.min(v, 90)) + } else if (rateType === 'RetentionRate') { + // 7-day retention: 40-60% + values = generatePatternedValues(length, 50, 10, 0.1, 1.1).map((v) => Math.min(v, 75)) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [rateType]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: 100, // Percentage chart + format: '%', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +export const getOnboardingCompletionMockData = (stage: string) => { + const length = DEFAULT_TIME_POINTS.length + let values: number[] = [] + + if (stage === 'OnboardingStarts') { + values = generatePatternedValues(length, 70, 25, 0.1, 1.4) + } else if (stage === 'ProfileCompletions') { + values = generatePatternedValues(length, 50, 20, 0.1, 1.3) + } else if (stage === 'VerificationCompletions') { + values = generatePatternedValues(length, 40, 15, 0.1, 1.3) + } else { + values = Array(length).fill(0) + } + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [stage]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(2)), + total, + } +} + +export const getOnboardingTimeMockData = (metric: string) => { + const length = DEFAULT_TIME_POINTS.length + // Onboarding time in minutes, generally 5-15 minutes + const values = generatePatternedValues(length, 10, 5, 0.1, 1.3) + + const data = DEFAULT_TIME_POINTS.map((time, index) => ({ + period_start: time, + [metric]: values[index], + })) + + const total = values.reduce((sum, val) => sum + val, 0) + const totalAverage = total / values.length + + return { + data, + yAxisLimit: Math.max(...values) * 1.2, + format: '', + totalAverage: parseFloat(totalAverage.toFixed(1)), + total, + } +} + +// Master mock data resolver - routes to the appropriate data generator based on attribute +export const getMockDataForAttribute = (attribute: string) => { + // Auth Requests (original chart) + if ( + [ + 'Email', + 'Google', + 'GitHub', + 'Phone', + 'SAML', + 'Apple', + 'Azure', + 'Bitbucket', + 'Discord', + 'Facebook', + 'Figma', + 'GitLab', + 'Kakao', + 'KeyCloak', + 'LinkedIn', + 'Notion', + 'Twitch', + 'Twitter', + 'Slack', + 'Spotify', + 'WorkOS', + 'Zoom', + ].includes(attribute) + ) { + return getAuthRequestsMockData(attribute) + } + + // Provider-specific sessions + if ( + [ + 'EmailSessions', + 'PhoneSessions', + 'SAMLSessions', + 'AppleSessions', + 'AzureSessions', + 'BitbucketSessions', + 'DiscordSessions', + 'FacebookSessions', + 'FigmaSessions', + 'GitHubSessions', + 'GitLabSessions', + 'GoogleSessions', + 'KakaoSessions', + 'KeyCloakSessions', + 'LinkedInSessions', + 'NotionSessions', + 'TwitchSessions', + 'TwitterSessions', + 'SlackSessions', + 'SpotifySessions', + 'WorkOSSessions', + 'ZoomSessions', + ].includes(attribute) + ) { + return getProviderSessionsMockData(attribute) + } + + // Provider-specific churn + if ( + [ + 'EmailChurn', + 'PhoneChurn', + 'SAMLChurn', + 'AppleChurn', + 'AzureChurn', + 'BitbucketChurn', + 'DiscordChurn', + 'FacebookChurn', + 'FigmaChurn', + 'GitHubChurn', + 'GitLabChurn', + 'GoogleChurn', + 'KakaoChurn', + 'KeyCloakChurn', + 'LinkedInChurn', + 'NotionChurn', + 'TwitchChurn', + 'TwitterChurn', + 'SlackChurn', + 'SpotifyChurn', + 'WorkOSChurn', + 'ZoomChurn', + ].includes(attribute) + ) { + return getProviderChurnMockData(attribute) + } + + // Overall churn rate + if (attribute === 'ChurnRate') { + return getChurnRateMockData(attribute) + } + + // Active Users charts + if (['DAU', 'WAU', 'MAU'].includes(attribute)) { + return getActiveUsersMockData(attribute) + } + + // User Types charts + if (['NewUsers', 'ReturningUsers'].includes(attribute)) { + return getUserTypesMockData(attribute) + } + + // Auth Sessions charts + if (['TotalSessions', 'WebSessions', 'MobileSessions'].includes(attribute)) { + return getAuthSessionsMockData(attribute) + } + + // Session Duration + if (attribute === 'AvgSessionDuration') { + return getSessionDurationMockData(attribute) + } + + // Password Reset metrics + if (['PasswordResetRequests', 'PasswordResetCompleted'].includes(attribute)) { + return getPasswordResetMockData(attribute) + } + + // Email Verification metrics + if (['VerificationSent', 'VerificationCompleted'].includes(attribute)) { + return getEmailVerificationMockData(attribute) + } + + // Verification Rates + if (['EmailOpenRate', 'VerificationRate'].includes(attribute)) { + return getVerificationRatesMockData(attribute) + } + + // MFA Usage + if (['MfaSMS', 'MfaAuthenticator', 'MfaEmail'].includes(attribute)) { + return getMfaUsageMockData(attribute) + } + + // MFA Adoption Rate + if (attribute === 'MfaAdoptionRate') { + return getMfaAdoptionMockData(attribute) + } + + // Auth Errors + if (['Status403', 'Status422', 'Status429', 'Status500'].includes(attribute)) { + return getAuthErrorsMockData(attribute) + } + + // Provider Error Rates + if ( + ['EmailErrorRate', 'GoogleErrorRate', 'GitHubErrorRate', 'FacebookErrorRate'].includes( + attribute + ) + ) { + return getProviderErrorRateMockData(attribute) + } + + // Rate Limiting + if (['IPRateLimited', 'UserRateLimited', 'BruteForceAttempts'].includes(attribute)) { + return getRateLimitingMockData(attribute) + } + + // Auth Latency + if (['SignInLatency', 'SignUpLatency', 'TokenRefreshLatency'].includes(attribute)) { + return getAuthLatencyMockData(attribute) + } + + // Security Events + if (['SuspiciousLogins', 'NewDeviceLogins', 'PasswordBreachDetections'].includes(attribute)) { + return getSecurityEventsMockData(attribute) + } + + // Token Usage + if (['TokenIssuance', 'TokenRefresh', 'TokenRevocation'].includes(attribute)) { + return getTokenUsageMockData(attribute) + } + + // Auth Funnel + if (['PageVisits', 'SignupStarts', 'SignupCompletes', 'FirstLogins'].includes(attribute)) { + return getAuthFunnelMockData(attribute) + } + + // Conversion Rates + if (['VisitToSignupRate', 'SignupCompletionRate', 'RetentionRate'].includes(attribute)) { + return getConversionRatesMockData(attribute) + } + + // Onboarding Completion + if (['OnboardingStarts', 'ProfileCompletions', 'VerificationCompletions'].includes(attribute)) { + return getOnboardingCompletionMockData(attribute) + } + + // Onboarding Time + if (attribute === 'TimeToCompleteOnboarding') { + return getOnboardingTimeMockData(attribute) + } + + // Default fallback + return { + data: DEFAULT_TIME_POINTS.map((time) => ({ period_start: time, [attribute]: 0 })), + yAxisLimit: 10, + format: '', + totalAverage: 0, + total: 0, + } +} diff --git a/apps/studio/data/reports/auth-report-query.ts b/apps/studio/data/reports/auth-report-query.ts new file mode 100644 index 00000000000..99f6ec0c352 --- /dev/null +++ b/apps/studio/data/reports/auth-report-query.ts @@ -0,0 +1,328 @@ +import { useQuery } from '@tanstack/react-query' +import { get } from 'data/fetchers' +import { AnalyticsInterval } from 'data/analytics/constants' +import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' +import { getHttpStatusCodeInfo } from 'lib/http-status-codes' +import { analyticsIntervalToGranularity } from './report.utils' + +/** + * METRICS + * Each chart in the UI has a corresponding metric key. + */ + +const METRIC_KEYS = [ + 'ActiveUsers', + 'SignInAttempts', + 'PasswordResetRequests', + 'TotalSignUps', + 'SignInLatency', + 'SignUpLatency', + 'ErrorsByStatus', +] + +const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = { + '400': { light: '#FFD54F', dark: '#FFF176' }, + '401': { light: '#FF8A65', dark: '#FFAB91' }, + '403': { light: '#FFB74D', dark: '#FFCC80' }, + '404': { light: '#90A4AE', dark: '#B0BEC5' }, + '409': { light: '#BA68C8', dark: '#CE93D8' }, + '410': { light: '#A1887F', dark: '#BCAAA4' }, + '422': { light: '#FF9800', dark: '#FFB74D' }, + '429': { light: '#E65100', dark: '#F57C00' }, + '500': { light: '#B71C1C', dark: '#D32F2F' }, + '502': { light: '#9575CD', dark: '#B39DDB' }, + '503': { light: '#0097A7', dark: '#4DD0E1' }, + '504': { light: '#C0CA33', dark: '#D4E157' }, + default: { light: '#757575', dark: '#9E9E9E' }, +} + +type MetricKey = (typeof METRIC_KEYS)[number] + +/** + * SQL + * Each metric has a corresponding SQL query. + */ + +const METRIC_SQL: Record string> = { + ActiveUsers: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --active-users + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(distinct json_value(f.event_message, "$.auth_event.actor_id")) as count + from auth_logs f + where json_value(f.event_message, "$.auth_event.action") in ( + 'login', 'user_signedup', 'token_refreshed', 'user_modified', + 'user_recovery_requested', 'user_reauthenticate_requested' + ) + group by timestamp + order by timestamp desc + ` + }, + SignInAttempts: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --sign-in-attempts + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + json_value(event_message, "$.grant_type") as grant_type, + count(*) as count + from auth_logs + where json_value(event_message, "$.path") = '/token' + group by timestamp, grant_type + order by timestamp desc, grant_type + ` + }, + PasswordResetRequests: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --password-reset-requests + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count + from auth_logs f + where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested' + group by timestamp + order by timestamp desc + ` + }, + TotalSignUps: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --total-signups + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count + from auth_logs + where json_value(event_message, "$.auth_event.action") = 'user_signedup' + group by timestamp + order by timestamp desc + ` + }, + SignInLatency: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --signin-latency + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + json_value(event_message, "$.grant_type") as grant_type, + count(*) as request_count, + round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms, + round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms, + round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms + from auth_logs + where json_value(event_message, "$.path") = '/token' + group by timestamp, grant_type + order by timestamp desc, grant_type + ` + }, + SignUpLatency: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --signup-latency + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + json_value(event_message, "$.auth_event.traits.provider") as provider, + round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms, + round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms, + round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms + from auth_logs + where json_value(event_message, "$.auth_event.action") = 'user_signedup' + and json_value(event_message, "$.status") = '200' + group by timestamp, provider + order by timestamp desc, provider + ` + }, + ErrorsByStatus: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --auth-errors-by-status +select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count, + response.status_code +from edge_logs + cross join unnest(metadata) as m + cross join unnest(m.request) as request + cross join unnest(m.response) as response +where path like '%/auth%' + and response.status_code >= 400 and response.status_code <= 599 +group by timestamp, status_code +order by timestamp desc + ` + }, +} + +/** + * FORMATTERS. + * Metrics need to be formatted before being passed on to the UI charts. + */ + +function defaultFormatter(rawData: any, attributes: MultiAttribute[]) { + const chartAttributes = attributes + if (!rawData) return { data: undefined, chartAttributes } + const result = rawData.result || [] + const timestamps = new Set(result.map((p: any) => p.timestamp)) + const data = Array.from(timestamps) + .sort() + .map((timestamp) => { + const point: any = { period_start: timestamp } + chartAttributes.forEach((attr) => { + point[attr.attribute] = 0 + }) + const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) + matchingPoints.forEach((p: any) => { + point[attributes[0].attribute] = p.count + }) + return point + }) + return { data, chartAttributes } +} + +const METRIC_FORMATTER: Record< + MetricKey, + ( + rawData: any, + attributes: MultiAttribute[], + logsMetric: string + ) => { data: any; chartAttributes: any } +> = { + ActiveUsers: (rawData, attributes) => defaultFormatter(rawData, attributes), + SignInAttempts: (rawData, attributes) => { + const chartAttributes = attributes.map((attr) => { + if (attr.attribute === 'SignInAttempts' && attr.grantType) { + return { ...attr, attribute: `${attr.attribute}_${attr.grantType}` } + } + return attr + }) + if (!rawData) return { data: undefined, chartAttributes } + const result = rawData.result || [] + const timestamps = new Set(result.map((p: any) => p.timestamp)) + const data = Array.from(timestamps) + .sort() + .map((timestamp) => { + const point: any = { period_start: timestamp } + chartAttributes.forEach((attr) => { + point[attr.attribute] = 0 + }) + const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) + matchingPoints.forEach((p: any) => { + point[`SignInAttempts_${p.grant_type}`] = p.count + }) + return point + }) + return { data, chartAttributes } + }, + PasswordResetRequests: (rawData, attributes) => defaultFormatter(rawData, attributes), + TotalSignUps: (rawData, attributes) => defaultFormatter(rawData, attributes), + SignInLatency: (rawData, attributes) => defaultFormatter(rawData, attributes), + SignUpLatency: (rawData, attributes) => defaultFormatter(rawData, attributes), + ErrorsByStatus: (rawData, attributes) => { + if (!rawData) return { data: undefined, chartAttributes: attributes } + const result = rawData.result || [] + + const statusCodes = Array.from(new Set(result.map((p: any) => p.status_code))) + + const chartAttributes = statusCodes.map((statusCode) => { + const statusCodeInfo = getHttpStatusCodeInfo(Number(statusCode)) + const color = STATUS_CODE_COLORS[String(statusCode)] || STATUS_CODE_COLORS.default + + return { + attribute: `status_${statusCode}`, + label: `${statusCode} ${statusCodeInfo.label}`, + provider: 'logs', + enabled: true, + color: color, + statusCode: String(statusCode), + } + }) + + const timestamps = new Set(result.map((p: any) => p.timestamp)) + const data = Array.from(timestamps) + .sort() + .map((timestamp) => { + const point: any = { period_start: timestamp } + chartAttributes.forEach((attr) => { + point[attr.attribute] = 0 + }) + const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) + matchingPoints.forEach((p: any) => { + point[`status_${p.status_code}`] = p.count + }) + return point + }) + + return { data, chartAttributes } + }, +} + +/** + * REPORT QUERY. + * Fetching and state management for the report. + */ + +export function useAuthLogsReport({ + projectRef, + attributes, + startDate, + endDate, + interval, + enabled = true, +}: { + projectRef: string + attributes: MultiAttribute[] + startDate: string + endDate: string + interval: AnalyticsInterval + enabled?: boolean +}) { + const logsMetric = attributes.length > 0 ? attributes[0].attribute : '' + + const isAuthMetric = METRIC_KEYS.includes(logsMetric) + + const sql = isAuthMetric ? METRIC_SQL[logsMetric](interval) : '' + + const { + data: rawData, + error, + isLoading, + } = useQuery( + ['auth-logs-report', projectRef, logsMetric, startDate, endDate, interval, sql], + async () => { + const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { + path: { ref: projectRef }, + query: { + sql, + iso_timestamp_start: startDate, + iso_timestamp_end: endDate, + }, + }, + }) + if (error) throw error + return data + }, + { + enabled: Boolean(projectRef && sql && enabled && isAuthMetric), + refetchOnWindowFocus: false, + } + ) + + // Use formatter if available + const formatter = + (isAuthMetric ? METRIC_FORMATTER[logsMetric as MetricKey] : undefined) || defaultFormatter + const { data, chartAttributes } = formatter(rawData, attributes, logsMetric) + + return { + data, + attributes: chartAttributes, + isLoading, + error, + } +} diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts index 82eb6551eee..0d3be727670 100644 --- a/apps/studio/data/reports/database-charts.ts +++ b/apps/studio/data/reports/database-charts.ts @@ -1,7 +1,8 @@ import { numberFormatter } from 'components/ui/Charts/Charts.utils' import { formatBytes } from 'lib/helpers' -import { Organization } from '../../types' +import { Organization } from 'types' import { Project } from '../projects/project-detail-query' +import { ReportAttributes } from 'components/ui/Charts/ComposedChart.utils' export const getReportAttributes = (isFreePlan: boolean) => [ { id: 'ram_usage', label: 'Memory usage', hide: false }, @@ -26,7 +27,10 @@ export const getReportAttributes = (isFreePlan: boolean) => [ }, ] -export const getReportAttributesV2 = (org: Organization, project: Project) => { +export const getReportAttributesV2: (org: Organization, project: Project) => ReportAttributes[] = ( + org, + project +) => { const isFreePlan = org?.plan?.id === 'free' const computeSize = project?.infra_compute_size || 'medium' const isSpendCapEnabled = @@ -41,12 +45,12 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { showLegend: true, hideChartType: false, defaultChartStyle: 'line', - showMaxValue: true, + showMaxValue: false, showGrid: true, syncId: 'database-reports', valuePrecision: 2, YAxisProps: { - width: 60, + width: 75, tickFormatter: (value: any) => formatBytes(value, 2), }, attributes: [ @@ -55,36 +59,21 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { provider: 'infra-monitoring', label: 'Used', tooltip: - 'Total RAM currently in use by the system and applications. High usage may indicate memory pressure.', + 'RAM in use by Postgres and the operating system. Sustained high usage may indicate memory pressure', }, { attribute: 'ram_usage_cache_and_buffers', provider: 'infra-monitoring', label: 'Cache + buffers', tooltip: - 'RAM used for caching and buffering disk operations. Higher values improve read performance.', + 'RAM used by the operating system page cache and PostgreSQL buffers to accelerate disk reads/writes', }, { attribute: 'ram_usage_free', provider: 'infra-monitoring', label: 'Free', tooltip: - 'Available unused RAM. A small amount is always reserved. High unused RAM present with erratic Used RAM may indicate unoptimized queries disrupting cache.', - }, - { - attribute: 'ram_usage_swap', - provider: 'infra-monitoring', - label: 'Swap', - omitFromTotal: false, // we can set this to true if we want to omit the swap from the total - tooltip: - 'Memory swapped to disk when RAM is full. An instance has 1GB of SWAP. High swap with high disk I/O signals memory stress.', - }, - { - attribute: 'ram_usage_total', - provider: 'infra-monitoring', - label: 'Max', - isMaxValue: true, - tooltip: 'Total RAM', + 'Unallocated memory available for use. A small portion is always reserved by the operating system', }, ], }, @@ -112,7 +101,7 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { label: 'System', format: '%', tooltip: - 'CPU time spent on kernel operations (e.g., process scheduling, memory management). High values may indicate system overhead.', + 'CPU time spent on kernel operations (e.g., process scheduling, memory management). High values may indicate system overhead', }, { attribute: 'cpu_usage_busy_user', @@ -120,7 +109,7 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { label: 'User', format: '%', tooltip: - 'CPU time used by database queries and user-space processes. High values may suggest CPU-intensive queries.', + 'CPU time used by database queries and user-space processes. High values may suggest CPU-intensive queries', }, { attribute: 'cpu_usage_busy_iowait', @@ -128,7 +117,7 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { label: 'IOwait', format: '%', tooltip: - 'CPU time waiting for disk or network I/O. High values may indicate disk bottlenecks.', + 'CPU time waiting for disk or network I/O. High values may indicate disk bottlenecks', }, { attribute: 'cpu_usage_busy_irqs', @@ -143,7 +132,7 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { label: 'Other', format: '%', tooltip: - 'CPU time spent on other tasks (e.g., background processes, software interrupts).', + 'CPU time spent on other tasks (e.g., background processes, software interrupts)', }, { attribute: 'cpu_usage_max', @@ -157,7 +146,7 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { }, { id: 'disk-iops', - label: 'Disk IOps', + label: 'Disk Input/Output operations per second (IOPS)', syncId: 'database-reports', hide: false, showTooltip: true, @@ -176,16 +165,16 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { { attribute: 'disk_iops_write', provider: 'infra-monitoring', - label: 'IOps write/s', + label: 'write IOPS', tooltip: - 'Number of write operations per second. High values indicate frequent data writes, logging, or transaction activity.', + 'Number of write operations per second. High values indicate frequent data writes, logging, or transaction activity', }, { attribute: 'disk_iops_read', provider: 'infra-monitoring', - label: 'IOps read/s', + label: 'read IOPS', tooltip: - 'Number of read operations per second. High values suggest frequent disk reads due to queries or poor caching.', + 'Number of read operations per second. High values suggest frequent disk reads due to queries or poor caching', }, { attribute: 'disk_iops_max', @@ -221,13 +210,13 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { provider: 'infra-monitoring', label: 'IO Usage', tooltip: - 'The actual number of IO operations per second that the database is currently using.', + 'The actual number of IO operations per second that the database is currently using', }, ], }, { - id: 'db-size', - label: 'Database Size', + id: 'disk-size', + label: 'Disk Size', syncId: 'database-reports', valuePrecision: 2, hide: false, @@ -244,18 +233,35 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { docsUrl: 'https://supabase.com/docs/guides/platform/database-size', attributes: [ { - attribute: 'pg_database_size', + attribute: 'disk_fs_used_system', provider: 'infra-monitoring', - label: 'Database', - tooltip: 'Total space on disk used by your database (tables, indexes, data, ...).', + format: 'bytes', + label: 'System', + tooltip: 'Reserved space for the system to ensure your database runs smoothly', }, { - attribute: 'max_pg_database_size', - provider: 'reference-line', - label: 'Disk size', - value: (project?.volumeSizeGb || getRecommendedDbSize(computeSize)) * 1024 * 1024 * 1024, - tooltip: 'Disk Size refers to the total space your project occupies on disk', + attribute: 'disk_fs_used_wal', + provider: 'infra-monitoring', + format: 'bytes', + label: 'WAL', + tooltip: + 'Disk usage by the write-ahead log. The usage depends on your WAL settings and the amount of data being written to the database', + }, + + { + attribute: 'pg_database_size', + provider: 'infra-monitoring', + format: 'bytes', + label: 'Database', + tooltip: 'Disk usage by your database (tables, indexes, data, ...)', + }, + { + attribute: 'disk_fs_size', + provider: 'infra-monitoring', isMaxValue: true, + format: 'bytes', + label: 'Disk Size', + tooltip: 'Disk Size refers to the total space your project occupies on disk', }, !isFreePlan && (isSpendCapEnabled @@ -285,42 +291,6 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { }), ], }, - { - id: 'network-traffic', - label: 'Network Traffic', - syncId: 'database-reports', - valuePrecision: 1, - showTooltip: true, - showLegend: true, - showMaxValue: false, - showGrid: true, - YAxisProps: { - width: 65, - tickFormatter: (value: any) => formatBytes(value, 1), - }, - hideChartType: false, - defaultChartStyle: 'line', - hideHighlightedValue: true, - showTotal: false, - attributes: [ - { - attribute: 'network_transmit_bytes', - provider: 'infra-monitoring', - label: 'Transmit', - tooltip: - 'Data sent from your database to clients. High values may indicate large query results or numerous outgoing connections.', - stackId: '2', - }, - { - attribute: 'network_receive_bytes', - provider: 'infra-monitoring', - label: 'Receive', - tooltip: - 'Data received by your database from clients. High values may indicate frequent queries, large data inserts, or many incoming connections.', - stackId: '1', - }, - ], - }, { id: 'client-connections', label: 'Database client connections', @@ -330,56 +300,61 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { showLegend: true, showMaxValue: true, hideChartType: false, + showGrid: true, + YAxisProps: { width: 30 }, defaultChartStyle: 'line', attributes: [ { attribute: 'client_connections_postgres', provider: 'infra-monitoring', - label: 'postgres', - tooltip: 'Active connections', + label: 'Postgres', + tooltip: + 'Direct connections to the Postgres database from your application and external clients', }, { attribute: 'client_connections_authenticator', provider: 'infra-monitoring', - label: 'postgrest', - tooltip: 'Active connections', - }, - { - attribute: 'client_connections_supabase_auth_admin', - provider: 'infra-monitoring', - label: 'auth', - tooltip: 'Active connections', - }, - { - attribute: 'client_connections_supabase_storage_admin', - provider: 'infra-monitoring', - label: 'storage', - tooltip: 'Active connections', + label: 'PostgREST', + tooltip: 'Connection pool managed by PostgREST', }, { attribute: 'client_connections_supabase_admin', provider: 'infra-monitoring', - label: 'supabase-admin', - tooltip: 'Active connections', + label: 'Reserved', + tooltip: + 'Administrative connections used by various Supabase services for internal operations and maintenance tasks', + }, + { + attribute: 'client_connections_supabase_auth_admin', + provider: 'infra-monitoring', + label: 'Auth', + tooltip: 'Connection pool managed by Supabase Auth', + }, + { + attribute: 'client_connections_supabase_storage_admin', + provider: 'infra-monitoring', + label: 'Storage', + tooltip: 'Connection pool managed by Supabase Storage', }, { attribute: 'client_connections_other', provider: 'infra-monitoring', - label: 'other', - tooltip: 'Active connections', + label: 'Other roles', + tooltip: "Miscellaneous database connections that don't fall into other categories.", }, { attribute: 'max_db_connections', - provider: 'infra-monitoring', - label: 'Maximum connections allowed', - tooltip: 'Maximum connections for instance size', + provider: 'reference-line', + label: 'Max connections', + value: getConnectionLimits(computeSize).direct, + tooltip: 'Max available connections for your current compute size', isMaxValue: true, }, ], }, { id: 'pgbouncer-connections', - label: 'Pooler client connections', + label: 'Dedicated Pooler client connections', syncId: 'database-reports', valuePrecision: 0, hide: false, @@ -410,7 +385,7 @@ export const getReportAttributesV2 = (org: Organization, project: Project) => { }, { id: 'supavisor-connections-active', - label: 'Supavisor client connections', + label: 'Shared Pooler (Supavisor) client connections', syncId: 'database-reports', valuePrecision: 0, hide: isFreePlan, diff --git a/apps/studio/data/reports/report.utils.ts b/apps/studio/data/reports/report.utils.ts new file mode 100644 index 00000000000..e41bf7e759d --- /dev/null +++ b/apps/studio/data/reports/report.utils.ts @@ -0,0 +1,21 @@ +import { AnalyticsInterval } from 'data/analytics/constants' + +export type Granularity = 'minute' | 'hour' | 'day' | 'week' +export function analyticsIntervalToGranularity(interval: AnalyticsInterval): Granularity { + switch (interval) { + case '1m': + return 'minute' + case '5m': + return 'minute' + case '10m': + return 'minute' + case '30m': + return 'minute' + case '1h': + return 'hour' + case '1d': + return 'day' + default: + return 'hour' + } +} diff --git a/apps/studio/data/service-status/edge-functions-status-query.ts b/apps/studio/data/service-status/edge-functions-status-query.ts index 9010e058064..ea97a0b4141 100644 --- a/apps/studio/data/service-status/edge-functions-status-query.ts +++ b/apps/studio/data/service-status/edge-functions-status-query.ts @@ -1,6 +1,5 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' -import { get } from 'lib/common/fetch' import type { ResponseError } from 'types' import { serviceStatusKeys } from './keys' @@ -9,10 +8,16 @@ export type EdgeFunctionServiceStatusVariables = { } export async function getEdgeFunctionServiceStatus(signal?: AbortSignal) { - const res = await get(`https://obuldanrptloktxcffvn.supabase.co/functions/v1/health-check`, { - signal, - }) - return res as { healthy: boolean } + try { + const res = await fetch('https://obuldanrptloktxcffvn.supabase.co/functions/v1/health-check', { + method: 'GET', + signal, + }) + const response = await res.json() + return response as { healthy: boolean } + } catch (err) { + return { healthy: false } + } } export type EdgeFunctionServiceStatusData = Awaited> diff --git a/apps/studio/data/storage/bucket-object-download-mutation.ts b/apps/studio/data/storage/bucket-object-download-mutation.ts index dca56be8d93..02073d937ff 100644 --- a/apps/studio/data/storage/bucket-object-download-mutation.ts +++ b/apps/studio/data/storage/bucket-object-download-mutation.ts @@ -2,7 +2,7 @@ import { UseMutationOptions, useMutation } from '@tanstack/react-query' import { toast } from 'sonner' import { components } from 'data/api' -import { post } from 'lib/common/fetch' +import { fetchPost } from 'data/fetchers' import { API_URL, IS_PLATFORM } from 'lib/constants' import { ResponseError } from 'types' @@ -18,15 +18,9 @@ export const downloadBucketObject = async ( ) => { if (!bucketId) throw new Error('bucketId is required') - // [Joshen] JFYI we have to use lib/common/fetch post as post from openapi-fetch doesn't support receiving octet-streams - // Opting to hard code /platform for non platform just for this particular mutation, so that it's clear what's happening - const response = await post( + const response = await fetchPost( `${API_URL}${IS_PLATFORM ? '' : '/platform'}/storage/${projectRef}/buckets/${bucketId}/objects/download`, - { - path, - options, - abortSignal: signal, - } + { path, options, abortSignal: signal } ) if (response.error) throw response.error diff --git a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts index ed6065771c8..fc660901b76 100644 --- a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts +++ b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts @@ -4,31 +4,36 @@ 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' +import { subscriptionKeys } from './keys' +import { usageKeys } from 'data/usage/keys' +import { invoicesKeys } from 'data/invoices/keys' export type PendingSubscriptionChangeVariables = { payment_intent_id: string - name: string - kind?: string - size?: string + slug?: string } export async function confirmPendingSubscriptionChange({ payment_intent_id, - name, - kind, - size, + slug, }: PendingSubscriptionChangeVariables) { - const { data, error } = await post('/platform/organizations/confirm-subscription', { - body: { - payment_intent_id, - name, - kind, - size, - }, - }) + if (!slug) { + throw new Error('Organization slug is required to confirm pending subscription change') + } + + const { data, error } = await post( + '/platform/organizations/{slug}/billing/subscription/confirm', + { + params: { + path: { + slug, + }, + }, + body: { + payment_intent_id, + }, + } + ) if (error) handleError(error) return data @@ -56,33 +61,30 @@ export const useConfirmPendingSubscriptionChangeMutation = ({ 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'] - ), - ] - } - ) + const { slug } = variables - await queryClient.invalidateQueries(permissionKeys.list()) + // [Kevin] Backend can return stale data as it's waiting for the Stripe-sync to complete. Until that's solved in the backend + // we are going back to monkey here and delay the invalidation + await new Promise((resolve) => setTimeout(resolve, 2000)) + + await Promise.all([ + queryClient.invalidateQueries(subscriptionKeys.orgSubscription(slug)), + queryClient.invalidateQueries(subscriptionKeys.orgPlans(slug)), + queryClient.invalidateQueries(usageKeys.orgUsage(slug)), + queryClient.invalidateQueries(invoicesKeys.orgUpcomingPreview(slug)), + queryClient.invalidateQueries(organizationKeys.detail(slug)), + queryClient.invalidateQueries(organizationKeys.list()), + queryClient.invalidateQueries(organizationKeys.paymentMethods(slug)), + ]) - // 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}`) + toast.error(data.message, { + dismissible: true, + duration: 10_000, + }) } else { onError(data, variables, context) } diff --git a/apps/studio/data/subscriptions/org-subscription-confirm-pending-create.ts b/apps/studio/data/subscriptions/org-subscription-confirm-pending-create.ts new file mode 100644 index 00000000000..8d60276bb94 --- /dev/null +++ b/apps/studio/data/subscriptions/org-subscription-confirm-pending-create.ts @@ -0,0 +1,95 @@ +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 PendingSubscriptionCreateVariables = { + payment_intent_id: string + name: string + kind?: string + size?: string +} + +export async function confirmPendingSubscriptionCreate({ + payment_intent_id, + name, + kind, + size, +}: PendingSubscriptionCreateVariables) { + const { data, error } = await post('/platform/organizations/confirm-subscription', { + body: { + payment_intent_id, + name, + kind, + size, + }, + }) + + if (error) handleError(error) + return data +} + +type PendingSubscriptionCreateData = Awaited> + +export const useConfirmPendingSubscriptionCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + PendingSubscriptionCreateData, + ResponseError, + PendingSubscriptionCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + PendingSubscriptionCreateData, + ResponseError, + PendingSubscriptionCreateVariables + >((vars) => confirmPendingSubscriptionCreate(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(data.message, { + dismissible: true, + duration: 10_000, + }) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/subscriptions/org-subscription-update-mutation.ts b/apps/studio/data/subscriptions/org-subscription-update-mutation.ts index 348eb311780..d2806ab227b 100644 --- a/apps/studio/data/subscriptions/org-subscription-update-mutation.ts +++ b/apps/studio/data/subscriptions/org-subscription-update-mutation.ts @@ -55,24 +55,43 @@ export const useOrgSubscriptionUpdateMutation = ({ async onSuccess(data, variables, context) { const { slug } = variables - // [Kevin] Backend can return stale data as it's waiting for the Stripe-sync to complete. Until that's solved in the backend - // we are going back to monkey here and delay the invalidation - await new Promise((resolve) => setTimeout(resolve, 2000)) + if (!data.pending_payment_intent_secret) { + // [Kevin] Backend can return stale data as it's waiting for the Stripe-sync to complete. Until that's solved in the backend + // we are going back to monkey here and delay the invalidation + await new Promise((resolve) => setTimeout(resolve, 2000)) - await Promise.all([ - queryClient.invalidateQueries(subscriptionKeys.orgSubscription(slug)), - queryClient.invalidateQueries(subscriptionKeys.orgPlans(slug)), - queryClient.invalidateQueries(usageKeys.orgUsage(slug)), - queryClient.invalidateQueries(invoicesKeys.orgUpcomingPreview(slug)), - queryClient.invalidateQueries(organizationKeys.detail(slug)), - queryClient.invalidateQueries(organizationKeys.list()), - ]) + await Promise.all([ + queryClient.invalidateQueries(subscriptionKeys.orgSubscription(slug)), + queryClient.invalidateQueries(subscriptionKeys.orgPlans(slug)), + queryClient.invalidateQueries(usageKeys.orgUsage(slug)), + queryClient.invalidateQueries(invoicesKeys.orgUpcomingPreview(slug)), + queryClient.invalidateQueries(organizationKeys.detail(slug)), + queryClient.invalidateQueries(organizationKeys.list()), + ]) + + if (variables.paymentMethod) { + queryClient.setQueriesData(organizationKeys.paymentMethods(slug), (prev: any) => { + if (!prev) return prev + return { + ...prev, + defaultPaymentMethodId: variables.paymentMethod, + data: prev.data.map((pm: any) => ({ + ...pm, + is_default: pm.id === variables.paymentMethod, + })), + } + }) + } + } await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { if (onError === undefined) { - toast.error(`Failed to update subscription: ${data.message}`) + toast.error(data.message, { + dismissible: true, + duration: 10_000, + }) } else { onError(data, variables, context) } diff --git a/apps/studio/hooks/analytics/useLogsPreview.tsx b/apps/studio/hooks/analytics/useLogsPreview.tsx index 1e6d5a69ef6..27a62b248f2 100644 --- a/apps/studio/hooks/analytics/useLogsPreview.tsx +++ b/apps/studio/hooks/analytics/useLogsPreview.tsx @@ -1,6 +1,6 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' -import { useMemo, useState, useCallback } from 'react' +import { useCallback, useMemo, useState } from 'react' import { LogsTableName, @@ -22,8 +22,8 @@ import { genDefaultQuery, } from 'components/interfaces/Settings/Logs/Logs.utils' import { get } from 'data/fetchers' -import { useLogsUrlState } from './useLogsUrlState' import { useFillTimeseriesSorted } from './useFillTimeseriesSorted' +import { useLogsUrlState } from './useLogsUrlState' import useTimeseriesUnixToIso from './useTimeseriesUnixToIso' interface LogsPreviewHook { @@ -83,12 +83,11 @@ function useLogsPreview({ const params: LogsEndpointParams = useMemo(() => { const currentSql = genDefaultQuery(table, mergedFilters, limit) return { - project: projectRef, iso_timestamp_start: timestampStart, iso_timestamp_end: timestampEnd, sql: currentSql, } - }, [projectRef, timestampStart, timestampEnd, table, mergedFilters, limit]) + }, [timestampStart, timestampEnd, table, mergedFilters, limit]) const queryKey = useMemo(() => ['projects', projectRef, 'logs', params], [projectRef, params]) @@ -177,7 +176,6 @@ function useLogsPreview({ params: { path: { ref: projectRef }, query: { - project: projectRef, sql: countQuerySql, iso_timestamp_start: latestRefresh, iso_timestamp_end: timestampEnd, @@ -228,7 +226,6 @@ function useLogsPreview({ query: { iso_timestamp_start: timestampStart, iso_timestamp_end: timestampEnd, - project: projectRef, sql: chartQuery, }, }, diff --git a/apps/studio/hooks/analytics/useLogsQuery.tsx b/apps/studio/hooks/analytics/useLogsQuery.tsx index 77fec615d88..4e4f8b84aed 100644 --- a/apps/studio/hooks/analytics/useLogsQuery.tsx +++ b/apps/studio/hooks/analytics/useLogsQuery.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { Dispatch, SetStateAction, useState, useEffect } from 'react' +import { Dispatch, SetStateAction, useEffect, useState } from 'react' import { IS_PLATFORM } from 'common' import { @@ -37,7 +37,6 @@ const useLogsQuery = ( const defaultHelper = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS) const [params, setParams] = useState({ sql: initialParams?.sql || '', - project: projectRef, iso_timestamp_start: initialParams.iso_timestamp_start ? initialParams.iso_timestamp_start : defaultHelper.calcFrom(), @@ -50,17 +49,11 @@ const useLogsQuery = ( setParams((prev) => ({ ...prev, ...initialParams, - project: projectRef, sql: initialParams?.sql ?? prev.sql, iso_timestamp_start: initialParams.iso_timestamp_start ?? prev.iso_timestamp_start, iso_timestamp_end: initialParams.iso_timestamp_end ?? prev.iso_timestamp_end, })) - }, [ - projectRef, - initialParams.sql, - initialParams.iso_timestamp_start, - initialParams.iso_timestamp_end, - ]) + }, [initialParams.sql, initialParams.iso_timestamp_start, initialParams.iso_timestamp_end]) const _enabled = enabled && typeof projectRef !== 'undefined' && Boolean(params.sql) diff --git a/apps/studio/hooks/analytics/useSingleLog.tsx b/apps/studio/hooks/analytics/useSingleLog.tsx index 291c17ecb47..6ca288c2122 100644 --- a/apps/studio/hooks/analytics/useSingleLog.tsx +++ b/apps/studio/hooks/analytics/useSingleLog.tsx @@ -31,7 +31,7 @@ function useSingleLog({ const table = queryType ? LOGS_TABLES[queryType] : undefined const sql = id && table ? genSingleLogQuery(table, id) : '' - const params: LogsEndpointParams = { ...paramsToMerge, project: projectRef, sql } + const params: LogsEndpointParams = { ...paramsToMerge, sql } const isWarehouseQuery = queryType === 'warehouse' // Warehouse queries are handled differently diff --git a/apps/studio/hooks/forms/useAIOptInForm.ts b/apps/studio/hooks/forms/useAIOptInForm.ts new file mode 100644 index 00000000000..a0c293e93ae --- /dev/null +++ b/apps/studio/hooks/forms/useAIOptInForm.ts @@ -0,0 +1,137 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' + +import { LOCAL_STORAGE_KEYS } from 'common' +import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useFlag } from 'hooks/ui/useFlag' +import { OPT_IN_TAGS } from 'lib/constants' +import type { ResponseError } from 'types' + +// Shared schema definition +export const AIOptInSchema = z.object({ + aiOptInLevel: z.enum(['disabled', 'schema', 'schema_and_log', 'schema_and_log_and_data'], { + required_error: 'AI Opt-in level selection is required', + }), +}) + +export type AIOptInFormValues = z.infer + +/** + * Hook to manage the AI Opt-In form state and submission logic. + * Optionally takes an onSuccess callback (e.g., to close a modal). + */ +export const useAIOptInForm = (onSuccessCallback?: () => void) => { + const queryClient = useQueryClient() + const selectedOrganization = useSelectedOrganization() + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + + const useBedrockAssistant = useFlag('useBedrockAssistant') + + const [_, setUpdatedOptInSinceMCP] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN, + false + ) + + // [Joshen] This is to prevent users from changing their opt in levels until the migration + // to clean up the existing opt in tags are completed. Once toggled on, users can then change their + // opt in levels again and we can clean this feature flag up + const newOrgAiOptIn = useFlag('newOrgAiOptIn') + + const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() + + const form = useForm({ + resolver: zodResolver(AIOptInSchema), + defaultValues: { + aiOptInLevel: getAiOptInLevel(selectedOrganization?.opt_in_tags), + }, + }) + + const onSubmit = async (values: AIOptInFormValues) => { + if (!canUpdateOrganization) { + return toast.error('You do not have the required permissions to update this organization') + } + if (!selectedOrganization?.slug) { + console.error('Organization slug is required') + return toast.error('Failed to update settings: Organization not found.') + } + const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] + + if (!useBedrockAssistant) { + const updatedOptInTags = values.aiOptInLevel === 'schema' ? [OPT_IN_TAGS.AI_SQL] : [] + + updateOrganization( + { slug: selectedOrganization.slug, opt_in_tags: updatedOptInTags }, + { + onSuccess: () => { + invalidateOrganizationsQuery(queryClient) + toast.success('Successfully updated AI opt-in settings') + setUpdatedOptInSinceMCP(true) + onSuccessCallback?.() // Call optional callback on success + }, + onError: (error: ResponseError) => { + toast.error(`Failed to update settings: ${error.message}`) + }, + } + ) + } else { + let updatedOptInTags = existingOptInTags.filter( + (tag: string) => + tag !== OPT_IN_TAGS.AI_SQL && + tag !== (OPT_IN_TAGS.AI_DATA ?? 'AI_DATA') && + tag !== (OPT_IN_TAGS.AI_LOG ?? 'AI_LOG') + ) + + if ( + values.aiOptInLevel === 'schema' || + values.aiOptInLevel === 'schema_and_log' || + values.aiOptInLevel === 'schema_and_log_and_data' + ) { + updatedOptInTags.push(OPT_IN_TAGS.AI_SQL) + } + if ( + values.aiOptInLevel === 'schema_and_log' || + values.aiOptInLevel === 'schema_and_log_and_data' + ) { + updatedOptInTags.push(OPT_IN_TAGS.AI_LOG) + } + if (values.aiOptInLevel === 'schema_and_log_and_data') { + updatedOptInTags.push(OPT_IN_TAGS.AI_DATA) + } + + updatedOptInTags = [...new Set(updatedOptInTags)] + + updateOrganization( + { slug: selectedOrganization.slug, opt_in_tags: updatedOptInTags }, + { + onSuccess: () => { + invalidateOrganizationsQuery(queryClient) + toast.success('Successfully updated AI opt-in settings') + setUpdatedOptInSinceMCP(true) + onSuccessCallback?.() // Call optional callback on success + }, + onError: (error: ResponseError) => { + toast.error(`Failed to update settings: ${error.message}`) + }, + } + ) + } + } + + return { + form, + onSubmit, + isUpdating, + currentOptInLevel: !newOrgAiOptIn + ? 'disabled' + : getAiOptInLevel(selectedOrganization?.opt_in_tags), + } +} diff --git a/apps/studio/hooks/misc/useDisallowHipaa.ts b/apps/studio/hooks/misc/useDisallowHipaa.ts deleted file mode 100644 index 5c3cab91b62..00000000000 --- a/apps/studio/hooks/misc/useDisallowHipaa.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useCallback } from 'react' - -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 { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' - -export function useDisallowHipaa() { - const selectedOrganization = useSelectedOrganization() - const project = useSelectedProject() - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrganization?.slug }) - const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) - const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: project?.ref }) - - const disallowHipaa = useCallback( - (allowed: boolean) => { - return hasHipaaAddon && projectSettings?.is_sensitive ? false : allowed - }, - [hasHipaaAddon, projectSettings] - ) - - return disallowHipaa -} diff --git a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts index 54219afe9b0..eca1a2d618b 100644 --- a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts +++ b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts @@ -1,35 +1,86 @@ -import { useDisallowHipaa } from 'hooks/misc/useDisallowHipaa' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { z } from 'zod' + +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 { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { OPT_IN_TAGS } from 'lib/constants' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useFlag } from 'hooks/ui/useFlag' +import { IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' + +export const aiOptInLevelSchema = z.enum([ + 'disabled', + 'schema', + 'schema_and_log', + 'schema_and_log_and_data', +]) + +export type AiOptInLevel = z.infer + +export const getAiOptInLevel = (tags: string[] | undefined): AiOptInLevel => { + const hasSql = tags?.includes(OPT_IN_TAGS.AI_SQL) + const hasData = tags?.includes(OPT_IN_TAGS.AI_DATA) + const hasLog = tags?.includes(OPT_IN_TAGS.AI_LOG) + + if (hasData) { + return 'schema_and_log_and_data' + } else if (hasLog) { + return 'schema_and_log' + } else if (hasSql) { + return 'schema' + } else { + return 'disabled' + } +} /** - * Checks if the organization has opted into sending anonymous data to OpenAI. - * Also considers if the organization has the HIPAA addon. - * @returns boolean (false if either not opted in or has the HIPAA addon) + * Determines if the organization has opted into *any* level of AI features (schema or schema_and_log or schema_and_log_and_data). + * This is primarily for backward compatibility. + * @returns boolean (true if opted into schema or schema_and_log or schema_and_log_and_data, false otherwise) */ -export function useOrgOptedIntoAi() { - const selectedOrganization = useSelectedOrganization() +export function useOrgOptedIntoAi(): boolean { + const { aiOptInLevel } = useOrgAiOptInLevel() + return !IS_PLATFORM || aiOptInLevel !== 'disabled' +} + +/** + * Determines the organization's specific AI opt-in level and whether schema metadata should be included. + * @returns Object with aiOptInLevel and includeSchemaMetadata + */ +export function useOrgAiOptInLevel(): { + aiOptInLevel: AiOptInLevel + includeSchemaMetadata: boolean + isHipaaProjectDisallowed: boolean +} { const selectedProject = useSelectedProject() - const optInTags = selectedOrganization?.opt_in_tags - const isOptedIntoAI = optInTags?.includes(OPT_IN_TAGS.AI_SQL) ?? false - - const disallowHipaa = useDisallowHipaa() - /* if we are in a project context and this has been called, - * ensure that we aren't letting HIPAA projects activate AI - * returns true if optedIntoAI and no project selected - * returns true if optedIntoAI and we are in a project and not HIPAA project - * returns false if opted out of AI - * returns false if optedIntoAI and we are in a HIPAA project - */ - return isOptedIntoAI && (!selectedProject || disallowHipaa(isOptedIntoAI)) -} - -export function useOrgOptedIntoAiAndHippaProject() { const selectedOrganization = useSelectedOrganization() - const optInTags = selectedOrganization?.opt_in_tags - const isOptedIntoAI = optInTags?.includes(OPT_IN_TAGS.AI_SQL) ?? false + const newOrgAiOptIn = useFlag('newOrgAiOptIn') - const disallowHipaa = useDisallowHipaa() - return { isOptedInToAI: isOptedIntoAI, isHipaaProjectDisallowed: disallowHipaa(isOptedIntoAI) } + // [Joshen] Default to disabled until migration to clean up existing opt in tags are completed + // Once toggled on, then we can default to their set opt in level and clean up feature flag + const optInTags = selectedOrganization?.opt_in_tags + const level = !newOrgAiOptIn ? 'disabled' : getAiOptInLevel(optInTags) + const isOptedIntoAI = level !== 'disabled' + + 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 + + // [Joshen] For CLI / self-host, we'd default to 'schema' as opt in level + const aiOptInLevel = !IS_PLATFORM + ? 'schema' + : (isOptedIntoAI && !selectedProject) || (isOptedIntoAI && !preventProjectFromUsingAI) + ? level + : 'disabled' + const includeSchemaMetadata = !IS_PLATFORM || aiOptInLevel !== 'disabled' + + return { + aiOptInLevel, + includeSchemaMetadata, + isHipaaProjectDisallowed: preventProjectFromUsingAI, + } } diff --git a/apps/studio/hooks/misc/useSelectedProject.ts b/apps/studio/hooks/misc/useSelectedProject.ts index af954876684..e16af5c23a7 100644 --- a/apps/studio/hooks/misc/useSelectedProject.ts +++ b/apps/studio/hooks/misc/useSelectedProject.ts @@ -34,11 +34,18 @@ export function useProjectByRef( }, [project, projects, ref]) } -export const useIsAwsNew = () => { +export const useIsAwsCloudProvider = () => { const project = useSelectedProject() - const isAwsNew = - project?.cloud_provider !== PROVIDERS.AWS.id && project?.cloud_provider !== PROVIDERS.FLY.id - return isAwsNew + const isAws = project?.cloud_provider === PROVIDERS.AWS.id + + return isAws +} + +export const useIsAwsK8sCloudProvider = () => { + const project = useSelectedProject() + const isAwsK8s = project?.cloud_provider === PROVIDERS.AWS_K8S.id + + return isAwsK8s } export const useIsOrioleDb = () => { diff --git a/apps/studio/hooks/ui/useCopyToClipboard.ts b/apps/studio/hooks/ui/useCopyToClipboard.ts new file mode 100644 index 00000000000..13601360c2e --- /dev/null +++ b/apps/studio/hooks/ui/useCopyToClipboard.ts @@ -0,0 +1,46 @@ +import { useCallback, useState } from 'react' +import { toast } from 'sonner' + +// [Joshen] This hook can replace all usage of copyToClipboard from lib/helpers +export function useCopyToClipboard() { + const [text, setText] = useState(null) + + const copy = useCallback( + async ( + text: string, + { timeout, withToast }: { timeout?: number; withToast?: boolean } = { + timeout: 3000, + withToast: false, + } + ) => { + if (!navigator?.clipboard) { + console.warn('Clipboard not supported') + return false + } + + try { + await navigator.clipboard.writeText(text) + setText(text) + + if (timeout) { + setTimeout(() => { + setText(null) + }, timeout) + } + + if (withToast) { + toast.success('Copied to clipboard') + } + + return true + } catch (error) { + console.warn('Copy failed', error) + setText(null) + return false + } + }, + [] + ) + + return { text, copy, isCopied: text !== null } +} diff --git a/apps/studio/hooks/ui/useHotKey.ts b/apps/studio/hooks/ui/useHotKey.ts new file mode 100644 index 00000000000..d0c381b9beb --- /dev/null +++ b/apps/studio/hooks/ui/useHotKey.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react' + +export function useHotKey(callback: () => void, key: string): void { + useEffect(() => { + function handler(e: KeyboardEvent) { + if (e.key === key && (e.metaKey || e.ctrlKey)) { + // e.preventDefault(); + callback() + } + } + + window.addEventListener('keydown', handler) + return () => { + window.removeEventListener('keydown', handler) + } + }, [key]) +} diff --git a/apps/studio/hooks/useChartData.ts b/apps/studio/hooks/useChartData.ts new file mode 100644 index 00000000000..a413d7ee726 --- /dev/null +++ b/apps/studio/hooks/useChartData.ts @@ -0,0 +1,188 @@ +/** + * useChartData + * + * A hook for fetching and processing data for a chart. + * This hook is responsible for all the data fetching, combining, and state management logic + * that was previously inside ComposedChartHandler. + * + * It takes all necessary parameters like project reference, date range, and attributes, + * and returns the final chart data, loading state, and derived attributes. + */ +import { useMemo } from 'react' +import { useRouter } from 'next/router' + +import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' +import type { AnalyticsInterval, DataPoint } from 'data/analytics/constants' +import { useAuthLogsReport } from 'data/reports/auth-report-query' +import type { ChartData } from 'components/ui/Charts/Charts.types' +import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' +import { useAttributeQueries } from 'components/ui/Charts/ComposedChartHandler' + +export const useChartData = ({ + attributes, + startDate, + endDate, + interval, + data, + highlightedValue, +}: { + attributes: MultiAttribute[] + startDate: string + endDate: string + interval: string + data?: ChartData + highlightedValue?: string | number +}) => { + const router = useRouter() + const { ref } = router.query + const state = useDatabaseSelectorStateSnapshot() + + const logsAttributes = attributes.filter((attr) => attr.provider === 'logs') + const nonLogsAttributes = attributes.filter((attr) => attr.provider !== 'logs') + + const { + data: logsData, + attributes: logsChartAttributes, + isLoading: isLogsLoading, + } = useAuthLogsReport({ + projectRef: ref as string, + attributes: logsAttributes, + startDate, + endDate, + interval: interval as AnalyticsInterval, + enabled: logsAttributes.length > 0, + }) + + const chartAttributes = useMemo( + () => nonLogsAttributes.concat(logsChartAttributes || []), + [nonLogsAttributes, logsChartAttributes] + ) + + const databaseIdentifier = state.selectedDatabaseId + + // Use the custom hook at the top level of the component + const attributeQueries = useAttributeQueries( + attributes, + ref, + startDate, + endDate, + interval as AnalyticsInterval, + databaseIdentifier, + data, + true + ) + + // Combine all the data into a single dataset + const combinedData = useMemo(() => { + if (data) return data + + const regularAttributeQueries = attributeQueries.filter( + (q) => q.data?.provider !== 'logs' && q.data?.provider !== 'reference-line' + ) + const isLoading = + (logsAttributes.length > 0 && isLogsLoading) || + regularAttributeQueries.some((query: any) => query.isLoading) + if (isLoading) { + return undefined + } + + const hasError = regularAttributeQueries.some((query: any) => !query.data) + if (hasError) { + return undefined + } + + // Get all unique timestamps from all datasets + const timestamps = new Set() + if (logsData) { + logsData.forEach((point: any) => { + if (point?.period_start) { + timestamps.add(point.period_start) + } + }) + } + regularAttributeQueries.forEach((query: any) => { + query.data?.data?.forEach((point: any) => { + if (point?.period_start) { + timestamps.add(point.period_start) + } + }) + }) + + const referenceLineQueries = attributeQueries.filter( + (q) => q.data?.provider === 'reference-line' + ) + + // Combine data points for each timestamp + const combined = Array.from(timestamps) + .sort() + .map((timestamp) => { + const point: any = { period_start: timestamp } + + const logPoint = logsData?.find((p: any) => p.period_start === timestamp) || {} + Object.assign(point, logPoint) + + chartAttributes.forEach((attr) => { + if (!attr) return + if (attr.provider === 'logs') return + if (attr.provider === 'reference-line') return + if (attr.customValue !== undefined) { + point[attr.attribute] = attr.customValue + return + } + + const query = regularAttributeQueries.find((q) => q.data?.attribute === attr.attribute) + const matchingPoint = query?.data?.data?.find((p: any) => p.period_start === timestamp) + point[attr.attribute] = matchingPoint?.[attr.attribute] ?? 0 + }) + + // Add reference line values for each timestamp + referenceLineQueries.forEach((query: any) => { + const attr = query.data.attribute + const value = query.data.total + point[attr] = value + }) + + return point as DataPoint + }) + + return combined as DataPoint[] + }, [data, attributeQueries, attributes, chartAttributes, isLogsLoading, logsData, logsAttributes]) + + const loading = + (logsAttributes.length > 0 && isLogsLoading) || + attributeQueries.some((query: any) => query.isLoading) + + // Calculate highlighted value based on the first attribute's data + const _highlightedValue = useMemo(() => { + if (highlightedValue !== undefined) return highlightedValue + + const firstAttr = attributes[0] + const firstQuery = attributeQueries[0] + const firstData = firstQuery?.data + + if (!firstData) return undefined + + const shouldHighlightMaxValue = + firstAttr.provider === 'daily-stats' && + !firstAttr.attribute.includes('ingress') && + !firstAttr.attribute.includes('egress') && + 'maximum' in firstData + + const shouldHighlightTotalGroupedValue = 'totalGrouped' in firstData + + return shouldHighlightMaxValue + ? firstData.maximum + : firstAttr.provider === 'daily-stats' + ? firstData.total + : shouldHighlightTotalGroupedValue + ? firstData.totalGrouped?.[firstAttr.attribute as keyof typeof firstData.totalGrouped] + : (firstData.data?.[firstData.data?.length - 1] as any)?.[firstAttr.attribute] + }, [highlightedValue, attributes, attributeQueries]) + + return { + data: combinedData, + isLoading: loading, + chartAttributes, + highlightedValue: _highlightedValue, + } +} diff --git a/apps/studio/lib/ai/bedrock.ts b/apps/studio/lib/ai/bedrock.ts new file mode 100644 index 00000000000..b5fde1a08dd --- /dev/null +++ b/apps/studio/lib/ai/bedrock.ts @@ -0,0 +1,29 @@ +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock' +import { createCredentialChain, fromNodeProviderChain } from '@aws-sdk/credential-providers' +import { awsCredentialsProvider } from '@vercel/functions/oidc' + +const credentialProvider = createCredentialChain( + // Vercel OIDC provider will be used for staging/production + awsCredentialsProvider({ + roleArn: process.env.AWS_BEDROCK_ROLE_ARN!, + }), + + // AWS profile will be used for local development + fromNodeProviderChain({ + profile: process.env.AWS_BEDROCK_PROFILE, + }) +) + +export const bedrock = createAmazonBedrock({ + credentialProvider, + region: process.env.AWS_BEDROCK_REGION, +}) + +export async function checkAwsCredentials() { + try { + const credentials = await credentialProvider() + return !!credentials + } catch (error) { + return false + } +} diff --git a/apps/studio/lib/ai/model.test.ts b/apps/studio/lib/ai/model.test.ts new file mode 100644 index 00000000000..bb087a1e6a0 --- /dev/null +++ b/apps/studio/lib/ai/model.test.ts @@ -0,0 +1,65 @@ +import { openai } from '@ai-sdk/openai' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as bedrockModule from './bedrock' +import { bedrock } from './bedrock' +import { getModel, ModelErrorMessage, modelsByProvider } from './model' + +vi.mock('@ai-sdk/openai', () => ({ + openai: vi.fn(() => 'openai-model'), +})) + +vi.mock('./bedrock', () => ({ + bedrock: vi.fn(() => 'bedrock-model'), + checkAwsCredentials: vi.fn(), +})) + +describe('getModel', () => { + const originalEnv = { ...process.env } + + beforeEach(() => { + vi.resetAllMocks() + }) + + afterEach(() => { + process.env = { ...originalEnv } + }) + + it('should return bedrock model when AWS credentials are available and AWS_BEDROCK_REGION is set', async () => { + vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(true) + process.env.AWS_BEDROCK_REGION = 'us-east-1' + + const { model, error } = await getModel() + + expect(model).toEqual('bedrock-model') + expect(bedrock).toHaveBeenCalledWith(modelsByProvider.bedrock) + expect(error).toBeUndefined() + }) + + it('should return error when AWS credentials are available but AWS_BEDROCK_REGION is not set', async () => { + vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(true) + delete process.env.AWS_BEDROCK_REGION + + const { error } = await getModel() + + expect(error).toEqual(new Error('AWS_BEDROCK_REGION is not set')) + }) + + it('should return OpenAI model when AWS credentials are not available but OPENAI_API_KEY is set', async () => { + vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(false) + process.env.OPENAI_API_KEY = 'test-key' + + const { model } = await getModel() + + expect(model).toEqual('openai-model') + expect(openai).toHaveBeenCalledWith(modelsByProvider.openai) + }) + + it('should return error when neither AWS credentials nor OPENAI_API_KEY is available', async () => { + vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(false) + delete process.env.OPENAI_API_KEY + + const { error } = await getModel() + + expect(error).toEqual(new Error(ModelErrorMessage)) + }) +}) diff --git a/apps/studio/lib/ai/model.ts b/apps/studio/lib/ai/model.ts new file mode 100644 index 00000000000..a419e5152d6 --- /dev/null +++ b/apps/studio/lib/ai/model.ts @@ -0,0 +1,53 @@ +import { openai } from '@ai-sdk/openai' +import { LanguageModel } from 'ai' +import { bedrock, checkAwsCredentials } from './bedrock' + +export const modelsByProvider = { + bedrock: 'us.anthropic.claude-sonnet-4-20250514-v1:0', + openai: 'gpt-4.1-2025-04-14', +} + +export type ModelSuccess = { + model: LanguageModel + error?: never +} + +export type ModelError = { + model?: never + error: Error +} + +export type ModelResponse = ModelSuccess | ModelError + +export const ModelErrorMessage = + 'No valid AI model available. Please set up a local AWS profile to use Bedrock, or pass an OPENAI_API_KEY to use OpenAI.' + +/** + * Retrieves the appropriate AI model based on available credentials. + */ +export async function getModel(): Promise { + const hasAwsCredentials = await checkAwsCredentials() + const hasOpenAIKey = !!process.env.OPENAI_API_KEY + + if (hasAwsCredentials) { + if (!process.env.AWS_BEDROCK_REGION) { + return { + error: new Error('AWS_BEDROCK_REGION is not set'), + } + } + + return { + model: bedrock(modelsByProvider.bedrock), + } + } + + if (hasOpenAIKey) { + return { + model: openai(modelsByProvider.openai), + } + } + + return { + error: new Error(ModelErrorMessage), + } +} diff --git a/apps/studio/lib/common/fetch/base.ts b/apps/studio/lib/common/fetch/base.ts index f9bf3c87aa7..3f000da9971 100644 --- a/apps/studio/lib/common/fetch/base.ts +++ b/apps/studio/lib/common/fetch/base.ts @@ -15,7 +15,6 @@ export async function handleResponse( ): Promise> { const contentType = response.headers.get('Content-Type') if (contentType === 'application/octet-stream') return response as any - try { const resTxt = await response.text() try { diff --git a/apps/studio/lib/common/fetch/get.ts b/apps/studio/lib/common/fetch/get.ts index 0d9382c9529..b1f912934c9 100644 --- a/apps/studio/lib/common/fetch/get.ts +++ b/apps/studio/lib/common/fetch/get.ts @@ -25,30 +25,3 @@ export async function get( return handleError(error, requestId) } } - -export async function getWithTimeout( - url: string, - options?: { [prop: string]: any } -): Promise> { - const requestId = uuidv4() - try { - const timeout = options?.timeout ?? 60000 - const controller = new AbortController() - const id = setTimeout(() => controller.abort(), timeout) - const { headers: optionHeaders, ...otherOptions } = options ?? {} - const headers = await constructHeaders(requestId, optionHeaders) - const response = await fetch(url, { - method: 'GET', - referrerPolicy: 'no-referrer-when-downgrade', - headers, - ...otherOptions, - signal: controller.signal, - }) - clearTimeout(id) - - if (!response.ok) return handleResponseError(response, requestId) - return handleResponse(response, requestId) - } catch (error) { - return handleError(error, requestId) - } -} diff --git a/apps/studio/lib/common/fetch/index.ts b/apps/studio/lib/common/fetch/index.ts index e374cad9dc1..f476c3c02f4 100644 --- a/apps/studio/lib/common/fetch/index.ts +++ b/apps/studio/lib/common/fetch/index.ts @@ -9,7 +9,7 @@ export { isResponseOk, } from './base' export { delete_ } from './delete' -export { get, getWithTimeout } from './get' +export { get } from './get' export { head, headWithTimeout } from './head' export { patch } from './patch' export { post } from './post' diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index a429c02ff6e..7113b142d08 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -41,6 +41,8 @@ export const USAGE_APPROACHING_THRESHOLD = 0.75 export const OPT_IN_TAGS = { AI_SQL: 'AI_SQL_GENERATOR_OPT_IN', + AI_DATA: 'AI_DATA_GENERATOR_OPT_IN', + AI_LOG: 'AI_LOG_GENERATOR_OPT_IN', } export const GB = 1024 * 1024 * 1024 diff --git a/apps/studio/lib/constants/infrastructure.ts b/apps/studio/lib/constants/infrastructure.ts index f240cc2bb0d..cfd69d3244e 100644 --- a/apps/studio/lib/constants/infrastructure.ts +++ b/apps/studio/lib/constants/infrastructure.ts @@ -41,8 +41,8 @@ export const PROVIDERS = { default_region: AWS_REGIONS_DEFAULT, regions: { ...AWS_REGIONS }, }, - AWS_NEW: { - id: 'AWS_NEW', + AWS_K8S: { + id: 'AWS_K8S', name: 'AWS (Revamped)', DEFAULT_SSH_KEY: 'supabase-app-instance', default_region: AWS_REGIONS_DEFAULT, diff --git a/apps/studio/lib/http-status-codes.test.ts b/apps/studio/lib/http-status-codes.test.ts new file mode 100644 index 00000000000..171f4950fe0 --- /dev/null +++ b/apps/studio/lib/http-status-codes.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { getHttpStatusCodeInfo } from './http-status-codes' + +describe('getHttpStatusCodeInfo', () => { + it('should return the correct status code info', () => { + const statusCodeInfo = getHttpStatusCodeInfo(400) + expect(statusCodeInfo).toEqual({ + code: 400, + name: 'BAD_REQUEST', + message: 'The server cannot or will not process the request due to an apparent client error.', + label: 'Bad Request', + }) + }) + + it('should return unknown for an unknown status code', () => { + const statusCodeInfo = getHttpStatusCodeInfo(999) + expect(statusCodeInfo).toEqual({ + code: 999, + name: 'UNKNOWN', + message: 'Unknown status code', + label: 'Unknown', + }) + }) +}) diff --git a/apps/studio/lib/http-status-codes.ts b/apps/studio/lib/http-status-codes.ts new file mode 100644 index 00000000000..4e0d2042985 --- /dev/null +++ b/apps/studio/lib/http-status-codes.ts @@ -0,0 +1,30 @@ +import { status } from 'http-status' + +export function getHttpStatusCodeInfo(codeNumber: number): { + code: number + name: string + message: string + label: string +} { + type StatusCodeKey = keyof typeof status + + if (!(codeNumber in status)) { + return { + code: codeNumber, + name: 'UNKNOWN', + message: 'Unknown status code', + label: 'Unknown', + } + } + + const statusCodeLabel = status[codeNumber as StatusCodeKey] + const statusCodeMessage = status[`${codeNumber}_MESSAGE` as StatusCodeKey] + const statusCodeName = status[`${codeNumber}_NAME` as StatusCodeKey] + + return { + code: codeNumber, + name: statusCodeName as string, + label: statusCodeLabel as string, + message: statusCodeMessage as string, + } +} diff --git a/apps/studio/lib/self-hosted.ts b/apps/studio/lib/self-hosted.ts index a87ca513c38..b8ab1b779b7 100644 --- a/apps/studio/lib/self-hosted.ts +++ b/apps/studio/lib/self-hosted.ts @@ -10,6 +10,6 @@ export async function queryPgMetaSelfHosted(sql: string, headersInit?: { [prop: if (response.error) { return { error: response.error as ResponseError } } else { - return { data: response.data } + return { data: response } } } diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts index 339d6116acd..37f16b6a41d 100644 --- a/apps/studio/middleware.ts +++ b/apps/studio/middleware.ts @@ -7,12 +7,20 @@ export const config = { // [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: const HOSTED_SUPPORTED_API_URLS = [ + // These are using OpenAI, can be removed once Bedrock is default '/ai/sql/generate-v3', + '/ai/sql/complete', + '/ai/sql/cron', + '/ai/sql/title', + // These are using Bedrock + '/ai/sql/generate-v4', + '/ai/sql/complete-v2', + '/ai/sql/cron-v2', + '/ai/sql/title-v2', + '/ai/edge-function/complete-v2', + // Others '/ai/edge-function/complete', '/ai/onboarding/design', - '/ai/sql/complete', - '/ai/sql/title', - '/ai/sql/cron', '/ai/feedback/classify', '/get-ip-address', '/get-utc-time', diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 1e836d3a296..9d03a64c76f 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -29,6 +29,11 @@ const SUPABASE_DOCS_PROJECT_URL = process.env.NEXT_PUBLIC_SUPABASE_URL ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin : '' +// Needed to test docs content API in local dev +const SUPABASE_CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL + ? new URL(process.env.NEXT_PUBLIC_CONTENT_API_URL).origin + : '' + const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red' const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red' const SUPABASE_COM_URL = 'https://supabase.com' @@ -78,7 +83,7 @@ const csp = [ process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' ? [ - `default-src 'self' ${DEFAULT_SRC_URLS} ${SUPABASE_STAGING_PROJECTS_URL} ${SUPABASE_STAGING_PROJECTS_URL_WS} ${VERCEL_LIVE_URL} ${PUSHER_URL_WS} ${SUPABASE_DOCS_PROJECT_URL} ${SENTRY_URL};`, + `default-src 'self' ${DEFAULT_SRC_URLS} ${SUPABASE_STAGING_PROJECTS_URL} ${SUPABASE_STAGING_PROJECTS_URL_WS} ${VERCEL_LIVE_URL} ${PUSHER_URL_WS} ${SUPABASE_DOCS_PROJECT_URL} ${SUPABASE_CONTENT_API_URL} ${SENTRY_URL};`, `script-src 'self' 'unsafe-eval' 'unsafe-inline' ${SCRIPT_SRC_URLS} ${VERCEL_LIVE_URL} ${PUSHER_URL};`, `frame-src 'self' ${FRAME_SRC_URLS} ${VERCEL_LIVE_URL};`, `img-src 'self' blob: data: ${IMG_SRC_URLS} ${SUPABASE_STAGING_PROJECTS_URL} ${VERCEL_URL};`, diff --git a/apps/studio/package.json b/apps/studio/package.json index d9ddaacf3b0..f7e586bacd0 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -20,13 +20,19 @@ "typecheck": "tsc --noEmit", "prettier:check": "prettier --check .", "prettier:write": "prettier --write .", - "build:deno-types": "tsx scripts/deno-types.ts" + "build:deno-types": "tsx scripts/deno-types.ts", + "build:graphql-types": "tsx scripts/download-graphql-schema.mts && pnpm graphql-codegen --config scripts/codegen.ts", + "build:graphql-types:watch": "pnpm graphql-codegen --config scripts/codegen.ts --watch" }, "dependencies": { - "@ai-sdk/openai": "^0.0.72", + "@ai-sdk/amazon-bedrock": "^2.2.9", + "@ai-sdk/openai": "^1.3.22", + "@ai-sdk/react": "^1.2.12", + "@aws-sdk/credential-providers": "^3.804.0", "@dagrejs/dagre": "^1.0.4", "@deno/eszip": "0.83.0", "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@electric-sql/pglite": "^0.2.15", @@ -40,11 +46,16 @@ "@monaco-editor/react": "^4.6.0", "@next/bundle-analyzer": "15.3.1", "@number-flow/react": "^0.3.2", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-visually-hidden": "^1.1.3", "@sentry/nextjs": "^8.52.1", "@std/path": "npm:@jsr/std__path@^1.0.8", "@stripe/react-stripe-js": "^3.1.1", "@stripe/stripe-js": "^5.5.0", "@supabase/auth-js": "catalog:", + "@supabase/mcp-server-supabase": "^0.4.4", + "@supabase/mcp-utils": "^0.2.0", "@supabase/pg-meta": "workspace:*", "@supabase/realtime-js": "catalog:", "@supabase/shared-types": "0.1.80", @@ -52,10 +63,13 @@ "@supabase/supabase-js": "catalog:", "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", + "@tanstack/react-table": "^8.21.3", "@uidotdev/usehooks": "^2.4.1", + "@vercel/flags": "^2.6.0", + "@vercel/functions": "^2.1.0", "@vitejs/plugin-react": "^4.3.4", "@zip.js/zip.js": "^2.7.29", - "ai": "^3.4.33", + "ai": "^4.3.16", "ai-commands": "workspace:*", "awesome-debounce-promise": "^2.1.0", "common": "workspace:*", @@ -70,6 +84,7 @@ "framer-motion": "^11.11.17", "generate-password-browser": "^1.1.0", "html-to-image": "^1.10.8", + "http-status": "^2.1.0", "icons": "workspace:*", "idb": "^8.0.2", "immutability-helper": "^3.1.1", @@ -96,6 +111,7 @@ "react-copy-to-clipboard": "^5.1.0", "react-data-grid": "7.0.0-beta.41", "react-datepicker": "^4.18.0", + "react-day-picker": "^8.8.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "catalog:", @@ -130,6 +146,8 @@ "zxcvbn": "^4.4.2" }, "devDependencies": { + "@graphql-codegen/cli": "5.0.5", + "@graphql-typed-document-node/core": "^3.2.0", "@radix-ui/react-use-escape-keydown": "^1.0.3", "@supabase/postgres-meta": "^0.64.4", "@tailwindcss/container-queries": "^0.1.1", diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index f8f95bfbb55..ebe3f4a9bc9 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -39,7 +39,6 @@ import { StudioCommandMenu } from 'components/interfaces/App/CommandMenu' import { FeaturePreviewContextProvider } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/FeaturePreviewModal' import { MonacoThemeProvider } from 'components/interfaces/App/MonacoThemeProvider' -import { GenerateSql } from 'components/interfaces/SqlGenerator/SqlGenerator' import { GlobalErrorBoundaryState } from 'components/ui/GlobalErrorBoundaryState' import { useRootQueryClient } from 'data/query-client' import { customFont, sourceCodePro } from 'fonts' @@ -109,6 +108,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { Supabase + {/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */}