Merge branch 'master' into feature/database-tests

This commit is contained in:
Saxon Fletcher committed 2025-07-25 10:49:24 +10:00
commit d793bbcb7b
265 files changed
+5064 -4775

No files matched your search

+1 -1
View File
@@ -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
@@ -56,7 +56,7 @@ hideToc: true
<StepHikeCompact.Details title="Connect">
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).
</StepHikeCompact.Details>
@@ -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 <Price price="999" /> 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.
<Admonition type="note">
@@ -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 |
<Admonition type="note">
@@ -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
+2 -1
View File
@@ -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",
+6
View File
@@ -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
@@ -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<string>('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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Export table data via CLI</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-y-4">
<p className="text-sm">
We highly recommend using <code>{selectedTab === 'csv' ? 'psql' : 'pg_dump'}</code> 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:
</p>
<Tabs_Shadcn_ value={selectedTab} onValueChange={setSelectedTab}>
<TabsList_Shadcn_ className="gap-x-3">
<TabsTrigger_Shadcn_ value="csv">As CSV</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="sql">As SQL</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<TabsContent_Shadcn_ value="csv">
<CodeBlock
hideLineNumbers
wrapperClassName={cn('[&_pre]:px-4 [&_pre]:py-3')}
language="bash"
value={csvExportCommand}
className="[&_code]:text-[12px] [&_code]:text-foreground"
/>
</TabsContent_Shadcn_>
<TabsContent_Shadcn_ value="sql">
<CodeBlock
hideLineNumbers
wrapperClassName={cn('[&_pre]:px-4 [&_pre]:py-3')}
language="bash"
value={sqlExportCommand}
className="[&_code]:text-[12px] [&_code]:text-foreground"
/>
</TabsContent_Shadcn_>
</Tabs_Shadcn_>
<p className="text-sm">
You will be prompted for your database password, and the output file{' '}
<code>
{outputName}.{selectedTab}
</code>{' '}
will be saved in the current directory that your terminal is in.
</p>
{selectedTab === 'sql' && (
<Admonition
type="note"
title="The pg_dump version needs to match your Postgres version"
>
<p className="!leading-normal">
If you run into a server version mismatch error, you will need to update{' '}
<code>pg_dump</code> before running the command.
</p>
</Admonition>
)}
</DialogSection>
<DialogFooter>
<Button type="default" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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 (
<div className="flex items-center gap-x-2">
{snap.editable && (
<ButtonTooltip
type="default"
size="tiny"
icon={<Trash />}
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`}
</ButtonTooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
<>
<div className="flex items-center gap-x-2">
{snap.editable && (
<ButtonTooltip
type="default"
size="tiny"
iconRight={<ChevronDown />}
loading={isExporting}
disabled={isExporting}
icon={<Trash />}
onClick={onRowsDelete}
disabled={snap.allRowsSelected && isImpersonatingRole}
tooltip={{
content: {
side: 'bottom',
text:
snap.allRowsSelected && isImpersonatingRole
? 'Table truncation is not supported when impersonating a role'
: undefined,
},
}}
>
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40">
<DropdownMenuItem onClick={onRowsExportCSV}>
<span className="text-foreground-light">Export to CSV</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={onRowsExportSQL}>Export to SQL</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{snap.allRowsSelected
? `Delete all rows in table`
: snap.selectedRows.size > 1
? `Delete ${snap.selectedRows.size} rows`
: `Delete ${snap.selectedRows.size} row`}
</ButtonTooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
size="tiny"
iconRight={<ChevronDown />}
loading={isExporting}
disabled={isExporting}
>
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className={snap.allRowsSelected ? 'w-52' : 'w-40'}>
<DropdownMenuItem onClick={onRowsExportCSV}>Export as CSV</DropdownMenuItem>
<DropdownMenuItem onClick={onRowsExportSQL}>Export as SQL</DropdownMenuItem>
{/* [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 && (
<DropdownMenuItem className="group" onClick={() => setShowExportModal(true)}>
<div>
<p className="group-hover:text-foreground">Export via CLI</p>
<p className="text-foreground-lighter">Recommended for large tables</p>
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{!snap.allRowsSelected && totalRows > allRows.length && (
<>
<div className="h-6 ml-0.5">
<Separator orientation="vertical" />
</div>
<Button type="text" onClick={() => onSelectAllRows()}>
Select all rows in table
</Button>
</>
)}
</div>
{!snap.allRowsSelected && totalRows > allRows.length && (
<>
<div className="h-6 ml-0.5">
<Separator orientation="vertical" />
</div>
<Button type="text" onClick={() => onSelectAllRows()}>
Select all rows in table
</Button>
</>
)}
</div>
<ExportDialog open={showExportModal} onOpenChange={() => setShowExportModal(false)} />
</>
)
}
@@ -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(<NewAccessTokenButton onCreateToken={onCreateToken} />)
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(<NewAccessTokenButton onCreateToken={onCreateToken} />)
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(<NewAccessTokenButton onCreateToken={vi.fn()} />)
// 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`)
})
})
@@ -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<z.infer<typeof TokenSchema>>({
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<z.infer<typeof TokenSchema>> = 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 (
<>
<div className="container max-w-7xl">
<div className="flex justify-between">
<div className="flex items-center">
<div className="flex items-center">
<Button
className="rounded-r-none px-3"
onClick={() => {
setTokenScope(undefined)
setVisible(true)
}}
>
Generate new token
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="rounded-r-none px-3"
type="primary"
title="Choose token scope"
className="rounded-l-none px-[4px] py-[5px]"
icon={<ChevronDown />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
key="experimental-token"
onClick={() => {
setTokenScope(undefined)
setIsOpen(true)
setTokenScope('V0')
setVisible(true)
}}
>
Generate new token
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="primary"
className="rounded-l-none px-[4px] py-[5px]"
icon={<ChevronDown />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
key="experimental-token"
onClick={() => {
setTokenScope('V0')
setIsOpen(true)
}}
>
<div className="space-y-1">
<p className="block text-foreground">Generate token for experimental API</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="space-y-1">
<p className="block text-foreground">Generate token for experimental API</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Modal
hideFooter
size="small"
visible={isOpen}
onCancel={() => setIsOpen(!isOpen)}
header={
<div className="flex items-baseline gap-2">
<h5 className="text-sm text-foreground">
{tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
</h5>
</div>
}
<Dialog
open={visible}
onOpenChange={(open) => {
if (!open) form.reset()
setVisible(open)
}}
>
<Form
validateOnBlur
initialValues={{ tokenName: '' }}
onSubmit={onFormSubmit}
validate={validate}
>
{() => (
<>
{tokenScope === 'V0' && (
<Modal.Content>
<Admonition
type="warning"
title="The experimental API provides additional endpoints which allows you to manage
your organizations and projects."
description={
<>
<p>
These include deleting organizations and projects which cannot be undone.
As such, be very careful when using this API.
</p>
<div className="mt-4">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://api.supabase.com/api/v0"
target="_blank"
rel="noreferrer"
>
Experimental API documentation
</Link>
</Button>
</div>
</>
}
/>
</Modal.Content>
)}
<Modal.Content>
<Input
id="tokenName"
label="Name"
placeholder="Provide a name for your token"
className="w-full"
<DialogContent>
<DialogHeader>
<DialogTitle>
{tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'}
</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-4">
{tokenScope === 'V0' && (
<Admonition
type="warning"
title="The experimental API provides additional endpoints which allows you to manage your organizations and projects."
description={
<>
<p>
These include deleting organizations and projects which cannot be undone. As
such, be very careful when using this API.
</p>
<div className="mt-4">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
href="https://api.supabase.com/api/v0"
target="_blank"
rel="noreferrer"
>
Experimental API documentation
</Link>
</Button>
</div>
</>
}
/>
)}
<Form_Shadcn_ {...form}>
<form
id={formId}
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
key="tokenName"
name="tokenName"
control={form.control}
render={({ field }) => (
<FormItemLayout name="tokenName" label="Name">
<FormControl_Shadcn_>
<Input_Shadcn_
id="tokenName"
{...field}
placeholder="Provide a name for your token"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</Modal.Content>
<DialogFooter>
<div className="flex items-center space-x-2 justify-end">
<Button type="default" disabled={isLoading} onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button htmlType="submit" loading={isLoading} disabled={isLoading}>
Generate token
</Button>
</div>
</DialogFooter>
</>
)}
</Form>
</Modal>
</form>
</Form_Shadcn_>
</DialogSection>
<DialogFooter>
<Button
type="default"
disabled={isLoading}
onClick={() => {
form.reset()
setVisible(false)
}}
>
Cancel
</Button>
<Button form={formId} htmlType="submit" loading={isLoading}>
Generate token
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -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: () => (
<span className="flex items-center gap-2">
<span className="flex items-center gap-x-1">
<Badge>Project: {project?.name}</Badge>
<Badge>Public</Badge>
<Badge className="capitalize">{anonKey.type}</Badge>
</span>
),
icon: () => <Key />,
@@ -58,13 +56,47 @@ export function useApiKeysCommands() {
setIsOpen(false)
},
badge: () => (
<span className="flex items-center gap-2">
<span className="flex items-center gap-x-1">
<Badge>Project: {project?.name}</Badge>
<Badge variant="destructive">Secret</Badge>
<Badge className="capitalize">{serviceKey.type}</Badge>
</span>
),
icon: () => <Key />,
},
project &&
publishableKey && {
id: 'publishable-key',
name: `Copy publishable key`,
action: () => {
copyToClipboard(publishableKey.api_key ?? '')
setIsOpen(false)
},
badge: () => (
<span className="flex items-center gap-x-1">
<Badge>Project: {project?.name}</Badge>
<Badge className="capitalize">{publishableKey.type}</Badge>
</span>
),
icon: () => <Key />,
},
...(project && allSecretKeys
? allSecretKeys.map((key) => ({
id: key.id,
name: `Copy secret key (${key.name})`,
action: () => {
copyToClipboard(key.api_key ?? '')
setIsOpen(false)
},
badge: () => (
<span className="flex items-center gap-x-1">
<Badge>Project: {project?.name}</Badge>
<Badge className="capitalize">{key.type}</Badge>
</span>
),
icon: () => <Key />,
}))
: []),
!(anonKey || serviceKey) && {
id: 'api-keys-project-settings',
name: 'See API keys in Project Settings',
@@ -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)
}}
/>
</>
)
@@ -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<any>()
const selectedOrganization = useSelectedOrganization()
const captchaLoaded = useIsHCaptchaLoaded()
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(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}
/>
</Elements>
</Modal>
@@ -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<PaymentMethodElementRef | null>(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 (
<Modal.Content>
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
<ShimmeringLoader />
<ShimmeringLoader />
<ShimmeringLoader />
</div>
</Modal.Content>
)
}
return (
<div>
<Modal.Content
className={`transition ${isSaving ? 'pointer-events-none opacity-75' : 'opacity-100'}`}
>
<PaymentElement
className="[.p-LinkAutofillPrompt]:pt-0"
options={{
defaultValues: { billingDetails: { email: selectedOrganization?.billing_email ?? '' } },
}}
<NewPaymentMethodElement
readOnly={isSaving}
email={selectedOrganization?.billing_email}
currentAddress={customerProfile?.address}
customerName={customerProfile?.billing_name}
currentTaxId={taxId}
ref={paymentRef}
/>
{showSetDefaultCheckbox && (
<div className="flex items-center gap-x-2 mt-4 mb-2">
<Checkbox_Shadcn_
id="save-as-default"
checked={isDefault}
onCheckedChange={(checked) => {
if (typeof checked === 'boolean') {
setIsDefault(checked)
}
}}
/>
<Label_Shadcn_ htmlFor="save-as-default" className="text-foreground-light">
Save as default payment method
</Label_Shadcn_>
</div>
)}
<div className="flex items-center gap-x-2 mt-4 mb-2">
<Checkbox_Shadcn_
id="save-as-default"
checked={isDefaultPaymentMethod}
onCheckedChange={(checked) => {
if (typeof checked === 'boolean') {
setIsDefaultPaymentMethod(checked)
}
}}
/>
<Label_Shadcn_ htmlFor="save-as-default" className="text-foreground-light">
Save as default payment method
</Label_Shadcn_>
</div>
<div className="flex items-center gap-x-2 mt-4 mb-2">
<Checkbox_Shadcn_
id="is-primary-billing-address"
checked={isPrimaryBillingAddress}
onCheckedChange={(checked) => {
if (typeof checked === 'boolean') {
setIsPrimaryBillingAddress(checked)
}
}}
/>
<Label_Shadcn_ htmlFor="is-primary-billing-address" className="text-foreground-light">
Use the billing address as my organization's primary address
</Label_Shadcn_>
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content className="flex items-center space-x-2">
@@ -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',
},
},
}
}
@@ -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 } : {}),
})
}
@@ -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 = ({
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmUpdate}>Update anyway</AlertDialogAction>
<AlertDialogAction onClick={() => handleUpdate(true)}>
Update anyway
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -106,7 +129,7 @@ export const OutOfDateNotice = ({
<Button
type="default"
loading={isPushing}
onClick={handleUpdateClick}
onClick={() => handleUpdate()}
icon={<GitBranchIcon size={16} strokeWidth={1.5} />}
className="shrink-0"
>
@@ -5,6 +5,8 @@ import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { Branch } from 'data/branches/branches-query'
import { tablesToSQL } from 'lib/helpers'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
interface ReviewWithAIProps {
currentBranch?: Branch
@@ -22,6 +24,8 @@ export const ReviewWithAI = ({
disabled = false,
}: ReviewWithAIProps) => {
const aiSnap = useAiAssistantStateSnapshot()
const selectedOrg = useSelectedOrganization()
const { mutate: sendEvent } = useSendEventMutation()
// Get parent project for production schema
const parentProject = useProjectByRef(parentProjectRef)
@@ -40,6 +44,15 @@ export const ReviewWithAI = ({
const handleReviewWithAssistant = () => {
if (!currentBranch || !mainBranch) return
// Track review with assistant button pressed
sendEvent({
action: 'branch_review_with_assistant_clicked',
groups: {
project: parentProjectRef ?? 'Unknown',
organization: selectedOrg?.slug ?? 'Unknown',
},
})
// Prepare diff content for the assistant
const sqlSnippets = []
@@ -7,8 +7,8 @@ import { useMemo, useState } from 'react'
import { DatabaseConnectionString } from 'components/interfaces/Connect/DatabaseConnectionString'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import Panel from 'components/ui/Panel'
import { useAPIKeysQuery } from 'data/api-keys/api-keys-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 { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
@@ -138,21 +138,28 @@ export const Connect = () => {
return []
}
const { anonKey } = canReadAPIKeys ? getAPIKeys(settings) : { anonKey: null }
const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: false })
const { data: apiKeys } = useAPIKeysQuery({ projectRef })
const { anonKey, publishableKey } = canReadAPIKeys
? getKeys(apiKeys)
: { anonKey: null, publishableKey: null }
const projectKeys = useMemo(() => {
const protocol = settings?.app_config?.protocol ?? 'https'
const endpoint = settings?.app_config?.endpoint ?? ''
const apiHost = canReadAPIKeys ? `${protocol}://${endpoint ?? '-'}` : ''
const apiUrl = canReadAPIKeys ? apiHost : null
return {
apiUrl: apiHost ?? null,
anonKey: anonKey?.api_key ?? null,
publishableKey: apiKeys?.find(({ type }) => type === 'publishable')?.api_key ?? null,
publishableKey: publishableKey?.api_key ?? null,
}
}, [apiKeys, anonKey, canReadAPIKeys, settings])
}, [
settings?.app_config?.protocol,
settings?.app_config?.endpoint,
canReadAPIKeys,
anonKey?.api_key,
publishableKey?.api_key,
])
const filePath = getContentFilePath({
connectionObject,
@@ -1,25 +1,18 @@
import type { PostgresTable, PostgresTrigger } from '@supabase/postgres-meta'
import Image from 'next/legacy/image'
import { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react'
import { PGTriggerCreate } from '@supabase/pg-meta/src/pg-meta-triggers'
import type { PostgresTrigger } from '@supabase/postgres-meta'
import { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/database-trigger-create-mutation'
import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-transaction-mutation'
import {
EdgeFunctionsResponse,
useEdgeFunctionsQuery,
} from 'data/edge-functions/edge-functions-query'
import { getTableEditor } from 'data/table-editor/table-editor-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { isValidHttpUrl, uuidv4 } from 'lib/helpers'
import { Button, Checkbox, Form, Input, Listbox, Radio, SidePanel } from 'ui'
import { Button, Form, SidePanel } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import HTTPRequestFields from './HTTPRequestFields'
import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants'
import { PGTriggerCreate } from '@supabase/pg-meta/src/pg-meta-triggers'
import { FormContents } from './FormContents'
export interface EditHookPanelProps {
visible: boolean
@@ -29,7 +22,19 @@ export interface EditHookPanelProps {
export type HTTPArgument = { id: string; name: string; value: string }
const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) => {
export const isEdgeFunction = ({
ref,
restUrlTld,
url,
}: {
ref?: string
restUrlTld?: string
url: string
}) =>
url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) ||
url.includes(`https://${ref}.supabase.${restUrlTld}/functions/`)
export const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) => {
const { ref } = useParams()
const submitRef = useRef<any>(null)
const [isEdited, setIsEdited] = useState(false)
@@ -49,7 +54,6 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref })
const [isSubmitting, setIsSubmitting] = useState(false)
const { mutate: createDatabaseTrigger } = useDatabaseTriggerCreateMutation({
onSuccess: (res) => {
@@ -81,16 +85,12 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
const restUrl = project?.restUrl
const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co'
const isEdgeFunction = (url: string) =>
url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) ||
url.includes(`https://${ref}.supabase.${restUrlTld}/functions/`)
const initialValues = {
name: selectedHook?.name ?? '',
table_id: selectedHook?.table_id ?? '',
http_url: selectedHook?.function_args?.[0] ?? '',
http_method: selectedHook?.function_args?.[1] ?? 'POST',
function_type: isEdgeFunction(selectedHook?.function_args?.[0] ?? '')
function_type: isEdgeFunction({ ref, restUrlTld, url: selectedHook?.function_args?.[0] ?? '' })
? 'supabase_function'
: 'http_request',
timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000),
@@ -104,7 +104,7 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
if (selectedHook !== undefined) {
setEvents(selectedHook.events)
const [url, method, headers, parameters] = selectedHook.function_args
const [_, __, headers, parameters] = selectedHook.function_args
let parsedParameters: Record<string, string> = {}
@@ -314,10 +314,6 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
values={values}
resetForm={resetForm}
errors={errors}
projectRef={ref}
restUrlTld={restUrlTld}
functions={functions}
isEdgeFunction={isEdgeFunction}
tables={tables}
events={events}
eventsError={eventsError}
@@ -352,211 +348,3 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
</>
)
}
export default EditHookPanel
interface FormContentsProps {
values: any
resetForm: any
errors: any
projectRef?: string
restUrlTld?: string
selectedHook?: PostgresTrigger
functions: EdgeFunctionsResponse[] | undefined
isEdgeFunction: (url: string) => boolean
tables: PostgresTable[]
events: string[]
eventsError?: string
onUpdateSelectedEvents: (event: string) => void
httpHeaders: HTTPArgument[]
httpParameters: HTTPArgument[]
setHttpHeaders: (arr: HTTPArgument[]) => void
setHttpParameters: (arr: HTTPArgument[]) => void
submitRef: MutableRefObject<any>
}
const FormContents = ({
values,
resetForm,
errors,
projectRef,
restUrlTld,
selectedHook,
functions,
isEdgeFunction,
tables,
events,
eventsError,
onUpdateSelectedEvents,
httpHeaders,
httpParameters,
setHttpHeaders,
setHttpParameters,
submitRef,
}: FormContentsProps) => {
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') {
const fnSlug = (functions ?? [])[0]?.slug
const defaultFunctionUrl = `https://${projectRef}.supabase.${restUrlTld}/functions/v1/${fnSlug}`
const updatedValues = {
...values,
http_url: isEdgeFunction(values.http_url) ? values.http_url : defaultFunctionUrl,
}
resetForm({ values: updatedValues, initialValues: updatedValues })
}
}, [values.function_type])
return (
<div>
<FormSection header={<FormSectionLabel className="lg:!col-span-4">General</FormSectionLabel>}>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Input
id="name"
name="name"
label="Name"
descriptionText="Do not use spaces/whitespaces"
/>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel
className="lg:!col-span-4"
description={
<p className="text-sm text-foreground-light">
Select which table and events will trigger your webhook
</p>
}
>
Conditions to fire webhook
</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Listbox
size="medium"
id="table_id"
name="table_id"
label="Table"
descriptionText="This is the table the trigger will watch for changes. You can only select 1 table for a trigger."
>
<Listbox.Option
key={'table-no-selection'}
id={'table-no-selection'}
label={'---'}
value={'no-selection'}
>
---
</Listbox.Option>
{tables.map((table) => (
<Listbox.Option
key={table.id}
id={table.id.toString()}
value={table.id}
label={table.name}
>
<div className="flex items-center space-x-2">
<p className="text-foreground-light">{table.schema}</p>
<p className="text-foreground">{table.name}</p>
</div>
</Listbox.Option>
))}
</Listbox>
<Checkbox.Group
id="events"
name="events"
label="Events"
error={eventsError}
descriptionText="These are the events that are watched by the webhook, only the events selected above will fire the webhook on the table you've selected."
>
{HOOK_EVENTS.map((event) => (
<Checkbox
key={event.value}
value={event.value}
label={event.label}
description={event.description}
checked={events.includes(event.value)}
onChange={() => onUpdateSelectedEvents(event.value)}
/>
))}
</Checkbox.Group>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel className="lg:!col-span-4">Webhook configuration</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Radio.Group id="function_type" name="function_type" label="Type of webhook" type="cards">
{AVAILABLE_WEBHOOK_TYPES.map((webhook) => (
<Radio
key={webhook.value}
id={webhook.value}
value={webhook.value}
label=""
beforeLabel={
<div className="flex items-center space-x-5">
<Image src={webhook.icon} layout="fixed" width="32" height="32" />
<div className="flex-col space-y-0">
<div className="flex space-x-2">
<p className="text-foreground">{webhook.label}</p>
</div>
<p className="text-foreground-light">{webhook.description}</p>
</div>
</div>
}
/>
))}
</Radio.Group>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<HTTPRequestFields
type={values.function_type}
errors={errors}
httpHeaders={httpHeaders}
httpParameters={httpParameters}
onAddHeader={(header?: any) => {
if (header) setHttpHeaders(httpHeaders.concat(header))
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))}
/>
<button ref={submitRef} type="submit" className="hidden" />
</div>
)
}
@@ -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<any>
}
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 (
<div>
<FormSection header={<FormSectionLabel className="lg:!col-span-4">General</FormSectionLabel>}>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Input
id="name"
name="name"
label="Name"
descriptionText="Do not use spaces/whitespaces"
/>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel
className="lg:!col-span-4"
description={
<p className="text-sm text-foreground-light">
Select which table and events will trigger your webhook
</p>
}
>
Conditions to fire webhook
</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Listbox
size="medium"
id="table_id"
name="table_id"
label="Table"
descriptionText="This is the table the trigger will watch for changes. You can only select 1 table for a trigger."
>
<Listbox.Option
key={'table-no-selection'}
id={'table-no-selection'}
label={'---'}
value={'no-selection'}
>
---
</Listbox.Option>
{tables.map((table) => (
<Listbox.Option
key={table.id}
id={table.id.toString()}
value={table.id}
label={table.name}
>
<div className="flex items-center space-x-2">
<p className="text-foreground-light">{table.schema}</p>
<p className="text-foreground">{table.name}</p>
</div>
</Listbox.Option>
))}
</Listbox>
<Checkbox.Group
id="events"
name="events"
label="Events"
error={eventsError}
descriptionText="These are the events that are watched by the webhook, only the events selected above will fire the webhook on the table you've selected."
>
{HOOK_EVENTS.map((event) => (
<Checkbox
key={event.value}
value={event.value}
label={event.label}
description={event.description}
checked={events.includes(event.value)}
onChange={() => onUpdateSelectedEvents(event.value)}
/>
))}
</Checkbox.Group>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel className="lg:!col-span-4">Webhook configuration</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Radio.Group id="function_type" name="function_type" label="Type of webhook" type="cards">
{AVAILABLE_WEBHOOK_TYPES.map((webhook) => (
<Radio
key={webhook.value}
id={webhook.value}
value={webhook.value}
label=""
beforeLabel={
<div className="flex items-center space-x-5">
<Image
alt={webhook.label}
src={webhook.icon}
layout="fixed"
width="32"
height="32"
/>
<div className="flex-col space-y-0">
<div className="flex space-x-2">
<p className="text-foreground">{webhook.label}</p>
</div>
<p className="text-foreground-light">{webhook.description}</p>
</div>
</div>
}
/>
))}
</Radio.Group>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<HTTPRequestFields
type={values.function_type}
errors={errors}
httpHeaders={httpHeaders}
httpParameters={httpParameters}
onAddHeaders={(headers?: any[]) => {
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))}
/>
<button ref={submitRef} type="submit" className="hidden" />
</div>
)
}
@@ -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)}
/>
<Button
type="default"
size="medium"
icon={<Trash size="14" />}
className="px-[10px] py-[9px]"
<ButtonTooltip
type="text"
icon={<X />}
className="py-4"
onClick={() => onRemoveHeader(idx)}
tooltip={{ content: { side: 'bottom', text: 'Remove header' } }}
/>
</div>
))}
@@ -158,30 +160,37 @@ const HTTPRequestFields = ({
size="tiny"
icon={<Plus />}
className={cn(type === 'supabase_function' && 'rounded-r-none px-3')}
onClick={onAddHeader}
onClick={() => onAddHeaders()}
>
Add a new header
</Button>
{type === 'supabase_function' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="rounded-l-none px-[4px] py-[5px]">
<ChevronDown />
</Button>
<Button
type="default"
icon={<ChevronDown />}
className="rounded-l-none px-[4px] py-[5px]"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
key="add-auth-header"
onClick={() =>
onAddHeader({
id: uuidv4(),
name: 'Authorization',
value: `Bearer ${apiKey}`,
})
}
onClick={() => {
onAddHeaders([
{
id: uuidv4(),
name: 'Authorization',
value: `Bearer ${apiKey}`,
},
...(serviceKey?.type === 'secret'
? [{ id: uuidv4(), name: 'apikey', value: apiKey }]
: []),
])
}}
>
<div className="space-y-1">
<p className="block text-foreground">Add auth header with service key</p>
<p className="block text-foreground">Add auth header with secret key</p>
<p className="text-foreground-light">
Required if your edge function enforces JWT verification
</p>
@@ -191,11 +200,13 @@ const HTTPRequestFields = ({
<DropdownMenuItem
key="add-source-header"
onClick={() =>
onAddHeader({
id: uuidv4(),
name: 'x-supabase-webhook-source',
value: `[Use a secret value]`,
})
onAddHeaders([
{
id: uuidv4(),
name: 'x-supabase-webhook-source',
value: `[Use a secret value]`,
},
])
}
>
<div className="space-y-1">
@@ -234,12 +245,12 @@ const HTTPRequestFields = ({
placeholder="Parameter value"
onChange={(event: any) => onUpdateParameter(idx, 'value', event.target.value)}
/>
<Button
type="default"
size="medium"
icon={<Trash size="14" />}
className="px-[10px] py-[9px]"
<ButtonTooltip
type="text"
className="py-4"
icon={<X />}
onClick={() => onRemoveParameter(idx)}
tooltip={{ content: { side: 'bottom', text: 'Remove parameter' } }}
/>
</div>
))}
@@ -1,20 +1,20 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { includes, map as lodashMap, uniqBy } from 'lodash'
import { BookOpen, Search } from 'lucide-react'
import { Search } from 'lucide-react'
import { useState } from 'react'
import { Button, Input } from 'ui'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DocsButton } from 'components/ui/DocsButton'
import NoSearchResults from 'components/ui/NoSearchResults'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useDatabaseHooksQuery } from 'data/database-triggers/database-triggers-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { noop } from 'lib/void'
import { Input } from 'ui'
import HooksListEmpty from './HooksListEmpty'
import SchemaTable from './SchemaTable'
import { DocsButton } from 'components/ui/DocsButton'
export interface HooksListProps {
createHook: () => void
@@ -1,13 +1,15 @@
import Table from 'components/to-be-cleaned/Table'
import { motion } from 'framer-motion'
import { BASE_PATH } from 'lib/constants'
import { ArrowUpRight, Circle, Database, MoreVertical, Plus, Search } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useMemo } from 'react'
import ReactFlow, { Background, Handle, Position, ReactFlowProvider } from 'reactflow'
import 'reactflow/dist/style.css'
import { Button, Input } from 'ui'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import Table from 'components/to-be-cleaned/Table'
import { BASE_PATH } from 'lib/constants'
import { Button, Input_Shadcn_ } from 'ui'
import { NODE_WIDTH } from '../../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
const STATIC_NODES = [
@@ -62,6 +64,14 @@ const STATIC_EDGES = [
{ id: 'e1-4', source: '1', target: '4', type: 'smoothstep', animated: true },
]
export const ReplicationComingSoon = () => {
return (
<ReactFlowProvider>
<ReplicationStaticMockup />
</ReactFlowProvider>
)
}
const ReplicationStaticMockup = () => {
const nodes = useMemo(() => STATIC_NODES, [])
const edges = useMemo(() => STATIC_EDGES, [])
@@ -108,16 +118,6 @@ const ReplicationStaticMockup = () => {
)
}
const ReplicationComingSoon = () => {
return (
<ReactFlowProvider>
<ReplicationStaticMockup />
</ReactFlowProvider>
)
}
export default ReplicationComingSoon
const PrimaryNode = ({
data,
}: {
@@ -251,58 +251,68 @@ const StaticDestinations = () => {
return (
<>
<div className="flex flex-col bg-surface-100 px-6 pt-6 border-t relative h-full">
<div className="bg-surface-300 w-full h-full absolute top-0 left-0 opacity-30"></div>
<div className="flex items-center justify-between">
<Input
size="small"
className="w-52 bg-transparent"
iconContainerClassName="pl-2"
icon={<Search size={14} className="text-foreground-lighter" />}
placeholder="Search..."
/>
<Button
icon={<Plus size={16} />}
type="primary"
className="flex items-center"
onClick={() => {}}
>
New destination
</Button>
</div>
<Table
head={[
<Table.th key="name">Name</Table.th>,
<Table.th key="publication">Publication</Table.th>,
<Table.th key="lag">Lag</Table.th>,
<Table.th key="status">Status</Table.th>,
<Table.th key="actions"></Table.th>,
]}
className="mt-4"
body={mockRows.map((row, i) => (
<Table.tr key={i}>
<Table.td>{row.name}</Table.td>
<Table.td>
<span className="flex items-center gap-2">
<span className="font-bold">All</span>
<span className="text-sm text-foreground-lighter">{row.tables} tables</span>
</span>
</Table.td>
<Table.td>{row.lag}</Table.td>
<Table.td>
<span className="flex items-center gap-3">
<Circle size={10} className="bg-brand-500 stroke-none rounded-full" />
{row.status}
</span>
</Table.td>
<Table.td className="text-right">
<button className="p-1">
<MoreVertical size={18} />
</button>
</Table.td>
</Table.tr>
))}
/>
<div className="flex flex-col bg-surface-100 px-6 py-6 border-t relative ">
<div className="z-10 bg-surface-300 w-full h-full absolute top-0 left-0 opacity-30" />
<ScaffoldContainer>
<ScaffoldSection className="!py-0">
<div className="col-span-12">
<div className="flex items-center justify-between">
<div className="relative w-52">
<Search
size={14}
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
/>
<Input_Shadcn_
className="pl-9 bg-transparent h-8 pointer-events-none"
placeholder="Search..."
/>
</div>
<Button
disabled
type="primary"
icon={<Plus size={16} />}
className="flex items-center pointer-events-none"
>
New destination
</Button>
</div>
<Table
head={[
<Table.th key="name">Name</Table.th>,
<Table.th key="publication">Publication</Table.th>,
<Table.th key="lag">Lag</Table.th>,
<Table.th key="status">Status</Table.th>,
<Table.th key="actions"></Table.th>,
]}
className="mt-4"
body={mockRows.map((row, i) => (
<Table.tr key={i}>
<Table.td>{row.name}</Table.td>
<Table.td>
<span className="flex items-center gap-2">
<span className="font-bold">All</span>
<span className="text-sm text-foreground-lighter">{row.tables} tables</span>
</span>
</Table.td>
<Table.td>{row.lag}</Table.td>
<Table.td>
<span className="flex items-center gap-3">
<Circle size={10} className="bg-brand-500 stroke-none rounded-full" />
{row.status}
</span>
</Table.td>
<Table.td className="text-right">
<button className="p-1">
<MoreVertical size={18} />
</button>
</Table.td>
</Table.tr>
))}
/>
</div>
</ScaffoldSection>
</ScaffoldContainer>
</div>
</>
)
@@ -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>(
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 && (
<AlertError error={pipelineError} subject="Failed to retrieve pipeline" />
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
)}
{isPipelineSuccess && (
<Table.tr>
<Table.td>
{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : destinationName}
</Table.td>
<Table.td>{isPipelineLoading ? <ShimmeringLoader></ShimmeringLoader> : type}</Table.td>
<Table.tr
className="hover:!bg-surface-200 transition"
onClick={() => {
if (pipeline) onSelectPipeline?.(pipeline.id, destinationName)
}}
>
<Table.td>{isPipelineLoading ? <ShimmeringLoader /> : destinationName}</Table.td>
<Table.td>{isPipelineLoading ? <ShimmeringLoader /> : type}</Table.td>
<Table.td>
{isPipelineLoading || !pipeline ? (
<ShimmeringLoader></ShimmeringLoader>
<ShimmeringLoader />
) : (
<PipelineStatus
pipelineStatus={pipelineStatusData?.status}
@@ -166,27 +126,28 @@ const DestinationRow = ({
isError={isPipelineStatusError}
isSuccess={isPipelineStatusSuccess}
requestStatus={requestStatus}
></PipelineStatus>
/>
)}
</Table.td>
<Table.td>
{isPipelineLoading || !pipeline ? (
<ShimmeringLoader></ShimmeringLoader>
<ShimmeringLoader />
) : (
pipeline.config.publication_name
)}
</Table.td>
<Table.td>
<RowMenu
pipelineStatus={pipelineStatusData?.status}
error={pipelineStatusError}
isLoading={isPipelineStatusLoading}
isError={isPipelineStatusError}
onEnableClick={onEnableClick}
onDisableClick={onDisableClick}
onDeleteClick={() => setShowDeleteDestinationForm(true)}
onEditClick={() => setShowEditDestinationPanel(true)}
></RowMenu>
<div className="flex items-center justify-end gap-x-2">
<RowMenu
pipeline={pipeline}
pipelineStatus={pipelineStatusData?.status}
error={pipelineStatusError}
isLoading={isPipelineStatusLoading}
isError={isPipelineStatusError}
onDeleteClick={() => setShowDeleteDestinationForm(true)}
onEditClick={() => setShowEditDestinationPanel(true)}
/>
</div>
</Table.td>
</Table.tr>
)}
@@ -211,5 +172,3 @@ const DestinationRow = ({
</>
)
}
export default DestinationRow
@@ -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<string>('')
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 (
<>
<ScaffoldSection isFullWidth>
<div className="flex justify-between items-center mb-4">
<ScaffoldSectionTitle>Destinations</ScaffoldSectionTitle>
<div className="mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="relative">
<Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
size={14}
/>
<Input_Shadcn_
className="pl-9 h-7"
placeholder={'Filter destinations'}
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
/>
</div>
</div>
<Button type="default" icon={<Plus />} onClick={() => setShowNewDestinationPanel(true)}>
Add destination
</Button>
</div>
</div>
<div className="w-full overflow-hidden overflow-x-auto">
{(isSourcesLoading || isDestinationsLoading) && <GenericSkeletonLoader />}
{(isSourcesError || isDestinationsError) && (
<AlertError
error={sourcesError || destinationsError}
subject="Failed to retrieve destinations"
subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_DESTINATIONS}
/>
)}
@@ -77,7 +106,7 @@ const Destinations = () => {
<Table.th key="publication">Publication</Table.th>,
<Table.th key="actions"></Table.th>,
]}
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}
></DestinationRow>
onSelectPipeline={onSelectPipeline}
/>
)
})}
></Table>
@@ -124,15 +154,22 @@ const Destinations = () => {
</div>
)
)}
</ScaffoldSection>
</div>
{!isSourcesLoading &&
!isDestinationsLoading &&
filteredDestinations.length === 0 &&
anyDestinations && (
<div className="text-center py-8 text-foreground-light">
<p>No destinations match "{filterString}"</p>
</div>
)}
<NewDestinationPanel
visible={showNewDestinationPanel}
sourceId={sourceId}
onClose={() => setShowNewDestinationPanel(false)}
></NewDestinationPanel>
/>
</>
)
}
export default Destinations
@@ -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
}
}
@@ -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<HTMLDivElement>(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 (
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-sm text-destructive">
{isLoadingLogs ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
<span>Failed</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{isLoadingLogs
? 'Pipeline failed - logs are being retrieved from container'
: 'Pipeline has failed - expand for error details'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{hasDetails && (
<Collapsible open={isErrorDetailsOpen} onOpenChange={setIsErrorDetailsOpen}>
<CollapsibleTrigger asChild>
<Button type="text" size="tiny" className="h-auto py-0 px-1 text-xs">
{isErrorDetailsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="absolute z-10 mt-1 left-0 min-w-[400px] max-w-[600px]">
<div
ref={errorDetailsRef}
className="rounded-lg border border-border bg-surface-100 p-3 shadow-md"
>
{isLoadingLogs ? (
<div className="flex items-center gap-3">
<Loader2 className="w-4 h-4 animate-spin text-warning-600" />
<div>
<div className="text-sm font-medium text-foreground mb-1">
Pipeline Failed - Loading logs...
</div>
<div className="text-xs text-foreground-light">
Error logs are being retrieved from the container. This may take a few
moments.
</div>
</div>
</div>
) : (
<>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">
Pipeline Error Details
</span>
{(failedStatus.message || failedStatus.reason) && (
<Button
type="text"
size="tiny"
onClick={async () => {
await handleCopyToClipboard(
[
failedStatus.exit_code !== undefined
? `Exit Code: ${failedStatus.exit_code}`
: null,
failedStatus.reason ? `Reason: ${failedStatus.reason}` : null,
failedStatus.message ? `Message: ${failedStatus.message}` : null,
]
.filter(Boolean)
.join('\n')
)
}}
>
<Copy className="w-3 h-3" />
</Button>
)}
</div>
<div className="space-y-2">
{failedStatus.exit_code !== undefined && (
<div>
<span className="text-xs font-medium text-foreground-light">
Exit Code:
</span>
<div className="font-mono text-sm text-foreground mt-1">
{failedStatus.exit_code}
</div>
</div>
)}
{failedStatus.reason && (
<div>
<span className="text-xs font-medium text-foreground-light">Reason:</span>
<pre className="font-mono text-sm text-foreground whitespace-pre-wrap break-words mt-1 max-h-32 overflow-y-auto">
{failedStatus.reason}
</pre>
</div>
)}
{failedStatus.message && (
<div>
<span className="text-xs font-medium text-foreground-light">
Message:
</span>
<pre className="font-mono text-sm text-foreground whitespace-pre-wrap break-words mt-1 max-h-32 overflow-y-auto">
{failedStatus.message}
</pre>
</div>
)}
</div>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="w-3 h-3" />
<span>Failed</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{getPipelineStateMessages(requestStatus, 'failed').message}
</TooltipContent>
</Tooltip>
)
}
// 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: <Loader2 className="animate-spin w-3 h-3 text-brand-600" />,
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: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
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: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
color: 'text-warning-600',
tooltip: 'Pipeline is initializing and will be ready soon',
tooltip: stateMessages.message,
}
case PipelineStatusName.STARTED:
return {
label: 'Running',
dot: <div className="w-2 h-2 bg-brand-600 rounded-full" />,
color: 'text-brand-600',
tooltip: 'Pipeline is active and processing data',
tooltip: stateMessages.message,
}
case PipelineStatusName.STOPPED:
return {
label: 'Stopped',
dot: <div className="w-2 h-2 bg-foreground-lighter rounded-full" />,
color: 'text-foreground-light',
tooltip: 'Pipeline is not running - enable to start processing',
tooltip: stateMessages.message,
}
case PipelineStatusName.UNKNOWN:
return {
label: 'Unknown',
dot: <div className="w-2 h-2 bg-warning-600 rounded-full" />,
color: 'text-warning-600',
tooltip: 'Pipeline status could not be determined',
tooltip: stateMessages.message,
}
default:
return {
label: 'Unknown',
dot: <div className="w-2 h-2 bg-destructive-600 rounded-full" />,
color: 'text-destructive-600',
tooltip: 'Pipeline status is unclear - check logs for details',
tooltip: stateMessages.message,
}
}
}
@@ -301,31 +149,27 @@ const PipelineStatus = ({
return (
<>
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
{isLoading && <ShimmeringLoader />}
{isError && (
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
)}
{isSuccess && (
<>
{statusConfig.isFailedStatus ? (
<div className="relative">{renderFailedStatus(pipelineStatus as FailedStatus)}</div>
<div className="relative">{renderFailedStatus()}</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-2 text-sm', statusConfig.color)}>
{statusConfig.dot}
<span>{statusConfig.label}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{statusConfig.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-2 text-sm w-min', statusConfig.color)}>
{statusConfig.dot}
<span>{statusConfig.label}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">{statusConfig.tooltip}</TooltipContent>
</Tooltip>
)}
</>
)}
</>
)
}
export default PipelineStatus
@@ -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<string>('')
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 (
<div className="flex flex-col gap-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-3">
<Button
type="outline"
onClick={onSelectBack}
icon={<ChevronLeft />}
style={{ padding: '5px' }}
/>
<h3 className="text-xl font-semibold">Pipeline Status</h3>
</div>
</div>
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
</div>
)
}
return (
<div className="space-y-4">
{/* Header with back button and filters */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-x-3">
<Button
type="outline"
onClick={onSelectBack}
icon={<ChevronLeft />}
style={{ padding: '5px' }}
/>
<div>
<div className="flex items-center gap-x-3">
<h3 className="text-xl font-semibold">{destinationName || 'Pipeline'}</h3>
<PipelineStatus
pipelineStatus={pipelineStatusData?.status}
error={pipelineStatusError}
isLoading={isPipelineStatusLoading}
isError={isPipelineStatusError}
isSuccess={isPipelineStatusSuccess}
requestStatus={requestStatus}
/>
</div>
</div>
</div>
<div className="flex items-center gap-x-2">
<div className="relative">
<Search
className="absolute left-2 top-1/2 transform -translate-y-1/2 text-foreground-lighter"
size={14}
/>
<Input_Shadcn_
className="pl-7 h-[26px] text-xs"
placeholder="Search for tables"
value={filterString}
onChange={(e) => setFilterString(e.target.value)}
/>
</div>
<Button
type={statusName === 'stopped' ? 'primary' : 'default'}
onClick={() => onTogglePipeline()}
loading={isStartingPipeline || isStoppingPipeline}
disabled={!['failed', 'started', 'stopped'].includes(statusName ?? '')}
>
{statusName === 'stopped' ? 'Enable' : 'Disable'} pipeline
</Button>
</div>
</div>
{(isPipelineLoading || isStatusLoading) && <GenericSkeletonLoader />}
{isStatusError && (
<AlertError
error={statusError}
subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_REPLICATION_STATUS}
/>
)}
{hasErrors && (
<div className="p-4 border border-destructive-300 bg-destructive-100 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-destructive-600 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-destructive-900 mb-1">
{errorTables.length} table{errorTables.length > 1 ? 's' : ''} failed
</h4>
<p className="text-sm text-destructive-700 mb-3">
Some tables encountered replication errors. Check the logs for detailed error
information.
</p>
<Button
asChild
type="outline"
size="tiny"
icon={<ExternalLink className="w-3 h-3" />}
className="text-destructive-600 border-destructive-300 hover:bg-destructive-50"
>
<Link
target="_blank"
rel="noreferrer noopener"
href={`/project/${projectRef}/logs/postgres-logs`}
>
View Logs
</Link>
</Button>
</div>
</div>
</div>
)}
{hasTableData && (
<div className="flex flex-col gap-y-4">
{showDisabledState && (
<div
className={cn(
'p-4 border border-default rounded-lg flex items-center justify-between',
config.colors.bg
)}
>
<div className="flex items-center gap-x-3">
<div
className={cn(
'w-10 h-10 rounded-full flex items-center justify-center',
config.colors.iconBg
)}
>
<div className={config.colors.icon}>{config.icon}</div>
</div>
<div className="flex-1">
<h4 className={`text-sm font-medium ${config.colors.text}`}>{config.title}</h4>
<p className={`text-sm ${config.colors.subtext}`}>{config.message}</p>
</div>
</div>
</div>
)}
<div className="w-full overflow-hidden overflow-x-auto">
<Table
head={[
<Table.th key="table">Table</Table.th>,
<Table.th key="status">Status</Table.th>,
<Table.th key="details">Details</Table.th>,
<Table.th key="actions" className="w-16 text-center">
Actions
</Table.th>,
]}
body={filteredTableStatuses.map((table: TableState, index: number) => {
const statusConfig = getStatusConfig(table.state)
return (
<Table.tr key={`${table.table_name}-${index}`} className="border-t">
<Table.td>
<div className="flex items-center gap-x-2">
<p>{table.table_name}</p>
<ButtonTooltip
asChild
type="text"
className="px-1.5"
icon={<ExternalLink />}
tooltip={{ content: { side: 'bottom', text: 'Open in Table Editor' } }}
>
<a
target="_blank"
rel="noopener noreferrer"
href={`/project/${projectRef}/editor/${table.table_id}`}
/>
</ButtonTooltip>
</div>
</Table.td>
<Table.td>
{showDisabledState ? (
<Badge variant="default">Not Available</Badge>
) : (
statusConfig.badge
)}
</Table.td>
<Table.td>
{showDisabledState ? (
<p className="text-sm text-foreground-lighter">
Status unavailable while pipeline is {config.badge.toLowerCase()}
</p>
) : (
<div className="space-y-1">
<div className="text-sm text-foreground">{statusConfig.description}</div>
{'lag' in table.state && (
<div className="text-xs text-foreground-light">
Lag: {table.state.lag}ms
</div>
)}
</div>
)}
</Table.td>
<Table.td className="text-center">
<ButtonTooltip
type="text"
size="tiny"
icon={<Copy className="w-3 h-3" />}
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',
},
}}
/>
</Table.td>
</Table.tr>
)
})}
/>
</div>
</div>
)}
{filteredTableStatuses.length === 0 && hasTableData && (
<Table.tr>
<Table.td colSpan={4}>
<div className="space-y-1">
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</div>
</Table.td>
</Table.tr>
)}
{!isStatusLoading && tableStatuses.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 px-4">
<div className="w-full max-w-sm mx-auto text-center space-y-4">
<div className="w-16 h-16 bg-surface-200 rounded-full flex items-center justify-center mx-auto">
<Activity className="w-8 h-8 text-foreground-lighter" />
</div>
<div className="space-y-2">
<h4 className="text-lg font-semibold text-foreground">
{showDisabledState ? 'Pipeline Not Running' : 'No table status information'}
</h4>
<p className="text-sm text-foreground-light leading-relaxed">
{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.`}
</p>
</div>
<p className="text-xs text-foreground-lighter">
Data refreshes automatically every 2 seconds
</p>
</div>
</div>
)}
</div>
)
}
@@ -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 }
}
@@ -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: <Badge variant="warning">Queued</Badge>,
description: 'Waiting to start replication',
color: 'text-warning-600',
}
case 'copying_table':
return {
badge: <Badge variant="brand">Copying</Badge>,
description: 'Initial data copy in progress',
color: 'text-brand-600',
}
case 'copied_table':
return {
badge: <Badge variant="success">Copied</Badge>,
description: 'Initial copy completed',
color: 'text-success-600',
}
case 'following_wal':
return {
badge: <Badge variant="success">Live</Badge>,
description: `Replicating live changes`,
color: 'text-success-600',
}
case 'error':
return {
badge: <Badge variant="destructive">Error</Badge>,
description: state.message,
color: 'text-destructive-600',
}
default:
return {
badge: <Badge variant="warning">Unknown</Badge>,
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 ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : statusName === 'failed' ? (
<XCircle className="w-6 h-6" />
) : statusName === 'starting' ? (
<Clock className="w-6 h-6" />
) : statusName === 'unknown' ? (
<HelpCircle className="w-6 h-6" />
) : (
<Activity className="w-6 h-6" />
)
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 }
}
@@ -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 (
<div className="flex justify-end items-center space-x-2">
{isLoading && <ShimmeringLoader></ShimmeringLoader>}
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
{isLoading && <ShimmeringLoader />}
{isError && (
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="px-1.5" icon={<MoreVertical />} />
<Button
type="default"
className="px-1.5"
icon={<MoreVertical />}
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
{pipelineEnabled ? (
<DropdownMenuItem className="space-x-2" onClick={onDisableClick}>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
onDisablePipeline()
}}
>
<Pause size={14} />
<p>Disable</p>
<p>Disable pipeline</p>
</DropdownMenuItem>
) : (
<DropdownMenuItem className="space-x-2" onClick={onEnableClick}>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
onEnablePipeline()
}}
>
<Play size={14} />
<p>Enable</p>
<p>Enable pipeline</p>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="space-x-2" onClick={onEditClick}>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
onEditClick()
}}
>
<Edit size={14} />
<p>Edit destination</p>
</DropdownMenuItem>
<DropdownMenuItem className="space-x-2" onClick={onDeleteClick}>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
onDeleteClick()
}}
>
<Trash stroke="red" size={14} />
<p>Delete destination</p>
</DropdownMenuItem>
@@ -78,5 +160,3 @@ const RowMenu = ({
</div>
)
}
export default RowMenu
@@ -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 ?? ''}`
@@ -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"}'`,
},
@@ -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 ?? ''
@@ -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<ResponseData | null>(null)
const [error, setError] = useState<string | null>(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({
@@ -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 (
<>
<span className="text-brand-600">curl</span> -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"}'`}
</>
)
@@ -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 = () => {
<div className="space-y-2">
<p className="text-sm">API Key</p>
<div className="flex items-center space-x-1 -ml-1">
{anonKey?.tags?.split(',').map((x: any, i: number) => (
<code key={`${x}${i}`} className="text-xs">
{x}
</code>
))}
<code className="text-xs">{'public'}</code>
<code className="text-xs">{anonKey?.name}</code>
<code className="text-xs">public</code>
</div>
</div>
}
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'
@@ -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])
@@ -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 (
<SheetSection>
@@ -104,15 +105,18 @@ export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProp
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
key="add-auth-header"
onClick={() =>
onClick={() => {
append({
name: 'Authorization',
value: `Bearer ${apiKey}`,
})
}
if (serviceKey?.type === 'secret') {
append({ name: 'apikey', value: apiKey })
}
}}
>
<div className="space-y-1">
<p className="block text-foreground">Add auth header with service key</p>
<p className="block text-foreground">Add auth header with secret key</p>
<p className="text-foreground-light">
Required if your edge function enforces JWT verification
</p>
@@ -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}`,
},
})
}
@@ -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<boolean>(false)
const canReadWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers')
const canCreateWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers')
const isPermissionsLoaded = usePermissionsLoaded()
const createHook = () => {
@@ -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<Partial<BillingCustomerDataFormValues>>(
() => ({
...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,
@@ -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 (
<>
<div>
<Label htmlFor="billing-address-btn" className="text-foreground-light block mb-0">
Billing Address / Tax Id
</Label>
{!canReadBillingCustomerData ? (
<NoPermission resourceText="view this organization's billing address" />
) : (
<>
{(isLoading || isLoadingTaxId) && (
<div className="space-y-2">
<ShimmeringLoader />
</div>
)}
{(error || errorTaxId) && (
<AlertError
subject="Failed to retrieve organization customer profile"
error={(error || errorTaxId) as any}
/>
)}
{isSuccess && isSuccessTaxId && (
<div className="flex items-center justify-between">
<p className="text-xs text-foreground">{getAddressSummary()}</p>
<Button
id="billing-address-btn"
onClick={() => setOpen(true)}
type="text"
aria-label="Edit"
size={'tiny'}
className="w-8 h-8 p-0 text-foreground-light"
disabled={!canUpdateBillingCustomerData}
>
<Pencil size={14} strokeWidth={1.5} />
</Button>
</div>
)}
</>
)}
</div>
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) setOpen(false)
else setOpen(true)
}}
>
<DialogContent size={'large'}>
<DialogHeader>
<DialogTitle>Billing Address &amp; Tax Id</DialogTitle>
<DialogDescription>
This will be reflected in every upcoming invoice, past invoices are not affected
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<BillingCustomerDataForm
className="p-5"
form={form}
disabled={!canUpdateBillingCustomerData}
/>
<DialogFooter className="justify-end">
{!canUpdateBillingCustomerData && (
<span className="text-sm text-foreground-lighter">
You need additional permissions to manage this organization's billing address
</span>
)}
<div className="flex items-center gap-2">
<Button type="default" onClick={handleClose}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
disabled={isSubmitDisabled}
loading={isSubmitting}
>
Save
</Button>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</>
)
}
@@ -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 (
<div className={cn('flex flex-col space-y-4', className)}>
<FormField
control={form.control}
name="billing_name"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Name">
<FormControl>
<Input {...field} placeholder="Name" disabled={disabled} />
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -112,9 +118,9 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="line1"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Address line 1">
<FormControl>
<Input {...field} placeholder="Address line 1" disabled={disabled} />
<Input {...field} placeholder="123 Main Street" disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -125,9 +131,13 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="line2"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Address line 2 (optional)">
<FormControl>
<Input {...field} placeholder="Address line 2 (Optional)" disabled={disabled} />
<Input
{...field}
placeholder="Apartment, suite, unit, building, floor, etc."
disabled={disabled}
/>
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -139,7 +149,7 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="country"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Country">
<Popover open={showCountriesPopover} onOpenChange={setShowCountriesPopover}>
<PopoverTrigger asChild>
<FormControl>
@@ -207,9 +217,9 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="postal_code"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="Postal code">
<FormControl>
<Input {...field} placeholder="Postal code" disabled={disabled} />
<Input {...field} placeholder="12345" disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -222,9 +232,9 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="city"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="City">
<FormControl>
<Input {...field} placeholder="City" disabled={disabled} />
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -234,9 +244,9 @@ export const BillingCustomerDataForm = ({
control={form.control}
name="state"
render={({ field }: { field: any }) => (
<FormItemLayout hideMessage>
<FormItemLayout hideMessage label="State / Province">
<FormControl>
<Input {...field} placeholder="State / Province" disabled={disabled} />
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItemLayout>
@@ -281,26 +291,24 @@ export const BillingCustomerDataForm = ({
<CommandList>
<CommandEmpty>No tax ID found.</CommandEmpty>
<CommandGroup>
{TAX_IDS.sort((a, b) => a.country.localeCompare(b.country)).map(
(option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name ? 'opacity-100' : 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
)
)}
{availableTaxIds.map((option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name ? 'opacity-100' : 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
@@ -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 (
<div className="flex items-center justify-between">
<label className="text-sm text-foreground-light" htmlFor="billing-address-btn">
{getAddressSummary()}
</label>
<Dialog
open={open}
onOpenChange={(value) => {
if (!value) handleDialogClose()
else setOpen(true)
}}
>
<DialogTrigger asChild>
<Button
id="billing-address-btn"
onClick={() => setOpen(true)}
type="text"
aria-label="Edit"
size={'tiny'}
className="w-8 h-8 p-0 text-foreground-light"
>
<Pencil size={14} strokeWidth={1.5} />
</Button>
</DialogTrigger>
<DialogContent size="large">
<DialogHeader>
<DialogTitle>Billing Address &amp; Tax Id</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
{/*
[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
*/}
<FocusTrap>
<Form {...form}>
<form
id="new-org-billing-data-form"
onSubmit={(e) => {
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()
}
}}
>
<BillingCustomerDataForm className="p-5" form={form} />
<DialogFooter className="justify-end">
<div className="flex items-center gap-2">
<Button type="default" onClick={handleClose}>
Cancel
</Button>
<Button type="primary" htmlType="submit" disabled={isSubmitDisabled}>
Continue
</Button>
</div>
</DialogFooter>
</form>
</Form>
</FocusTrap>
</DialogContent>
</Dialog>
</div>
)
}
@@ -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
@@ -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
}
@@ -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<typeof FormSchema>
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<string | null>(null)
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(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<PaymentIntentResult>()
@@ -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 }) => {
/>
<DialogHeader>
<DialogTitle>Top Up Credits</DialogTitle>
<DialogDescription>
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.
<DialogDescription className="space-y-2">
<p className="prose text-sm">
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.
</p>
<p className="prose text-sm">
For larger discounted credit packages, please{' '}
<Link
href={`/support/new?slug=${slug}&subject=${encodeURIComponent('I would like to inquire about larger credit packages')}&category=${SupportCategories.SALES_ENQUIRY}`}
target="_blank"
>
reach out.
</Link>
</p>
</DialogDescription>
</DialogHeader>
@@ -252,7 +273,7 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
name="paymentMethod"
render={() => (
<PaymentMethodSelection
createPaymentMethodInline={false}
ref={paymentMethodSelectionRef}
onSelectPaymentMethod={(pm) => form.setValue('paymentMethod', pm)}
selectedPaymentMethod={form.getValues('paymentMethod')}
readOnly={executingTopUp || paymentConfirmationLoading}
@@ -260,16 +281,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
)}
/>
<p className="prose text-sm">
For larger discounted credit packages, please{' '}
<Link
href={`/support/new?slug=${slug}&subject=${encodeURIComponent('I would like to inquire about larger credit packages')}&category=${SupportCategories.SALES_ENQUIRY}`}
target="_blank"
>
reach out.
</Link>
</p>
{paymentIntentConfirmation && paymentIntentConfirmation.error && (
<Alert_Shadcn_ variant="destructive">
<AlertCircle className="h-4 w-4" />
@@ -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<typeof BillingCustomerDataSchema>
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<BillingCustomerDataFormValues>({
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<PaymentMethodElementRef['confirmSetup']> => {
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 (
<PaymentElement
options={{ defaultValues: { billingDetails: { email: email ?? undefined } }, readOnly }}
/>
<div className="space-y-4">
<p className="text-sm text-foreground-light mb-2">
Please ensure CVC and postal codes match what is on file for your card.
</p>
<PaymentElement
options={{
layout: 'tabs',
defaultValues: { billingDetails: { email: email ?? undefined } },
readOnly,
}}
/>
{fullyLoaded && (
<div className="flex items-center space-x-2">
<Checkbox_Shadcn_
id="business"
checked={purchasingAsBusiness}
onCheckedChange={() => setPurchasingAsBusiness(!purchasingAsBusiness)}
/>
<label htmlFor="business" className="text-foreground-light text-sm leading-none">
I'm purchasing as a business
</label>
</div>
)}
<AddressElement
options={addressOptions}
// Force reload after changing purchasingAsBusiness setting, it seems like the element does not reload otherwise
key={`address-elements-${purchasingAsBusiness}`}
onChange={(evt) => setStripeAddress(evt.value)}
onReady={() => setFullyLoaded(true)}
/>
{purchasingAsBusiness && availableTaxIds.length > 0 && (
<Form {...form}>
<div className="grid grid-cols-2 gap-x-2 w-full">
<FormField
name="tax_id_name"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage layout="vertical">
<Popover open={showTaxIDsPopover} onOpenChange={setShowTaxIDsPopover}>
<PopoverTrigger asChild>
<FormControl>
<Button
type="default"
role="combobox"
size="medium"
className={cn(
'w-full justify-between h-[34px]',
!selectedTaxId && 'text-muted'
)}
iconRight={
<ChevronsUpDown
className="ml-2 h-4 w-4 shrink-0 opacity-50"
strokeWidth={1.5}
/>
}
>
{selectedTaxId
? `${selectedTaxId.country} - ${selectedTaxId.name}`
: 'Select tax ID'}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent sameWidthAsTrigger className="p-0" align="start">
<Command>
<CommandInput placeholder="Search tax ID..." />
<CommandList>
<CommandEmpty>No tax ID found.</CommandEmpty>
<CommandGroup>
{availableTaxIds.map((option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name
? 'opacity-100'
: 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItemLayout>
)}
/>
{selectedTaxId && (
<FormField
name="tax_id_value"
control={form.control}
render={({ field }) => (
<FormItem_Shadcn_>
<FormControl>
<Input {...field} placeholder={selectedTaxId?.placeholder} />
</FormControl>
<FormMessage />
</FormItem_Shadcn_>
)}
/>
)}
</div>
</Form>
)}
</div>
)
}
)
@@ -176,7 +176,6 @@ const PaymentMethods = () => {
setShowAddPaymentMethodModal(false)
toast.success('Successfully added new payment method')
}}
showSetDefaultCheckbox={true}
/>
<ChangePaymentMethodModal
@@ -1,7 +1,7 @@
import { Check } from 'lucide-react'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { PricingInformation } from 'shared-data'
import { pickFeatures } from 'shared-data/plans'
import { Button, cn } from 'ui'
@@ -13,7 +13,7 @@ export interface EnterpriseCardProps {
}
export const EnterpriseCard = ({ plan, isCurrentPlan, billingPartner }: EnterpriseCardProps) => {
const selectedOrganization = useSelectedOrganization()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
const orgSlug = selectedOrganization?.slug
const features = pickFeatures(plan, billingPartner)
@@ -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 },
@@ -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<string | null>(null)
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
const [setupIntent, setSetupIntent] = useState<SetupIntentResponse | undefined>(undefined)
const [useAsDefaultBillingAddress, setUseAsDefaultBillingAddress] = useState(true)
const { resolvedTheme } = useTheme()
const paymentRef = useRef<{ createPaymentMethod: () => Promise<PaymentMethod | undefined> }>(null)
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState<boolean | null>(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(
<Loader className="animate-spin" size={14} />
<p className="text-sm text-foreground-light">Retrieving payment methods</p>
</div>
) : paymentMethods?.data.length === 0 && !createPaymentMethodInline ? (
<div className="flex items-center justify-between px-4 py-2 border border-dashed rounded-md bg-alternative">
<div className="flex items-center space-x-4 text-foreground-light">
<AlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm">No payment methods</p>
</div>
<ButtonTooltip
type="default"
disabled={!canUpdatePaymentMethods}
icon={<CreditCard />}
onClick={() => {
if (createPaymentMethodInline) {
setSetupNewPaymentMethod(true)
} else {
setShowAddNewPaymentMethodModal(true)
}
}}
htmlType="button"
tooltip={{
content: {
side: 'bottom',
text: !canUpdatePaymentMethods ? (
<div className="w-48 text-center">
<span>
You need additional permissions to add new payment methods to this
organization
</span>
</div>
) : undefined,
},
}}
>
Add new
</ButtonTooltip>
</div>
) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? (
<Listbox
layout={layout}
@@ -267,11 +265,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
<div
className="flex items-center px-3 py-2 space-x-2 transition cursor-pointer group hover:bg-surface-300"
onClick={() => {
if (createPaymentMethodInline) {
setSetupNewPaymentMethod(true)
} else {
setShowAddNewPaymentMethodModal(true)
}
setSetupNewPaymentMethod(true)
}}
>
<Plus size={16} />
@@ -282,17 +276,39 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
</Listbox>
) : null}
{stripePromise && setupIntent && (
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
<NewPaymentMethodElement
ref={paymentRef}
email={selectedOrganization?.billing_email ?? undefined}
readOnly={readOnly}
/>
</Elements>
{stripePromise && setupIntent && customerProfile && (
<>
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
<NewPaymentMethodElement
ref={paymentRef}
email={selectedOrganization?.billing_email ?? undefined}
readOnly={readOnly}
customerName={customerProfile?.billing_name}
currentAddress={customerProfile?.address}
currentTaxId={taxId}
/>
</Elements>
{/* 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 && (
<div className="flex items-center space-x-2 mt-4">
<Checkbox_Shadcn_
id="defaultBillingAddress"
checked={useAsDefaultBillingAddress}
onCheckedChange={() => setUseAsDefaultBillingAddress(!useAsDefaultBillingAddress)}
/>
<label
htmlFor="defaultBillingAddress"
className="text-sm leading-none text-foreground-light"
>
Use address as my org's billing address
</label>
</div>
)}
</>
)}
{setupIntentLoading && (
{(setupIntentLoading || isCustomerProfileLoading || isCustomerTaxIdLoading) && (
<div className="space-y-2">
<ShimmeringLoader className="h-10" />
<div className="grid grid-cols-2 gap-4">
@@ -303,26 +319,6 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
</div>
)}
</div>
<AddNewPaymentMethodModal
visible={showAddNewPaymentMethodModal}
returnUrl={`${getURL()}/org/${selectedOrganization?.slug}/billing?panel=subscriptionPlan&source=paymentMethod`}
onCancel={() => 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)
}
}}
/>
</>
)
})
@@ -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<string>()
@@ -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<string>()
const [paymentIntentSecret, setPaymentIntentSecret] = useState<string | null>(null)
const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
const paymentMethodSelection = useRef<{
createPaymentMethod: () => Promise<PaymentMethod | undefined>
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 */}
<div className="p-8 pb-8 flex flex-col xl:col-span-3">
<div className="flex-1">
<h3 className="text-base mb-4">
{changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '}
<span className="font-bold">{selectedOrganization?.name}</span> to{' '}
{changeType === 'downgrade'
? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default']
: PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}
</h3>
<div>
{!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
<div className="space-y-2 mb-4">
<PaymentMethodSelection
ref={paymentMethodSelectionRef}
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
/>
</div>
)}
{billingViaPartner && (
<div className="mb-4">
<p className="text-sm">
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.</>
)}
</p>
{billingViaPartner &&
billingPartner === 'fly' &&
subscriptionPreview?.plan_change_type === 'downgrade' && (
<p className="text-sm">
Your organization will be downgraded at the end of your current billing
cycle.
</p>
)}
</div>
)}
</div>
{subscriptionPreviewIsLoading && (
<div className="space-y-2 mb-4 mt-2">
@@ -264,48 +291,6 @@ export const SubscriptionPlanUpdateDialog = ({
{subscriptionPreviewInitialized && (
<>
<div className="mt-2 mb-4 text-foreground-light text-sm">
<div className="flex items-center justify-between gap-2 border-b border-muted">
<div className="py-2 pl-0 flex items-center gap-1">
<span>{subscriptionPlanMeta?.name} Plan</span>
<Badge variant={'brand'} size={'small'} className="ml-1">
New
</Badge>
</div>
<div className="py-2 pr-0 text-right" translate="no">
{formatCurrency(newPlanCost)}
</div>
</div>
{subscription?.plan?.id !== 'free' && (
<div className="flex items-center justify-between gap-2 border-b border-muted">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Unused Time on {subscription?.plan?.name} Plan</span>
<InfoTooltip className="max-w-sm">
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.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
-{formatCurrency(proratedCredit)}
</div>
</div>
)}
{/* Ignore rare case with negative balance (debt) */}
{customerBalance > 0 && (
<div className="flex items-center justify-between gap-2 border-b border-muted">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Credits</span>
<InfoTooltip>
Credits will be used first before charging your card.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
{formatCurrency(customerBalance)}
</div>
</div>
)}
<div className="flex items-center justify-between gap-2 border-b border-muted text-foreground">
<div className="py-2 pl-0">Charge today</div>
<div className="py-2 pr-0 text-right" translate="no">
@@ -324,6 +309,39 @@ export const SubscriptionPlanUpdateDialog = ({
)}
</div>
</div>
{subscription?.plan?.id !== 'free' && (
<div className="flex items-center justify-between gap-2 border-b border-muted text-xs">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Unused Time on {subscription?.plan?.name} Plan</span>
<InfoTooltip className="max-w-sm">
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.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
-{formatCurrency(proratedCredit)}
</div>
</div>
)}
{/* Ignore rare case with negative balance (debt) */}
{customerBalance > 0 && (
<div className="flex items-center justify-between gap-2 border-b border-muted text-xs">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Credits</span>
<InfoTooltip>
Credits will be used first before charging your card.
</InfoTooltip>
</div>
<div className="py-2 pr-0 text-right" translate="no">
{formatCurrency(customerBalance)}
</div>
</div>
)}
<div className="flex items-center justify-between gap-2 text-foreground-lighter text-xs">
<div className="py-2 pl-0 flex items-center gap-1">
<span>Monthly invoice estimate</span>
@@ -542,47 +560,11 @@ export const SubscriptionPlanUpdateDialog = ({
</div>
<div className="pt-4">
{!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
<div className="space-y-2 mb-4">
<BillingCustomerDataExistingOrgDialog />
<PaymentMethodSelection
ref={paymentMethodSelection}
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={(pm) => setSelectedPaymentMethod(pm)}
createPaymentMethodInline={true}
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
/>
</div>
)}
{billingViaPartner && (
<div className="mb-4">
<p className="text-sm">
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.</>
)}
</p>
{billingViaPartner &&
billingPartner === 'fly' &&
subscriptionPreview?.plan_change_type === 'downgrade' && (
<p className="text-sm">
Your organization will be downgraded at the end of your current billing
cycle.
</p>
)}
</div>
)}
{projects.filter(
(it) =>
it.status === PROJECT_STATUS.ACTIVE_HEALTHY ||
it.status === PROJECT_STATUS.COMING_UP
).length === 0 &&
).length === 5 &&
subscriptionPreview?.plan_change_type !== 'downgrade' && (
<div className="pb-2">
<Admonition title="Empty organization" type="warning">
@@ -618,16 +600,13 @@ export const SubscriptionPlanUpdateDialog = ({
)}
<div className="flex space-x-2">
<Button type="default" size="medium" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
loading={isUpdating || paymentConfirmationLoading || isConfirming}
disabled={subscriptionPreviewIsLoading}
type="primary"
onClick={onUpdateSubscription}
className="flex-1"
size="medium"
size="small"
>
Confirm {changeType === 'downgrade' ? 'downgrade' : 'upgrade'}
</Button>
@@ -637,6 +616,13 @@ export const SubscriptionPlanUpdateDialog = ({
{/* Right Column */}
<div className="bg-surface-100 p-8 flex flex-col border-l xl:col-span-2">
<h3 className="text-base mb-8">
{changeType === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '}
<span className="font-bold">{selectedOrganization?.name}</span> to{' '}
{changeType === 'downgrade'
? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default']
: PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}
</h3>
{changeType === 'downgrade'
? featuresToLose.length > 0 && (
<div className="mb-4">
@@ -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 (
<FormItemLayout
label={label}
layout={layout}
description={
<div className="flex flex-col gap-y-4 my-4 max-w-xl">
{useBedrockAssistant && (
<>
{!newOrgAiOptIn && (
<Admonition
type="note"
title="Assistant Opt-in is temporarily disabled"
description="We will re-enable opting in to the assistant shortly!"
/>
)}
<p>
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.
</p>
</>
)}
<p>
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.
</p>
<OptInToOpenAIToggle />
</div>
}
@@ -107,7 +74,7 @@ export const AIOptInLevelSelector = ({
disabled={disabled}
className="space-y-2 mb-6"
>
{optInLevels.map((item) => (
{AI_OPT_IN_LEVELS.map((item) => (
<div key={item.value} className="flex items-start space-x-3">
<RadioGroupItem_Shadcn_
value={item.value}
@@ -4,12 +4,10 @@ import { useEffect } from 'react'
import { FormActions } from 'components/ui/Forms/FormActions'
import { useAIOptInForm } from 'hooks/forms/useAIOptInForm'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useFlag } from 'hooks/ui/useFlag'
import { Card, CardContent, CardFooter, Form_Shadcn_ } from 'ui'
import { AIOptInLevelSelector } from './AIOptInLevelSelector'
export const DataPrivacyForm = () => {
const newOrgAiOptIn = useFlag('newOrgAiOptIn')
const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm()
const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
@@ -28,7 +26,7 @@ export const DataPrivacyForm = () => {
<CardContent className="pt-6">
<AIOptInLevelSelector
control={form.control}
disabled={!canUpdateOrganization || !newOrgAiOptIn || isUpdating}
disabled={!canUpdateOrganization || isUpdating}
layout="flex-row-reverse"
label="Supabase Assistant Opt-in Level"
/>
@@ -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 (
<Dialog>
<DialogTrigger asChild>
@@ -29,81 +26,37 @@ export const OptInToOpenAIToggle = () => {
padding="small"
className="flex flex-col gap-y-4 text-sm text-foreground-light"
>
{useBedrockAssistant ? (
<>
<p>
Supabase AI utilizes Amazon Bedrock ("Bedrock"), a service designed with a strong
focus on data privacy and security.
</p>
<p>
Supabase AI utilizes Amazon Bedrock ("Bedrock"), a service designed with a strong focus
on data privacy and security.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
For more detailed information about how we collect and use your data, see our{' '}
<InlineLink href="https://supabase.com/privacy">Privacy Policy</InlineLink>. You can
choose which types of information you consent to share by selecting from the options
in the AI settings.
</p>
</>
) : (
<>
<p>
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.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
For more information about how we use personal information, please see our{' '}
<InlineLink href="https://supabase.com/privacy">privacy policy</InlineLink>.
</p>
</>
)}
<p>
For more detailed information about how we collect and use your data, see our{' '}
<InlineLink href="https://supabase.com/privacy">Privacy Policy</InlineLink>. You can
choose which types of information you consent to share by selecting from the options in
the AI settings.
</p>
</DialogSection>
</DialogContent>
</Dialog>
@@ -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,
@@ -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<typeof formSchema>
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<FormCustomerData | null>(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<any> }>(null)
const paymentRef = useRef<PaymentMethodElementRef | null>(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
</div>
</div>
}
className="overflow-visible"
>
<Panel.Content>
<p className="text-sm">This is your organization within Supabase.</p>
@@ -532,21 +549,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
</>
)}
{formState.plan !== 'FREE' && (
<Panel.Content className="border-b border-panel-border-interior-light dark:border-panel-border-interior-dark">
<div className="grid grid-cols-3">
<div className="col-span-1 flex space-x-2 text-sm items-center">
<Label_Shadcn_ htmlFor="spend-cap" className=" leading-normal">
Billing Address
</Label_Shadcn_>
</div>
<div className="col-span-2">
<BillingCustomerDataNewOrgDialog onCustomerDataChange={setCustomerData} />
</div>
</div>
</Panel.Content>
)}
{setupIntent && formState.plan !== 'FREE' && (
<Panel.Content>
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
@@ -15,17 +15,19 @@ export const OrgNotFound = ({ slug }: { slug?: string }) => {
return (
<>
<Admonition type="danger">
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 <code>{slug}</code>{' '}
organization.
</>
) : (
<>Contact the owner or administrator to create a new project.</>
)}
</Admonition>
{slug !== '_' && (
<Admonition type="danger">
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 <code>{slug}</code>{' '}
organization.
</>
) : (
<>Contact the owner or administrator to create a new project.</>
)}
</Admonition>
)}
<h3 className="text-sm">Select an organization to create your new project from</h3>
@@ -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"
/>
<ScaffoldActionsGroup className="w-full md:w-auto">
<DocsButton href="https://supabase.com/docs/guides/platform/access-control" />
<InviteMemberButton />
</ScaffoldActionsGroup>
</ScaffoldActionsContainer>
@@ -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
}
}
@@ -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 (
<>
@@ -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'
@@ -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 <RoleImpersonationPopover align="start" variant="connected-on-both" />
return (
<RoleImpersonationPopover
serviceRoleLabel={!serviceKey ? 'anon' : undefined}
disabled={!serviceKey}
disabledTooltip={
!serviceKey ? (
<>
Role impersonation for the Realtime Inspector is currently unavailable temporarily due
to the new API keys. Please re-enable{' '}
<InlineLink href={`/project/${ref}/settings/api-keys`}>legacy JWT keys</InlineLink> if
you'd like to use role impersonation with the Realtime Inspector.
</>
) : undefined
}
align="start"
variant="connected-on-both"
/>
)
}
@@ -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 (
<Popover_Shadcn_ open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
<ButtonTooltip
size="tiny"
type="default"
className={cn(
@@ -40,6 +47,10 @@ const RoleImpersonationPopover = ({
variant === 'connected-on-left' && 'rounded-l-none border-l-0',
variant === 'connected-on-both' && 'rounded-none border-x-0'
)}
disabled={disabled}
tooltip={{
content: { side: 'bottom', text: disabledTooltip, className: 'text-center w-72' },
}}
>
<div className="flex items-center gap-1">
<span className="text-foreground-muted">Role</span>
@@ -58,7 +69,7 @@ const RoleImpersonationPopover = ({
)}
<ChevronDown className="text-muted" strokeWidth={1} size={12} />
</div>
</Button>
</ButtonTooltip>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
portal={portal}
@@ -13,7 +13,6 @@ import { Snippet } from 'data/content/sql-folders-query'
import type { SqlSnippet } from 'data/content/sql-snippets-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { AiIconAnimation, Button, Form, Input, Modal } from 'ui'
@@ -34,7 +33,6 @@ const RenameQueryModal = ({
}: RenameQueryModalProps) => {
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')
}
@@ -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,
@@ -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
@@ -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 (
<section id="custom-domains">
<FormHeader title="Custom Domains" description="Present a branded experience to your users" />
@@ -94,24 +96,25 @@ const CustomDomainConfig = () => {
</div>
</Panel.Content>
</Panel>
) : data?.status === '0_no_hostname_configured' ? (
) : status === '0_no_hostname_configured' ? (
<CustomDomainsConfigureHostname />
) : (
<Panel>
{isSuccess && (
<div className="flex flex-col">
{(data.status === '1_not_started' ||
data.status === '2_initiated' ||
data.status === '3_challenge_verified') && (
<CustomDomainVerify customDomain={data.customDomain} />
{(status === '1_not_started' ||
status === '2_initiated' ||
status === '3_challenge_verified') && <CustomDomainVerify />}
{customDomainData.status === '4_origin_setup_completed' && (
<CustomDomainActivate
projectRef={ref}
customDomain={customDomainData.customDomain}
/>
)}
{data.status === '4_origin_setup_completed' && (
<CustomDomainActivate projectRef={ref} customDomain={data.customDomain} />
)}
{data.status === '5_services_reconfigured' && (
<CustomDomainDelete projectRef={ref} customDomain={data.customDomain} />
{customDomainData.status === '5_services_reconfigured' && (
<CustomDomainDelete projectRef={ref} customDomain={customDomainData.customDomain} />
)}
</div>
)}
@@ -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)
},
})
@@ -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) => {
<div>
<h4 className="text-foreground mb-2">
Configure TXT verification for your custom domain{' '}
<code className="text-sm">{customDomain.hostname}</code>
<code className="text-sm">{customDomain?.hostname}</code>
</h4>
<p className="text-sm text-foreground-light">
Set the following TXT record(s) in your DNS provider, then click verify to confirm your
@@ -102,7 +110,7 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => {
<Link
target="_blank"
rel="noreferrer"
href={`https://whatsmydns.net/#TXT/${customDomain.hostname}`}
href={`https://whatsmydns.net/#TXT/${customDomain?.hostname}`}
className="text-brand"
>
here
@@ -131,13 +139,13 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => {
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Please add a CAA record allowing "digicert.com" to issue certificates for{' '}
<code className="text-xs">{customDomain.hostname}</code>. For example:{' '}
<code className="text-xs">{customDomain?.hostname}</code>. For example:{' '}
<code className="text-xs">0 issue "digicert.com"</code>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{customDomain.ssl.status === 'validation_timed_out' ? (
{customDomain?.ssl.status === 'validation_timed_out' ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>Validation timed out</AlertTitle_Shadcn_>
@@ -147,27 +155,27 @@ const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => {
</Alert_Shadcn_>
) : (
<div className="space-y-2">
<DNSTableHeaders display={customDomain.ssl.txt_name ?? ''} />
<DNSTableHeaders display={customDomain?.ssl.txt_name ?? ''} />
{customDomain.verification_errors?.includes(
{customDomain?.verification_errors?.includes(
'custom hostname does not CNAME to this zone.'
) && (
<DNSRecord
type="CNAME"
name={customDomain.hostname}
name={customDomain?.hostname}
value={settings?.app_config?.endpoint ?? 'Loading...'}
/>
)}
{!isValidating && customDomain.ssl.status === 'pending_validation' && (
{!isValidating && customDomain?.ssl.status === 'pending_validation' && (
<DNSRecord
type="TXT"
name={customDomain.ssl.txt_name ?? 'Loading...'}
value={customDomain.ssl.txt_value ?? 'Loading...'}
name={customDomain?.ssl.txt_name ?? 'Loading...'}
value={customDomain?.ssl.txt_value ?? 'Loading...'}
/>
)}
{customDomain.ssl.status === 'pending_deployment' && (
{customDomain?.ssl.status === 'pending_deployment' && (
<div className="flex items-center justify-center space-x-2 py-8">
<AlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm text-foreground-light">
@@ -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 && (
<Tooltip>
<TooltipTrigger>
<Badge variant="warning" className="mr-1 capitalize">
{isOnNonGenerallyAvailableReleaseChannel}
{isVisibleReleaseChannel}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="w-44 text-center">
This project uses a {isOnNonGenerallyAvailableReleaseChannel}{' '}
database version release
This project uses a {isVisibleReleaseChannel} database version
release
</TooltipContent>
</Tooltip>
),
@@ -202,16 +197,7 @@ const InfrastructureInfo = () => {
) : null}
{!data.eligible ? (
hasExtensionDependentObjects ? (
<DatabaseExtensionsWarning
// @ts-ignore
extensions={data.extension_dependent_objects ?? []}
potentialBreakingChanges={
// @ts-ignore
projectUpgradeEligibilityData?.potential_breaking_changes
}
/>
) : hasObjectsToBeDropped ? (
hasObjectsToBeDropped ? (
<ObjectsToBeDroppedWarning
objectsToBeDropped={data.objects_to_be_dropped}
/>
@@ -14,52 +14,6 @@ export const ReadReplicasWarning = ({ latestPgVersion }: { latestPgVersion: stri
)
}
export const DatabaseExtensionsWarning = ({
extensions,
potentialBreakingChanges,
}: {
extensions: string[]
potentialBreakingChanges?: string[]
}) => {
return (
<Alert_Shadcn_
variant="warning"
title="A new version of Postgres is available for your project"
>
<AlertTitle_Shadcn_>A new version of Postgres is available</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-3">
<div>
<p className="mb-1">You'll need to remove the following extensions before upgrading:</p>
<ul className="pl-4">
{extensions.map((obj) => (
<li className="list-disc" key={obj}>
{obj}
</li>
))}
</ul>
</div>
<p>
{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.'}
</p>
<div>
<Button size="tiny" type="default" asChild>
<a
target="_blank"
rel="noreferrer"
href="https://supabase.com/docs/guides/platform/upgrading#extensions"
>
View docs
</a>
</Button>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
export const ObjectsToBeDroppedWarning = ({
objectsToBeDropped,
}: {
@@ -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<string | null>(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,
@@ -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<string, string> = {
vault_token: serviceApiKey,
@@ -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<typeof FormSchema>
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 })
@@ -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'
@@ -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
}
@@ -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
@@ -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<any>(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 = () => (
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff. Show more