diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3feaae032a1..043d489e617 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,7 @@ /packages/ui/ @supabase/design /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 +/packages/common/telemetry-constants.ts @supabase/growth-eng /apps/studio/ @supabase/Dashboard diff --git a/apps/docs/content/guides/database/pgadmin.mdx b/apps/docs/content/guides/database/pgadmin.mdx index a7917fb205b..8a3dfbc6f4a 100644 --- a/apps/docs/content/guides/database/pgadmin.mdx +++ b/apps/docs/content/guides/database/pgadmin.mdx @@ -56,7 +56,7 @@ hideToc: true - Add the connection info. Go to your [`Database Settings`](https://supabase.com/dashboard/project/_/settings/database). Make sure `Use connection pooling` is enabled. Switch the connection mode to `Session` and copy your connection parameters. Fill in your Database password that you made when creating your project (It can be reset in Database Settings above if you don't have it). + Add the connection info. Click the "Connect" button at the top of the page to open the connect Modal. Scroll down to "session pooler", click "view parameters" to toggle the parameters menu open and copy your connection parameters. Fill in your Database password that you made when creating your project (It can be reset in Database Settings above if you don't have it). diff --git a/apps/docs/content/guides/platform/credits.mdx b/apps/docs/content/guides/platform/credits.mdx index 04ced3dae40..a71d1574de0 100644 --- a/apps/docs/content/guides/platform/credits.mdx +++ b/apps/docs/content/guides/platform/credits.mdx @@ -29,6 +29,8 @@ As an example, if you start a Pro Plan subscription on January 1 and downgrade t You can top up credits at any time, with a maximum of per top-up. These credits do not expire and are non-refundable. +If you have any outstanding invoices, we’ll automatically use your credits to pay them off. Any remaining credits will be applied to future invoices. + You may want to consider this option to avoid issues with recurring payments, gain more control over how often your credit card is charged, and potentially make things easier for your accounting department. diff --git a/apps/docs/content/guides/storage/uploads/file-limits.mdx b/apps/docs/content/guides/storage/uploads/file-limits.mdx index af1628c5bc9..d890c477687 100644 --- a/apps/docs/content/guides/storage/uploads/file-limits.mdx +++ b/apps/docs/content/guides/storage/uploads/file-limits.mdx @@ -8,13 +8,13 @@ sidebar_label: 'Limits' ## Global file size -You can set the max file size across all your buckets by setting this global value in the dashboard [here](https://supabase.com/dashboard/project/_/settings/storage). For Free projects, the limit can't exceed 50 MB. On the Pro Plan and up, you can set this value to up to 50 GB. If you need more than 50 GB, [contact us](https://supabase.com/dashboard/support/new). +You can set the max file size across all your buckets by setting this global value in the dashboard [here](https://supabase.com/dashboard/project/_/settings/storage). For Free projects, the limit can't exceed 50 MB. On the Pro Plan and up, you can set this value to up to 500 GB. If you need more than 500 GB, [contact us](https://supabase.com/dashboard/support/new). | Plan | Max File Size Limit | | ---------- | ------------------- | | Free | 50 MB | -| Pro | 50 GB | -| Team | 50 GB | +| Pro | 500 GB | +| Team | 500 GB | | Enterprise | Custom | diff --git a/apps/docs/content/guides/storage/uploads/s3-uploads.mdx b/apps/docs/content/guides/storage/uploads/s3-uploads.mdx index 13f888e2fb5..93c3f4f2be9 100644 --- a/apps/docs/content/guides/storage/uploads/s3-uploads.mdx +++ b/apps/docs/content/guides/storage/uploads/s3-uploads.mdx @@ -17,7 +17,7 @@ The S3 protocol supports file upload using: The `PutObject` action uploads the file in a single request. This matches the behavior of the Supabase SDK [Standard Upload](/docs/guides/storage/uploads/standard-uploads). -Use `PutObject` to upload smaller files, where retrying the entire upload won't be an issue. The maximum file size on paid plans is 50 GB. +Use `PutObject` to upload smaller files, where retrying the entire upload won't be an issue. The maximum file size on paid plans is 500 GB. For example, using JavaScript and the `aws-sdk` client: @@ -42,7 +42,7 @@ await s3Client.send(uploadCommand) Multipart Uploads split the file into smaller parts and upload them in parallel, maximizing the upload speed on a fast network. When uploading large files, this allows you to retry the upload of individual parts in case of network issues. -This method is preferable over [Resumable Upload](/docs/guides/storage/uploads/resumable-uploads) for server-side uploads, when you want to maximize upload speed at the cost of resumability. The maximum file size on paid plans is 50 GB. +This method is preferable over [Resumable Upload](/docs/guides/storage/uploads/resumable-uploads) for server-side uploads, when you want to maximize upload speed at the cost of resumability. The maximum file size on paid plans is 500 GB. ### Upload a file in parts diff --git a/apps/docs/package.json b/apps/docs/package.json index 1dd7f1f831c..96c1cdb065a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -9,7 +9,7 @@ "build:llms": "tsx ./scripts/llms.ts", "build:sitemap": "tsx ./internals/generate-sitemap.ts", "clean": "rimraf .next .turbo node_modules features/docs/generated examples __generated__", - "codegen:examples": "cp -r ../../examples ./examples", + "codegen:examples": "shx cp -r ../../examples ./examples", "codegen:graphql": "tsx --conditions=react-server ./scripts/graphqlSchema.ts && graphql-codegen --config codegen.ts", "codegen:references": "tsx features/docs/Reference.generated.script.ts", "codemod:frontmatter": "node ./scripts/codemod/mdx-meta.mjs && prettier --cache --write \"content/**/*.mdx\"", @@ -149,6 +149,7 @@ "openapi-types": "^12.1.3", "postcss": "^8.5.3", "shiki": "^3.2.1", + "shx": "^0.4.0", "simple-git": "^3.24.0", "slugify": "^1.6.6", "smol-toml": "^1.3.1", diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index fdd236e51a0..4a367b66d20 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -27,11 +27,13 @@ Chris Chandler Chris Copplestone Chris Gwilliams Chris Stockton +Chris Ward Craig Cannon Danny White Darren Cunningham Dave Wilson David A. Ventimiglia +Deepthi Sigireddi Deji I Div Arora Divit D @@ -53,6 +55,7 @@ Inian P Ivan Vasilov Jenny Kibiri Jess Shears +Jim Chanco Jr John Pena Jon M Jonny Summers-Muir @@ -65,15 +68,18 @@ Kang Ming Tay Karan S Karlo Ison Katerina Skroumpelou +Kemal Y Kevin Brolly Kevin Grüneberg Lakshan Perera Laura C Laurence Isla Lenny Urbanowski +Leonardo Santiago Long Hoang Luca Forstner Łukasz Niemier +Manan Gupta Margarita Sandomirskaia Mark Burggraf Monica Khoury diff --git a/apps/studio/components/grid/components/header/ExportDialog.tsx b/apps/studio/components/grid/components/header/ExportDialog.tsx new file mode 100644 index 00000000000..83d6f275d74 --- /dev/null +++ b/apps/studio/components/grid/components/header/ExportDialog.tsx @@ -0,0 +1,129 @@ +import { useParams } from 'common' +import { getConnectionStrings } from 'components/interfaces/Connect/DatabaseSettings.utils' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import { pluckObjectFields } from 'lib/helpers' +import { useState } from 'react' +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' +import { + Button, + cn, + CodeBlock, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + Tabs_Shadcn_, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, +} from 'ui' +import { Admonition } from 'ui-patterns' + +interface ExportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export const ExportDialog = ({ open, onOpenChange }: ExportDialogProps) => { + const { ref: projectRef } = useParams() + const snap = useTableEditorTableStateSnapshot() + const [selectedTab, setSelectedTab] = useState('csv') + + const { data: databases } = useReadReplicasQuery({ projectRef }) + const primaryDatabase = (databases ?? []).find((db) => db.identifier === projectRef) + const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] + const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } + + const connectionInfo = pluckObjectFields(primaryDatabase || emptyState, DB_FIELDS) + const { db_host, db_port, db_user, db_name } = connectionInfo + + const connectionStrings = getConnectionStrings({ + connectionInfo, + metadata: { projectRef }, + // [Joshen] We don't need any pooler details for this context, we only want direct + poolingInfo: { connectionString: '', db_host: '', db_name: '', db_port: 0, db_user: '' }, + }) + + const outputName = `${snap.table.name}_rows` + + const csvExportCommand = ` +${connectionStrings.direct.psql} -c "COPY (SELECT * FROM "${snap.table.schema}"."${snap.table.name}") TO STDOUT WITH CSV HEADER DELIMITER ',';" > ${outputName}.csv +`.trim() + + const sqlExportCommand = ` +pg_dump -h ${db_host} -p ${db_port} -d ${db_name} -U ${db_user} --table="${snap.table.schema}.${snap.table.name}" --data-only --column-inserts > ${outputName}.sql + `.trim() + + return ( + + + + Export table data via CLI + + + + + +

+ We highly recommend using {selectedTab === 'csv' ? 'psql' : 'pg_dump'} to + export your table data, in particular if your table is relatively large. This can be + done via the following command that you can run in your terminal: +

+ + + + As CSV + As SQL + + + + + + + + + +

+ You will be prompted for your database password, and the output file{' '} + + {outputName}.{selectedTab} + {' '} + will be saved in the current directory that your terminal is in. +

+ + {selectedTab === 'sql' && ( + +

+ If you run into a server version mismatch error, you will need to update{' '} + pg_dump before running the command. +

+
+ )} +
+ + + +
+
+ ) +} diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 55847f98d3e..67d4d2886e8 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -35,6 +35,7 @@ import { Separator, SonnerProgress, } from 'ui' +import { ExportDialog } from './ExportDialog' import { FilterPopover } from './filter/FilterPopover' import { SortPopover } from './sort/SortPopover' // [Joshen] CSV exports require this guard as a fail-safe if the table is @@ -230,6 +231,7 @@ const RowHeader = () => { const { sorts } = useTableSort() const [isExporting, setIsExporting] = useState(false) + const [showExportModal, setShowExportModal] = useState(false) const { data } = useTableRowsQuery({ projectRef: project?.ref, @@ -441,61 +443,73 @@ const RowHeader = () => { }) return ( -
- {snap.editable && ( - } - onClick={onRowsDelete} - disabled={snap.allRowsSelected && isImpersonatingRole} - tooltip={{ - content: { - side: 'bottom', - text: - snap.allRowsSelected && isImpersonatingRole - ? 'Table truncation is not supported when impersonating a role' - : undefined, - }, - }} - > - {snap.allRowsSelected - ? `Delete all rows in table` - : snap.selectedRows.size > 1 - ? `Delete ${snap.selectedRows.size} rows` - : `Delete ${snap.selectedRows.size} row`} - - )} - - - - - - - Export to CSV - - Export to SQL - - + {snap.allRowsSelected + ? `Delete all rows in table` + : snap.selectedRows.size > 1 + ? `Delete ${snap.selectedRows.size} rows` + : `Delete ${snap.selectedRows.size} row`} + + )} + + + + + + Export as CSV + Export as SQL + {/* [Joshen] Should make this available for all cases, but that'll involve updating + the Dialog's SQL output to be dynamic based on any filters applied */} + {snap.allRowsSelected && ( + setShowExportModal(true)}> +
+

Export via CLI

+

Recommended for large tables

+
+
+ )} +
+
- {!snap.allRowsSelected && totalRows > allRows.length && ( - <> -
- -
- - - )} -
+ {!snap.allRowsSelected && totalRows > allRows.length && ( + <> +
+ +
+ + + )} + + + setShowExportModal(false)} /> + ) } diff --git a/apps/studio/components/interfaces/Account/NewAccessTokenButton.test.tsx b/apps/studio/components/interfaces/Account/NewAccessTokenButton.test.tsx new file mode 100644 index 00000000000..18cadf221a2 --- /dev/null +++ b/apps/studio/components/interfaces/Account/NewAccessTokenButton.test.tsx @@ -0,0 +1,97 @@ +import { faker } from '@faker-js/faker' +import { screen, waitFor } from '@testing-library/dom' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { render } from 'tests/helpers' +import { addAPIMock } from 'tests/lib/msw' +import NewAccessTokenButton from './NewAccessTokenButton' + +describe(`NewAccessTokenButton`, () => { + beforeEach(() => { + addAPIMock({ + method: `post`, + path: `/platform/profile/access-tokens`, + response: { + name: faker.lorem.word(), + scope: faker.helpers.arrayElement(['V0', undefined]), + created_at: faker.date.past().toISOString(), + id: faker.number.int(), + token_alias: faker.lorem.words(), + token: faker.lorem.words(), + }, + }) + }) + + it(`generates regular tokens`, async () => { + const onCreateToken = vi.fn() + render() + + const dialogTrigger = screen.getByRole(`button`, { name: `Generate new token` }) + await userEvent.click(dialogTrigger) + + const nameInput = screen.getByLabelText(`Name`) + await userEvent.type(nameInput, `test{enter}`) + + await waitFor(() => expect(onCreateToken).toHaveBeenCalledTimes(1)) + }) + + it(`generates experimental tokens`, async () => { + const onCreateToken = vi.fn() + render() + + const dropdownTrigger = screen.getByTitle(`Choose token scope`) + await userEvent.click(dropdownTrigger) + + const experimentalMenuItem = await screen.findByRole(`menuitem`, { + name: `Generate token for experimental API`, + }) + await userEvent.click(experimentalMenuItem) + + await waitFor(async () => { + await expect( + screen.findByRole(`heading`, { name: `Generate token for experimental API` }) + ).resolves.toBeInTheDocument() + await expect(screen.findByRole(`alert`)).resolves.toBeInTheDocument() + }) + + const nameInput = screen.getByLabelText(`Name`) + await userEvent.type(nameInput, `test{enter}`) + + await waitFor(() => expect(onCreateToken).toHaveBeenCalledTimes(1)) + }) + + it(`resets the form on close/cancel`, async () => { + render() + + // pass 1: open dialog and fill form + const dialogTrigger = screen.getByRole(`button`, { name: `Generate new token` }) + await userEvent.click(dialogTrigger) + + let nameInput = screen.getByLabelText(`Name`) + await userEvent.type(nameInput, `cancel button test`) + expect(nameInput).toHaveValue(`cancel button test`) + + // reset the form by pressing the cancel button + const cancelButton = screen.getByRole(`button`, { name: `Cancel` }) + await userEvent.click(cancelButton) + + // pass 2: check that the form is reset, then fill it again + await userEvent.click(dialogTrigger) + + nameInput = screen.getByLabelText(`Name`) + expect(nameInput).not.toHaveValue(`cancel button test`) + + await userEvent.type(nameInput, `close modal test`) + expect(nameInput).toHaveValue(`close modal test`) + + // reset the form by closing the dialog + await userEvent.keyboard(`{Escape}`) + + // pass 3: check that the form has been rest again + await userEvent.click(dialogTrigger) + + nameInput = screen.getByLabelText(`Name`) + expect(nameInput).not.toHaveValue(`close modal test`) + }) +}) diff --git a/apps/studio/components/interfaces/Account/NewAccessTokenButton.tsx b/apps/studio/components/interfaces/Account/NewAccessTokenButton.tsx index 360546f9f4a..17a976ac4c8 100644 --- a/apps/studio/components/interfaces/Account/NewAccessTokenButton.tsx +++ b/apps/studio/components/interfaces/Account/NewAccessTokenButton.tsx @@ -1,157 +1,187 @@ +import { zodResolver } from '@hookform/resolvers/zod' import { ChevronDown, ExternalLink } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' +import { type SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' import { useAccessTokenCreateMutation } from 'data/access-tokens/access-tokens-create-mutation' import { Button, + Dialog, + DialogContent, DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Form, - Input, - Modal, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_, } from 'ui' import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' export interface NewAccessTokenButtonProps { onCreateToken: (token: any) => void } +const TokenSchema = z.object({ + tokenName: z.string().min(1, 'Please enter a name for the token'), +}) + +const formId = 'new-access-token-form' + const NewAccessTokenButton = ({ onCreateToken }: NewAccessTokenButtonProps) => { - const [isOpen, setIsOpen] = useState(false) + const [visible, setVisible] = useState(false) const [tokenScope, setTokenScope] = useState<'V0' | undefined>(undefined) - const validate = (values: any) => { - const errors: any = {} - if (!values.tokenName) errors.tokenName = 'Please enter a name for the token' - return errors - } - - const { mutate: createAccessToken, isLoading } = useAccessTokenCreateMutation({ - onSuccess: (res) => { - onCreateToken(res) - setIsOpen(false) - }, + const form = useForm>({ + resolver: zodResolver(TokenSchema), + defaultValues: { tokenName: '' }, + mode: 'onSubmit', }) + const { mutate: createAccessToken, isLoading } = useAccessTokenCreateMutation() - const onFormSubmit = async (values: any) => { - createAccessToken({ name: values.tokenName, scope: tokenScope }) + const onSubmit: SubmitHandler> = async (values) => { + createAccessToken( + { name: values.tokenName, scope: tokenScope }, + { + onSuccess: (data) => { + toast.success(`Your access token "${data.name}" is ready.`) + form.reset() + onCreateToken(data) + setVisible(false) + }, + } + ) } return ( <> -
-
-
+
+ + + - - -
-
+
+

Generate token for experimental API

+
+ + +
- setIsOpen(!isOpen)} - header={ -
-
- {tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'} -
-
- } + { + if (!open) form.reset() + setVisible(open) + }} > -
- {() => ( - <> - {tokenScope === 'V0' && ( - - -

- These include deleting organizations and projects which cannot be undone. - As such, be very careful when using this API. -

-
- -
- - } - /> -
- )} - - + + + {tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'} + + + + + {tokenScope === 'V0' && ( + +

+ These include deleting organizations and projects which cannot be undone. As + such, be very careful when using this API. +

+
+ +
+ + } + /> + )} + + + ( + + + + + + )} /> -
- -
- - -
-
- - )} -
-
+ + + + + + + + + ) } diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx index 7010e1a8c82..10c0e5df990 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx @@ -1,8 +1,8 @@ import { Key } from 'lucide-react' import { useMemo } from 'react' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Badge, copyToClipboard } from 'ui' import type { ICommand } from 'ui-patterns/CommandMenu' import { @@ -21,14 +21,11 @@ export function useApiKeysCommands() { const setIsOpen = useSetCommandMenuOpen() const setPage = useSetPage() - const project = useSelectedProject() + const { data: project } = useSelectedProjectQuery() const ref = project?.ref || '_' - const { data: settings } = useProjectSettingsV2Query( - { projectRef: project?.ref }, - { enabled: !!project } - ) - const { anonKey, serviceKey } = getAPIKeys(settings) + const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref, reveal: true }) + const { anonKey, serviceKey, publishableKey, allSecretKeys } = getKeys(apiKeys) const commands = useMemo( () => @@ -42,9 +39,10 @@ export function useApiKeysCommands() { setIsOpen(false) }, badge: () => ( - + Project: {project?.name} Public + {anonKey.type} ), icon: () => , @@ -58,13 +56,47 @@ export function useApiKeysCommands() { setIsOpen(false) }, badge: () => ( - + Project: {project?.name} Secret + {serviceKey.type} ), icon: () => , }, + project && + publishableKey && { + id: 'publishable-key', + name: `Copy publishable key`, + action: () => { + copyToClipboard(publishableKey.api_key ?? '') + setIsOpen(false) + }, + badge: () => ( + + Project: {project?.name} + {publishableKey.type} + + ), + icon: () => , + }, + ...(project && allSecretKeys + ? allSecretKeys.map((key) => ({ + id: key.id, + name: `Copy secret key (${key.name})`, + action: () => { + copyToClipboard(key.api_key ?? '') + setIsOpen(false) + }, + badge: () => ( + + Project: {project?.name} + {key.type} + + ), + icon: () => , + })) + : []), !(anonKey || serviceKey) && { id: 'api-keys-project-settings', name: 'See API keys in Project Settings', diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 63ee824ffb8..0593a02a59b 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -587,7 +587,10 @@ export const UsersV2 = () => { visible={!!selectedUserToDelete} selectedUser={selectedUserToDelete} onClose={() => setSelectedUserToDelete(undefined)} - onDeleteSuccess={() => setSelectedUserToDelete(undefined)} + onDeleteSuccess={() => { + if (selectedUserToDelete?.id === selectedUser) setSelectedUser(undefined) + setSelectedUserToDelete(undefined) + }} /> ) diff --git a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx index d24f02b8036..e4feb1d958f 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx @@ -9,7 +9,6 @@ import { Modal } from 'ui' import { useOrganizationPaymentMethodSetupIntent } from 'data/organizations/organization-payment-method-setup-intent-mutation' 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' @@ -18,8 +17,6 @@ interface AddNewPaymentMethodModalProps { returnUrl: string onCancel: () => void onConfirm: () => void - showSetDefaultCheckbox?: boolean - autoMarkAsDefaultPaymentMethod?: boolean } const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -29,14 +26,11 @@ const AddNewPaymentMethodModal = ({ returnUrl, onCancel, onConfirm, - showSetDefaultCheckbox, - autoMarkAsDefaultPaymentMethod, }: AddNewPaymentMethodModalProps) => { const { resolvedTheme } = useTheme() const [intent, setIntent] = useState() const selectedOrganization = useSelectedOrganization() - const captchaLoaded = useIsHCaptchaLoaded() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) @@ -64,7 +58,7 @@ const AddNewPaymentMethodModal = ({ } const loadPaymentForm = async () => { - if (visible && captchaRef && captchaLoaded) { + if (visible && captchaRef) { let token = captchaToken try { @@ -82,7 +76,7 @@ const AddNewPaymentMethodModal = ({ } loadPaymentForm() - }, [visible, captchaRef, captchaLoaded]) + }, [visible, captchaRef]) const resetCaptcha = () => { setCaptchaToken(null) @@ -142,8 +136,6 @@ const AddNewPaymentMethodModal = ({ returnUrl={returnUrl} onCancel={onLocalCancel} onConfirm={onLocalConfirm} - showSetDefaultCheckbox={showSetDefaultCheckbox} - autoMarkAsDefaultPaymentMethod={autoMarkAsDefaultPaymentMethod} /> diff --git a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx index cb470dcb3e5..0171d9f5610 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx @@ -1,49 +1,56 @@ -import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' import { useQueryClient } from '@tanstack/react-query' +import { + NewPaymentMethodElement, + type PaymentMethodElementRef, +} from 'components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement' import { organizationKeys } from 'data/organizations/keys' +import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' +import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation' import { useOrganizationPaymentMethodMarkAsDefaultMutation } from 'data/organizations/organization-payment-method-default-mutation' +import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query' +import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useState } from 'react' +import { isEqual } from 'lodash' +import { useRef, useState } from 'react' import { toast } from 'sonner' import { Button, Checkbox_Shadcn_, Label_Shadcn_, Modal } from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' interface AddPaymentMethodFormProps { returnUrl: string onCancel: () => void onConfirm: () => void - showSetDefaultCheckbox?: boolean - autoMarkAsDefaultPaymentMethod?: boolean } // Stripe docs recommend to use the new SetupIntent flow over // manually creating and attaching payment methods via the API // Small UX annoyance here, that the page will be refreshed -const AddPaymentMethodForm = ({ - returnUrl, - onCancel, - onConfirm, - showSetDefaultCheckbox = false, - autoMarkAsDefaultPaymentMethod = false, -}: AddPaymentMethodFormProps) => { - const stripe = useStripe() - const elements = useElements() +const AddPaymentMethodForm = ({ onCancel, onConfirm }: AddPaymentMethodFormProps) => { const selectedOrganization = useSelectedOrganization() + const { data: customerProfile, isLoading: customerProfileLoading } = + useOrganizationCustomerProfileQuery({ + slug: selectedOrganization?.slug, + }) + const [isSaving, setIsSaving] = useState(false) - const [isDefault, setIsDefault] = useState(showSetDefaultCheckbox) + const [isDefaultPaymentMethod, setIsDefaultPaymentMethod] = useState(true) + const [isPrimaryBillingAddress, setIsPrimaryBillingAddress] = useState(true) const queryClient = useQueryClient() const { mutateAsync: markAsDefault } = useOrganizationPaymentMethodMarkAsDefaultMutation() + const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation() + const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation() + const { data: taxId, isLoading: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({ + slug: selectedOrganization?.slug, + }) + + const paymentRef = useRef(null) const handleSubmit = async (event: any) => { event.preventDefault() - if (!stripe || !elements) { - console.error('Stripe.js has not loaded') - return - } - setIsSaving(true) if (document !== undefined) { @@ -51,25 +58,20 @@ const AddPaymentMethodForm = ({ document.body.classList.add('!pointer-events-auto') } - const { error, setupIntent } = await stripe.confirmSetup({ - elements, - redirect: 'if_required', - confirmParams: { return_url: returnUrl }, - }) + const result = await paymentRef.current?.confirmSetup() - if (error) { + if (!result) { setIsSaving(false) - toast.error(error?.message ?? ' Failed to save card details') } else { if ( - (isDefault || autoMarkAsDefaultPaymentMethod) && + isDefaultPaymentMethod && selectedOrganization && - typeof setupIntent?.payment_method === 'string' + typeof result.setupIntent?.payment_method === 'string' ) { try { await markAsDefault({ slug: selectedOrganization.slug, - paymentMethodId: setupIntent.payment_method, + paymentMethodId: result.setupIntent.payment_method, }) await queryClient.invalidateQueries( @@ -82,10 +84,10 @@ const AddPaymentMethodForm = ({ if (!prev) return prev return { ...prev, - defaultPaymentMethodId: setupIntent.payment_method, + defaultPaymentMethodId: result.setupIntent.payment_method, data: prev.data.map((pm: any) => ({ ...pm, - is_default: pm.id === setupIntent.payment_method, + is_default: pm.id === result.setupIntent.payment_method, })), } } @@ -101,6 +103,28 @@ const AddPaymentMethodForm = ({ } } + if (isPrimaryBillingAddress) { + try { + if ( + result.address && + (!isEqual(result.address, customerProfile?.address) || + customerProfile?.billing_name !== result.customerName) + ) { + await updateCustomerProfile({ + slug: selectedOrganization?.slug, + billing_name: result.customerName, + address: result.address, + }) + } + + if (result.taxId && !isEqual(result.taxId, taxId)) { + await updateTaxId({ taxId: result.taxId, slug: selectedOrganization?.slug }) + } + } catch (error) { + toast.error('Failed to update billing address') + } + } + setIsSaving(false) onConfirm() } @@ -110,33 +134,64 @@ const AddPaymentMethodForm = ({ } } + if (customerProfileLoading || isCustomerTaxIdLoading) { + return ( + +
+ + + + + + +
+
+ ) + } + return (
- - {showSetDefaultCheckbox && ( -
- { - if (typeof checked === 'boolean') { - setIsDefault(checked) - } - }} - /> - - Save as default payment method - -
- )} + +
+ { + if (typeof checked === 'boolean') { + setIsDefaultPaymentMethod(checked) + } + }} + /> + + Save as default payment method + +
+ +
+ { + if (typeof checked === 'boolean') { + setIsPrimaryBillingAddress(checked) + } + }} + /> + + Use the billing address as my organization's primary address + +
diff --git a/apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts b/apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts index 51f095f2e3a..ff8b08bbc7d 100644 --- a/apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts +++ b/apps/studio/components/interfaces/Billing/Payment/Payment.utils.ts @@ -4,6 +4,7 @@ export const getStripeElementsAppearanceOptions = ( resolvedTheme: string | undefined ): Appearance => { return { + labels: 'floating', theme: (resolvedTheme?.includes('dark') ? 'night' : 'flat') as 'night' | 'flat', variables: { fontSizeBase: '14px', @@ -12,18 +13,27 @@ export const getStripeElementsAppearanceOptions = ( : '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', }, + '.Label--floating': { + fontSize: '14px', + }, + '.Label--resting': { + fontSize: '14px', + color: 'rgb(137, 137, 137)', + }, + '.Input': { + boxShadow: 'none', + height: '34px', + lineHeight: '16px', + padding: '8px 12px', + }, + '.AccordionItem': { + boxShadow: 'none', + }, }, } } diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index 93c82251740..67e66aae6a9 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -22,6 +22,7 @@ import { useCheckGithubBranchValidity } from 'data/integrations/github-branch-ch import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' import { projectKeys } from 'data/projects/keys' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' @@ -83,12 +84,26 @@ export const CreateBranchModal = () => { onError: () => {}, }) + const { mutate: sendEvent } = useSendEventMutation() + const { mutate: createBranch, isLoading: isCreating } = useBranchCreateMutation({ onSuccess: async (data) => { toast.success(`Successfully created preview branch "${data.name}"`) if (projectRef) { await Promise.all([queryClient.invalidateQueries(projectKeys.detail(projectRef))]) } + sendEvent({ + action: 'branch_create_button_clicked', + properties: { + branchType: data.persistent ? 'persistent' : 'preview', + gitlessBranching, + }, + groups: { + project: ref ?? 'Unknown', + organization: selectedOrg?.slug ?? 'Unknown', + }, + }) + setShowCreateBranchModal(false) router.push(`/project/${data.project_ref}`) }, @@ -167,6 +182,7 @@ export const CreateBranchModal = () => { createBranch({ projectRef, branchName: data.branchName, + is_default: false, ...(data.gitBranchName ? { gitBranch: data.gitBranchName } : {}), }) } diff --git a/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx index 73444da1e93..c90b7f99166 100644 --- a/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx +++ b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx @@ -13,6 +13,9 @@ import { } from 'ui' import { GitBranchIcon } from 'lucide-react' import { Admonition } from 'ui-patterns' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' interface OutOfDateNoticeProps { isBranchOutOfDateMigrations: boolean @@ -41,6 +44,12 @@ export const OutOfDateNotice = ({ }: OutOfDateNoticeProps) => { const [isDialogOpen, setIsDialogOpen] = useState(false) const hasOutdatedMigrations = isBranchOutOfDateMigrations && missingMigrationsCount > 0 + const selectedOrg = useSelectedOrganization() + const project = useSelectedProject() + const { mutate: sendEvent } = useSendEventMutation() + + const isBranch = project?.parent_project_ref !== undefined + const parentProjectRef = isBranch ? project?.parent_project_ref : project?.ref const getTitle = () => { if (hasOutdatedMigrations && (hasMissingFunctions || hasOutOfDateFunctions)) { @@ -57,12 +66,24 @@ export const OutOfDateNotice = ({ return 'Update this branch to get the latest changes from the production branch.' } - const handleUpdateClick = () => { - onPush() - } + const handleUpdate = (shouldCloseDialog = false) => { + if (shouldCloseDialog) { + setIsDialogOpen(false) + } + + // Track branch update + sendEvent({ + action: 'branch_updated', + properties: { + modifiedEdgeFunctions: hasEdgeFunctionModifications, + source: 'out_of_date_notice', + }, + groups: { + project: parentProjectRef ?? 'Unknown', + organization: selectedOrg?.slug ?? 'Unknown', + }, + }) - const handleConfirmUpdate = () => { - setIsDialogOpen(false) onPush() } @@ -98,7 +119,9 @@ export const OutOfDateNotice = ({ Cancel - Update anyway + handleUpdate(true)}> + Update anyway + @@ -106,7 +129,7 @@ export const OutOfDateNotice = ({
- ) -} diff --git a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx new file mode 100644 index 00000000000..a1a9dbe3239 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx @@ -0,0 +1,260 @@ +import type { PostgresTable, PostgresTrigger } from '@supabase/postgres-meta' +import Image from 'next/legacy/image' +import { MutableRefObject, useEffect } from 'react' + +import { useParams } from 'common' +import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' +import { useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { uuidv4 } from 'lib/helpers' +import { Checkbox, Input, Listbox, Radio, SidePanel } from 'ui' +import { HTTPArgument, isEdgeFunction } from './EditHookPanel' +import HTTPRequestFields from './HTTPRequestFields' +import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants' + +export interface FormContentsProps { + values: any + resetForm: any + errors: any + selectedHook?: PostgresTrigger + tables: PostgresTable[] + events: string[] + eventsError?: string + onUpdateSelectedEvents: (event: string) => void + httpHeaders: HTTPArgument[] + httpParameters: HTTPArgument[] + setHttpHeaders: (arr: HTTPArgument[]) => void + setHttpParameters: (arr: HTTPArgument[]) => void + submitRef: MutableRefObject +} + +export const FormContents = ({ + values, + resetForm, + errors, + selectedHook, + tables, + events, + eventsError, + onUpdateSelectedEvents, + httpHeaders, + httpParameters, + setHttpHeaders, + setHttpParameters, + submitRef, +}: FormContentsProps) => { + const { ref } = useParams() + const project = useSelectedProject() + + const restUrl = project?.restUrl + const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co' + + const { data: keys = [] } = useAPIKeysQuery({ projectRef: ref, reveal: true }) + const { data: functions = [] } = useEdgeFunctionsQuery({ projectRef: ref }) + + const legacyServiceRole = keys.find((x) => x.name === 'service_role')?.api_key ?? '[YOUR API KEY]' + + useEffect(() => { + if (values.function_type === 'http_request') { + if (selectedHook !== undefined) { + const [url, method] = selectedHook.function_args + const updatedValues = { ...values, http_url: url, http_method: method } + resetForm({ values: updatedValues, initialValues: updatedValues }) + } else { + const updatedValues = { ...values, http_url: '' } + resetForm({ values: updatedValues, initialValues: updatedValues }) + } + } else if (values.function_type === 'supabase_function') { + // Default to first edge function in the list + const fnSlug = functions[0]?.slug + const defaultFunctionUrl = `https://${ref}.supabase.${restUrlTld}/functions/v1/${fnSlug}` + const updatedValues = { + ...values, + http_url: isEdgeFunction({ ref, restUrlTld, url: values.http_url }) + ? values.http_url + : defaultFunctionUrl, + } + resetForm({ values: updatedValues, initialValues: updatedValues }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values.function_type]) + + useEffect(() => { + const isEdgeFunctionSelected = isEdgeFunction({ ref, restUrlTld, url: values.http_url }) + + if (values.http_url && isEdgeFunctionSelected) { + const fnSlug = values.http_url.split('/').at(-1) + const fn = functions.find((x) => x.slug === fnSlug) + + if (fn?.verify_jwt) { + if (!httpHeaders.some((x) => x.name === 'Authorization')) { + const authorizationHeader = { + id: uuidv4(), + name: 'Authorization', + value: `Bearer ${legacyServiceRole}`, + } + setHttpHeaders([...httpHeaders, authorizationHeader]) + } + } else { + const updatedHttpHeaders = httpHeaders.filter((x) => x.name !== 'Authorization') + setHttpHeaders(updatedHttpHeaders) + } + } else { + const updatedHttpHeaders = httpHeaders.filter((x) => x.name !== 'Authorization') + setHttpHeaders(updatedHttpHeaders) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values.http_url]) + + return ( +
+ General}> + + + + + + + Select which table and events will trigger your webhook +

+ } + > + Conditions to fire webhook + + } + > + + + + --- + + {tables.map((table) => ( + +
+

{table.schema}

+

{table.name}

+
+
+ ))} +
+ + {HOOK_EVENTS.map((event) => ( + onUpdateSelectedEvents(event.value)} + /> + ))} + +
+
+ + Webhook configuration + } + > + + + {AVAILABLE_WEBHOOK_TYPES.map((webhook) => ( + + {webhook.label} +
+
+

{webhook.label}

+
+

{webhook.description}

+
+
+ } + /> + ))} + + + + + + { + if (headers) setHttpHeaders(httpHeaders.concat(headers)) + else setHttpHeaders(httpHeaders.concat({ id: uuidv4(), name: '', value: '' })) + }} + onUpdateHeader={(idx, property, value) => + setHttpHeaders( + httpHeaders.map((header, i) => { + if (idx === i) return { ...header, [property]: value } + else return header + }) + ) + } + onRemoveHeader={(idx) => setHttpHeaders(httpHeaders.filter((_, i) => idx !== i))} + onAddParameter={() => + setHttpParameters(httpParameters.concat({ id: uuidv4(), name: '', value: '' })) + } + onUpdateParameter={(idx, property, value) => + setHttpParameters( + httpParameters.map((param, i) => { + if (idx === i) return { ...param, [property]: value } + else return param + }) + ) + } + onRemoveParameter={(idx) => setHttpParameters(httpParameters.filter((_, i) => idx !== i))} + /> + +
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx index 78081397bea..ec8cf9e4d75 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx @@ -1,10 +1,11 @@ -import { ChevronDown, Plus, Trash } from 'lucide-react' +import { ChevronDown, Plus, X } from 'lucide-react' import Link from 'next/link' import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' import { uuidv4 } from 'lib/helpers' import { @@ -26,7 +27,7 @@ interface HTTPRequestFieldsProps { errors: any httpHeaders: HTTPArgument[] httpParameters: HTTPArgument[] - onAddHeader: (header?: any) => void + onAddHeaders: (headers?: any[]) => void onUpdateHeader: (idx: number, property: string, value: string) => void onRemoveHeader: (idx: number) => void onAddParameter: () => void @@ -39,21 +40,22 @@ const HTTPRequestFields = ({ errors, httpHeaders = [], httpParameters = [], - onAddHeader, + onAddHeaders, onUpdateHeader, onRemoveHeader, onAddParameter, onUpdateParameter, onRemoveParameter, }: HTTPRequestFieldsProps) => { - const { project: selectedProject } = useProjectContext() const { ref } = useParams() - const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) + const { project: selectedProject } = useProjectContext() + const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref }) + const { data: apiKeys } = useAPIKeysQuery({ projectRef: ref, reveal: true }) const edgeFunctions = functions ?? [] - const { serviceKey } = getAPIKeys(settings) - const apiKey = serviceKey?.api_key ?? '[YOUR API KEY]' + const { serviceKey, secretKey } = getKeys(apiKeys) + const apiKey = secretKey?.api_key ?? serviceKey?.api_key ?? '[YOUR API KEY]' return ( <> @@ -143,12 +145,12 @@ const HTTPRequestFields = ({ placeholder="Header value" onChange={(event: any) => onUpdateHeader(idx, 'value', event.target.value)} /> - {type === 'supabase_function' && ( - + - - Name, - Publication, - Lag, - Status, - , - ]} - className="mt-4" - body={mockRows.map((row, i) => ( - - {row.name} - - - All - {row.tables} tables - - - {row.lag} - - - - {row.status} - - - - - - - ))} - /> +
+
+ + + +
+
+
+ + +
+ +
+
Name, + Publication, + Lag, + Status, + , + ]} + className="mt-4" + body={mockRows.map((row, i) => ( + + {row.name} + + + All + {row.tables} tables + + + {row.lag} + + + + {row.status} + + + + + + + ))} + /> + + + ) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx index edfab6d9239..04386674eeb 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx @@ -1,26 +1,28 @@ +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { useParams } from 'common' import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' +import { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation' +import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query' import { ReplicationPipelinesData } from 'data/replication/pipelines-query' +import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' +import { + PipelineStatusRequestStatus, + usePipelineRequestStatus, +} from 'state/replication-pipeline-request-status' import { ResponseError } from 'types' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' -import RowMenu from './RowMenu' -import PipelineStatus, { PipelineStatusRequestStatus, PipelineStatusName } from './PipelineStatus' -import { useParams } from 'common' -import { - ReplicationPipelineStatusData, - useReplicationPipelineStatusQuery, -} from 'data/replication/pipeline-status-query' -import { useState } from 'react' -import { toast } from 'sonner' -import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' -import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' -import { useDeleteDestinationMutation } from 'data/replication/delete-destination-mutation' import DeleteDestination from './DeleteDestination' import DestinationPanel from './DestinationPanel' +import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' +import { PipelineStatus, PipelineStatusName } from './PipelineStatus' +import { RowMenu } from './RowMenu' export type Pipeline = ReplicationPipelinesData['pipelines'][0] -const refreshFrequencyMs: number = 5000 +const refreshFrequencyMs: number = 2000 interface DestinationRowProps { sourceId: number | undefined @@ -32,9 +34,10 @@ interface DestinationRowProps { isLoading: boolean isError: boolean isSuccess: boolean + onSelectPipeline?: (pipelineId: number, destinationName: string) => void } -const DestinationRow = ({ +export const DestinationRow = ({ sourceId, destinationId, destinationName, @@ -44,6 +47,7 @@ const DestinationRow = ({ isLoading: isPipelineLoading, isError: isPipelineError, isSuccess: isPipelineSuccess, + onSelectPipeline, }: DestinationRowProps) => { const { ref: projectRef } = useParams() const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false) @@ -62,76 +66,23 @@ const DestinationRow = ({ }, { refetchInterval: refreshFrequencyMs } ) - const [requestStatus, setRequestStatus] = useState( - PipelineStatusRequestStatus.None - ) - const { mutateAsync: startPipeline } = useStartPipelineMutation() + const { getRequestStatus, updatePipelineStatus } = usePipelineRequestStatus() + const requestStatus = pipeline?.id + ? getRequestStatus(pipeline.id) + : PipelineStatusRequestStatus.None + const { mutateAsync: stopPipeline } = useStopPipelineMutation() - const pipelineStatus = pipelineStatusData?.status - const getStatusName = ( - status: ReplicationPipelineStatusData['status'] | undefined - ): string | undefined => { - if (status && typeof status === 'object' && 'name' in status) { - return status.name - } - - return undefined - } - - const statusName = getStatusName(pipelineStatus) - if ( - (requestStatus === PipelineStatusRequestStatus.EnableRequested && - (statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED)) || - (requestStatus === PipelineStatusRequestStatus.DisableRequested && - (statusName === PipelineStatusName.STOPPED || statusName === PipelineStatusName.FAILED)) - ) { - setRequestStatus(PipelineStatusRequestStatus.None) - } - - const onEnableClick = async () => { - if (!projectRef) { - console.error('Project ref is required') - return - } - if (!pipeline) { - toast.error('No pipeline found') - return - } - - try { - await startPipeline({ projectRef, pipelineId: pipeline.id }) - } catch (error) { - toast.error('Failed to enable destination') - } - setRequestStatus(PipelineStatusRequestStatus.EnableRequested) - } - const onDisableClick = async () => { - if (!projectRef) { - console.error('Project ref is required') - return - } - if (!pipeline) { - toast.error('No pipeline found') - return - } - - try { - await stopPipeline({ projectRef, pipelineId: pipeline.id }) - } catch (error) { - toast.error('Failed to disable destination') - } - setRequestStatus(PipelineStatusRequestStatus.DisableRequested) - } const { mutateAsync: deleteDestination } = useDeleteDestinationMutation({}) + const pipelineStatus = pipelineStatusData?.status + const statusName = getStatusName(pipelineStatus) + const onDeleteClick = async () => { if (!projectRef) { - console.error('Project ref is required') - return + return console.error('Project ref is required') } if (!pipeline) { - toast.error('No pipeline found') - return + return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) } try { @@ -140,24 +91,33 @@ const DestinationRow = ({ // so we don't need to call deletePipeline explicitly await deleteDestination({ projectRef, destinationId: destinationId }) } catch (error) { - toast.error('Failed to delete destination') + toast.error(PIPELINE_ERROR_MESSAGES.DELETE_DESTINATION) } } + useEffect(() => { + if (pipeline?.id) { + updatePipelineStatus(pipeline.id, statusName) + } + }, [pipeline?.id, statusName, updatePipelineStatus]) + return ( <> {isPipelineError && ( - + )} {isPipelineSuccess && ( - - - {isPipelineLoading ? : destinationName} - - {isPipelineLoading ? : type} + { + if (pipeline) onSelectPipeline?.(pipeline.id, destinationName) + }} + > + {isPipelineLoading ? : destinationName} + {isPipelineLoading ? : type} {isPipelineLoading || !pipeline ? ( - + ) : ( + /> )} {isPipelineLoading || !pipeline ? ( - + ) : ( pipeline.config.publication_name )} - setShowDeleteDestinationForm(true)} - onEditClick={() => setShowEditDestinationPanel(true)} - > +
+ setShowDeleteDestinationForm(true)} + onEditClick={() => setShowEditDestinationPanel(true)} + /> +
)} @@ -211,5 +172,3 @@ const DestinationRow = ({ ) } - -export default DestinationRow diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index d1bbfc71b5d..8d384e7840d 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -1,19 +1,26 @@ +import { noop } from 'lodash' +import { Plus, Search } from 'lucide-react' +import { useState } from 'react' + import { useParams } from 'common' import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' import { useReplicationDestinationsQuery } from 'data/replication/destinations-query' -import { Plus } from 'lucide-react' -import { Button, cn } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' -import DestinationRow from './DestinationRow' import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query' -import { useState } from 'react' -import NewDestinationPanel from './DestinationPanel' import { useReplicationSourcesQuery } from 'data/replication/sources-query' -import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' +import { Button, cn, Input_Shadcn_ } from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns' +import NewDestinationPanel from './DestinationPanel' +import { DestinationRow } from './DestinationRow' +import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' -const Destinations = () => { +interface DestinationsProps { + onSelectPipeline?: (pipelineId: number, destinationName: string) => void +} + +export const Destinations = ({ onSelectPipeline = noop }: DestinationsProps) => { const [showNewDestinationPanel, setShowNewDestinationPanel] = useState(false) + const [filterString, setFilterString] = useState('') const { ref: projectRef } = useParams() const { @@ -21,12 +28,11 @@ const Destinations = () => { error: sourcesError, isLoading: isSourcesLoading, isError: isSourcesError, - isSuccess: isSourcesSuccess, } = useReplicationSourcesQuery({ projectRef, }) - let sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id + const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id const { data: destinationsData, @@ -50,21 +56,44 @@ const Destinations = () => { const anyDestinations = isDestinationsSuccess && destinationsData.destinations.length > 0 + const filteredDestinations = + filterString.length === 0 + ? destinationsData?.destinations ?? [] + : (destinationsData?.destinations ?? []).filter((destination) => + destination.name.toLowerCase().includes(filterString.toLowerCase()) + ) + return ( <> - -
- Destinations +
+
+
+
+ + setFilterString(e.target.value)} + /> +
+
+
+ +
{(isSourcesLoading || isDestinationsLoading) && } {(isSourcesError || isDestinationsError) && ( )} @@ -77,7 +106,7 @@ const Destinations = () => { Publication, , ]} - body={destinationsData.destinations.map((destination) => { + body={filteredDestinations.map((destination) => { const pipeline = pipelinesData?.pipelines.find( (p) => p.destination_id === destination.id ) @@ -93,7 +122,8 @@ const Destinations = () => { isLoading={isPipelinesLoading} isError={isPipelinesError} isSuccess={isPipelinesSuccess} - > + onSelectPipeline={onSelectPipeline} + /> ) })} >
@@ -124,15 +154,22 @@ const Destinations = () => { ) )} - + + + {!isSourcesLoading && + !isDestinationsLoading && + filteredDestinations.length === 0 && + anyDestinations && ( +
+

No destinations match "{filterString}"

+
+ )} setShowNewDestinationPanel(false)} - > + /> ) } - -export default Destinations diff --git a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts new file mode 100644 index 00000000000..d15fe8040cc --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts @@ -0,0 +1,96 @@ +import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query' +import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status' + +export const PIPELINE_ERROR_MESSAGES = { + RETRIEVE_PIPELINE: 'Failed to retrieve pipeline information', + RETRIEVE_PIPELINE_STATUS: 'Failed to retrieve pipeline status', + RETRIEVE_REPLICATION_STATUS: 'Failed to retrieve table replication status', + RETRIEVE_DESTINATIONS: 'Failed to retrieve destinations', + ENABLE_DESTINATION: 'Failed to enable destination', + DISABLE_DESTINATION: 'Failed to disable destination', + DELETE_DESTINATION: 'Failed to delete destination', + NO_PIPELINE_FOUND: 'No pipeline found', + COPY_TABLE_STATUS: 'Failed to copy table status', +} as const + +export const getStatusName = ( + status: ReplicationPipelineStatusData['status'] | undefined +): ReplicationPipelineStatusData['status']['name'] | undefined => { + if (status && typeof status === 'object' && 'name' in status) { + return status.name + } + return undefined +} + +export const PIPELINE_STATE_MESSAGES = { + enabling: { + title: 'Pipeline Enabling', + message: 'Starting the pipeline. Table replication will resume once enabled.', + badge: 'Enabling', + }, + disabling: { + title: 'Pipeline Disabling', + message: 'Stopping the pipeline. Table replication will be paused once disabled.', + badge: 'Disabling', + }, + failed: { + title: 'Pipeline Failed', + message: 'Replication has encountered an error. Check the logs for details.', + badge: 'Failed', + }, + stopped: { + title: 'Pipeline Stopped', + message: 'Replication is paused. Enable the pipeline to resume data synchronization.', + badge: 'Stopped', + }, + starting: { + title: 'Pipeline Starting', + message: 'Initializing replication. Table status will be available once running.', + badge: 'Starting', + }, + running: { + title: 'Pipeline Running', + message: 'Replication is active and processing data', + badge: 'Running', + }, + unknown: { + title: 'Pipeline Status Unknown', + message: 'Unable to determine replication status. Check the logs for more information.', + badge: 'Unknown', + }, + notRunning: { + title: 'Pipeline Not Running', + message: 'Replication is not active. Enable the pipeline to start data synchronization.', + badge: 'Disabled', + }, +} as const + +export const getPipelineStateMessages = ( + requestStatus: PipelineStatusRequestStatus | undefined, + statusName: string | undefined +) => { + // Always prioritize request status (enabling/disabling) over pipeline status + if (requestStatus === PipelineStatusRequestStatus.EnableRequested) { + return PIPELINE_STATE_MESSAGES.enabling + } + + if (requestStatus === PipelineStatusRequestStatus.DisableRequested) { + return PIPELINE_STATE_MESSAGES.disabling + } + + // Only check pipeline status if no request is in progress + switch (statusName) { + case 'failed': + return PIPELINE_STATE_MESSAGES.failed + case 'stopped': + return PIPELINE_STATE_MESSAGES.stopped + case 'starting': + return PIPELINE_STATE_MESSAGES.starting + case 'started': + return PIPELINE_STATE_MESSAGES.running + case 'unknown': + return PIPELINE_STATE_MESSAGES.unknown + default: + return PIPELINE_STATE_MESSAGES.notRunning + } +} diff --git a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx index 6fb360e6d97..f81a8df536a 100644 --- a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx @@ -1,28 +1,11 @@ import AlertError from 'components/ui/AlertError' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' -import { cn } from 'ui' -import { ResponseError } from 'types' -import { Loader2, ChevronDown, ChevronRight, Copy, AlertTriangle } from 'lucide-react' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, - Button, - Collapsible_Shadcn_ as Collapsible, - CollapsibleContent_Shadcn_ as CollapsibleContent, - CollapsibleTrigger_Shadcn_ as CollapsibleTrigger, -} from 'ui' -import { useState, useEffect, useRef } from 'react' -import { copyToClipboard } from 'ui' -import { toast } from 'sonner' import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query' - -export enum PipelineStatusRequestStatus { - None = 'None', - EnableRequested = 'EnableRequested', - DisableRequested = 'DisableRequested', -} +import { AlertTriangle, Loader2 } from 'lucide-react' +import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status' +import { ResponseError } from 'types' +import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { getPipelineStateMessages, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' export enum PipelineStatusName { FAILED = 'failed', @@ -41,10 +24,10 @@ interface PipelineStatusProps { isLoading: boolean isError: boolean isSuccess: boolean - requestStatus: PipelineStatusRequestStatus + requestStatus?: PipelineStatusRequestStatus } -const PipelineStatus = ({ +export const PipelineStatus = ({ pipelineStatus, error, isLoading, @@ -52,34 +35,6 @@ const PipelineStatus = ({ isSuccess, requestStatus, }: PipelineStatusProps) => { - const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false) - const errorDetailsRef = useRef(null) - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (errorDetailsRef.current && !errorDetailsRef.current.contains(event.target as Node)) { - setIsErrorDetailsOpen(false) - } - } - - if (isErrorDetailsOpen) { - document.addEventListener('mousedown', handleClickOutside) - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, [isErrorDetailsOpen]) - - const handleCopyToClipboard = async (text: string) => { - try { - await copyToClipboard(text) - toast.success('Error details copied to clipboard') - } catch (error) { - toast.error('Failed to copy error details') - } - } - const isFailedStatus = ( status: ReplicationPipelineStatusData['status'] | undefined ): status is FailedStatus => { @@ -91,144 +46,37 @@ const PipelineStatus = ({ ) } - const isLogLoadingState = (failedStatus: FailedStatus): boolean => { - // Right now we hardcode the error message which is returned when k8s is not able to find logs, which seems - // to be a transient error. In case we find a way to properly handle this in the backend, this hack will not - // be needed anymore. + const renderFailedStatus = () => { return ( - failedStatus.message?.startsWith('unable to retrieve container logs for containerd://') === - true - ) - } - - const renderFailedStatus = (failedStatus: FailedStatus) => { - const hasDetails = - failedStatus.message || failedStatus.reason || failedStatus.exit_code !== undefined - const isLoadingLogs = isLogLoadingState(failedStatus) - - return ( -
- - - -
- {isLoadingLogs ? ( - - ) : ( - - )} - Failed -
-
- -

- {isLoadingLogs - ? 'Pipeline failed - logs are being retrieved from container' - : 'Pipeline has failed - expand for error details'} -

-
-
-
- {hasDetails && ( - - - - - -
- {isLoadingLogs ? ( -
- -
-
- Pipeline Failed - Loading logs... -
-
- Error logs are being retrieved from the container. This may take a few - moments. -
-
-
- ) : ( - <> -
- - Pipeline Error Details - - {(failedStatus.message || failedStatus.reason) && ( - - )} -
-
- {failedStatus.exit_code !== undefined && ( -
- - Exit Code: - -
- {failedStatus.exit_code} -
-
- )} - {failedStatus.reason && ( -
- Reason: -
-                            {failedStatus.reason}
-                          
-
- )} - {failedStatus.message && ( -
- - Message: - -
-                            {failedStatus.message}
-                          
-
- )} -
- - )} -
-
-
- )} -
+ + +
+ + Failed +
+
+ + {getPipelineStateMessages(requestStatus, 'failed').message} + +
) } // Map backend statuses to UX-friendly display const getStatusConfig = () => { + const statusName = + pipelineStatus && typeof pipelineStatus === 'object' && 'name' in pipelineStatus + ? pipelineStatus.name + : undefined + + // Get consistent tooltip message using the same logic as other components + const stateMessages = getPipelineStateMessages(requestStatus, statusName) + if (requestStatus === PipelineStatusRequestStatus.EnableRequested) { return { label: 'Enabling...', dot: , color: 'text-brand-600', - tooltip: 'Pipeline is being enabled and will start shortly', + tooltip: stateMessages.message, } } @@ -237,7 +85,7 @@ const PipelineStatus = ({ label: 'Disabling...', dot: , color: 'text-warning-600', - tooltip: 'Pipeline is being disabled and will stop shortly', + tooltip: stateMessages.message, } } @@ -255,35 +103,35 @@ const PipelineStatus = ({ label: 'Starting', dot: , color: 'text-warning-600', - tooltip: 'Pipeline is initializing and will be ready soon', + tooltip: stateMessages.message, } case PipelineStatusName.STARTED: return { label: 'Running', dot:
, color: 'text-brand-600', - tooltip: 'Pipeline is active and processing data', + tooltip: stateMessages.message, } case PipelineStatusName.STOPPED: return { label: 'Stopped', dot:
, color: 'text-foreground-light', - tooltip: 'Pipeline is not running - enable to start processing', + tooltip: stateMessages.message, } case PipelineStatusName.UNKNOWN: return { label: 'Unknown', dot:
, color: 'text-warning-600', - tooltip: 'Pipeline status could not be determined', + tooltip: stateMessages.message, } default: return { label: 'Unknown', dot:
, color: 'text-destructive-600', - tooltip: 'Pipeline status is unclear - check logs for details', + tooltip: stateMessages.message, } } } @@ -301,31 +149,27 @@ const PipelineStatus = ({ return ( <> - {isLoading && } - {isError && } + {isLoading && } + {isError && ( + + )} {isSuccess && ( <> {statusConfig.isFailedStatus ? ( -
{renderFailedStatus(pipelineStatus as FailedStatus)}
+
{renderFailedStatus()}
) : ( - - - -
- {statusConfig.dot} - {statusConfig.label} -
-
- -

{statusConfig.tooltip}

-
-
-
+ + +
+ {statusConfig.dot} + {statusConfig.label} +
+
+ {statusConfig.tooltip} +
)} )} ) } - -export default PipelineStatus diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx new file mode 100644 index 00000000000..24d41862f06 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx @@ -0,0 +1,396 @@ +import { Activity, AlertTriangle, ChevronLeft, Copy, ExternalLink, Search } from 'lucide-react' +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { useParams } from 'common' +import Table from 'components/to-be-cleaned/Table' +import AlertError from 'components/ui/AlertError' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query' +import { useReplicationPipelineReplicationStatusQuery } from 'data/replication/pipeline-replication-status-query' +import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query' +import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' +import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' +import { + PipelineStatusRequestStatus, + usePipelineRequestStatus, +} from 'state/replication-pipeline-request-status' +import { Badge, Button, cn, copyToClipboard, Input_Shadcn_ } from 'ui' +import { GenericSkeletonLoader } from 'ui-patterns' +import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' +import { PipelineStatus } from './PipelineStatus' +import { TableState } from './ReplicationPipelineStatus.types' +import { getDisabledStateConfig, getStatusConfig } from './ReplicationPipelineStatus.utils' + +interface ReplicationPipelineStatusProps { + pipelineId: number + destinationName?: string + onSelectBack: () => void +} + +export const ReplicationPipelineStatus = ({ + pipelineId, + destinationName, + onSelectBack, +}: ReplicationPipelineStatusProps) => { + const { ref: projectRef } = useParams() + const [filterString, setFilterString] = useState('') + + const { getRequestStatus, updatePipelineStatus, setRequestStatus } = usePipelineRequestStatus() + const requestStatus = getRequestStatus(pipelineId) + + const { + data: pipeline, + error: pipelineError, + isLoading: isPipelineLoading, + isError: isPipelineError, + } = useReplicationPipelineByIdQuery({ + projectRef, + pipelineId, + }) + + const { + data: pipelineStatusData, + error: pipelineStatusError, + isLoading: isPipelineStatusLoading, + isError: isPipelineStatusError, + isSuccess: isPipelineStatusSuccess, + } = useReplicationPipelineStatusQuery( + { projectRef, pipelineId }, + { + enabled: !!pipelineId, + refetchInterval: 2000, // Poll every 2 seconds + } + ) + + const { + data: replicationStatusData, + error: statusError, + isLoading: isStatusLoading, + isError: isStatusError, + } = useReplicationPipelineReplicationStatusQuery( + { projectRef, pipelineId }, + { + enabled: !!pipelineId, + refetchInterval: 2000, // Poll every 2 seconds + } + ) + + const { mutateAsync: startPipeline, isLoading: isStartingPipeline } = useStartPipelineMutation() + const { mutateAsync: stopPipeline, isLoading: isStoppingPipeline } = useStopPipelineMutation() + + const statusName = getStatusName(pipelineStatusData?.status) + const config = getDisabledStateConfig({ requestStatus, statusName }) + + const tableStatuses = replicationStatusData?.table_statuses || [] + const filteredTableStatuses = + filterString.length === 0 + ? tableStatuses + : tableStatuses.filter((table: TableState) => + table.table_name.toLowerCase().includes(filterString.toLowerCase()) + ) + + const errorTables = tableStatuses.filter((table: TableState) => table.state.name === 'error') + const hasErrors = errorTables.length > 0 + const isPipelineRunning = statusName === 'started' + const hasTableData = tableStatuses.length > 0 + const isEnablingDisabling = + requestStatus === PipelineStatusRequestStatus.EnableRequested || + requestStatus === PipelineStatusRequestStatus.DisableRequested + const showDisabledState = !isPipelineRunning || isEnablingDisabling + + const handleCopyTableStatus = async (tableName: string, state: TableState['state']) => { + const statusText = `Table: ${tableName}\nStatus: ${state.name}${ + 'message' in state ? `\nError: ${state.message}` : '' + }${'lag' in state ? `\nLag: ${state.lag}ms` : ''}` + + try { + await copyToClipboard(statusText) + toast.success('Table status copied to clipboard') + } catch { + toast.error(PIPELINE_ERROR_MESSAGES.COPY_TABLE_STATUS) + } + } + + const onTogglePipeline = async () => { + if (!projectRef) { + return console.error('Project ref is required') + } + if (!pipeline) { + return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + } + + try { + if (statusName === 'stopped') { + await startPipeline({ projectRef, pipelineId: pipeline.id }) + setRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested) + } else if (statusName === 'started') { + await stopPipeline({ projectRef, pipelineId: pipeline.id }) + setRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested) + } + } catch (error) { + toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION) + } + } + + useEffect(() => { + updatePipelineStatus(pipelineId, statusName) + }, [pipelineId, statusName, updatePipelineStatus]) + + if (isPipelineError) { + return ( +
+
+
+
+
+ +
+ ) + } + + return ( +
+ {/* Header with back button and filters */} +
+
+
+
+
+ + setFilterString(e.target.value)} + /> +
+ +
+
+ + {(isPipelineLoading || isStatusLoading) && } + + {isStatusError && ( + + )} + + {hasErrors && ( +
+
+ +
+

+ {errorTables.length} table{errorTables.length > 1 ? 's' : ''} failed +

+

+ Some tables encountered replication errors. Check the logs for detailed error + information. +

+ +
+
+
+ )} + + {hasTableData && ( +
+ {showDisabledState && ( +
+
+
+
{config.icon}
+
+
+

{config.title}

+

{config.message}

+
+
+
+ )} + +
+ Table, + Status, + Details, + + Actions + , + ]} + body={filteredTableStatuses.map((table: TableState, index: number) => { + const statusConfig = getStatusConfig(table.state) + return ( + + +
+

{table.table_name}

+ + } + tooltip={{ content: { side: 'bottom', text: 'Open in Table Editor' } }} + > + + +
+
+ + {showDisabledState ? ( + Not Available + ) : ( + statusConfig.badge + )} + + + {showDisabledState ? ( +

+ Status unavailable while pipeline is {config.badge.toLowerCase()} +

+ ) : ( +
+
{statusConfig.description}
+ {'lag' in table.state && ( +
+ Lag: {table.state.lag}ms +
+ )} +
+ )} +
+ + } + className="px-1.5" + disabled={showDisabledState} + onClick={() => handleCopyTableStatus(table.table_name, table.state)} + tooltip={{ + content: { + side: 'bottom', + text: showDisabledState + ? `Copy unavailable while pipeline is ${config.badge.toLowerCase()}` + : 'Copy status details', + }, + }} + /> + +
+ ) + })} + /> + + + )} + + {filteredTableStatuses.length === 0 && hasTableData && ( + + +
+

No results found

+

+ Your search for "{filterString}" did not return any results +

+
+
+
+ )} + + {!isStatusLoading && tableStatuses.length === 0 && ( +
+
+
+ +
+
+

+ {showDisabledState ? 'Pipeline Not Running' : 'No table status information'} +

+

+ {showDisabledState + ? `The replication pipeline is currently ${statusName || 'not active'}. Table status + information is not available while the pipeline is in this state.` + : `This pipeline doesn't have any table replication status data available yet. The status will appear here once replication begins.`} +

+
+

+ Data refreshes automatically every 2 seconds +

+
+
+ )} + + ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.types.ts b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.types.ts new file mode 100644 index 00000000000..c5ab523ec1e --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.types.ts @@ -0,0 +1,10 @@ +export type TableState = { + table_id: number + table_name: string + state: + | { name: 'queued' } + | { name: 'copying_table' } + | { name: 'copied_table' } + | { name: 'following_wal'; lag: number } + | { name: 'error'; message: string } +} diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx new file mode 100644 index 00000000000..f9ff22a9e9a --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx @@ -0,0 +1,108 @@ +import { ReplicationPipelineStatusData } from 'data/replication/pipeline-status-query' +import { Activity, Clock, HelpCircle, Loader2, XCircle } from 'lucide-react' +import { PipelineStatusRequestStatus } from 'state/replication-pipeline-request-status' +import { Badge } from 'ui' +import { getPipelineStateMessages } from './Pipeline.utils' +import { TableState } from './ReplicationPipelineStatus.types' + +export const getStatusConfig = (state: TableState['state']) => { + switch (state.name) { + case 'queued': + return { + badge: Queued, + description: 'Waiting to start replication', + color: 'text-warning-600', + } + case 'copying_table': + return { + badge: Copying, + description: 'Initial data copy in progress', + color: 'text-brand-600', + } + case 'copied_table': + return { + badge: Copied, + description: 'Initial copy completed', + color: 'text-success-600', + } + case 'following_wal': + return { + badge: Live, + description: `Replicating live changes`, + color: 'text-success-600', + } + case 'error': + return { + badge: Error, + description: state.message, + color: 'text-destructive-600', + } + default: + return { + badge: Unknown, + description: 'Unknown status', + color: 'text-warning-600', + } + } +} + +export const getDisabledStateConfig = ({ + requestStatus, + statusName, +}: { + requestStatus: PipelineStatusRequestStatus + statusName?: ReplicationPipelineStatusData['status']['name'] +}) => { + const { title, message, badge } = getPipelineStateMessages(requestStatus, statusName) + + // Get icon and colors based on current state + const isEnabling = requestStatus === PipelineStatusRequestStatus.EnableRequested + const isDisabling = requestStatus === PipelineStatusRequestStatus.DisableRequested + const isTransitioning = isEnabling || isDisabling + + const icon = isTransitioning ? ( + + ) : statusName === 'failed' ? ( + + ) : statusName === 'starting' ? ( + + ) : statusName === 'unknown' ? ( + + ) : ( + + ) + + const colors = isEnabling + ? { + bg: 'bg-brand-50', + text: 'text-brand-900', + subtext: 'text-brand-700', + iconBg: 'bg-brand-600', + icon: 'text-white dark:text-black', + } + : isDisabling || statusName === 'starting' || statusName === 'unknown' + ? { + bg: 'bg-warning-50', + text: 'text-warning-900', + subtext: 'text-warning-700', + iconBg: 'bg-warning-600', + icon: 'text-white dark:text-black', + } + : statusName === 'failed' + ? { + bg: 'bg-destructive-50', + text: 'text-destructive-900', + subtext: 'text-destructive-700', + iconBg: 'bg-destructive-600', + icon: 'text-white dark:text-black', + } + : { + bg: 'bg-surface-100', + text: 'text-foreground', + subtext: 'text-foreground-light', + iconBg: 'bg-foreground-lighter', + icon: 'text-white dark:text-black', + } + + return { title, message, badge, icon, colors } +} diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx index f09cc703093..ccd49c4b520 100644 --- a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx @@ -1,5 +1,14 @@ -import AlertError from 'components/ui/AlertError' import { Edit, MoreVertical, Pause, Play, Trash } from 'lucide-react' +import { toast } from 'sonner' + +import { useParams } from 'common' +import AlertError from 'components/ui/AlertError' +import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' +import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' +import { + PipelineStatusRequestStatus, + usePipelineRequestStatus, +} from 'state/replication-pipeline-request-status' import { ResponseError } from 'types' import { Button, @@ -10,29 +19,31 @@ import { DropdownMenuTrigger, } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { Pipeline } from './DestinationRow' +import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' import { PipelineStatusName } from './PipelineStatus' interface RowMenuProps { + pipeline: Pipeline | undefined pipelineStatus: any error: ResponseError | null isLoading: boolean isError: boolean - onEnableClick: () => void - onDisableClick: () => void onEditClick: () => void onDeleteClick: () => void } -const RowMenu = ({ +export const RowMenu = ({ + pipeline, pipelineStatus, error, isLoading, isError, - onEnableClick, - onDisableClick, onEditClick, onDeleteClick, }: RowMenuProps) => { + const { ref: projectRef } = useParams() + const getStatusName = (status: any) => { if (status && typeof status === 'object' && 'name' in status) { return status.name @@ -43,33 +54,104 @@ const RowMenu = ({ const statusName = getStatusName(pipelineStatus) const pipelineEnabled = statusName !== PipelineStatusName.STOPPED + const { mutateAsync: startPipeline } = useStartPipelineMutation() + const { mutateAsync: stopPipeline } = useStopPipelineMutation() + const { setRequestStatus: setGlobalRequestStatus } = usePipelineRequestStatus() + + const onEnablePipeline = async () => { + if (!projectRef) { + return console.error('Project ref is required') + } + if (!pipeline) { + return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + } + + try { + await startPipeline({ projectRef, pipelineId: pipeline.id }) + toast(`Enabling pipeline ${pipeline.destination_name}`) + setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested) + } catch (error) { + toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION) + } + } + + const onDisablePipeline = async () => { + if (!projectRef) { + console.error('Project ref is required') + return + } + if (!pipeline) { + toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND) + return + } + + try { + await stopPipeline({ projectRef, pipelineId: pipeline.id }) + toast(`Disabling pipeline ${pipeline.destination_name}`) + setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested) + } catch (error) { + toast.error(PIPELINE_ERROR_MESSAGES.DISABLE_DESTINATION) + } + } + return (
- {isLoading && } - {isError && } + {isLoading && } + {isError && ( + + )} -
) } - -export default RowMenu diff --git a/apps/studio/components/interfaces/Docs/Authentication.tsx b/apps/studio/components/interfaces/Docs/Authentication.tsx index f6d24271122..3662e4572f5 100644 --- a/apps/studio/components/interfaces/Docs/Authentication.tsx +++ b/apps/studio/components/interfaces/Docs/Authentication.tsx @@ -1,7 +1,8 @@ import Link from 'next/link' import { useParams } from 'common' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import CodeSnippet from './CodeSnippet' import Snippets from './Snippets' @@ -12,9 +13,10 @@ interface AuthenticationProps { const Authentication = ({ selectedLang, showApiKey }: AuthenticationProps) => { const { ref: projectRef } = useParams() + const { data: apiKeys } = useAPIKeysQuery({ projectRef }) const { data: settings } = useProjectSettingsV2Query({ projectRef }) - const { anonKey, serviceKey } = getAPIKeys(settings) + const { anonKey, serviceKey } = getKeys(apiKeys) const protocol = settings?.app_config?.protocol ?? 'https' const hostEndpoint = settings?.app_config?.endpoint const endpoint = `${protocol}://${hostEndpoint ?? ''}` diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts index 5303cc8bc86..41bf5a58644 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.constants.ts @@ -12,7 +12,7 @@ export const INVOCATION_TABS: InvocationTab[] = [ label: 'cURL', language: 'bash', code: (functionUrl, _, apiKey) => `curl -L -X POST '${functionUrl}' \\ - -H 'Authorization: Bearer ${apiKey}' \\ + -H 'Authorization: Bearer ${apiKey}' \\ ${apiKey.includes('publishable') ? `\n -H 'apikey: ${apiKey}' \\` : ''} -H 'Content-Type: application/json' \\ --data '{"name":"Functions"}'`, }, diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index fdcbd0ed0e4..6358d77eac5 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -12,7 +12,8 @@ import z from 'zod' import { useParams } from 'common' import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' import { useEdgeFunctionDeleteMutation } from 'data/edge-functions/edge-functions-delete-mutation' @@ -60,6 +61,7 @@ export const EdgeFunctionDetails = () => { const [showDeleteModal, setShowDeleteModal] = useState(false) const canUpdateEdgeFunction = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*') + const { data: apiKeys } = useAPIKeysQuery({ projectRef }) const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) const { @@ -86,8 +88,8 @@ export const EdgeFunctionDetails = () => { defaultValues: { name: '', verify_jwt: false }, }) - const { anonKey } = getAPIKeys(settings) - const apiKey = anonKey?.api_key ?? '[YOUR ANON KEY]' + const { anonKey, publishableKey } = getKeys(apiKeys) + const apiKey = publishableKey?.api_key ?? anonKey?.api_key ?? '[YOUR ANON KEY]' const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint ?? '' diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx index 7c65503db22..4e07ccbf571 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -6,9 +6,10 @@ import * as z from 'zod' import { useParams } from 'common' import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useEdgeFunctionTestMutation } from 'data/edge-functions/edge-function-test-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' @@ -86,10 +87,11 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester const [response, setResponse] = useState(null) const [error, setError] = useState(null) + const { data: apiKeys } = useAPIKeysQuery({ projectRef }) const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) - const { serviceKey } = getAPIKeys(settings) + const { serviceKey } = getKeys(apiKeys) const { mutate: sendEvent } = useSendEventMutation() const { mutate: testEdgeFunction, isLoading } = useEdgeFunctionTestMutation({ diff --git a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx index fa2b18867df..363fd7c1056 100644 --- a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx +++ b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx @@ -6,7 +6,8 @@ import { useParams } from 'common' import CommandRender from 'components/interfaces/Functions/CommandRender' import { DocsButton } from 'components/ui/DocsButton' import { useAccessTokensQuery } from 'data/access-tokens/access-tokens-query' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { Button, @@ -30,11 +31,12 @@ export const TerminalInstructions = forwardRef< const [showInstructions, setShowInstructions] = useState(!closable) const { data: tokens } = useAccessTokensQuery() + const { data: apiKeys } = useAPIKeysQuery({ projectRef }) const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) - const { anonKey } = getAPIKeys(settings) - const apiKey = anonKey?.api_key ?? '[YOUR ANON KEY]' + const { anonKey, publishableKey } = getKeys(apiKeys) + const apiKey = publishableKey?.api_key ?? anonKey?.api_key ?? '[YOUR ANON KEY]' const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint ?? '' @@ -74,15 +76,14 @@ export const TerminalInstructions = forwardRef< comment: 'Deploy your function', }, { - command: `curl -L -X POST 'https://${projectRef}.supabase.${restUrlTld}/functions/v1/hello-world' -H 'Authorization: Bearer ${ - apiKey ?? '[YOUR ANON KEY]' - }' --data '{"name":"Functions"}'`, + command: `curl -L -X POST 'https://${projectRef}.supabase.${restUrlTld}/functions/v1/hello-world' -H 'Authorization: Bearer ${apiKey}'${anonKey?.type === 'publishable' ? ` -H 'apikey: ${apiKey}'` : ''} --data '{"name":"Functions"}'`, description: 'Invokes the hello-world function', jsx: () => { return ( <> curl -L -X POST '{functionsEndpoint} - /hello-world' -H 'Authorization: Bearer [YOUR ANON KEY]'{' '} + /hello-world' -H 'Authorization: Bearer [YOUR ANON KEY]' s + {anonKey?.type === 'publishable' ? " -H 'apikey: [YOUR ANON KEY]' " : ''} {`--data '{"name":"Functions"}'`} ) diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx index e9f05f5cd25..5c62c1c2a5b 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx @@ -4,13 +4,13 @@ import { AlertCircle, Loader } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' -import { SimpleCodeBlock } from 'ui' import { useParams } from 'common' import Panel from 'components/ui/Panel' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { Input } from 'ui' +import { Input, SimpleCodeBlock } from 'ui' const generateInitSnippet = (endpoint: string) => ({ js: ` @@ -42,13 +42,13 @@ const APIKeys = () => { data: settings, isError: isProjectSettingsError, isLoading: isProjectSettingsLoading, - } = useProjectSettingsV2Query({ - projectRef, - }) + } = useProjectSettingsV2Query({ projectRef }) + + const { data: apiKeys } = useAPIKeysQuery({ projectRef }) + const { anonKey, serviceKey } = getKeys(apiKeys) // API keys should not be empty. However it can be populated with a delay on project creation - const apiKeys = settings?.service_api_keys ?? [] - const isApiKeysEmpty = apiKeys.length === 0 + const isApiKeysEmpty = !anonKey && !serviceKey const { data, @@ -56,9 +56,7 @@ const APIKeys = () => { isLoading: isJwtSecretUpdateStatusLoading, } = useJwtSecretUpdatingStatusQuery( { projectRef }, - { - enabled: !isProjectSettingsLoading && isApiKeysEmpty, - } + { enabled: !isProjectSettingsLoading && isApiKeysEmpty } ) // Only show JWT loading state if the query is actually enabled @@ -75,7 +73,6 @@ const APIKeys = () => { const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint const apiUrl = `${protocol}://${endpoint ?? '-'}` - const { anonKey } = getAPIKeys(settings) const clientInitSnippet: any = generateInitSnippet(apiUrl) const selectedLanguageSnippet = clientInitSnippet[selectedLanguage.key] ?? 'No snippet available' @@ -144,17 +141,13 @@ const APIKeys = () => {

API Key

- {anonKey?.tags?.split(',').map((x: any, i: number) => ( - - {x} - - ))} - {'public'} + {anonKey?.name} + public
} copy={canReadAPIKeys && isNotUpdatingJwtSecret} - reveal={anonKey?.tags !== 'anon' && canReadAPIKeys && isNotUpdatingJwtSecret} + reveal={anonKey?.name !== 'anon' && canReadAPIKeys && isNotUpdatingJwtSecret} value={ !canReadAPIKeys ? 'You need additional permissions to view API keys' diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx index 09ad555434f..813d94eecf9 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx @@ -6,7 +6,6 @@ import { useDebounce } from 'use-debounce' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useSqlCronGenerateMutation } from 'data/ai/sql-cron-mutation' import { useCronTimezoneQuery } from 'data/database-cron-jobs/database-cron-timezone-query' -import { useFlag } from 'hooks/ui/useFlag' import { Accordion_Shadcn_, AccordionContent_Shadcn_, @@ -36,7 +35,6 @@ interface CronJobScheduleSectionProps { export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobScheduleSectionProps) => { const { project } = useProjectContext() - const useBedrockAssistant = useFlag('useBedrockAssistant') const [inputValue, setInputValue] = useState('') const [debouncedValue] = useDebounce(inputValue, 750) const [useNaturalLanguage, setUseNaturalLanguage] = useState(false) @@ -67,7 +65,7 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul useEffect(() => { if (useNaturalLanguage && debouncedValue) { - generateCronSyntax({ prompt: debouncedValue, useBedrockAssistant }) + generateCronSyntax({ prompt: debouncedValue }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedValue, useNaturalLanguage]) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx index 1ee72a8896f..536fd890501 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx @@ -2,7 +2,7 @@ import { ChevronDown, Plus, Trash } from 'lucide-react' import { useFieldArray } from 'react-hook-form' import { useParams } from 'common' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { Button, cn, @@ -32,9 +32,10 @@ export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProp }) const { ref } = useParams() - const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) - const { serviceKey } = getAPIKeys(settings) - const apiKey = serviceKey?.api_key ?? '[YOUR API KEY]' + const { data: apiKeys } = useAPIKeysQuery({ projectRef: ref, reveal: true }) + + const { serviceKey, secretKey } = getKeys(apiKeys) + const apiKey = secretKey?.api_key ?? serviceKey?.api_key ?? '[YOUR API KEY]' return ( @@ -104,15 +105,18 @@ export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProp + onClick={() => { append({ name: 'Authorization', value: `Bearer ${apiKey}`, }) - } + if (serviceKey?.type === 'secret') { + append({ name: 'apikey', value: apiKey }) + } + }} >
-

Add auth header with service key

+

Add auth header with secret key

Required if your edge function enforces JWT verification

diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx index f235358ea38..790babb0bc9 100644 --- a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx +++ b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx @@ -9,9 +9,9 @@ import ExtensionCard from 'components/interfaces/Database/Extensions/ExtensionCa import GraphiQL from 'components/interfaces/GraphQL/GraphiQL' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { Loading } from 'components/ui/Loading' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { API_URL, IS_PLATFORM } from 'lib/constants' import { getRoleImpersonationJWT } from 'lib/role-impersonation' @@ -30,9 +30,9 @@ export const GraphiQLTab = () => { const pgGraphqlExtension = (data ?? []).find((ext) => ext.name === 'pg_graphql') const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) - const { data: settings, isFetched } = useProjectSettingsV2Query({ projectRef }) - const { serviceKey } = getAPIKeys(settings) + const { data: apiKeys, isFetched } = useAPIKeysQuery({ projectRef, reveal: true }) + const { serviceKey, secretKey } = getKeys(apiKeys) const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) const jwtSecret = config?.jwt_secret @@ -74,7 +74,7 @@ export const GraphiQLTab = () => { opts?.headers?.['Authorization'] ?? opts?.headers?.['authorization'] ?? userAuthorization ?? - `Bearer ${serviceKey?.api_key}`, + `Bearer ${secretKey?.api_key ?? serviceKey?.api_key}`, }, }) } diff --git a/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx b/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx index 4afe8207280..adc735b52e5 100644 --- a/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx @@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useState } from 'react' import DeleteHookModal from 'components/interfaces/Database/Hooks/DeleteHookModal' -import EditHookPanel from 'components/interfaces/Database/Hooks/EditHookPanel' +import { EditHookPanel } from 'components/interfaces/Database/Hooks/EditHookPanel' import HooksList from 'components/interfaces/Database/Hooks/HooksList/HooksList' import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' @@ -13,7 +13,6 @@ export const WebhooksListTab = () => { const [showDeleteHookForm, setShowDeleteHookForm] = useState(false) const canReadWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') - const canCreateWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') const isPermissionsLoaded = usePermissionsLoaded() const createHook = () => { diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx index 5082e90df99..a137720baa9 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx @@ -20,15 +20,18 @@ import { useAsyncCheckProjectPermissions, useCheckPermissions, } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Button, Card, CardFooter, Form_Shadcn_ as Form } from 'ui' -import { BillingCustomerDataForm } from './BillingCustomerDataForm' +import { + BillingCustomerDataForm, + type BillingCustomerDataFormValues, +} from './BillingCustomerDataForm' import { TAX_IDS } from './TaxID.constants' import { useBillingCustomerDataForm } from './useBillingCustomerDataForm' export const BillingCustomerData = () => { const { slug } = useParams() - const selectedOrganization = useSelectedOrganization() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const { isSuccess: isPermissionsLoaded, can: canReadBillingCustomerData } = useAsyncCheckProjectPermissions(PermissionAction.BILLING_READ, 'stripe.customer') @@ -51,9 +54,13 @@ export const BillingCustomerData = () => { isSuccess: loadedTaxId, } = useOrganizationTaxIdQuery({ slug }) - const initialCustomerData = useMemo( + const initialCustomerData = useMemo>( () => ({ - ...customerProfile?.address, + city: customerProfile?.address?.city ?? undefined, + country: customerProfile?.address?.country, + line1: customerProfile?.address?.line1, + line2: customerProfile?.address?.line2 ?? undefined, + postal_code: customerProfile?.address?.postal_code ?? undefined, billing_name: customerProfile?.billing_name, tax_id_type: taxId?.type, tax_id_value: taxId?.value, diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataExistingOrgDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataExistingOrgDialog.tsx deleted file mode 100644 index 9fac673f8fc..00000000000 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataExistingOrgDialog.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Pencil } from 'lucide-react' -import { useMemo, useState } from 'react' -import { toast } from 'sonner' - -import AlertError from 'components/ui/AlertError' -import NoPermission from 'components/ui/NoPermission' -import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' -import { useOrganizationCustomerProfileUpdateMutation } from 'data/organizations/organization-customer-profile-update-mutation' -import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query' -import { useOrganizationTaxIdUpdateMutation } from 'data/organizations/organization-tax-id-update-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { - Button, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogSectionSeparator, - DialogTitle, - Form_Shadcn_ as Form, - Label_Shadcn_ as Label, -} from 'ui' -import { BillingCustomerDataForm } from './BillingCustomerDataForm' -import { TAX_IDS } from './TaxID.constants' -import { useBillingCustomerDataForm } from './useBillingCustomerDataForm' - -export const BillingCustomerDataExistingOrgDialog = () => { - const [open, setOpen] = useState(false) - const [isSubmitting, setIsSubmitting] = useState(false) - - const { slug } = useSelectedOrganization() ?? {} - - const canReadBillingCustomerData = useCheckPermissions( - PermissionAction.BILLING_READ, - 'stripe.customer' - ) - - const canUpdateBillingCustomerData = useCheckPermissions( - PermissionAction.BILLING_WRITE, - 'stripe.customer' - ) - - const { - data: customerProfile, - error, - isLoading, - isSuccess, - } = useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingCustomerData }) - - const { - data: taxId, - error: errorTaxId, - isLoading: isLoadingTaxId, - isSuccess: isSuccessTaxId, - } = useOrganizationTaxIdQuery({ slug }) - - const initialCustomerData = useMemo( - () => ({ - ...customerProfile?.address, - billing_name: customerProfile?.billing_name, - tax_id_type: taxId?.type, - tax_id_value: taxId?.value, - tax_id_name: taxId - ? TAX_IDS.find( - (option) => option.type === taxId.type && option.countryIso2 === taxId.country - )?.name || '' - : '', - }), - [customerProfile, taxId] - ) - - const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation() - const { mutateAsync: updateTaxId } = useOrganizationTaxIdUpdateMutation() - - const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({ - initialCustomerData, - onCustomerDataChange: async (data) => { - setIsSubmitting(true) - - try { - await updateCustomerProfile({ - slug, - address: data.address ?? undefined, - billing_name: data.billing_name, - }) - - await updateTaxId({ slug, taxId: data.tax_id }) - - toast.success('Successfully updated billing data') - - setOpen(false) - - setIsSubmitting(false) - } catch (error: any) { - toast.error(`Failed updating billing data: ${error.message}`) - setIsSubmitting(false) - } - }, - }) - - const handleClose = () => { - handleReset() - setOpen(false) - } - - const getAddressSummary = () => { - if (!customerProfile?.address?.line1) return 'Optionally add a billing address' - - const parts = [ - customerProfile?.billing_name, - customerProfile?.address?.line1, - customerProfile?.address?.city, - customerProfile?.address?.state, - customerProfile?.address?.country, - ].filter(Boolean) - - return parts.join(', ') - } - - const isSubmitDisabled = !isDirty || !canUpdateBillingCustomerData || isSubmitting - - return ( - <> -
- - {!canReadBillingCustomerData ? ( - - ) : ( - <> - {(isLoading || isLoadingTaxId) && ( -
- -
- )} - {(error || errorTaxId) && ( - - )} - {isSuccess && isSuccessTaxId && ( -
-

{getAddressSummary()}

- -
- )} - - )} -
- - { - if (!value) setOpen(false) - else setOpen(true) - }} - > - - - Billing Address & Tax Id - - This will be reflected in every upcoming invoice, past invoices are not affected - - - -
- - - - {!canUpdateBillingCustomerData && ( - - You need additional permissions to manage this organization's billing address - - )} -
- - -
-
- - -
-
- - ) -} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx index 7b7a8da1333..98e84f71649 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx @@ -2,7 +2,7 @@ import { Check, ChevronsUpDown, X } from 'lucide-react' import { UseFormReturn } from 'react-hook-form' import { z } from 'zod' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { Button, cn, @@ -90,18 +90,24 @@ export const BillingCustomerDataForm = ({ form.setValue('tax_id_value', '', { shouldDirty: true }) } - const { tax_id_name } = form.watch() + const { tax_id_name, country } = form.watch() const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name) + const availableTaxIds = useMemo(() => { + return TAX_IDS.filter((taxId) => !country || taxId.countryIso2 === country).sort((a, b) => + a.country.localeCompare(b.country) + ) + }, [country]) + return (
( - + - + @@ -112,9 +118,9 @@ export const BillingCustomerDataForm = ({ control={form.control} name="line1" render={({ field }: { field: any }) => ( - + - + @@ -125,9 +131,13 @@ export const BillingCustomerDataForm = ({ control={form.control} name="line2" render={({ field }: { field: any }) => ( - + - + @@ -139,7 +149,7 @@ export const BillingCustomerDataForm = ({ control={form.control} name="country" render={({ field }: { field: any }) => ( - + @@ -207,9 +217,9 @@ export const BillingCustomerDataForm = ({ control={form.control} name="postal_code" render={({ field }: { field: any }) => ( - + - + @@ -222,9 +232,9 @@ export const BillingCustomerDataForm = ({ control={form.control} name="city" render={({ field }: { field: any }) => ( - + - + @@ -234,9 +244,9 @@ export const BillingCustomerDataForm = ({ control={form.control} name="state" render={({ field }: { field: any }) => ( - + - + @@ -281,26 +291,24 @@ export const BillingCustomerDataForm = ({ No tax ID found. - {TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map( - (option) => ( - { - onSelectTaxIdType(option.name) - setShowTaxIDsPopover(false) - }} - > - - {option.country} - {option.name} - - ) - )} + {availableTaxIds.map((option) => ( + { + onSelectTaxIdType(option.name) + setShowTaxIDsPopover(false) + }} + > + + {option.country} - {option.name} + + ))} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog.tsx deleted file mode 100644 index dd072469401..00000000000 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { FocusTrap } from '@headlessui/react' -import { Pencil } from 'lucide-react' -import { useState } from 'react' - -import { - Button, - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogSectionSeparator, - DialogTitle, - DialogTrigger, - Form_Shadcn_ as Form, -} from 'ui' -import { BillingCustomerDataForm } from './BillingCustomerDataForm' -import { FormCustomerData, useBillingCustomerDataForm } from './useBillingCustomerDataForm' - -interface BillingCustomerDataNewOrgDialogProps { - onCustomerDataChange: (data: FormCustomerData) => void -} - -export const BillingCustomerDataNewOrgDialog = ({ - onCustomerDataChange, -}: BillingCustomerDataNewOrgDialogProps) => { - const [open, setOpen] = useState(false) - - const handleDialogClose = () => { - setOpen(false) - } - - const { form, handleSubmit, handleReset, isDirty } = useBillingCustomerDataForm({ - onCustomerDataChange, - }) - - const handleClose = () => { - handleReset() - handleDialogClose() - } - - const getAddressSummary = () => { - const { line1, city, state, country, billing_name } = form.getValues() - - if (!line1) return 'Optionally add a billing address' - - const parts = [billing_name, line1, city, state, country].filter(Boolean) - - return parts.join(', ') - } - - const isSubmitDisabled = !isDirty - - return ( -
- - { - if (!value) handleDialogClose() - else setOpen(true) - }} - > - - - - - - Billing Address & Tax Id - - - {/* - [Joshen] There's something odd going on with using Dialog and RHF FormField here - where the focus keeps going to the Dialog when tabbing across the input fields, hence the FocusTrap - What's weirder is that once you've cycled the focus through all the inputs at least once, the focus - then no longer goes to the Dialog thereafter - FWIW it's likely happening across the dashboard whereever we're using Dialog and FormField - */} - -
- { - e.preventDefault() - e.stopPropagation() - form.handleSubmit(handleSubmit)(e) - - // Only close the dialog if the form is valid - if (!Object.keys(form.formState.errors).length) { - handleDialogClose() - } - }} - > - - -
- - -
-
- - -
-
-
-
- ) -} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts index dcc09c8e795..290af4077ed 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts @@ -1,8 +1,3 @@ -// List of available Tax IDs as reflected in Stripe's web portal -// This was manually ported over so there may be a chance of mistakes -// Last updated as of 29th March 2022. -// The code may not necessarily match with the name (ref SE_VAT) -// https://stripe.com/docs/api/customer_tax_ids/create export interface TaxId { name: string type: string diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts index aef1e286377..176a3e4f19d 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts @@ -6,9 +6,10 @@ import { BillingCustomerDataFormValues, BillingCustomerDataSchema } from './Bill import { TAX_IDS } from './TaxID.constants' import { sanitizeTaxIdValue } from './TaxID.utils' import { components } from 'api-types' +import type { CustomerAddress } from 'data/organizations/types' export type FormCustomerData = { - address: components['schemas']['CustomerResponse']['address'] | undefined + address: CustomerAddress | undefined billing_name: string tax_id: components['schemas']['TaxIdResponse']['tax_id'] | null } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index b33f047760d..9141ad9efc6 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -7,7 +7,7 @@ import { useQueryClient } from '@tanstack/react-query' import { AlertCircle, Info } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' @@ -17,7 +17,6 @@ import { useOrganizationCreditTopUpMutation } from 'data/organizations/organizat import { subscriptionKeys } from 'data/subscriptions/keys' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import { STRIPE_PUBLIC_KEY } from 'lib/constants' -import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store' import { Alert_Shadcn_, AlertDescription_Shadcn_, @@ -40,6 +39,7 @@ 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' +import type { PaymentMethodElementRef } from './PaymentMethods/NewPaymentMethodElement' const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -59,6 +59,9 @@ type CreditTopUpForm = z.infer export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { const { resolvedTheme } = useTheme() const queryClient = useQueryClient() + const paymentMethodSelectionRef = useRef<{ + createPaymentMethod: PaymentMethodElementRef['createPaymentMethod'] + }>(null) const canTopUpCredits = useCheckPermissions( PermissionAction.BILLING_WRITE, @@ -82,7 +85,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { const [topUpModalVisible, setTopUpModalVisible] = useState(false) const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) - const captchaLoaded = useIsHCaptchaLoaded() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) @@ -96,7 +98,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { } const initHcaptcha = async () => { - if (topUpModalVisible && captchaRef && captchaLoaded) { + if (topUpModalVisible && captchaRef) { let token = captchaToken try { @@ -116,7 +118,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { useEffect(() => { initHcaptcha() - }, [topUpModalVisible, captchaRef, captchaLoaded]) + }, [topUpModalVisible, captchaRef]) const [paymentIntentSecret, setPaymentIntentSecret] = useState('') const [paymentIntentConfirmation, setPaymentIntentConfirmation] = useState() @@ -126,12 +128,20 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { const token = await initHcaptcha() + const paymentMethodResult = await paymentMethodSelectionRef.current?.createPaymentMethod() + if (!paymentMethodResult) { + return + } + await topUpCredits( { slug, amount, - payment_method_id: paymentMethod, + payment_method_id: paymentMethodResult.paymentMethod.id, hcaptchaToken: token, + address: paymentMethodResult.address, + tax_id: paymentMethodResult.taxId ?? undefined, + billing_name: paymentMethodResult.customerName, }, { onSuccess: (data) => { @@ -225,10 +235,21 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { /> Top Up Credits - - On successful payment, an invoice will be issued and you'll be granted credits. - Credits will be applied to future invoices only and are not refundable. The topped up - credits do not expire. + +

+ On successful payment, an invoice will be issued and you'll be granted credits. + Credits will be applied to outstanding and future invoices and are not refundable. + The topped up credits do not expire. +

+

+ For larger discounted credit packages, please{' '} + + reach out. + +

@@ -252,7 +273,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { name="paymentMethod" render={() => ( form.setValue('paymentMethod', pm)} selectedPaymentMethod={form.getValues('paymentMethod')} readOnly={executingTopUp || paymentConfirmationLoading} @@ -260,16 +281,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { )} /> -

- For larger discounted credit packages, please{' '} - - reach out. - -

- {paymentIntentConfirmation && paymentIntentConfirmation.error && ( diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx index 9070143191f..11a41994047 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx @@ -4,26 +4,148 @@ * 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 { AddressElement, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import { + StripeAddressElementChangeEvent, + StripeAddressElementOptions, + type SetupIntent, +} from '@stripe/stripe-js' +import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' import { toast } from 'sonner' +import { + Button, + Checkbox_Shadcn_, + 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, + FormControl_Shadcn_ as FormControl, + FormField_Shadcn_ as FormField, + FormItem_Shadcn_, + FormMessage_Shadcn_ as FormMessage, + Input_Shadcn_ as Input, + Popover_Shadcn_ as Popover, + PopoverContent_Shadcn_ as PopoverContent, + PopoverTrigger_Shadcn_ as PopoverTrigger, +} from 'ui' +import { TAX_IDS, type TaxId } from '../BillingCustomerData/TaxID.constants' +import { z } from 'zod' +import { useForm } from 'react-hook-form' +import { Form } from '@ui/components/shadcn/ui/form' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { Check, ChevronsUpDown } from 'lucide-react' +import { zodResolver } from '@hookform/resolvers/zod' +import { getURL } from 'lib/helpers' +import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types' +import type { PaymentMethod } from '@stripe/stripe-js' + +export const BillingCustomerDataSchema = z.object({ + tax_id_type: z.string(), + tax_id_value: z.string().min(2, { + message: 'Tax ID needs to be set.', + }), + tax_id_name: z.string(), +}) + +type BillingCustomerDataFormValues = z.infer + +export type PaymentMethodElementRef = { + confirmSetup: () => Promise< + | { + setupIntent: SetupIntent + address: CustomerAddress + customerName: string + taxId: CustomerTaxId | null + } + | undefined + > + createPaymentMethod: () => Promise< + | { + paymentMethod: PaymentMethod + address: CustomerAddress | null + customerName: string | null + taxId: CustomerTaxId | null + } + | undefined + > +} const NewPaymentMethodElement = forwardRef( ( { email, readOnly, + currentAddress, + currentTaxId, + customerName, }: { email?: string | null | undefined readOnly: boolean + currentAddress?: CustomerAddress | null + currentTaxId?: CustomerTaxId | null + customerName?: string | undefined }, ref ) => { const stripe = useStripe() const elements = useElements() - const createPaymentMethod = async () => { + const form = useForm({ + resolver: zodResolver(BillingCustomerDataSchema), + defaultValues: { + tax_id_name: currentTaxId + ? TAX_IDS.find( + (option) => + option.type === currentTaxId.type && option.countryIso2 === currentTaxId.country + )?.name || '' + : '', + tax_id_type: currentTaxId ? currentTaxId.type : '', + tax_id_value: currentTaxId ? currentTaxId.value : '', + }, + }) + + // To avoid rendering the business checkbox prematurely and causing weird layout shifts, we wait until the address element is fully loaded + const [fullyLoaded, setFullyLoaded] = useState(false) + + const [showTaxIDsPopover, setShowTaxIDsPopover] = useState(false) + + const onSelectTaxIdType = (name: string) => { + const selectedTaxIdOption = TAX_IDS.find((option) => option.name === name) + if (!selectedTaxIdOption) return + form.setValue('tax_id_type', selectedTaxIdOption.type) + form.setValue('tax_id_value', '') + form.setValue('tax_id_name', name) + } + + const { tax_id_name } = form.watch() + const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name) + + const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null) + const [stripeAddress, setStripeAddress] = useState< + StripeAddressElementChangeEvent['value'] | undefined + >(undefined) + + const availableTaxIds = useMemo(() => { + const country = stripeAddress?.address.country || null + + return TAX_IDS.filter((taxId) => country == null || taxId.countryIso2 === country).sort( + (a, b) => a.country.localeCompare(b.country) + ) + }, [stripeAddress]) + + const createPaymentMethod = async (): ReturnType< + PaymentMethodElementRef['createPaymentMethod'] + > => { if (!stripe || !elements) return + await form.trigger() + + if (purchasingAsBusiness && availableTaxIds.length > 0 && !form.getValues('tax_id_value')) { + 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 @@ -34,17 +156,209 @@ const NewPaymentMethodElement = forwardRef( toast.error(error?.message ?? ' Failed to process card details') return } - return paymentMethod + + const addressElement = await elements.getElement('address')!.getValue() + return { + paymentMethod, + address: { + ...addressElement.value.address, + line2: addressElement.value.address.line2 || undefined, + }, + customerName: addressElement.value.name, + taxId: getConfiguredTaxId(), + } + } + + function getConfiguredTaxId(): CustomerTaxId | null { + return purchasingAsBusiness && selectedTaxId + ? { + country: selectedTaxId.countryIso2, + type: selectedTaxId.type, + value: form.getValues('tax_id_value'), + } + : null + } + + const confirmSetup = async (): ReturnType => { + if (!stripe || !elements) return + + await elements.submit() + + const { error, setupIntent } = await stripe.confirmSetup({ + elements, + redirect: 'if_required', + confirmParams: { return_url: `${getURL()}/org/_/billing` }, + }) + + if (error || setupIntent == null) { + toast.error(error?.message ?? ' Failed to process card details') + return + } + + const addressElement = await elements.getElement('address')!.getValue() + return { + setupIntent, + address: { + ...addressElement.value.address, + line2: addressElement.value.address.line2 || undefined, + }, + customerName: addressElement.value.name, + taxId: getConfiguredTaxId(), + } } useImperativeHandle(ref, () => ({ createPaymentMethod, + confirmSetup, })) + const addressOptions: StripeAddressElementOptions = useMemo( + () => ({ + mode: 'billing', + autocomplete: { + apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!, + mode: 'google_maps_api', + }, + display: { name: purchasingAsBusiness ? 'organization' : 'full' }, + defaultValues: { + address: currentAddress ?? undefined, + name: customerName, + }, + }), + [purchasingAsBusiness] + ) + + // Preselect tax id if there is no more than 2 available tax ids (even if there are two options, first one in the list is likely to be it) + useEffect(() => { + if (availableTaxIds.length && stripeAddress?.address.country && !currentTaxId) { + const taxIdOption = availableTaxIds[0] + form.setValue('tax_id_type', taxIdOption.type) + form.setValue('tax_id_value', '') + form.setValue('tax_id_name', taxIdOption.name) + } + }, [availableTaxIds, stripeAddress]) + return ( - +
+

+ Please ensure CVC and postal codes match what is on file for your card. +

+ + + + {fullyLoaded && ( +
+ setPurchasingAsBusiness(!purchasingAsBusiness)} + /> + +
+ )} + + setStripeAddress(evt.value)} + onReady={() => setFullyLoaded(true)} + /> + + {purchasingAsBusiness && availableTaxIds.length > 0 && ( +
+
+ ( + + + + + + + + + + + + No tax ID found. + + {availableTaxIds.map((option) => ( + { + onSelectTaxIdType(option.name) + setShowTaxIDsPopover(false) + }} + > + + {option.country} - {option.name} + + ))} + + + + + + + + )} + /> + + {selectedTaxId && ( + ( + + + + + + + )} + /> + )} +
+ + )} +
) } ) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx index 15b85fa5ecf..93e42b96b51 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/PaymentMethods.tsx @@ -176,7 +176,6 @@ const PaymentMethods = () => { setShowAddPaymentMethodModal(false) toast.success('Successfully added new payment method') }} - showSetDefaultCheckbox={true} /> { - const selectedOrganization = useSelectedOrganization() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const orgSlug = selectedOrganization?.slug const features = pickFeatures(plan, billingPartner) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/MembersExceedLimitModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/MembersExceedLimitModal.tsx index 44ea19292d2..510d5a1b8c2 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/MembersExceedLimitModal.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/MembersExceedLimitModal.tsx @@ -1,5 +1,5 @@ import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Button, Modal } from 'ui' export interface MembersExceedLimitModalProps { @@ -8,7 +8,7 @@ export interface MembersExceedLimitModalProps { } const MembersExceedLimitModal = ({ visible, onClose }: MembersExceedLimitModalProps) => { - const selectedOrganization = useSelectedOrganization() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const slug = selectedOrganization?.slug const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery( { slug }, diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx index 3754491fbd1..b775173a3c8 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx @@ -1,4 +1,3 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { forwardRef, useCallback, @@ -9,26 +8,30 @@ import { useState, } from 'react' import { toast } from 'sonner' - -import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal' -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 { + useSelectedOrganization, + useSelectedOrganizationQuery, +} from 'hooks/misc/useSelectedOrganization' 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 { Loader, Plus } from 'lucide-react' +import { Checkbox_Shadcn_, 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 { + NewPaymentMethodElement, + type PaymentMethodElementRef, +} from '../PaymentMethods/NewPaymentMethodElement' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { useFlag } from 'hooks/ui/useFlag' +import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' +import { useOrganizationTaxIdQuery } from 'data/organizations/organization-tax-id-query' +import { useParams } from 'common' const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -36,7 +39,6 @@ export interface PaymentMethodSelectionProps { selectedPaymentMethod?: string onSelectPaymentMethod: (id: string) => void layout?: 'vertical' | 'horizontal' - createPaymentMethodInline: boolean readOnly: boolean } @@ -45,27 +47,48 @@ 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 { slug } = useParams() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) const [setupIntent, setSetupIntent] = useState(undefined) + const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true) const { resolvedTheme } = useTheme() - const paymentRef = useRef<{ createPaymentMethod: () => Promise }>(null) + const paymentRef = useRef(null) const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState(null) + const { data: customerProfile, isLoading: isCustomerProfileLoading } = + useOrganizationCustomerProfileQuery({ + slug, + }) + const { data: taxId, isLoading: isCustomerTaxIdLoading } = useOrganizationTaxIdQuery({ slug }) - const { - data: paymentMethods, - isLoading, - refetch: refetchPaymentMethods, - } = useOrganizationPaymentMethodsQuery({ slug }) + const hidePaymentMethodsWithoutAddress = useFlag('hidePaymentMethodsWithoutAddress') + + const { data: allPaymentMethods, isLoading } = useOrganizationPaymentMethodsQuery({ slug }) + + const paymentMethods = useMemo(() => { + if (!allPaymentMethods) + return { + data: [], + defaultPaymentMethodId: null, + } + + const filtered = allPaymentMethods.data.filter( + (pm) => !hidePaymentMethodsWithoutAddress || pm.has_address + ) + return { + data: filtered, + defaultPaymentMethodId: allPaymentMethods.data.some( + (pm) => pm.id === allPaymentMethods.defaultPaymentMethodId + ) + ? allPaymentMethods.defaultPaymentMethodId + : null, + } + }, [allPaymentMethods]) const captchaRefCallback = useCallback((node: any) => { setCaptchaRef(node) @@ -98,7 +121,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( } const loadPaymentForm = async () => { - if (setupNewPaymentMethod && createPaymentMethodInline && captchaRef && captchaLoaded) { + if (setupNewPaymentMethod && captchaRef) { let token = captchaToken try { @@ -116,18 +139,13 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( } loadPaymentForm() - }, [createPaymentMethodInline, captchaRef, captchaLoaded, setupNewPaymentMethod]) + }, [captchaRef, setupNewPaymentMethod]) const resetCaptcha = () => { setCaptchaToken(null) captchaRef?.resetCaptcha() } - const canUpdatePaymentMethods = useCheckPermissions( - PermissionAction.BILLING_WRITE, - 'stripe.payment_methods' - ) - const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo( () => ({ @@ -156,11 +174,27 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( }, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod]) // If createPaymentMethod already exists, use it. Otherwise, define it here. - const createPaymentMethod = async () => { + const createPaymentMethod = async (): ReturnType< + PaymentMethodElementRef['createPaymentMethod'] + > => { if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) { - return paymentRef.current?.createPaymentMethod() + const paymentResult = await paymentRef.current?.createPaymentMethod() + + if (!paymentResult) return paymentResult + + return { + paymentMethod: paymentResult.paymentMethod, + customerName: useAsDefaultBillingAddress ? paymentResult.customerName : null, + address: useAsDefaultBillingAddress ? paymentResult.address : null, + taxId: useAsDefaultBillingAddress ? paymentResult.taxId : null, + } } else { - return { id: selectedPaymentMethod } + return { + paymentMethod: { id: selectedPaymentMethod } as PaymentMethod, + customerName: useAsDefaultBillingAddress ? customerProfile?.billing_name || '' : null, + address: useAsDefaultBillingAddress ? customerProfile?.address ?? null : null, + taxId: useAsDefaultBillingAddress ? taxId ?? null : null, + } } } @@ -197,42 +231,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(

Retrieving payment methods

- ) : paymentMethods?.data.length === 0 && !createPaymentMethodInline ? ( -
-
- -

No payment methods

-
- - } - onClick={() => { - if (createPaymentMethodInline) { - setSetupNewPaymentMethod(true) - } else { - setShowAddNewPaymentMethodModal(true) - } - }} - htmlType="button" - tooltip={{ - content: { - side: 'bottom', - text: !canUpdatePaymentMethods ? ( -
- - You need additional permissions to add new payment methods to this - organization - -
- ) : undefined, - }, - }} - > - Add new -
-
) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? ( { - if (createPaymentMethodInline) { - setSetupNewPaymentMethod(true) - } else { - setShowAddNewPaymentMethodModal(true) - } + setSetupNewPaymentMethod(true) }} > @@ -282,17 +276,39 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection( ) : null} - {stripePromise && setupIntent && ( - - - + {stripePromise && setupIntent && customerProfile && ( + <> + + + + + {/* If the customer already has a billing address, optionally allow overwriting it - if they have no address, we use that as a default */} + {customerProfile?.address != null && ( +
+ setUseAsDefaultBillingAddress(!useAsDefaultBillingAddress)} + /> + +
+ )} + )} - {setupIntentLoading && ( + {(setupIntentLoading || isCustomerProfileLoading || isCustomerTaxIdLoading) && (
@@ -303,26 +319,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
)}
- - setShowAddNewPaymentMethodModal(false)} - autoMarkAsDefaultPaymentMethod={true} - onConfirm={async () => { - setShowAddNewPaymentMethodModal(false) - toast.success('Successfully added new payment method') - const { data: refetchedPaymentMethods } = await refetchPaymentMethods() - if (refetchedPaymentMethods?.data?.length) { - // Preselect the card that was just added - const mostRecentPaymentMethod = refetchedPaymentMethods?.data.reduce( - (prev, current) => (prev.created > current.created ? prev : current), - refetchedPaymentMethods.data[0] - ) - onSelectPaymentMethod(mostRecentPaymentMethod.id) - } - }} - /> ) }) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index a91fa6368eb..e8c1082ff4e 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -18,7 +18,10 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que import type { OrgPlan } from 'data/subscriptions/types' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { + useSelectedOrganization, + useSelectedOrganizationQuery, +} from 'hooks/misc/useSelectedOrganization' import { formatCurrency } from 'lib/helpers' import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' @@ -26,14 +29,15 @@ import { Button, SidePanel, cn } from 'ui' import DowngradeModal from './DowngradeModal' import { EnterpriseCard } from './EnterpriseCard' import { ExitSurveyModal } from './ExitSurveyModal' +import { useParams } from 'common' import MembersExceedLimitModal from './MembersExceedLimitModal' import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog' import UpgradeSurveyModal from './UpgradeModal' const PlanUpdateSidePanel = () => { const router = useRouter() - const selectedOrganization = useSelectedOrganization() - const slug = selectedOrganization?.slug + const { slug } = useParams() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const { mutate: sendEvent } = useSendEventMutation() const originalPlanRef = useRef() diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index 4b180b48862..88892c72e4b 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -13,22 +13,23 @@ import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizatio 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, STRIPE_PUBLIC_KEY } from 'lib/constants' import { formatCurrency } from 'lib/helpers' -import { Badge, Button, Dialog, DialogContent, Table, TableBody, TableCell, TableRow } from 'ui' +import { 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 { loadStripe, 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' +import type { PaymentMethodElementRef } from '../PaymentMethods/NewPaymentMethodElement' +import { useParams } from 'common' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) @@ -76,12 +77,12 @@ export const SubscriptionPlanUpdateDialog = ({ projects, }: Props) => { const { resolvedTheme } = useTheme() - const selectedOrganization = useSelectedOrganization() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const [selectedPaymentMethod, setSelectedPaymentMethod] = useState() const [paymentIntentSecret, setPaymentIntentSecret] = useState(null) const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) - const paymentMethodSelection = useRef<{ - createPaymentMethod: () => Promise + const paymentMethodSelectionRef = useRef<{ + createPaymentMethod: PaymentMethodElementRef['createPaymentMethod'] }>(null) const billingViaPartner = subscription?.billing_via_partner === true @@ -161,18 +162,14 @@ export const SubscriptionPlanUpdateDialog = ({ setPaymentConfirmationLoading(true) - const paymentMethod = await paymentMethodSelection.current?.createPaymentMethod() - if (paymentMethod) { - setSelectedPaymentMethod(paymentMethod.id) + const result = await paymentMethodSelectionRef.current?.createPaymentMethod() + if (result) { + setSelectedPaymentMethod(result.paymentMethod.id) } else { setPaymentConfirmationLoading(false) } - if ( - !paymentMethod && - subscription?.payment_method_type !== 'invoice' && - changeType === 'upgrade' - ) { + if (!result && subscription?.payment_method_type !== 'invoice' && changeType === 'upgrade') { return } @@ -185,7 +182,10 @@ export const SubscriptionPlanUpdateDialog = ({ updateOrgSubscription({ slug: selectedOrganization?.slug, tier, - paymentMethod: paymentMethod?.id, + paymentMethod: result?.paymentMethod?.id, + address: result?.address, + tax_id: result?.taxId ?? undefined, + billing_name: result?.customerName ?? undefined, }) } @@ -246,13 +246,40 @@ export const SubscriptionPlanUpdateDialog = ({ {/* Left Column */}
-

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

+
+ {!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && ( +
+ setSelectedPaymentMethod(pm)} + readOnly={paymentConfirmationLoading || isConfirming || isUpdating} + /> +
+ )} + + {billingViaPartner && ( +
+

+ This organization is billed through our partner{' '} + {billingPartnerLabel(billingPartner)}.{' '} + {billingPartner === 'aws' ? ( + <>The organization's credit balance will be decreased accordingly. + ) : ( + <>You will be charged by them directly. + )} +

+ {billingViaPartner && + billingPartner === 'fly' && + subscriptionPreview?.plan_change_type === 'downgrade' && ( +

+ Your organization will be downgraded at the end of your current billing + cycle. +

+ )} +
+ )} +
{subscriptionPreviewIsLoading && (
@@ -264,48 +291,6 @@ export const SubscriptionPlanUpdateDialog = ({ {subscriptionPreviewInitialized && ( <>
-
-
- {subscriptionPlanMeta?.name} Plan - - New - -
-
- {formatCurrency(newPlanCost)} -
-
- {subscription?.plan?.id !== 'free' && ( -
-
- Unused Time on {subscription?.plan?.name} Plan - - Your previous plan was charged upfront, so a plan change will prorate - any unused time in credits. If the prorated credits exceed the new plan - charge, the excessive credits are added to your organization for future - use. - -
-
- -{formatCurrency(proratedCredit)} -
-
- )} - - {/* Ignore rare case with negative balance (debt) */} - {customerBalance > 0 && ( -
-
- Credits - - Credits will be used first before charging your card. - -
-
- {formatCurrency(customerBalance)} -
-
- )}
Charge today
@@ -324,6 +309,39 @@ export const SubscriptionPlanUpdateDialog = ({ )}
+ + {subscription?.plan?.id !== 'free' && ( +
+
+ Unused Time on {subscription?.plan?.name} Plan + + Your previous plan was charged upfront, so a plan change will prorate + any unused time in credits. If the prorated credits exceed the new plan + charge, the excessive credits are added to your organization for future + use. + +
+
+ -{formatCurrency(proratedCredit)} +
+
+ )} + + {/* Ignore rare case with negative balance (debt) */} + {customerBalance > 0 && ( +
+
+ Credits + + Credits will be used first before charging your card. + +
+
+ {formatCurrency(customerBalance)} +
+
+ )} +
Monthly invoice estimate @@ -542,47 +560,11 @@ export const SubscriptionPlanUpdateDialog = ({
- {!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && ( -
- - - setSelectedPaymentMethod(pm)} - createPaymentMethodInline={true} - readOnly={paymentConfirmationLoading || isConfirming || isUpdating} - /> -
- )} - - {billingViaPartner && ( -
-

- This organization is billed through our partner{' '} - {billingPartnerLabel(billingPartner)}.{' '} - {billingPartner === 'aws' ? ( - <>The organization's credit balance will be decreased accordingly. - ) : ( - <>You will be charged by them directly. - )} -

- {billingViaPartner && - billingPartner === 'fly' && - subscriptionPreview?.plan_change_type === 'downgrade' && ( -

- Your organization will be downgraded at the end of your current billing - cycle. -

- )} -
- )} - {projects.filter( (it) => it.status === PROJECT_STATUS.ACTIVE_HEALTHY || it.status === PROJECT_STATUS.COMING_UP - ).length === 0 && + ).length === 5 && subscriptionPreview?.plan_change_type !== 'downgrade' && (
@@ -618,16 +600,13 @@ export const SubscriptionPlanUpdateDialog = ({ )}
- @@ -637,6 +616,13 @@ export const SubscriptionPlanUpdateDialog = ({ {/* Right Column */}
+

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

{changeType === 'downgrade' ? featuresToLose.length > 0 && (
diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx index 7d890a0fb09..5744f9e177b 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx @@ -2,9 +2,7 @@ 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' @@ -15,21 +13,6 @@ interface AIOptInLevelSelectorProps { 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', @@ -63,35 +46,19 @@ export const AIOptInLevelSelector = ({ 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. -

- - )} +

+ 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. +

} @@ -107,7 +74,7 @@ export const AIOptInLevelSelector = ({ disabled={disabled} className="space-y-2 mb-6" > - {optInLevels.map((item) => ( + {AI_OPT_IN_LEVELS.map((item) => (
{ - const newOrgAiOptIn = useFlag('newOrgAiOptIn') const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm() const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') @@ -28,7 +26,7 @@ export const DataPrivacyForm = () => { diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx index becd7b76488..33197145ab3 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx @@ -1,5 +1,4 @@ import { InlineLink } from 'components/ui/InlineLink' -import { useFlag } from 'hooks/ui/useFlag' import { Button, @@ -12,8 +11,6 @@ import { } from 'ui' export const OptInToOpenAIToggle = () => { - const useBedrockAssistant = useFlag('useBedrockAssistant') - return ( @@ -29,81 +26,37 @@ export const OptInToOpenAIToggle = () => { padding="small" className="flex flex-col gap-y-4 text-sm text-foreground-light" > - {useBedrockAssistant ? ( - <> -

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

+

+ 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. -

+

+ 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. -

+

+ 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. -

+

+ 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. -

- - )} +

+ 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. +

diff --git a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx index 2001d661385..e70d9a7ab87 100644 --- a/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx +++ b/apps/studio/components/interfaces/Organization/InvoicesSettings/InvoicesSettings.tsx @@ -11,20 +11,20 @@ import PartnerManagedResource from 'components/ui/PartnerManagedResource' import { getInvoice } from 'data/invoices/invoice-query' import { useInvoicesCountQuery } from 'data/invoices/invoices-count-query' import { useInvoicesQuery } from 'data/invoices/invoices-query' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { formatCurrency } from 'lib/helpers' import { ChevronLeft, ChevronRight, Download, FileText } from 'lucide-react' import { Button } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import InvoicePayButton from './InvoicePayButton' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' const PAGE_LIMIT = 5 const InvoicesSettings = () => { const [page, setPage] = useState(1) - const selectedOrganization = useSelectedOrganization() - const { slug } = selectedOrganization ?? {} + const { data: selectedOrganization } = useSelectedOrganizationQuery() + const slug = selectedOrganization?.slug const offset = (page - 1) * PAGE_LIMIT const { data: count, isError: isErrorCount } = useInvoicesCountQuery( @@ -33,7 +33,7 @@ const InvoicesSettings = () => { }, { enabled: selectedOrganization?.managed_by === 'supabase' } ) - const { data, error, isLoading, isError, isSuccess } = useInvoicesQuery( + const { data, error, isLoading, isError } = useInvoicesQuery( { slug, offset, diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index 9721137b0bb..8128ed29eb4 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -32,8 +32,6 @@ import { TooltipTrigger, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { BillingCustomerDataNewOrgDialog } from '../BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog' -import { FormCustomerData } from '../BillingSettings/BillingCustomerData/useBillingCustomerDataForm' import { useConfirmPendingSubscriptionCreateMutation } from 'data/subscriptions/org-subscription-confirm-pending-create' import { loadStripe } from '@stripe/stripe-js' import { useTheme } from 'next-themes' @@ -41,7 +39,12 @@ import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation' import { useProfile } from 'lib/profile' import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' -import { NewPaymentMethodElement } from '../BillingSettings/PaymentMethods/NewPaymentMethodElement' +import { + NewPaymentMethodElement, + type PaymentMethodElementRef, +} from '../BillingSettings/PaymentMethods/NewPaymentMethodElement' +import { components } from 'api-types' +import type { CustomerAddress, CustomerTaxId } from 'data/organizations/types' const ORG_KIND_TYPES = { PERSONAL: 'Personal', @@ -90,6 +93,8 @@ type FormState = z.infer const stripePromise = loadStripe(STRIPE_PUBLIC_KEY) +const newMandatoryAddressInput = true + /** * No org selected yet, create a new one * [Joshen] Need to refactor to use Form_Shadcn here @@ -115,8 +120,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr const [isOrgCreationConfirmationModalVisible, setIsOrgCreationConfirmationModalVisible] = useState(false) - const [customerData, setCustomerData] = useState(null) - const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo( () => ({ @@ -244,7 +247,14 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr } as StripeElementsOptions }, [paymentIntentSecret, resolvedTheme]) - async function createOrg(paymentMethodId?: string) { + async function createOrg( + paymentMethodId?: string, + customerData?: { + address: CustomerAddress | null + billing_name: string | null + tax_id: CustomerTaxId | null + } + ) { const dbTier = formState.plan === 'PRO' && !formState.spend_cap ? 'PAYG' : formState.plan createOrganization({ @@ -258,12 +268,12 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr ...(formState.kind == 'COMPANY' ? { size: formState.size } : {}), payment_method: paymentMethodId, billing_name: dbTier === 'FREE' ? undefined : customerData?.billing_name, - address: dbTier === 'FREE' ? undefined : customerData?.address, + address: dbTier === 'FREE' ? null : customerData?.address, tax_id: dbTier === 'FREE' ? undefined : customerData?.tax_id ?? undefined, }) } - const paymentRef = useRef<{ createPaymentMethod: () => Promise }>(null) + const paymentRef = useRef(null) const handleSubmit = async () => { setNewOrgLoading(true) @@ -271,10 +281,16 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr if (formState.plan === 'FREE') { await createOrg() } else if (!paymentMethod) { - const paymentMethod = await paymentRef.current?.createPaymentMethod() - if (paymentMethod) { - setPaymentMethod(paymentMethod) - createOrg(paymentMethod.id) + const result = await paymentRef.current?.createPaymentMethod() + if (result) { + setPaymentMethod(result.paymentMethod) + const customerData = { + address: result.address, + billing_name: result.customerName, + tax_id: result.taxId, + } + + createOrg(result.paymentMethod.id, customerData) } else { setNewOrgLoading(false) } @@ -340,6 +356,7 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
} + className="overflow-visible" >

This is your organization within Supabase.

@@ -532,21 +549,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr )} - {formState.plan !== 'FREE' && ( - -
-
- - Billing Address - -
-
- -
-
-
- )} - {setupIntent && formState.plan !== 'FREE' && ( diff --git a/apps/studio/components/interfaces/Organization/OrgNotFound.tsx b/apps/studio/components/interfaces/Organization/OrgNotFound.tsx index 673c8e116c0..2da3c4f293d 100644 --- a/apps/studio/components/interfaces/Organization/OrgNotFound.tsx +++ b/apps/studio/components/interfaces/Organization/OrgNotFound.tsx @@ -15,17 +15,19 @@ export const OrgNotFound = ({ slug }: { slug?: string }) => { 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. - )} - + {slug !== '_' && ( + + 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

diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index 711deddee3e..7df15972893 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -9,6 +9,7 @@ import { ScaffoldSectionContent, ScaffoldTitle, } from 'components/layouts/Scaffold' +import { DocsButton } from 'components/ui/DocsButton' import { Input } from 'ui-patterns/DataInputs/Input' import { InviteMemberButton } from './InviteMemberButton' import MembersView from './MembersView' @@ -32,6 +33,7 @@ export const TeamSettings = () => { placeholder="Filter members" /> + diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx index fc37dd4fe91..246dfd391d0 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx @@ -90,10 +90,11 @@ export const UpdateRolesConfirmationModal = ({ }) toast.success(`Successfully updated role for ${member.username}`) onClose(true) - return } catch (error: any) { + toast.error(`Failed to update role: ${error.message}`) + } finally { setSaving(false) - return toast.error(`Failed to update role: ${error.message}`) + return } } @@ -130,8 +131,10 @@ export const UpdateRolesConfirmationModal = ({ toast.success(`Successfully updated role for ${member.username}`) onClose(true) } catch (error: any) { + toast.error(`Failed to update role: ${error.message}`) + } finally { setSaving(false) - return toast.error(`Failed to update role: ${error.message}`) + return } } diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx index 9ec3c001396..e8d906fe5da 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx @@ -1,6 +1,7 @@ import { useParams } from 'common' import { Button, Input, copyToClipboard } from 'ui' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { Copy } from 'lucide-react' import { useEffect, useState } from 'react' @@ -10,6 +11,7 @@ import type { ContentProps } from './Content.types' const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => { const { ref } = useParams() + const { data: apiKeys } = useAPIKeysQuery({ projectRef: ref }) const { data } = useProjectSettingsV2Query({ projectRef: ref }) const [copied, setCopied] = useState<'anon' | 'service'>() @@ -18,10 +20,9 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => if (copied !== undefined) setTimeout(() => setCopied(undefined), 2000) }, [copied]) - const anonApiKey = (data?.service_api_keys ?? []).find((key) => key.tags === 'anon')?.api_key - const serviceApiKey = - (data?.service_api_keys ?? []).find((key) => key.tags === 'service_role')?.api_key ?? - 'SUPABASE_CLIENT_SERVICE_KEY' + const { anonKey, serviceKey } = getKeys(apiKeys) + const anonApiKey = anonKey?.api_key + const serviceApiKey = serviceKey?.api_key ?? 'SUPABASE_CLIENT_SERVICE_KEY' return ( <> diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx index 936ed8e0b8a..4ec31a5b9d5 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx @@ -2,7 +2,8 @@ import { useState } from 'react' import { Button, SidePanel } from 'ui' import { useParams } from 'common' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useAppStateSnapshot } from 'state/app-state' import { @@ -44,6 +45,10 @@ const ProjectAPIDocs = () => { const [showKeys, setShowKeys] = useState(false) const language = snap.docsLanguage + const { data: apiKeys } = useAPIKeysQuery( + { projectRef: ref }, + { enabled: snap.showProjectApiDocs } + ) const { data: settings } = useProjectSettingsV2Query( { projectRef: ref }, { enabled: snap.showProjectApiDocs } @@ -53,7 +58,7 @@ const ProjectAPIDocs = () => { { enabled: snap.showProjectApiDocs } ) - const { anonKey } = getAPIKeys(settings) + const { anonKey } = getKeys(apiKeys) const apikey = showKeys ? anonKey?.api_key ?? 'SUPABASE_CLIENT_ANON_KEY' : 'SUPABASE_CLIENT_ANON_KEY' diff --git a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx index ddfafe20953..cb6225cca9a 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx @@ -3,8 +3,9 @@ import { toast } from 'sonner' import { useParams } from 'common' import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector' +import { InlineLink } from 'components/ui/InlineLink' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' -import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { IS_PLATFORM } from 'lib/constants' @@ -18,10 +19,15 @@ interface RealtimeTokensPopoverProps { } export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokensPopoverProps) => { + const { ref } = useParams() + const org = useSelectedOrganization() const snap = useRoleImpersonationStateSnapshot() - const { data: settings } = useProjectSettingsV2Query({ projectRef: config.projectRef }) - const { anonKey, serviceKey } = getAPIKeys(settings) + const { data: apiKeys } = useAPIKeysQuery({ + projectRef: config.projectRef, + reveal: true, + }) + const { anonKey, serviceKey, publishableKey } = getKeys(apiKeys) const { data: postgrestConfig } = useProjectPostgrestConfigQuery( { projectRef: config.projectRef }, @@ -29,8 +35,6 @@ export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokens ) const jwtSecret = postgrestConfig?.jwt_secret - const { ref } = useParams() - const org = useSelectedOrganization() const { mutate: sendEvent } = useSendEventMutation() // only send a telemetry event if the user changes the role. Don't send an event during initial render. @@ -63,7 +67,7 @@ export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokens .then((b) => (bearer = b)) .catch((err) => toast.error(`Failed to get JWT for role: ${err.message}`)) } else { - token = serviceKey?.api_key + token = serviceKey?.api_key ?? publishableKey?.api_key } if (token) { onChangeConfig({ ...config, token, bearer }) @@ -74,5 +78,22 @@ export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokens // eslint-disable-next-line react-hooks/exhaustive-deps }, [snap.role, anonKey, serviceKey]) - return + return ( + + Role impersonation for the Realtime Inspector is currently unavailable temporarily due + to the new API keys. Please re-enable{' '} + legacy JWT keys if + you'd like to use role impersonation with the Realtime Inspector. + + ) : undefined + } + align="start" + variant="connected-on-both" + /> + ) } diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx index adbe74201f6..971d7ee7136 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react' -import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, cn } from 'ui' +import { ReactNode, useState } from 'react' +import { PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, cn } from 'ui' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { User } from 'data/auth/users-infinite-query' import { ChevronDown, User as IconUser } from 'lucide-react' import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' @@ -12,6 +13,8 @@ export interface RoleImpersonationPopoverProps { serviceRoleLabel?: string variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both' align?: 'center' | 'start' | 'end' + disabled?: boolean + disabledTooltip?: ReactNode disallowAuthenticatedOption?: boolean } @@ -21,6 +24,10 @@ const RoleImpersonationPopover = ({ variant = 'regular', align = 'end', disallowAuthenticatedOption = false, + + // [Joshen] We can clean these up once the API keys fix is done + disabled = false, + disabledTooltip, }: RoleImpersonationPopoverProps) => { const state = useRoleImpersonationStateSnapshot() @@ -31,7 +38,7 @@ const RoleImpersonationPopover = ({ return ( - + { const { ref } = useParams() const organization = useSelectedOrganization() - const useBedrockAssistant = useFlag('useBedrockAssistant') const snapV2 = useSqlEditorV2StateSnapshot() const tabsSnap = useTabsStateSnapshot() @@ -66,13 +64,11 @@ const RenameQueryModal = ({ const generateTitle = async () => { if ('content' in snippet && isSQLSnippet) { - titleSql({ sql: snippet.content.sql, useBedrockAssistant }) + titleSql({ sql: snippet.content.sql }) } else { try { const { content } = await getContentById({ projectRef: ref, id: snippet.id }) - if ('sql' in content) { - titleSql({ sql: content.sql, useBedrockAssistant }) - } + if ('sql' in content) titleSql({ sql: content.sql }) } catch (error) { toast.error('Unable to generate title based on query contents') } diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index c8f472df446..0efecee1d43 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -23,7 +23,6 @@ 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 { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH } from 'lib/constants' import { formatSql } from 'lib/formatSql' import { detectOS, uuidv4 } from 'lib/helpers' @@ -81,7 +80,6 @@ export const SQLEditor = () => { const os = detectOS() const router = useRouter() const { ref, id: urlId } = useParams() - const useBedrockAssistant = useFlag('useBedrockAssistant') const { profile } = useProfile() const project = useSelectedProject() @@ -209,7 +207,7 @@ export const SQLEditor = () => { const setAiTitle = useCallback( async (id: string, sql: string) => { try { - const { title: name } = await generateSqlTitle({ sql, useBedrockAssistant }) + const { title: name } = await generateSqlTitle({ sql }) snapV2.renameSnippet({ id, name }) const tabId = createTabId('sql', { id }) tabs.updateTab(tabId, { label: name }) @@ -217,7 +215,7 @@ export const SQLEditor = () => { // [Joshen] No error handler required as this happens in the background and not necessary to ping the user } }, - [generateSqlTitle, useBedrockAssistant, snapV2] + [generateSqlTitle, snapV2] ) const prettifyQuery = useCallback(async () => { @@ -461,9 +459,7 @@ export const SQLEditor = () => { completion, isLoading: isCompletionLoading, } = useCompletion({ - api: useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/complete-v2` - : `${BASE_PATH}/api/ai/sql/complete`, + api: `${BASE_PATH}/api/ai/sql/complete-v2`, body: { projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx index c471cd49438..ef87d4d509d 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx @@ -30,7 +30,13 @@ const CustomDomainActivate = ({ projectRef, customDomain }: CustomDomainActivate }, } ) - const { mutate: deleteCustomDomain, isLoading: isDeleting } = useCustomDomainDeleteMutation() + const { mutate: deleteCustomDomain, isLoading: isDeleting } = useCustomDomainDeleteMutation({ + onSuccess: () => { + toast.success( + 'Custom domain setup cancelled successfully. It may take a few seconds before your custom domain is fully removed, so you may need to refresh your browser.' + ) + }, + }) const endpoint = settings?.app_config?.endpoint diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index 6a7e80b13f0..aa2391f660b 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -27,10 +27,10 @@ const CustomDomainConfig = () => { const hasCustomDomainAddon = !!addons?.selected_addons.find((x) => x.type === 'custom_domain') const { + data: customDomainData, isLoading: isCustomDomainsLoading, isError, isSuccess, - data, } = useCustomDomainsQuery( { projectRef: ref }, { @@ -45,6 +45,8 @@ const CustomDomainConfig = () => { } ) + const { status, customDomain } = customDomainData || {} + return (
@@ -94,24 +96,25 @@ const CustomDomainConfig = () => {
- ) : data?.status === '0_no_hostname_configured' ? ( + ) : status === '0_no_hostname_configured' ? ( ) : ( {isSuccess && (
- {(data.status === '1_not_started' || - data.status === '2_initiated' || - data.status === '3_challenge_verified') && ( - + {(status === '1_not_started' || + status === '2_initiated' || + status === '3_challenge_verified') && } + + {customDomainData.status === '4_origin_setup_completed' && ( + )} - {data.status === '4_origin_setup_completed' && ( - - )} - - {data.status === '5_services_reconfigured' && ( - + {customDomainData.status === '5_services_reconfigured' && ( + )}
)} diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainDelete.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainDelete.tsx index a029d8a1817..4ec3c264442 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainDelete.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainDelete.tsx @@ -18,7 +18,9 @@ const CustomDomainDelete = ({ projectRef, customDomain }: CustomDomainDeleteProp const [isDeleteConfirmModalVisible, setIsDeleteConfirmModalVisible] = useState(false) const { mutate: deleteCustomDomain } = useCustomDomainDeleteMutation({ onSuccess: () => { - toast.success(`Successfully deleted custom domain`) + toast.success( + `Successfully deleted custom domain. It may take a few seconds before your custom domain is fully removed, hence you may need to refresh your browser.` + ) setIsDeleteConfirmModalVisible(false) }, }) diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx index 9d97d93a157..2e83375d731 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx @@ -1,13 +1,14 @@ import { AlertCircle, HelpCircle, RefreshCw } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' +import { toast } from 'sonner' import { useParams } from 'common' import { DocsButton } from 'components/ui/DocsButton' import Panel from 'components/ui/Panel' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainDeleteMutation } from 'data/custom-domains/custom-domains-delete-mutation' -import type { CustomDomainResponse } from 'data/custom-domains/custom-domains-query' +import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useCustomDomainReverifyMutation } from 'data/custom-domains/custom-domains-reverify-mutation' import { useInterval } from 'hooks/misc/useInterval' import { @@ -20,16 +21,17 @@ import { import DNSRecord from './DNSRecord' import { DNSTableHeaders } from './DNSTableHeaders' -export type CustomDomainVerifyProps = { - customDomain: CustomDomainResponse -} - -const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { +const CustomDomainVerify = () => { const { ref: projectRef } = useParams() const [isNotVerifiedYet, setIsNotVerifiedYet] = useState(false) const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) + const customDomain = customDomainData?.customDomain + const isSSLCertificateDeploying = + customDomain?.ssl.status !== undefined && customDomain.ssl.txt_name === undefined + const { mutate: reverifyCustomDomain, isLoading: isReverifyLoading } = useCustomDomainReverifyMutation({ onSuccess: (res) => { @@ -37,13 +39,19 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { }, }) - const { mutate: deleteCustomDomain, isLoading: isDeleting } = useCustomDomainDeleteMutation() + const { mutate: deleteCustomDomain, isLoading: isDeleting } = useCustomDomainDeleteMutation({ + onSuccess: () => { + toast.success( + 'Custom domain setup cancelled successfully. It may take a few seconds before your custom domain is fully removed, so you may need to refresh your browser.' + ) + }, + }) - const hasCAAErrors = customDomain.ssl.validation_errors?.reduce( + const hasCAAErrors = customDomain?.ssl.validation_errors?.reduce( (acc, error) => acc || error.message.includes('caa_error'), false ) - const isValidating = (customDomain.ssl.txt_name ?? '') === '' + const isValidating = (customDomain?.ssl.txt_name ?? '') === '' const onReverifyCustomDomain = () => { if (!projectRef) return console.error('Project ref is required') @@ -53,7 +61,7 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { useInterval( onReverifyCustomDomain, // Poll every 5 seconds if the SSL certificate is being deployed - customDomain.ssl.status !== undefined && customDomain.ssl.txt_name === undefined ? 5000 : false + isSSLCertificateDeploying && !isDeleting ? 5000 : false ) const onCancelCustomDomain = async () => { @@ -67,7 +75,7 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => {

Configure TXT verification for your custom domain{' '} - {customDomain.hostname} + {customDomain?.hostname}

Set the following TXT record(s) in your DNS provider, then click verify to confirm your @@ -102,7 +110,7 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { here @@ -131,13 +139,13 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { Please add a CAA record allowing "digicert.com" to issue certificates for{' '} - {customDomain.hostname}. For example:{' '} + {customDomain?.hostname}. For example:{' '} 0 issue "digicert.com" )} - {customDomain.ssl.status === 'validation_timed_out' ? ( + {customDomain?.ssl.status === 'validation_timed_out' ? ( Validation timed out @@ -147,27 +155,27 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { ) : (

- + - {customDomain.verification_errors?.includes( + {customDomain?.verification_errors?.includes( 'custom hostname does not CNAME to this zone.' ) && ( )} - {!isValidating && customDomain.ssl.status === 'pending_validation' && ( + {!isValidating && customDomain?.ssl.status === 'pending_validation' && ( )} - {customDomain.ssl.status === 'pending_deployment' && ( + {customDomain?.ssl.status === 'pending_deployment' && (

diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx index ca84227d92f..5fa6bc930c8 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx @@ -27,7 +27,6 @@ import { import { ProjectUpgradeAlert } from '../General/Infrastructure/ProjectUpgradeAlert' import InstanceConfiguration from './InfrastructureConfiguration/InstanceConfiguration' import { - DatabaseExtensionsWarning, ObjectsToBeDroppedWarning, ReadReplicasWarning, UnsupportedExtensionsWarning, @@ -58,9 +57,6 @@ const InfrastructureInfo = () => { isSuccess: isSuccessServiceVersions, } = useProjectServiceVersionsQuery({ projectRef: ref }) - const { data: projectUpgradeEligibilityData } = useProjectUpgradeEligibilityQuery({ - projectRef: ref, - }) const { data: databases } = useReadReplicasQuery({ projectRef: ref }) const { current_app_version, current_app_version_release_channel, latest_app_version } = data || {} @@ -69,8 +65,9 @@ const InfrastructureInfo = () => { const currentPgVersion = (current_app_version ?? '') .split('supabase-postgres-')[1] ?.replace('-orioledb', '') - const isOnNonGenerallyAvailableReleaseChannel = - current_app_version_release_channel && current_app_version_release_channel !== 'ga' + const isVisibleReleaseChannel = + current_app_version_release_channel && + !['ga', 'withdrawn'].includes(current_app_version_release_channel) ? current_app_version_release_channel : undefined const isOrioleDb = useIsOrioleDb() @@ -79,8 +76,6 @@ 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 @@ -150,16 +145,16 @@ const InfrastructureInfo = () => { value={currentPgVersion || serviceVersions?.['supabase-postgres'] || ''} label="Postgres version" actions={[ - isOnNonGenerallyAvailableReleaseChannel && ( + isVisibleReleaseChannel && ( - {isOnNonGenerallyAvailableReleaseChannel} + {isVisibleReleaseChannel} - This project uses a {isOnNonGenerallyAvailableReleaseChannel}{' '} - database version release + This project uses a {isVisibleReleaseChannel} database version + release ), @@ -202,16 +197,7 @@ const InfrastructureInfo = () => { ) : null} {!data.eligible ? ( - hasExtensionDependentObjects ? ( - - ) : hasObjectsToBeDropped ? ( + hasObjectsToBeDropped ? ( diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx index 4f4e9d8679a..29e67a85e57 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx @@ -14,52 +14,6 @@ export const ReadReplicasWarning = ({ latestPgVersion }: { latestPgVersion: stri ) } -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, }: { diff --git a/apps/studio/components/interfaces/SignIn/SignUpForm.tsx b/apps/studio/components/interfaces/SignIn/SignUpForm.tsx index 52a117d6e6b..bd6a7772c41 100644 --- a/apps/studio/components/interfaces/SignIn/SignUpForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignUpForm.tsx @@ -4,10 +4,12 @@ import { parseAsString, useQueryStates } from 'nuqs' import { useRef, useState } from 'react' import { toast } from 'sonner' import * as yup from 'yup' +import { useRouter } from 'next/router' import { useSignUpMutation } from 'data/misc/signup-mutation' import { BASE_PATH } from 'lib/constants' import { passwordSchema } from 'lib/schemas' +import { buildPathWithParams } from 'lib/gotrue' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, @@ -28,6 +30,7 @@ const SignUpForm = () => { const [isSubmitted, setIsSubmitted] = useState(false) const [passwordHidden, setPasswordHidden] = useState(true) const [captchaToken, setCaptchaToken] = useState(null) + const router = useRouter() const [searchParams] = useQueryStates({ auth_id: parseAsString.withDefault(''), @@ -59,9 +62,19 @@ const SignUpForm = () => { ? 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` + + let redirectTo: string + + if (isInsideOAuthFlow) { + redirectTo = `${redirectUrlBase}/authorize?auth_id=${searchParams.auth_id}${searchParams.token && `&token=${searchParams.token}`}` + } else { + // Use getRedirectToPath to handle redirect_to parameter and other query params + const { returnTo } = router.query + const basePath = returnTo || '/sign-in' + const fullPath = buildPathWithParams(basePath as string) + const fullRedirectUrl = `${redirectUrlBase}${fullPath}` + redirectTo = fullRedirectUrl + } signup({ email, diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/CopyEnvButton.tsx b/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/CopyEnvButton.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/CopyEnvButton.tsx rename to apps/studio/components/interfaces/Storage/AnalyticBucketDetails/CopyEnvButton.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/DecryptedReadOnlyInput.tsx b/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/DecryptedReadOnlyInput.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/DecryptedReadOnlyInput.tsx rename to apps/studio/components/interfaces/Storage/AnalyticBucketDetails/DecryptedReadOnlyInput.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/NamespaceRow.tsx b/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/NamespaceRow.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/NamespaceRow.tsx rename to apps/studio/components/interfaces/Storage/AnalyticBucketDetails/NamespaceRow.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx b/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx similarity index 91% rename from apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx rename to apps/studio/components/interfaces/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx index feccb8aa02f..d08489cf2e0 100644 --- a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx @@ -1,6 +1,7 @@ import Link from '@ui/components/Typography/Link' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ScaffoldSectionDescription, ScaffoldSectionTitle } from 'components/layouts/Scaffold' +import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { Card } from 'ui' import { getCatalogURI, getConnectionURL } from '../StorageSettings/StorageSettings.utils' @@ -19,12 +20,14 @@ const wrapperMeta = { export const SimpleConfigurationDetails = ({ bucketName }: { bucketName: string }) => { const { project } = useProjectContext() + + const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref }) const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref }) const protocol = settings?.app_config?.protocol ?? 'https' const endpoint = settings?.app_config?.endpoint - const serviceApiKey = - (settings?.service_api_keys ?? []).find((key) => key.tags === 'service_role')?.api_key ?? - 'SUPABASE_CLIENT_SERVICE_KEY' + + const { serviceKey } = getKeys(apiKeys) + const serviceApiKey = serviceKey?.api_key ?? 'SUPABASE_CLIENT_SERVICE_KEY' const values: Record = { vault_token: serviceApiKey, diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/constants.ts b/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/constants.ts similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/constants.ts rename to apps/studio/components/interfaces/Storage/AnalyticBucketDetails/constants.ts diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/index.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/index.tsx rename to apps/studio/components/interfaces/Storage/AnalyticBucketDetails/index.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx b/apps/studio/components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx rename to apps/studio/components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx diff --git a/apps/studio/components/layouts/StorageLayout/BucketRow.tsx b/apps/studio/components/interfaces/Storage/BucketRow.tsx similarity index 100% rename from apps/studio/components/layouts/StorageLayout/BucketRow.tsx rename to apps/studio/components/interfaces/Storage/BucketRow.tsx diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index 1d5f0fa8236..1523c488ee8 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -9,15 +9,17 @@ import { toast } from 'sonner' import z from 'zod' import { useParams } from 'common' -import { useIcebergWrapperExtension } from 'components/to-be-cleaned/Storage/AnalyticBucketDetails/useIcebergWrapper' -import { StorageSizeUnits } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.constants' +import { useIcebergWrapperExtension } from 'components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper' +import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants' import { convertFromBytes, convertToBytes, -} from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils' +} from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils' import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation' import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import { Alert_Shadcn_, @@ -74,6 +76,8 @@ export type CreateBucketForm = z.infer const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { const { ref } = useParams() + const org = useSelectedOrganization() + const { mutate: sendEvent } = useSendEventMutation() const router = useRouter() const { mutateAsync: createBucket, isLoading: isCreating } = useBucketCreateMutation() @@ -134,6 +138,11 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { file_size_limit: fileSizeLimit, allowed_mime_types: allowedMimeTypes, }) + sendEvent({ + action: 'storage_bucket_created', + properties: { bucketType: values.type }, + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) if (values.type === 'ANALYTICS' && icebergWrapperExtensionState === 'installed') { await createIcebergWrapper({ bucketName: values.name }) diff --git a/apps/studio/components/to-be-cleaned/Storage/DeleteBucketModal.tsx b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/DeleteBucketModal.tsx rename to apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx index 5abb63cd4d5..5a9ff0ea2a9 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx @@ -5,11 +5,11 @@ import { useEffect, useState } from 'react' import { toast } from 'sonner' import { Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui' -import { StorageSizeUnits } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.constants' +import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants' import { convertFromBytes, convertToBytes, -} from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils' +} from 'components/interfaces/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' diff --git a/apps/studio/components/to-be-cleaned/Storage/EmptyBucketModal.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/EmptyBucketModal.tsx rename to apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx diff --git a/apps/studio/components/layouts/StorageLayout/Storage.Commands.tsx b/apps/studio/components/interfaces/Storage/Storage.Commands.tsx similarity index 100% rename from apps/studio/components/layouts/StorageLayout/Storage.Commands.tsx rename to apps/studio/components/interfaces/Storage/Storage.Commands.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/Storage.constants.ts b/apps/studio/components/interfaces/Storage/Storage.constants.ts similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/Storage.constants.ts rename to apps/studio/components/interfaces/Storage/Storage.constants.ts diff --git a/apps/studio/components/interfaces/Storage/Storage.types.ts b/apps/studio/components/interfaces/Storage/Storage.types.ts index 8de8a32625e..c9943befe01 100644 --- a/apps/studio/components/interfaces/Storage/Storage.types.ts +++ b/apps/studio/components/interfaces/Storage/Storage.types.ts @@ -1,4 +1,5 @@ import type { PolicyFormField } from 'components/interfaces/Auth/Policies/Policies.types' +import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from './Storage.constants' export interface StoragePolicyFormField extends PolicyFormField { allowedOperations: string[] @@ -14,3 +15,38 @@ export interface BucketCreatePayload extends BucketUpdatePayload { id: string name?: string } + +export interface StorageColumn { + id: string | null + name: string + status: string + items: StorageItem[] + hasMoreItems?: boolean + isLoadingMoreItems?: boolean +} + +export interface StorageItem { + id: string | null + name: string + type: STORAGE_ROW_TYPES + status: STORAGE_ROW_STATUS + metadata: StorageItemMetadata | null + created_at: string | null + updated_at: string | null + last_accessed_at: string | null + // UI specific properties, not from API + isCorrupted: boolean + path?: string +} + +export type StorageItemWithColumn = StorageItem & { columnIndex: number } + +export interface StorageItemMetadata { + cacheControl: string + contentLength: number + size: number + httpStatusCode: number + eTag: string + lastModified: string + mimetype: string +} diff --git a/apps/studio/components/to-be-cleaned/Storage/Storage.utils.ts b/apps/studio/components/interfaces/Storage/Storage.utils.ts similarity index 98% rename from apps/studio/components/to-be-cleaned/Storage/Storage.utils.ts rename to apps/studio/components/interfaces/Storage/Storage.utils.ts index 5a50eab4ec4..36f64ef075f 100644 --- a/apps/studio/components/to-be-cleaned/Storage/Storage.utils.ts +++ b/apps/studio/components/interfaces/Storage/Storage.utils.ts @@ -1,6 +1,6 @@ import { groupBy, difference } from 'lodash' import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from './Storage.constants' -import type { StoragePolicyFormField } from 'components/interfaces/Storage/Storage.types' +import type { StoragePolicyFormField } from './Storage.types' const shortHash = (str: string) => { let hash = 0 diff --git a/apps/studio/components/layouts/StorageLayout/StorageBucketsError.tsx b/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx similarity index 100% rename from apps/studio/components/layouts/StorageLayout/StorageBucketsError.tsx rename to apps/studio/components/interfaces/Storage/StorageBucketsError.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ColumnContextMenu.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/ColumnContextMenu.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ColumnContextMenu.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/ColumnContextMenu.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ConfirmDeleteModal.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/ConfirmDeleteModal.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ConfirmDeleteModal.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/ConfirmDeleteModal.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/CustomExpiryModal.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/CustomExpiryModal.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorer.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorer.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorer.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx similarity index 95% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx index 7d768653923..84167c8d271 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerColumn.tsx +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerColumn.tsx @@ -1,11 +1,14 @@ import { Transition } from '@headlessui/react' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { get, noop, sum } from 'lodash' import { Upload } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { useContextMenu } from 'react-contexify' +import { toast } from 'sonner' import InfiniteList from 'components/ui/InfiniteList' import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' import { formatBytes } from 'lib/helpers' import { useStorageExplorerStateSnapshot } from 'state/storage-explorer' @@ -82,6 +85,7 @@ const FileExplorerColumn = ({ const fileExplorerColumnRef = useRef(null) const snap = useStorageExplorerStateSnapshot() + const canUpdateStorage = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') useEffect(() => { if (fileExplorerColumnRef) { @@ -126,7 +130,12 @@ const FileExplorerColumn = ({ const onDrop = (event: any) => { onDragOver(event) - onFilesUpload(event, index) + + if (!canUpdateStorage) { + toast('You need additional permissions to upload files to this project') + } else { + onFilesUpload(event, index) + } } const SelectAllCheckbox = () => ( diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRow.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRowEditing.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRowEditing.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FolderContextMenu.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FolderContextMenu.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FolderContextMenu.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/FolderContextMenu.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/ItemContextMenu.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/MoveItemsModal.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/MoveItemsModal.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/PreviewPane.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.utils.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.utils.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.utils.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/useCopyUrl.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/useCopyUrl.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useFetchFileUrlQuery.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useFetchFileUrlQuery.tsx rename to apps/studio/components/interfaces/Storage/StorageExplorer/useFetchFileUrlQuery.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useSelectedBucket.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/useSelectedBucket.ts similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useSelectedBucket.ts rename to apps/studio/components/interfaces/Storage/StorageExplorer/useSelectedBucket.ts diff --git a/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx b/apps/studio/components/interfaces/Storage/StorageMenu.tsx similarity index 98% rename from apps/studio/components/layouts/StorageLayout/StorageMenu.tsx rename to apps/studio/components/interfaces/Storage/StorageMenu.tsx index 094cbc5013d..152fa8ed735 100644 --- a/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx +++ b/apps/studio/components/interfaces/Storage/StorageMenu.tsx @@ -8,8 +8,8 @@ import { useState } from 'react' import { useParams } from 'common' import CreateBucketModal from 'components/interfaces/Storage/CreateBucketModal' import EditBucketModal from 'components/interfaces/Storage/EditBucketModal' -import { DeleteBucketModal } from 'components/to-be-cleaned/Storage' -import { EmptyBucketModal } from 'components/to-be-cleaned/Storage/EmptyBucketModal' +import { DeleteBucketModal } from 'components/interfaces/Storage' +import { EmptyBucketModal } from 'components/interfaces/Storage/EmptyBucketModal' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { Bucket, useBucketsQuery } from 'data/storage/buckets-query' diff --git a/apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePolicies.constants.ts b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.constants.ts similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePolicies.constants.ts rename to apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.constants.ts diff --git a/apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePolicies.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePolicies.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePoliciesEditor.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditor.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePoliciesEditor.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditor.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePoliciesPlaceholder.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesPlaceholder.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePoliciesPlaceholder.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesPlaceholder.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePoliciesReview.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesReview.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StoragePolicies/StoragePoliciesReview.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesReview.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/CreateCredentialModal.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/CreateCredentialModal.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageSettings/CreateCredentialModal.tsx rename to apps/studio/components/interfaces/Storage/StorageSettings/CreateCredentialModal.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/RevokeCredentialModal.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/RevokeCredentialModal.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageSettings/RevokeCredentialModal.tsx rename to apps/studio/components/interfaces/Storage/StorageSettings/RevokeCredentialModal.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx rename to apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageCredItem.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageCredItem.tsx rename to apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.constants.ts b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.constants.ts new file mode 100644 index 00000000000..411a4fb9507 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.constants.ts @@ -0,0 +1,12 @@ +export const STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_FREE_PLAN = 50 * 1024 * 1024 // 50 MB +/** Max bytes for capped storage (i.e. Pro with spend cap) */ +export const STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED = 50 * 1024 * 1024 * 1024 // 50 GB +/** Max bytes for uncapped storage (i.e. Pro without spend cap, Team, Enterprise) */ +export const STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED = 500 * 1024 * 1024 * 1024 // 500 GB + +export enum StorageSizeUnits { + BYTES = 'bytes', + KB = 'KB', + MB = 'MB', + GB = 'GB', +} diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx similarity index 87% rename from apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx rename to apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx index 536b07fde31..43868c50c03 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx @@ -1,7 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Clock } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' @@ -16,6 +15,7 @@ import { useProjectStorageConfigQuery } from 'data/config/project-storage-config import { useProjectStorageConfigUpdateUpdateMutation } from 'data/config/project-storage-config-update-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { formatBytes } from 'lib/helpers' import { Button, FormControl_Shadcn_, @@ -32,7 +32,12 @@ import { Select_Shadcn_, Switch, } from 'ui' -import { STORAGE_FILE_SIZE_LIMIT_MAX_BYTES, StorageSizeUnits } from './StorageSettings.constants' +import { + STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED, + STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_FREE_PLAN, + STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED, + StorageSizeUnits, +} from './StorageSettings.constants' import { convertFromBytes, convertToBytes } from './StorageSettings.utils' interface StorageSettingsState { @@ -56,6 +61,8 @@ const StorageSettings = () => { const organization = useSelectedOrganization() const isFreeTier = organization?.plan.id === 'free' + const isSpendCapOn = + organization?.plan.id === 'pro' && organization?.usage_billing_enabled === false const [initialValues, setInitialValues] = useState({ fileSizeLimit: 0, @@ -84,9 +91,17 @@ const StorageSettings = () => { } }, [isSuccess, config]) - const formattedMaxSizeBytes = `${new Intl.NumberFormat('en-US').format( - STORAGE_FILE_SIZE_LIMIT_MAX_BYTES - )} bytes` + const maxBytes = useMemo(() => { + if (organization?.plan.id === 'free') { + return STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_FREE_PLAN + } else if (organization?.usage_billing_enabled) { + return STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED + } else { + return STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED + } + }, [organization]) + + const formattedMaxSizeBytes = `${new Intl.NumberFormat('en-US').format(maxBytes)} bytes` const FormSchema = z .object({ @@ -96,7 +111,7 @@ const StorageSettings = () => { }) .superRefine((data, ctx) => { const { unit, fileSizeLimit } = data - const { value: formattedMaxLimit } = convertFromBytes(STORAGE_FILE_SIZE_LIMIT_MAX_BYTES, unit) + const { value: formattedMaxLimit } = convertFromBytes(maxBytes, unit) if (fileSizeLimit > formattedMaxLimit) { ctx.addIssue({ @@ -156,7 +171,7 @@ const StorageSettings = () => {
-
+
{ limit, storageUnit ).toLocaleString()} bytes. `} - Maximum size in bytes of a file that can be uploaded is 50 GB ( - {formattedMaxSizeBytes}). + Maximum upload file size is {formatBytes(maxBytes)}.

@@ -261,9 +275,18 @@ const StorageSettings = () => { {isFreeTier && (
} primaryText="Free Plan has a fixed upload file size limit of 50 MB." - secondaryText="Upgrade to the Pro Plan for a configurable upload file size limit of up to 50 GB." + secondaryText={`Upgrade to Pro Plan for a configurable upload file size limit of ${formatBytes(STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED)} and unlock image transformations.`} + source="storageSizeLimit" + /> +
+ )} + {isSpendCapOn && ( +
+
diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.utils.ts similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts rename to apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.utils.ts diff --git a/apps/studio/components/to-be-cleaned/Storage/index.ts b/apps/studio/components/interfaces/Storage/index.ts similarity index 100% rename from apps/studio/components/to-be-cleaned/Storage/index.ts rename to apps/studio/components/interfaces/Storage/index.ts diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index a29a158fc05..fe294b36169 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -12,10 +12,7 @@ import { isTableLike, isView, } from 'data/table-editor/table-editor-types' -import { useGetTables } from 'data/tables/tables-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -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 { useAppStateSnapshot } from 'state/app-state' @@ -37,12 +34,10 @@ export const TableGridEditor = ({ selectedTable, }: TableGridEditorProps) => { const router = useRouter() - const project = useSelectedProject() const appSnap = useAppStateSnapshot() const { ref: projectRef, id } = useParams() const tabs = useTabsStateSnapshot() - const { selectedSchema } = useQuerySchemaState() useLoadTableEditorStateFromLocalStorageIntoUrl({ projectRef, @@ -57,11 +52,6 @@ export const TableGridEditor = ({ const tabId = !!id ? tabs.openTabs.find((x) => x.endsWith(id)) : undefined const openTabs = tabs.openTabs.filter((x) => !x.startsWith('sql')) - const getTables = useGetTables({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const onClearDashboardHistory = useCallback(() => { if (projectRef) appSnap.setDashboardHistory(projectRef, 'editor', undefined) }, [appSnap, projectRef]) diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx index 528108172a4..127b273b0ef 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx @@ -38,8 +38,8 @@ const EdgeFunctionDetailsLayout = ({ children, }: PropsWithChildren) => { const router = useRouter() - const { functionSlug, ref } = useParams() const org = useSelectedOrganization() + const { functionSlug, ref } = useParams() const { mutate: sendEvent } = useSendEventMutation() const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index 8f6d7571d47..86f999ecd7f 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -229,19 +229,21 @@ export function LogsSidebarMenuV2() { return (
- Coming soon} - title="New logs" - description="Get early access" - actions={ - - - - } - /> + {IS_PLATFORM && ( + Coming soon} + title="New logs" + description="Get early access" + actions={ + + + + } + /> + )} {isUnifiedLogsPreviewAvailable && ( { const { ref } = useParams() const router = useRouter() const projectDetails = useSelectedProject() + const selectedOrg = useSelectedOrganization() const projectRef = projectDetails?.parent_project_ref || ref const { data: branches } = useBranchesQuery({ projectRef }, { enabled: Boolean(projectDetails) }) + const { mutate: sendEvent } = useSendEventMutation() + const { mutate: updateBranch, isLoading: isUpdating } = useBranchUpdateMutation({ onError: () => { toast.error(`Failed to open merge request`) @@ -44,6 +49,17 @@ export const MergeRequestButton = () => { onSuccess: () => { toast.success('Merge request created') router.push(`/project/${selectedBranch.project_ref}/merge`) + sendEvent({ + action: 'branch_create_merge_request_button_clicked', + properties: { + branchType: selectedBranch.persistent ? 'persistent' : 'preview', + origin: 'header', + }, + groups: { + project: projectRef ?? 'Unknown', + organization: selectedOrg?.slug ?? 'Unknown', + }, + }) }, } ) diff --git a/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx b/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx index 4b0eb1d68bd..5e32ed53df1 100644 --- a/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx +++ b/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx @@ -2,7 +2,7 @@ import { ReactNode } from 'react' import { withAuth } from 'hooks/misc/withAuth' import ProjectLayout from '../ProjectLayout/ProjectLayout' -import StorageMenu from './StorageMenu' +import StorageMenu from '../../interfaces/Storage/StorageMenu' export interface StorageLayoutProps { title: string diff --git a/apps/studio/components/layouts/useLayoutNavCommands.ts b/apps/studio/components/layouts/useLayoutNavCommands.ts index 1957bcc7278..0ce1cf08c0b 100644 --- a/apps/studio/components/layouts/useLayoutNavCommands.ts +++ b/apps/studio/components/layouts/useLayoutNavCommands.ts @@ -9,7 +9,7 @@ import { useLogsGotoCommands } from './LogsLayout/Logs.Commands' import { useProjectSettingsGotoCommands } from './ProjectSettingsLayout/ProjectSettings.Commands' import { useReportsGotoCommands } from './ReportsLayout/Reports.Commands' import { useSqlEditorGotoCommands } from './SQLEditorLayout/SqlEditor.Commands' -import { useStorageGotoCommands } from './StorageLayout/Storage.Commands' +import { useStorageGotoCommands } from '../interfaces/Storage/Storage.Commands' import { useTableEditorGotoCommands } from './TableEditorLayout/TableEditor.Commands' export function useLayoutNavCommands() { diff --git a/apps/studio/components/to-be-cleaned/Storage/Storage.types.ts b/apps/studio/components/to-be-cleaned/Storage/Storage.types.ts deleted file mode 100644 index b29ce672669..00000000000 --- a/apps/studio/components/to-be-cleaned/Storage/Storage.types.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from './Storage.constants' - -export interface StorageColumn { - id: string | null - name: string - status: string - items: StorageItem[] - hasMoreItems?: boolean - isLoadingMoreItems?: boolean -} - -export interface StorageItem { - id: string | null - name: string - type: STORAGE_ROW_TYPES - status: STORAGE_ROW_STATUS - metadata: StorageItemMetadata | null - created_at: string | null - updated_at: string | null - last_accessed_at: string | null - // UI specific properties, not from API - isCorrupted: boolean - path?: string -} - -export type StorageItemWithColumn = StorageItem & { columnIndex: number } - -export interface StorageItemMetadata { - cacheControl: string - contentLength: number - size: number - httpStatusCode: number - eTag: string - lastModified: string - mimetype: string -} diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.constants.ts b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.constants.ts deleted file mode 100644 index b46ecf2cca5..00000000000 --- a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const STORAGE_FILE_SIZE_LIMIT_MAX_BYTES = 50 * 1024 * 1024 * 1024 // 50 GB - -export enum StorageSizeUnits { - BYTES = 'bytes', - KB = 'KB', - MB = 'MB', - GB = 'GB', -} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 3b6d8e320e0..cd853d9d143 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -1,7 +1,7 @@ import type { Message as MessageType } from '@ai-sdk/react' import { useChat } from '@ai-sdk/react' import { AnimatePresence, motion } from 'framer-motion' -import { ArrowDown, FileText, Info, RefreshCw, Settings, X } from 'lucide-react' +import { ArrowDown, Info, RefreshCw, Settings, X } from 'lucide-react' import { useRouter } from 'next/router' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -19,21 +19,21 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import uuidv4 from 'lib/uuid' +import type { AssistantMessageType } from 'state/ai-assistant-state' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { AiIconAnimation, Button, cn } from 'ui' import { Admonition, GenericSkeletonLoader } from 'ui-patterns' import { ButtonTooltip } from '../ButtonTooltip' import { ErrorBoundary } from '../ErrorBoundary' +import { type SqlSnippet } from './AIAssistant.types' import { onErrorChat } from './AIAssistant.utils' import { AIAssistantChatSelector } from './AIAssistantChatSelector' import { AIOnboarding } from './AIOnboarding' import { AIOptInModal } from './AIOptInModal' import { AssistantChatForm } from './AssistantChatForm' -import { type SqlSnippet } from './AIAssistant.types' import { Message } from './Message' import { useAutoScroll } from './hooks' -import type { AssistantMessageType } from 'state/ai-assistant-state' const MemoizedMessage = memo( ({ @@ -80,9 +80,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const { ref, id: entityId } = useParams() const searchParams = useSearchParamsShallow() - const newOrgAiOptIn = useFlag('newOrgAiOptIn') const disablePrompts = useFlag('disableAssistantPrompts') - const useBedrockAssistant = useFlag('useBedrockAssistant') const { snippets } = useSqlEditorV2StateSnapshot() const snap = useAiAssistantStateSnapshot() @@ -98,8 +96,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { const showMetadataWarning = IS_PLATFORM && !!selectedOrganization && - ((!useBedrockAssistant && aiOptInLevel === 'disabled') || - (useBedrockAssistant && (aiOptInLevel === 'disabled' || aiOptInLevel === 'schema'))) + (aiOptInLevel === 'disabled' || aiOptInLevel === 'schema') // Add a ref to store the last user message const lastUserMessageRef = useRef(null) @@ -154,9 +151,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { reload, } = useChat({ id: snap.activeChatId, - api: useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/generate-v4` - : `${BASE_PATH}/api/ai/sql/generate-v3`, + api: `${BASE_PATH}/api/ai/sql/generate-v4`, maxSteps: 5, // [Alaister] typecast is needed here because valtio returns readonly arrays // and useChat expects a mutable array @@ -196,9 +191,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { schema: currentSchema, table: currentTable?.name, chatName: currentChat, - includeSchemaMetadata: !useBedrockAssistant - ? !IS_PLATFORM || aiOptInLevel !== 'disabled' - : undefined, orgSlug: selectedOrganization?.slug, }) }, @@ -392,7 +384,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { { : 'Limited metadata is shared to the Assistant' } description={ - newOrgAiOptIn && !updatedOptInSinceMCP + !updatedOptInSinceMCP ? 'You may now opt-in to share schema metadata and even logs for better results' : isHipaaProjectDisallowed ? 'Your organization has the HIPAA addon and will not send project metadata with your prompts for projects marked as HIPAA.' diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx index 2c5df30d838..1b09490417d 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx @@ -4,7 +4,6 @@ import { useEffect } from 'react' import { AIOptInLevelSelector } from 'components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector' import { useAIOptInForm } from 'hooks/forms/useAIOptInForm' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useFlag } from 'hooks/ui/useFlag' import { Button, cn, @@ -24,7 +23,6 @@ interface AIOptInModalProps { } export const AIOptInModal = ({ visible, onCancel }: AIOptInModalProps) => { - const newOrgAiOptIn = useFlag('newOrgAiOptIn') const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm(onCancel) const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') @@ -54,7 +52,7 @@ export const AIOptInModal = ({ visible, onCancel }: AIOptInModalProps) => { diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index 3dd9ae6aa3b..78e1e45a09c 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -1,12 +1,14 @@ 'use client' import dayjs from 'dayjs' +import { formatBytes } from 'lib/helpers' import { useTheme } from 'next-themes' import { ComponentProps, useEffect, useState } from 'react' import { Area, Bar, CartesianGrid, + Label, Line, ComposedChart as RechartComposedChart, ReferenceArea, @@ -14,7 +16,6 @@ import { Tooltip, XAxis, YAxis, - Label, } from 'recharts' import { CategoricalChartState } from 'recharts/types/chart/types' import { cn } from 'ui' @@ -28,11 +29,14 @@ import { } from './Charts.constants' import { CommonChartProps, Datum } from './Charts.types' import { numberFormatter, useChartSize } from './Charts.utils' -import { calculateTotalChartAggregate, CustomLabel, CustomTooltip } from './ComposedChart.utils' +import { + calculateTotalChartAggregate, + CustomLabel, + CustomTooltip, + MultiAttribute, +} from './ComposedChart.utils' import NoDataPlaceholder from './NoDataPlaceholder' -import { MultiAttribute } from './ComposedChart.utils' import { ChartHighlight } from './useChartHighlight' -import { formatBytes } from 'lib/helpers' export interface ComposedChartProps extends CommonChartProps { attributes: MultiAttribute[] diff --git a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx index 25ab41228eb..400a7a40afc 100644 --- a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx +++ b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx @@ -1,21 +1,22 @@ -import React, { PropsWithChildren, useState, useMemo, useEffect, useRef } from 'react' -import { useRouter } from 'next/router' import { Loader2 } from 'lucide-react' +import { useRouter } from 'next/router' +import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react' import { cn, WarningIcon } from 'ui' import Panel from 'components/ui/Panel' import ComposedChart from './ComposedChart' import { AnalyticsInterval, DataPoint } from 'data/analytics/constants' -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 { InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query' import { useProjectDailyStatsQueries } from 'data/analytics/project-daily-stats-queries' +import { ProjectDailyStatsAttribute } from 'data/analytics/project-daily-stats-query' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { useChartHighlight } from './useChartHighlight' -import type { ChartData } from './Charts.types' +import dayjs from 'dayjs' import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' +import type { ChartData } from './Charts.types' import { MultiAttribute } from './ComposedChart.utils' export interface ComposedChartHandlerProps { @@ -199,7 +200,12 @@ const ComposedChartHandler = ({ point[attr] = value }) - return point as DataPoint + const formattedDataPoint: DataPoint = + !('period_start' in point) && 'timestamp' in point + ? { ...point, period_start: dayjs.utc(point.timestamp).unix() * 1000 } + : point + + return formattedDataPoint }) return combined as DataPoint[] @@ -275,6 +281,7 @@ const ComposedChartHandler = ({ attributes={attributes} data={combinedData as DataPoint[]} format={format} + // [Joshen] This is where it's messing up xAxisKey="period_start" yAxisKey={attributes[0].attribute} highlightedValue={_highlightedValue} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx index 835d77cc54e..0267de60693 100644 --- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx @@ -15,7 +15,6 @@ import { DrawerTrigger, Tooltip, TooltipContent, - TooltipProvider, TooltipTrigger, } from 'ui' import { useMediaQuery } from '../hooks/useMediaQuery' @@ -32,26 +31,24 @@ export function DataTableFilterControlsDrawer() { return ( - - - - - - - - -

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

-
-
-
+ + + + + + + +

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

+
+
diff --git a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx index 0e9c18c8911..3e373ff2ee5 100644 --- a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx +++ b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx @@ -1,7 +1,7 @@ import { X } from 'lucide-react' import { useHotKey } from 'hooks/ui/useHotKey' -import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { Kbd } from './primitives/Kbd' import { useDataTable } from './providers/DataTableProvider' @@ -10,28 +10,21 @@ export function DataTableResetButton() { useHotKey(table.resetColumnFilters, 'Escape') return ( - - - - - - -

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

-
-
-
+ + + + + +

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

+
+
) } diff --git a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx index 4a5ed2200fe..29f7a7f0495 100644 --- a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx +++ b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx @@ -1,16 +1,7 @@ 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 { Button, cn, Separator, Skeleton, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { Kbd } from './primitives/Kbd' import { useDataTable } from './providers/DataTableProvider' @@ -79,44 +70,41 @@ export function DataTableSheetDetails({ {isLoading && !selectedRowKey ? : title}
- - - - - - -

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

-
-
-
+ + + + + +

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

+
+
diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index 4acab158d0f..855091cebd3 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -14,7 +14,6 @@ import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH } from 'lib/constants' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' @@ -57,7 +56,6 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { const snapV2 = useSqlEditorV2StateSnapshot() const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation() const { includeSchemaMetadata } = useOrgAiOptInLevel() - const useBedrockAssistant = useFlag('useBedrockAssistant') const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState() @@ -239,7 +237,6 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { setIsSaving(true) const { title: name } = await generateSqlTitle({ sql: currentValue, - useBedrockAssistant, }) const snippet = createSqlSnippetSkeletonV2({ id: uuidv4(), @@ -280,11 +277,7 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => { language="pgsql" value={currentValue} onChange={handleChange} - aiEndpoint={ - useBedrockAssistant - ? `${BASE_PATH}/api/ai/sql/complete-v2` - : `${BASE_PATH}/api/ai/sql/complete` - } + aiEndpoint={`${BASE_PATH}/api/ai/sql/complete-v2`} aiMetadata={{ projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx index 18aaf4c8c5e..4072f6f2caa 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx +++ b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -10,10 +10,10 @@ import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useFlag } from 'hooks/ui/useFlag' +import Link from 'next/link' import { Input } from 'ui' import { getLastUsedAPIKeys, useLastUsedAPIKeysLogQuery } from './DisplayApiSettings.utils' import { ToggleLegacyApiKeysPanel } from './ToggleLegacyApiKeys' -import Link from 'next/link' export const DisplayApiSettings = ({ showTitle = true, @@ -49,6 +49,7 @@ export const DisplayApiSettings = ({ const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated + 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 diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx index b9c3ddf9ba2..a98778b7f39 100644 --- a/apps/studio/components/ui/UpgradeToPro.tsx +++ b/apps/studio/components/ui/UpgradeToPro.tsx @@ -69,7 +69,7 @@ const UpgradeToPro = ({ }, }} > - Reset database password + {buttonText || (plan === 'free' ? 'Upgrade to Pro' : 'Enable add on')} ) : (