mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 05:39:11 +03:00
Merge branch 'master' into feature/database-tests
This commit is contained in:
commit
d793bbcb7b
265 files changed
+5064
-4775
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+10
@@ -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 }
|
||||
}
|
||||
+108
@@ -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 ?? ''}`
|
||||
|
||||
+1
-1
@@ -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"}'`,
|
||||
},
|
||||
|
||||
+5
-3
@@ -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 ?? ''
|
||||
|
||||
+4
-2
@@ -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])
|
||||
|
||||
+11
-7
@@ -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 = () => {
|
||||
|
||||
+12
-5
@@ -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,
|
||||
|
||||
-215
@@ -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 & 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+43
-35
@@ -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>
|
||||
|
||||
-122
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
-5
@@ -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
|
||||
|
||||
+2
-1
@@ -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" />
|
||||
|
||||
+321
-7
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
-1
@@ -176,7 +176,6 @@ const PaymentMethods = () => {
|
||||
setShowAddPaymentMethodModal(false)
|
||||
toast.success('Successfully added new payment method')
|
||||
}}
|
||||
showSetDefaultCheckbox={true}
|
||||
/>
|
||||
|
||||
<ChangePaymentMethodModal
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+2
-2
@@ -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 },
|
||||
|
||||
+99
-103
@@ -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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
+7
-3
@@ -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>()
|
||||
|
||||
+92
-106
@@ -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">
|
||||
|
||||
+8
-41
@@ -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"
|
||||
/>
|
||||
|
||||
+27
-74
@@ -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>
|
||||
|
||||
+4
-4
@@ -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>
|
||||
|
||||
+6
-3
@@ -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'
|
||||
|
||||
+28
-7
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
+15
-4
@@ -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,
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
|
||||
+15
-12
@@ -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>
|
||||
)}
|
||||
|
||||
+3
-1
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
+29
-21
@@ -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,
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+6
-3
@@ -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,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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 })
|
||||
|
||||
File renamed without changes.
@@ -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'
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
-1
@@ -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
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+10
-1
@@ -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
Reference in new issue
Block a user