mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 15:49:09 +03:00
Merge branch 'master' into feature/homepage
This commit is contained in:
commit
ae1dceb846
70 files changed
+3072
-1880
No files matched your search
@@ -32,7 +32,7 @@ Here are some basic examples of setting up WebSocket servers using Deno and Node
|
||||
Deno.serve((req) => {
|
||||
const upgrade = req.headers.get('upgrade') || ''
|
||||
|
||||
if (upgrade.toLowerCase() != 'WebSocket') {
|
||||
if (upgrade.toLowerCase() != 'websocket') {
|
||||
return new Response("request isn't trying to upgrade to WebSocket.", { status: 400 })
|
||||
}
|
||||
|
||||
|
||||
@@ -59,12 +59,12 @@ hideToc: true
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={4}>
|
||||
<StepHikeCompact.Details title="Query Supabase data from Next.js">
|
||||
<StepHikeCompact.Step step={4}>
|
||||
<StepHikeCompact.Details title="Create Supabase client">
|
||||
|
||||
Create a new file at `app/instruments/page.tsx` and populate with the following.
|
||||
Create a new file at `utils/supabase/server.ts` and populate with the following.
|
||||
|
||||
This will select all the rows from the `instruments` table in Supabase and render them on the page.
|
||||
This creates a Supabase client, using the credentials from the `env.local` file.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
@@ -72,17 +72,6 @@ hideToc: true
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=app/instruments/page.tsx
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
|
||||
export default async function Instruments() {
|
||||
const supabase = await createClient();
|
||||
const { data: instruments } = await supabase.from("instruments").select();
|
||||
|
||||
return <pre>{JSON.stringify(instruments, null, 2)}</pre>
|
||||
}
|
||||
```
|
||||
|
||||
```ts name=utils/supabase/server.ts
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
@@ -122,6 +111,36 @@ hideToc: true
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={5}>
|
||||
<StepHikeCompact.Details title="Query Supabase data from Next.js">
|
||||
|
||||
Create a new file at `app/instruments/page.tsx` and populate with the following.
|
||||
|
||||
This selects all the rows from the `instruments` table in Supabase and render them on the page.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```ts name=app/instruments/page.tsx
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
|
||||
export default async function Instruments() {
|
||||
const supabase = await createClient();
|
||||
const { data: instruments } = await supabase.from("instruments").select();
|
||||
|
||||
return <pre>{JSON.stringify(instruments, null, 2)}</pre>
|
||||
}
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={6}>
|
||||
<StepHikeCompact.Details title="Start the app">
|
||||
|
||||
Run the development server, go to http://localhost:3000/instruments in a browser and you should see the list of instruments.
|
||||
|
||||
@@ -1633,9 +1633,9 @@ functions:
|
||||
notes: |
|
||||
This section contains methods commonly used for Multi-Factor Authentication (MFA) and are invoked behind the `supabase.auth.mfa` namespace.
|
||||
|
||||
Currently, we only support time-based one-time password (TOTP) as the 2nd factor. We don't support recovery codes but we allow users to enroll more than 1 TOTP factor, with an upper limit of 10.
|
||||
Currently, Supabase supports time-based one-time password (TOTP) and phone verification code as the 2nd factor. Recovery codes are not supported but users can enroll multiple factors, with an upper limit of 10..
|
||||
|
||||
Having a 2nd TOTP factor for recovery means the user doesn't have to store their recovery codes. It also reduces the attack surface since the recovery factor is usually time-limited and not a single static code.
|
||||
Having a 2nd factor for recovery frees the user of the burden of having to store their recovery codes somewhere. It also reduces the attack surface since multiple recovery codes are usually generated compared to just having 1 backup factor.
|
||||
|
||||
Learn more about implementing MFA on your application on our guide [here](https://supabase.com/docs/guides/auth/auth-mfa#overview).
|
||||
- id: mfa-enroll
|
||||
@@ -1644,7 +1644,7 @@ functions:
|
||||
Starts the enrollment process for a new Multi-Factor Authentication (MFA) factor. This method creates a new `unverified` factor.
|
||||
To verify a factor, present the QR code or secret to the user and ask them to add it to their authenticator app.
|
||||
The user has to enter the code from their authenticator app to verify it.
|
||||
- Currently, `totp` is the only supported `factorType`. The returned `id` should be used to create a challenge.
|
||||
- Use `totp` or `phone` as the `factorType` and the returned `id` to create a challenge.
|
||||
- To create a challenge, see [`mfa.challenge()`](/docs/reference/dart/auth-mfa-challenge).
|
||||
- To verify a challenge, see [`mfa.verify()`](/docs/reference/dart/auth-mfa-verify).
|
||||
- To create and verify a challenge in a single step, see [`mfa.challengeAndVerify()`](/docs/reference/dart/auth-mfa-challengeandverify).
|
||||
@@ -1661,6 +1661,10 @@ functions:
|
||||
isOptional: true
|
||||
type: String
|
||||
description: Human readable name assigned to the factor.
|
||||
- name: phone
|
||||
isOptional: true
|
||||
type: String
|
||||
description: Phone number to enroll for phone factor type.
|
||||
examples:
|
||||
- id: enroll-totp-factor
|
||||
name: Enroll a time-based, one-time password (TOTP) factor
|
||||
@@ -1681,6 +1685,27 @@ functions:
|
||||
secret: '<SECRET>',
|
||||
uri: '<URI>',
|
||||
),
|
||||
phone: null,
|
||||
);
|
||||
```
|
||||
- id: enroll-phone-factor
|
||||
name: Enroll a Phone Factor
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```dart
|
||||
final res = await supabase.auth.mfa.enroll(factorType: FactorType.phone, phone: '+1234567890');
|
||||
|
||||
final phone = res.phone;
|
||||
```
|
||||
response: |
|
||||
```json
|
||||
AuthMFAEnrollResponse(
|
||||
id: '<ID>',
|
||||
type: FactorType.phone,
|
||||
totp: null,
|
||||
phone: PhoneEnrollment(
|
||||
phone: '+1234567890',
|
||||
),
|
||||
);
|
||||
```
|
||||
- id: mfa-challenge
|
||||
|
||||
@@ -2128,6 +2128,8 @@ functions:
|
||||
Currently, there is support for time-based one-time password (TOTP) and phone verification code as the 2nd factor. Recovery codes are not supported but users can enroll multiple factors, with an upper limit of 10.
|
||||
|
||||
Having a 2nd factor for recovery frees the user of the burden of having to store their recovery codes somewhere. It also reduces the attack surface since multiple recovery codes are usually generated compared to just having 1 backup factor.
|
||||
|
||||
Learn more about implementing MFA in your application [in the MFA guide](https://supabase.com/docs/guides/auth/auth-mfa#overview).
|
||||
- id: mfa-enroll
|
||||
title: 'mfa.enroll()'
|
||||
$ref: '@supabase/auth-js.GoTrueMFAApi.enroll'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Edit, MoreVertical, Search, Trash } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import SchemaSelector from 'components/ui/SchemaSelector'
|
||||
@@ -15,11 +14,18 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from 'hooks/useProtectedSchemas'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
|
||||
import CreateEnumeratedTypeSidePanel from './CreateEnumeratedTypeSidePanel'
|
||||
@@ -94,75 +100,79 @@ const EnumeratedTypes = () => {
|
||||
)}
|
||||
|
||||
{isSuccess && (
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="schema">Schema</Table.th>,
|
||||
<Table.th key="name">Name</Table.th>,
|
||||
<Table.th key="values">Values</Table.th>,
|
||||
<Table.th key="actions" />,
|
||||
]}
|
||||
body={
|
||||
<>
|
||||
{filteredEnumeratedTypes.length === 0 && search.length === 0 && (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={4}>
|
||||
<p className="text-sm text-foreground">No enumerated types created yet</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are no enumerated types found in the schema "{selectedSchema}"
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
{filteredEnumeratedTypes.length === 0 && search.length > 0 && (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={4}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{search}" did not return any results
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
{filteredEnumeratedTypes.length > 0 &&
|
||||
filteredEnumeratedTypes.map((type) => (
|
||||
<Table.tr key={type.id}>
|
||||
<Table.td className="w-20">
|
||||
<p className="w-20 truncate">{type.schema}</p>
|
||||
</Table.td>
|
||||
<Table.td>{type.name}</Table.td>
|
||||
<Table.td>{type.enums.join(', ')}</Table.td>
|
||||
<Table.td>
|
||||
{!isSchemaLocked && (
|
||||
<div className="flex justify-end items-center space-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-32">
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => setSelectedTypeToEdit(type)}
|
||||
>
|
||||
<Edit size={14} />
|
||||
<p>Update type</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => setSelectedTypeToDelete(type)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p>Delete type</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="schema">Schema</TableHead>
|
||||
<TableHead key="name">Name</TableHead>
|
||||
<TableHead key="values">Values</TableHead>
|
||||
<TableHead key="actions" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<>
|
||||
{filteredEnumeratedTypes.length === 0 && search.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<p className="text-sm text-foreground">No enumerated types created yet</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are no enumerated types found in the schema "{selectedSchema}"
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filteredEnumeratedTypes.length === 0 && search.length > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{search}" did not return any results
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filteredEnumeratedTypes.length > 0 &&
|
||||
filteredEnumeratedTypes.map((type) => (
|
||||
<TableRow key={type.id}>
|
||||
<TableCell className="w-20">
|
||||
<p className="w-20 truncate">{type.schema}</p>
|
||||
</TableCell>
|
||||
<TableCell>{type.name}</TableCell>
|
||||
<TableCell>{type.enums.join(', ')}</TableCell>
|
||||
<TableCell>
|
||||
{!isSchemaLocked && (
|
||||
<div className="flex justify-end items-center space-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-32">
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => setSelectedTypeToEdit(type)}
|
||||
>
|
||||
<Edit size={14} />
|
||||
<p>Update type</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
onClick={() => setSelectedTypeToDelete(type)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p>Delete type</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<CreateEnumeratedTypeSidePanel
|
||||
|
||||
+91
-99
@@ -1,5 +1,5 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { AlertTriangle, Book, Github, Loader2, Settings } from 'lucide-react'
|
||||
import { AlertTriangle, Book, Github, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -10,17 +10,17 @@ import { DatabaseExtension } from 'data/database-extensions/database-extensions-
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { extensions } from 'shared-data'
|
||||
import { Button, cn, Switch, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { Button, Switch, Tooltip, TooltipContent, TooltipTrigger, TableRow, TableCell } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import EnableExtensionModal from './EnableExtensionModal'
|
||||
import { EXTENSION_DISABLE_WARNINGS } from './Extensions.constants'
|
||||
|
||||
interface ExtensionCardProps {
|
||||
interface ExtensionRowProps {
|
||||
extension: DatabaseExtension
|
||||
}
|
||||
|
||||
const ExtensionCard = ({ extension }: ExtensionCardProps) => {
|
||||
const ExtensionRow = ({ extension }: ExtensionRowProps) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const isOn = extension.installed_version !== null
|
||||
const isOrioleDb = useIsOrioleDb()
|
||||
@@ -35,7 +35,6 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => {
|
||||
const orioleDbCheck = isOrioleDb && extension.name === 'orioledb'
|
||||
const disabled = !canUpdateExtensions || orioleDbCheck
|
||||
|
||||
const X_PADDING = 'px-5'
|
||||
const extensionMeta = extensions.find((item) => item.name === extension.name)
|
||||
const docsUrl = extensionMeta?.link.startsWith('/guides')
|
||||
? `https://supabase.com/docs${extensionMeta?.link}`
|
||||
@@ -60,20 +59,93 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-surface-100 border border-overlay flex flex-col overflow-hidden rounded shadow-sm">
|
||||
<div className={cn('border-b border-overlay flex justify-between w-full py-3', X_PADDING)}>
|
||||
<div className="max-w-[85%] flex items-center space-x-3 truncate">
|
||||
<h3
|
||||
title={extension.name}
|
||||
className="h-5 m-0 text-sm truncate cursor-pointer text-foreground"
|
||||
>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span title={extension.name} className="truncate inline-block max-w-48">
|
||||
{extension.name}
|
||||
</h3>
|
||||
<p className="text-sm text-foreground-light font-mono tracking-tighter">
|
||||
{extension?.installed_version ?? extension.default_version}
|
||||
</p>
|
||||
</span>
|
||||
{extensionMeta?.deprecated && extensionMeta?.deprecated.length > 0 && (
|
||||
<ButtonTooltip
|
||||
type="warning"
|
||||
icon={<AlertTriangle />}
|
||||
className="rounded-full"
|
||||
tooltip={{
|
||||
content: {
|
||||
text: `The extension is deprecated and will be removed in ${extensionMeta.deprecated.join(', ')}.`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Deprecated
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="font-mono tracking-tighter">
|
||||
{extension?.installed_version ?? extension.default_version}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="truncate">{isOn ? extension.schema : '-'}</TableCell>
|
||||
|
||||
<TableCell className="text-foreground-light">
|
||||
<p className="block max-w-96" title={extension.comment ?? undefined}>
|
||||
{extension.comment}
|
||||
</p>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{extensionMeta?.product ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{extensionMeta.product_url ? (
|
||||
<Link
|
||||
href={extensionMeta.product_url.replace('{ref}', project?.ref ?? '')}
|
||||
className="transition hover:text-foreground"
|
||||
>
|
||||
{extensionMeta.product}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{extensionMeta.product}</span>
|
||||
)}
|
||||
{!isOn && (
|
||||
<span className="text-foreground-lighter text-xs">
|
||||
Install extension to use {extensionMeta.product}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-foreground-lighter">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="flex gap-2 items-center">
|
||||
{extensionMeta?.github_url && (
|
||||
<Button asChild type="default" icon={<Github />} className="rounded-full">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={extensionMeta.github_url}
|
||||
className="font-mono tracking-tighter"
|
||||
>
|
||||
{extensionMeta.github_url.split('/').slice(-2).join('/')}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{docsUrl !== undefined && (
|
||||
<Button asChild type="default" icon={<Book />} className="rounded-full">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono tracking-tighter"
|
||||
href={docsUrl}
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-20 sticky bg-surface-100 border-l right-0">
|
||||
{isDisabling ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
@@ -98,88 +170,8 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => {
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOn && (
|
||||
<div className={cn('border-b border-overlay py-2', X_PADDING)}>
|
||||
<p className="text-foreground-light text-sm">
|
||||
Installed in <span className="text-foreground">{extension.schema}</span> schema
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('flex h-full flex-col gap-y-3 py-3', X_PADDING)}>
|
||||
<p className="text-sm text-foreground-light capitalize-sentence">{extension.comment}</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{extensionMeta?.github_url && (
|
||||
<Button asChild type="default" icon={<Github />} className="rounded-full">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={extensionMeta.github_url}
|
||||
className="font-mono tracking-tighter"
|
||||
>
|
||||
{extensionMeta.github_url.split('/').slice(-2).join('/')}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{docsUrl !== undefined && (
|
||||
<Button asChild type="default" icon={<Book />} className="rounded-full">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono tracking-tighter"
|
||||
href={docsUrl}
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{extensionMeta?.deprecated && extensionMeta?.deprecated.length > 0 && (
|
||||
<ButtonTooltip
|
||||
type="warning"
|
||||
icon={<AlertTriangle />}
|
||||
className="rounded-full"
|
||||
tooltip={{
|
||||
content: {
|
||||
text: `The extension is deprecated and will be removed in ${extensionMeta.deprecated.join(', ')}.`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Deprecated
|
||||
</ButtonTooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{extensionMeta?.product && (
|
||||
<div className={cn('border-t border-overlay py-3 flex items-center gap-x-3', X_PADDING)}>
|
||||
<div className="min-w-5 w-5 h-5 border border-brand/50 rounded flex items-center justify-center">
|
||||
<Settings className="text-brand" size={12} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-foreground-light text-xs">
|
||||
<span className="text-foreground">{extension.name}</span> is used by{' '}
|
||||
{extensionMeta.product_url ? (
|
||||
<Link
|
||||
href={extensionMeta.product_url.replace('{ref}', project?.ref ?? '')}
|
||||
className="transition hover:text-foreground"
|
||||
>
|
||||
{extensionMeta.product}
|
||||
</Link>
|
||||
) : (
|
||||
extensionMeta.product
|
||||
)}
|
||||
</p>
|
||||
{!isOn && (
|
||||
<p className="text-foreground-lighter text-xs">
|
||||
Install extension to use {extensionMeta.product}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<EnableExtensionModal
|
||||
visible={showConfirmEnableModal}
|
||||
@@ -211,4 +203,4 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default ExtensionCard
|
||||
export default ExtensionRow
|
||||
@@ -7,13 +7,12 @@ import { useParams } from 'common'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import NoSearchResults from 'components/ui/NoSearchResults'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Input } from 'ui'
|
||||
import ExtensionCard from './ExtensionCard'
|
||||
import ExtensionCardSkeleton from './ExtensionCardSkeleton'
|
||||
import { Card, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
|
||||
import ExtensionRow from './ExtensionRow'
|
||||
import { HIDDEN_EXTENSIONS, SEARCH_TERMS } from './Extensions.constants'
|
||||
|
||||
const Extensions = () => {
|
||||
@@ -73,50 +72,46 @@ const Extensions = () => {
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="my-8 w-full space-y-12">
|
||||
<div className="space-y-4">
|
||||
<ShimmeringLoader className="h-[28px] w-40" />
|
||||
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<ExtensionCardSkeleton key={index} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GenericSkeletonLoader />
|
||||
) : (
|
||||
<>
|
||||
{extensions.length === 0 && (
|
||||
<NoSearchResults
|
||||
searchString={filterString}
|
||||
onResetFilter={() => setFilterString('')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="my-8 w-full space-y-12">
|
||||
{enabledExtensions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg">Enabled extensions</h4>
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{enabledExtensions.map((extension) => (
|
||||
<ExtensionCard key={extension.name} extension={extension} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{disabledExtensions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg">Available extensions</h4>
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{disabledExtensions.map((extension) => (
|
||||
<ExtensionCard key={extension.name} extension={extension} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="name">Name</TableHead>
|
||||
<TableHead key="version">Version</TableHead>
|
||||
<TableHead key="schema">Schema</TableHead>
|
||||
<TableHead key="description">Description</TableHead>
|
||||
<TableHead key="used-by">Used by</TableHead>
|
||||
<TableHead key="links">Links</TableHead>
|
||||
<TableHead
|
||||
key="enabled"
|
||||
className="w-20 bg-background-200 border-l sticky right-0"
|
||||
>
|
||||
Enabled
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...enabledExtensions, ...disabledExtensions].map((extension) => (
|
||||
<ExtensionRow key={extension.name} extension={extension} />
|
||||
))}
|
||||
{extensions.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<NoSearchResults
|
||||
className="border-none !p-0 bg-transparent"
|
||||
searchString={filterString}
|
||||
onResetFilter={() => setFilterString('')}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
+23
-20
@@ -3,7 +3,6 @@ import { includes, noop, sortBy } from 'lodash'
|
||||
import { Edit, Edit2, FileText, MoreVertical, Trash } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useDatabaseFunctionsQuery } from 'data/database-functions/database-functions-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
@@ -16,6 +15,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from 'ui'
|
||||
|
||||
interface FunctionListProps {
|
||||
@@ -57,27 +58,27 @@ const FunctionList = ({
|
||||
|
||||
if (_functions.length === 0 && filterString.length === 0) {
|
||||
return (
|
||||
<Table.tr key={schema}>
|
||||
<Table.td colSpan={5}>
|
||||
<TableRow key={schema}>
|
||||
<TableCell colSpan={5}>
|
||||
<p className="text-sm text-foreground">No functions created yet</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are no functions found in the schema "{schema}"
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
if (_functions.length === 0 && filterString.length > 0) {
|
||||
return (
|
||||
<Table.tr key={schema}>
|
||||
<Table.td colSpan={5}>
|
||||
<TableRow key={schema}>
|
||||
<TableCell colSpan={5}>
|
||||
<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>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,8 +88,8 @@ const FunctionList = ({
|
||||
const isApiDocumentAvailable = schema == 'public' && x.return_type !== 'trigger'
|
||||
|
||||
return (
|
||||
<Table.tr key={x.id}>
|
||||
<Table.td className="truncate">
|
||||
<TableRow key={x.id}>
|
||||
<TableCell className="truncate">
|
||||
<Button
|
||||
type="text"
|
||||
className="text-foreground text-sm p-0 hover:bg-transparent"
|
||||
@@ -96,17 +97,19 @@ const FunctionList = ({
|
||||
>
|
||||
{x.name}
|
||||
</Button>
|
||||
</Table.td>
|
||||
<Table.td className="table-cell overflow-auto">
|
||||
</TableCell>
|
||||
<TableCell className="table-cell overflow-auto">
|
||||
<p title={x.argument_types} className="truncate">
|
||||
{x.argument_types || '-'}
|
||||
</p>
|
||||
</Table.td>
|
||||
<Table.td className="table-cell">
|
||||
</TableCell>
|
||||
<TableCell className="table-cell">
|
||||
<p title={x.return_type}>{x.return_type}</p>
|
||||
</Table.td>
|
||||
<Table.td className="table-cell">{x.security_definer ? 'Definer' : 'Invoker'}</Table.td>
|
||||
<Table.td className="text-right">
|
||||
</TableCell>
|
||||
<TableCell className="table-cell">
|
||||
{x.security_definer ? 'Definer' : 'Invoker'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!isLocked && (
|
||||
<div className="flex items-center justify-end">
|
||||
{canUpdateFunctions ? (
|
||||
@@ -189,8 +192,8 @@ const FunctionList = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
|
||||
+39
-30
@@ -6,7 +6,6 @@ import { useRouter } from 'next/router'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import SchemaSelector from 'components/ui/SchemaSelector'
|
||||
@@ -18,7 +17,17 @@ import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from 'hooks/useProtectedSchemas'
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
import { AiIconAnimation, Input } from 'ui'
|
||||
import {
|
||||
AiIconAnimation,
|
||||
Input,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
Card,
|
||||
} from 'ui'
|
||||
import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning'
|
||||
import FunctionList from './FunctionList'
|
||||
|
||||
@@ -168,34 +177,34 @@ const FunctionsList = ({
|
||||
</div>
|
||||
|
||||
{isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="functions" />}
|
||||
|
||||
<Table
|
||||
className="table-fixed overflow-x-auto"
|
||||
head={
|
||||
<>
|
||||
<Table.th key="name">Name</Table.th>
|
||||
<Table.th key="arguments" className="table-cell">
|
||||
Arguments
|
||||
</Table.th>
|
||||
<Table.th key="return_type" className="table-cell">
|
||||
Return type
|
||||
</Table.th>
|
||||
<Table.th key="security" className="table-cell w-[100px]">
|
||||
Security
|
||||
</Table.th>
|
||||
<Table.th key="buttons" className="w-1/6"></Table.th>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<FunctionList
|
||||
schema={selectedSchema}
|
||||
filterString={filterString}
|
||||
isLocked={isSchemaLocked}
|
||||
editFunction={editFunction}
|
||||
deleteFunction={deleteFunction}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<Table className="table-fixed overflow-x-auto">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="name">Name</TableHead>
|
||||
<TableHead key="arguments" className="table-cell">
|
||||
Arguments
|
||||
</TableHead>
|
||||
<TableHead key="return_type" className="table-cell">
|
||||
Return type
|
||||
</TableHead>
|
||||
<TableHead key="security" className="table-cell w-[100px]">
|
||||
Security
|
||||
</TableHead>
|
||||
<TableHead key="buttons" className="w-1/6"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<FunctionList
|
||||
schema={selectedSchema}
|
||||
filterString={filterString}
|
||||
isLocked={isSchemaLocked}
|
||||
editFunction={editFunction}
|
||||
deleteFunction={deleteFunction}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 CodeEditor from 'components/ui/CodeEditor/CodeEditor'
|
||||
import SchemaSelector from 'components/ui/SchemaSelector'
|
||||
@@ -15,7 +14,18 @@ import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from 'hooks/useProtectedSchemas'
|
||||
import { Button, Input, SidePanel } from 'ui'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
SidePanel,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
Card,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
|
||||
import CreateIndexSidePanel from './CreateIndexSidePanel'
|
||||
@@ -139,49 +149,51 @@ const Indexes = () => {
|
||||
)}
|
||||
|
||||
{isSuccessIndexes && (
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="schema">Schema</Table.th>,
|
||||
<Table.th key="table">Table</Table.th>,
|
||||
<Table.th key="name">Name</Table.th>,
|
||||
<Table.th key="buttons"></Table.th>,
|
||||
]}
|
||||
body={
|
||||
<>
|
||||
<div className="w-full overflow-hidden">
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="schema">Schema</TableHead>
|
||||
<TableHead key="table">Table</TableHead>
|
||||
<TableHead key="name">Name</TableHead>
|
||||
<TableHead key="buttons"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedIndexes.length === 0 && search.length === 0 && (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={4}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<p className="text-sm text-foreground">No indexes created yet</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are no indexes found in the schema "{selectedSchema}"
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{sortedIndexes.length === 0 && search.length > 0 && (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={4}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{search}" did not return any results
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{indexes.length > 0 &&
|
||||
indexes.map((index) => (
|
||||
<Table.tr key={index.name}>
|
||||
<Table.td>
|
||||
<TableRow key={index.name}>
|
||||
<TableCell>
|
||||
<p title={index.schema}>{index.schema}</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p title={index.table}>{index.table}</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p title={index.name}>{index.name}</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end items-center space-x-2">
|
||||
<Button type="default" onClick={() => setSelectedIndex(index)}>
|
||||
View definition
|
||||
@@ -196,12 +208,12 @@ const Indexes = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,23 @@ import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Button, Input, SidePanel } from 'ui'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
SidePanel,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import MigrationsEmptyState from './MigrationsEmptyState'
|
||||
|
||||
@@ -77,59 +87,63 @@ const Migrations = () => {
|
||||
icon={<Search size="14" />}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="version" style={{ width: '180px' }}>
|
||||
Version
|
||||
</Table.th>,
|
||||
<Table.th key="version">Name</Table.th>,
|
||||
<Table.th key="version">Inserted at (UTC)</Table.th>,
|
||||
<Table.th key="buttons"></Table.th>,
|
||||
]}
|
||||
body={
|
||||
migrations.length > 0 ? (
|
||||
migrations.map((migration) => {
|
||||
// [Joshen] LEFT OFF HERE
|
||||
const insertedAt = dayjs(migration.version, 'YYYYMMDDHHmmss').format(
|
||||
'DD MMM YYYY, HH:mm:ss'
|
||||
)
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="version" style={{ width: '180px' }}>
|
||||
Version
|
||||
</TableHead>
|
||||
<TableHead key="version">Name</TableHead>
|
||||
<TableHead key="version">Inserted at (UTC)</TableHead>
|
||||
<TableHead key="buttons"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{migrations.length > 0 ? (
|
||||
migrations.map((migration) => {
|
||||
// [Joshen] LEFT OFF HERE
|
||||
const insertedAt = dayjs(migration.version, 'YYYYMMDDHHmmss').format(
|
||||
'DD MMM YYYY, HH:mm:ss'
|
||||
)
|
||||
|
||||
return (
|
||||
<Table.tr key={migration.version}>
|
||||
<Table.td>{migration.version}</Table.td>
|
||||
<Table.td
|
||||
className={
|
||||
(migration?.name ?? '').length === 0
|
||||
? '!text-foreground-lighter'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{migration?.name ?? 'Name not available'}
|
||||
</Table.td>
|
||||
<Table.td>{insertedAt}</Table.td>
|
||||
<Table.td align="right">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => setSelectedMigration(migration)}
|
||||
return (
|
||||
<TableRow key={migration.version}>
|
||||
<TableCell>{migration.version}</TableCell>
|
||||
<TableCell
|
||||
className={
|
||||
(migration?.name ?? '').length === 0
|
||||
? '!text-foreground-lighter'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
View migration SQL
|
||||
</Button>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={3}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{search}" did not return any results
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{migration?.name ?? 'Name not available'}
|
||||
</TableCell>
|
||||
<TableCell>{insertedAt}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => setSelectedMigration(migration)}
|
||||
>
|
||||
View migration SQL
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{search}" did not return any results
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { Toggle } from 'ui'
|
||||
import { Switch, TableCell, TableRow } from 'ui'
|
||||
|
||||
export interface PublicationSkeletonProps {
|
||||
index?: number
|
||||
}
|
||||
|
||||
const PublicationSkeleton = ({ index }: PublicationSkeletonProps) => {
|
||||
export const PublicationSkeleton = ({ index }: PublicationSkeletonProps) => {
|
||||
return (
|
||||
<Table.tr className="border-t">
|
||||
<Table.td className="px-4 py-3" style={{ width: '25%' }}>
|
||||
<TableRow>
|
||||
<TableCell style={{ width: '35%' }}>
|
||||
<ShimmeringLoader className="h-4 w-24 my-0.5 p-0" delayIndex={index} />
|
||||
</Table.td>
|
||||
<Table.td className="hidden lg:table-cell" style={{ width: '25%' }}>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell" style={{ width: '15%' }}>
|
||||
<ShimmeringLoader className="h-4 w-14 my-0.5 p-0" delayIndex={index} />
|
||||
</Table.td>
|
||||
</TableCell>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Table.td key={i}>
|
||||
<Toggle size="tiny" checked={false} disabled={true} />
|
||||
</Table.td>
|
||||
<TableCell key={i}>
|
||||
<Switch size="small" checked={false} disabled={true} />
|
||||
</TableCell>
|
||||
))}
|
||||
<Table.td className="px-4 py-3 pr-2">
|
||||
<TableCell className="px-4 py-3 pr-2">
|
||||
<div className="flex justify-end">
|
||||
<ShimmeringLoader className="h-6 w-12 p-0" delayIndex={index} />
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublicationSkeleton
|
||||
@@ -1,34 +1,52 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { noop } from 'lodash'
|
||||
import { AlertCircle, Search } from 'lucide-react'
|
||||
import { AlertCircle, Info, Search } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, Input, Toggle } from 'ui'
|
||||
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { useParams } from 'common'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import NoSearchResults from 'components/ui/NoSearchResults'
|
||||
import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
|
||||
import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import PublicationSkeleton from './PublicationSkeleton'
|
||||
import { PublicationSkeleton } from './PublicationSkeleton'
|
||||
|
||||
interface PublicationEvent {
|
||||
event: string
|
||||
key: string
|
||||
}
|
||||
|
||||
interface PublicationsListProps {
|
||||
onSelectPublication: (id: number) => void
|
||||
}
|
||||
|
||||
export const PublicationsList = ({ onSelectPublication = noop }: PublicationsListProps) => {
|
||||
export const PublicationsList = () => {
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
|
||||
const { data, isLoading } = useDatabasePublicationsQuery({
|
||||
const {
|
||||
data = [],
|
||||
error,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isError,
|
||||
} = useDatabasePublicationsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
@@ -48,10 +66,11 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis
|
||||
{ event: 'Delete', key: 'publish_delete' },
|
||||
{ event: 'Truncate', key: 'publish_truncate' },
|
||||
]
|
||||
const publications =
|
||||
const publications = (
|
||||
filterString.length === 0
|
||||
? data ?? []
|
||||
: (data ?? []).filter((publication) => publication.name.includes(filterString))
|
||||
? data
|
||||
: data.filter((publication) => publication.name.includes(filterString))
|
||||
).sort((a, b) => a.id - b.id)
|
||||
|
||||
const [toggleListenEventValue, setToggleListenEventValue] = useState<{
|
||||
publication: any
|
||||
@@ -79,8 +98,9 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
size="tiny"
|
||||
icon={<Search size="14" />}
|
||||
placeholder={'Filter'}
|
||||
icon={<Search size={12} />}
|
||||
className="w-48 pl-8"
|
||||
placeholder="Search for a publication"
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
/>
|
||||
@@ -97,32 +117,62 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis
|
||||
</div>
|
||||
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="header.name">Name</Table.th>,
|
||||
<Table.th key="header.id">System ID</Table.th>,
|
||||
<Table.th key="header.insert">Insert</Table.th>,
|
||||
<Table.th key="header.update">Update</Table.th>,
|
||||
<Table.th key="header.delete">Delete</Table.th>,
|
||||
<Table.th key="header.truncate">Truncate</Table.th>,
|
||||
<Table.th key="header.source" className="text-right">
|
||||
Source
|
||||
</Table.th>,
|
||||
]}
|
||||
body={
|
||||
isLoading
|
||||
? Array.from({ length: 5 }).map((_, i) => <PublicationSkeleton key={i} index={i} />)
|
||||
: publications.map((x) => (
|
||||
<Table.tr className="border-t" key={x.name}>
|
||||
<Table.td className="px-4 py-3">{x.name}</Table.td>
|
||||
<Table.td>{x.id}</Table.td>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>System ID</TableHead>
|
||||
<TableHead>Insert</TableHead>
|
||||
<TableHead>Update</TableHead>
|
||||
<TableHead>Delete</TableHead>
|
||||
<TableHead>Truncate</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading &&
|
||||
Array.from({ length: 2 }).map((_, i) => <PublicationSkeleton key={i} index={i} />)}
|
||||
|
||||
{isError && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<AlertError error={error} subject="Failed to retrieve publications" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{isSuccess &&
|
||||
publications.map((x) => (
|
||||
<TableRow key={x.name}>
|
||||
<TableCell className="flex items-center gap-x-2 items-center">
|
||||
{x.name}
|
||||
{/* [Joshen] Making this tooltip very specific for these 2 publications */}
|
||||
{['supabase_realtime', 'supabase_realtime_messages_publication'].includes(
|
||||
x.name
|
||||
) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info size={14} className="text-foreground-light" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{x.name === 'supabase_realtime'
|
||||
? 'This publication is managed by Supabase and handles Postgres changes'
|
||||
: x.name === 'supabase_realtime_messages_publication'
|
||||
? 'This publication is managed by Supabase and handles broadcasts from the database'
|
||||
: undefined}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{x.id}</TableCell>
|
||||
{publicationEvents.map((event) => (
|
||||
<Table.td key={event.key}>
|
||||
<Toggle
|
||||
size="tiny"
|
||||
<TableCell key={event.key}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={(x as any)[event.key]}
|
||||
disabled={!canUpdatePublications}
|
||||
onChange={() => {
|
||||
onClick={() => {
|
||||
setToggleListenEventValue({
|
||||
publication: x,
|
||||
event,
|
||||
@@ -130,27 +180,24 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Table.td>
|
||||
</TableCell>
|
||||
))}
|
||||
<Table.td className="px-4 py-3 pr-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="default"
|
||||
style={{ paddingTop: 3, paddingBottom: 3 }}
|
||||
onClick={() => onSelectPublication(x.id)}
|
||||
>
|
||||
{x.tables == null
|
||||
? 'All tables'
|
||||
: `${x.tables.length} ${
|
||||
x.tables.length > 1 || x.tables.length == 0 ? 'tables' : 'table'
|
||||
}`}
|
||||
<TableCell>
|
||||
<div className="flex justify-end">
|
||||
<Button asChild type="default" style={{ paddingTop: 3, paddingBottom: 3 }}>
|
||||
<Link href={`/project/${ref}/database/publications/${x.id}`}>
|
||||
{x.tables === null
|
||||
? 'All tables'
|
||||
: `${x.tables.length} ${x.tables.length === 1 ? 'table' : 'tables'}`}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
))
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{!isLoading && publications.length === 0 && (
|
||||
|
||||
@@ -3,21 +3,27 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Badge, Toggle } from 'ui'
|
||||
import { useProtectedSchemas } from 'hooks/useProtectedSchemas'
|
||||
import { Badge, Switch, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
interface PublicationsTableItemProps {
|
||||
table: PostgresTable
|
||||
selectedPublication: PostgresPublication
|
||||
}
|
||||
|
||||
const PublicationsTableItem = ({ table, selectedPublication }: PublicationsTableItemProps) => {
|
||||
export const PublicationsTableItem = ({
|
||||
table,
|
||||
selectedPublication,
|
||||
}: PublicationsTableItemProps) => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: protectedSchemas } = useProtectedSchemas()
|
||||
const enabledForAllTables = selectedPublication.tables == null
|
||||
|
||||
const isProtected = protectedSchemas.map((x) => x.name).includes(table.schema)
|
||||
|
||||
const [checked, setChecked] = useState(
|
||||
selectedPublication.tables?.find((x: any) => x.id == table.id) != undefined
|
||||
)
|
||||
@@ -70,13 +76,13 @@ const PublicationsTableItem = ({ table, selectedPublication }: PublicationsTable
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.tr key={table.id}>
|
||||
<Table.td className="whitespace-nowrap">{table.name}</Table.td>
|
||||
<Table.td className="whitespace-nowrap">{table.schema}</Table.td>
|
||||
<Table.td className="hidden max-w-sm truncate whitespace-nowrap lg:table-cell">
|
||||
<TableRow key={table.id}>
|
||||
<TableCell className="py-3 whitespace-nowrap">{table.name}</TableCell>
|
||||
<TableCell className="py-3 whitespace-nowrap">{table.schema}</TableCell>
|
||||
<TableCell className="py-3 hidden max-w-sm truncate whitespace-nowrap lg:table-cell">
|
||||
{table.comment}
|
||||
</Table.td>
|
||||
<Table.td className="px-4 py-3 pr-2">
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
{enabledForAllTables ? (
|
||||
<Badge>
|
||||
@@ -84,19 +90,24 @@ const PublicationsTableItem = ({ table, selectedPublication }: PublicationsTable
|
||||
<span className="hidden lg:inline-block"> for all tables</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<Toggle
|
||||
size="tiny"
|
||||
align="right"
|
||||
disabled={!canUpdatePublications || isLoading}
|
||||
className="m-0 ml-2 mt-1 -mb-1 p-0"
|
||||
checked={checked}
|
||||
onChange={() => toggleReplicationForTable(table, selectedPublication)}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Switch
|
||||
size="small"
|
||||
disabled={!canUpdatePublications || isLoading || isProtected}
|
||||
checked={checked}
|
||||
onClick={() => toggleReplicationForTable(table, selectedPublication)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{isProtected && (
|
||||
<TooltipContent side="bottom" className="w-64 text-center">
|
||||
This table belongs to a protected schema, and its publication cannot be toggled
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublicationsTableItem
|
||||
@@ -1,39 +1,38 @@
|
||||
import type { PostgresPublication } from '@supabase/postgres-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { ChevronLeft, Search } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { Loading } from 'components/ui/Loading'
|
||||
import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
|
||||
import { useTablesQuery } from 'data/tables/tables-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useProtectedSchemas } from 'hooks/useProtectedSchemas'
|
||||
import { Button, Input } from 'ui'
|
||||
import { Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import PublicationsTableItem from './PublicationsTableItem'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { PublicationsTableItem } from './PublicationsTableItem'
|
||||
|
||||
interface PublicationsTablesProps {
|
||||
selectedPublication: PostgresPublication
|
||||
onSelectBack: () => void
|
||||
}
|
||||
|
||||
export const PublicationsTables = ({
|
||||
selectedPublication,
|
||||
onSelectBack,
|
||||
}: PublicationsTablesProps) => {
|
||||
export const PublicationsTables = () => {
|
||||
const { ref, id } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
|
||||
const { can: canUpdatePublications, isLoading: isLoadingPermissions } =
|
||||
useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'publications')
|
||||
|
||||
const { data: protectedSchemas } = useProtectedSchemas()
|
||||
const { data: publications = [] } = useDatabasePublicationsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const selectedPublication = publications.find((pub) => pub.id === Number(id))
|
||||
|
||||
const {
|
||||
data: tablesData,
|
||||
data: tablesData = [],
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isError,
|
||||
@@ -42,33 +41,35 @@ export const PublicationsTables = ({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const tables = useMemo(() => {
|
||||
return (tablesData || []).filter((table) =>
|
||||
filterString.length === 0
|
||||
? !protectedSchemas.find((s) => s.name === table.schema)
|
||||
: !protectedSchemas.find((s) => s.name === table.schema) &&
|
||||
table.name.includes(filterString)
|
||||
return tablesData.filter((table) =>
|
||||
filterString.length === 0 ? table : table.name.includes(filterString)
|
||||
)
|
||||
}, [tablesData, protectedSchemas, filterString])
|
||||
}, [tablesData, filterString])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
<ButtonTooltip
|
||||
asChild
|
||||
type="outline"
|
||||
onClick={() => onSelectBack()}
|
||||
icon={<ChevronLeft />}
|
||||
style={{ padding: '5px' }}
|
||||
/>
|
||||
tooltip={{ content: { side: 'bottom', text: 'Go back to publications list' } }}
|
||||
>
|
||||
<Link href={`/project/${ref}/database/publications`} />
|
||||
</ButtonTooltip>
|
||||
<div>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder={'Filter'}
|
||||
size="tiny"
|
||||
placeholder="Search for a table"
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
icon={<Search size="14" />}
|
||||
icon={<Search size={12} />}
|
||||
className="w-48 pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,40 +95,42 @@ export const PublicationsTables = ({
|
||||
(tables.length === 0 ? (
|
||||
<NoSearchResults />
|
||||
) : (
|
||||
<div>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="header-name">Name</Table.th>,
|
||||
<Table.th key="header-schema">Schema</Table.th>,
|
||||
<Table.th key="header-desc" className="hidden text-left lg:table-cell">
|
||||
Description
|
||||
</Table.th>,
|
||||
<Table.th key="header-all">
|
||||
{/* Temporarily disable All tables toggle for publications. See https://github.com/supabase/supabase/pull/7233.
|
||||
<div className="flex flex-row space-x-3 items-center justify-end">
|
||||
<div className="text-xs leading-4 font-medium text-gray-400 text-right ">
|
||||
All Tables
|
||||
</div>
|
||||
<Toggle
|
||||
size="tiny"
|
||||
align="right"
|
||||
error=""
|
||||
className="m-0 p-0 ml-2 mt-1 -mb-1"
|
||||
checked={enabledForAllTables}
|
||||
onChange={() => toggleReplicationForAllTables(publication, enabledForAllTables)}
|
||||
/>
|
||||
</div> */}
|
||||
</Table.th>,
|
||||
]}
|
||||
body={tables.map((table) => (
|
||||
<PublicationsTableItem
|
||||
key={table.id}
|
||||
table={table}
|
||||
selectedPublication={selectedPublication}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Schema</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
{/*
|
||||
We've disabled All tables toggle for publications.
|
||||
See https://github.com/supabase/supabase/pull/7233.
|
||||
*/}
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!!selectedPublication ? (
|
||||
tables.map((table) => (
|
||||
<PublicationsTableItem
|
||||
key={table.id}
|
||||
table={table}
|
||||
selectedPublication={selectedPublication}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<p>The selected publication with ID {id} cannot be found</p>
|
||||
<p className="text-foreground-light">
|
||||
Head back to the list of publications to select one from there
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
|
||||
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 { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip'
|
||||
@@ -39,6 +38,7 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from 'hooks/useProtectedSchemas'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox_Shadcn_,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -53,6 +53,12 @@ import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning'
|
||||
@@ -303,256 +309,265 @@ export const TableList = ({
|
||||
|
||||
{isSuccess && (
|
||||
<div className="w-full">
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="icon" className="!px-0" />,
|
||||
<Table.th key="name">Name</Table.th>,
|
||||
<Table.th key="description" className="hidden lg:table-cell">
|
||||
Description
|
||||
</Table.th>,
|
||||
<Table.th key="rows" className="hidden text-right xl:table-cell">
|
||||
Rows (Estimated)
|
||||
</Table.th>,
|
||||
<Table.th key="size" className="hidden text-right xl:table-cell">
|
||||
Size (Estimated)
|
||||
</Table.th>,
|
||||
<Table.th key="realtime" className="hidden xl:table-cell text-center">
|
||||
Realtime Enabled
|
||||
</Table.th>,
|
||||
<Table.th key="buttons"></Table.th>,
|
||||
]}
|
||||
body={
|
||||
<>
|
||||
{entities.length === 0 && filterString.length === 0 && (
|
||||
<Table.tr key={selectedSchema}>
|
||||
<Table.td colSpan={7}>
|
||||
{visibleTypes.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm text-foreground">
|
||||
Please select at least one entity type to filter with
|
||||
</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are currently no results based on the filter that you have applied
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-foreground">No tables created yet</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are no{' '}
|
||||
{visibleTypes.length === 5
|
||||
? 'tables'
|
||||
: visibleTypes.length === 1
|
||||
? `${formatTooltipText(visibleTypes[0])}s`
|
||||
: `${visibleTypes
|
||||
.slice(0, -1)
|
||||
.map((x) => `${formatTooltipText(x)}s`)
|
||||
.join(
|
||||
', '
|
||||
)}, and ${formatTooltipText(visibleTypes[visibleTypes.length - 1])}s`}{' '}
|
||||
found in the schema "{selectedSchema}"
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
{entities.length === 0 && filterString.length > 0 && (
|
||||
<Table.tr key={selectedSchema}>
|
||||
<Table.td colSpan={7}>
|
||||
<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>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)}
|
||||
{entities.length > 0 &&
|
||||
entities.map((x) => (
|
||||
<Table.tr key={x.id}>
|
||||
<Table.td className="!pl-5 !pr-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{x.type === ENTITY_TYPE.TABLE ? (
|
||||
<Table2
|
||||
size={15}
|
||||
strokeWidth={1.5}
|
||||
className="text-foreground-lighter"
|
||||
/>
|
||||
) : x.type === ENTITY_TYPE.VIEW ? (
|
||||
<Eye
|
||||
size={15}
|
||||
strokeWidth={1.5}
|
||||
className="text-foreground-lighter"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center text-xs h-4 w-4 rounded-[2px] font-bold',
|
||||
x.type === ENTITY_TYPE.FOREIGN_TABLE &&
|
||||
'text-yellow-900 bg-yellow-500',
|
||||
x.type === ENTITY_TYPE.MATERIALIZED_VIEW &&
|
||||
'text-purple-1000 bg-purple-500'
|
||||
// [Alaister]: tables endpoint doesn't distinguish between tables and partitioned tables
|
||||
// once we update the endpoint to include partitioned tables, we can uncomment this
|
||||
// x.type === ENTITY_TYPE.PARTITIONED_TABLE &&
|
||||
// 'text-foreground-light bg-border-stronger'
|
||||
)}
|
||||
>
|
||||
{Object.entries(ENTITY_TYPE)
|
||||
.find(([, value]) => value === x.type)?.[0]?.[0]
|
||||
?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="capitalize">
|
||||
{formatTooltipText(x.type)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{/* only show tooltips if required, to reduce noise */}
|
||||
{x.name.length > 20 ? (
|
||||
<Tooltip disableHoverableContent={true}>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
className="max-w-[95%] overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<p>{x.name}</p>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent side="bottom">{x.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="icon" className="!px-0" />
|
||||
<TableHead key="name">Name</TableHead>
|
||||
<TableHead key="description" className="hidden lg:table-cell">
|
||||
Description
|
||||
</TableHead>
|
||||
<TableHead key="rows" className="hidden text-right xl:table-cell">
|
||||
Rows (Estimated)
|
||||
</TableHead>
|
||||
<TableHead key="size" className="hidden text-right xl:table-cell">
|
||||
Size (Estimated)
|
||||
</TableHead>
|
||||
<TableHead key="realtime" className="hidden xl:table-cell text-center">
|
||||
Realtime Enabled
|
||||
</TableHead>
|
||||
<TableHead key="buttons"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<>
|
||||
{entities.length === 0 && filterString.length === 0 && (
|
||||
<TableRow key={selectedSchema}>
|
||||
<TableCell colSpan={7}>
|
||||
{visibleTypes.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm text-foreground">
|
||||
Please select at least one entity type to filter with
|
||||
</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are currently no results based on the filter that you have
|
||||
applied
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>{x.name}</p>
|
||||
<>
|
||||
<p className="text-sm text-foreground">No tables created yet</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are no{' '}
|
||||
{visibleTypes.length === 5
|
||||
? 'tables'
|
||||
: visibleTypes.length === 1
|
||||
? `${formatTooltipText(visibleTypes[0])}s`
|
||||
: `${visibleTypes
|
||||
.slice(0, -1)
|
||||
.map((x) => `${formatTooltipText(x)}s`)
|
||||
.join(
|
||||
', '
|
||||
)}, and ${formatTooltipText(visibleTypes[visibleTypes.length - 1])}s`}{' '}
|
||||
found in the schema "{selectedSchema}"
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td className="hidden lg:table-cell ">
|
||||
{x.comment !== null ? (
|
||||
<span className="lg:max-w-48 truncate inline-block" title={x.comment}>
|
||||
{x.comment}
|
||||
</span>
|
||||
) : (
|
||||
<p className="text-border-stronger">No description</p>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td className="hidden text-right xl:table-cell">
|
||||
{x.rows !== undefined ? x.rows.toLocaleString() : '-'}
|
||||
</Table.td>
|
||||
<Table.td className="hidden text-right xl:table-cell">
|
||||
{x.size !== undefined ? <code className="text-xs">{x.size}</code> : '-'}
|
||||
</Table.td>
|
||||
<Table.td className="hidden xl:table-cell text-center">
|
||||
{(realtimePublication?.tables ?? []).find((table) => table.id === x.id) ? (
|
||||
<div className="flex justify-center">
|
||||
<Check size={18} strokeWidth={2} className="text-brand" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<X size={18} strokeWidth={2} className="text-foreground-lighter" />
|
||||
</div>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
iconRight={<Columns size={14} className="text-foreground-light" />}
|
||||
className="whitespace-nowrap hover:border-muted"
|
||||
style={{ paddingTop: 3, paddingBottom: 3 }}
|
||||
>
|
||||
<Link href={`/project/${ref}/database/tables/${x.id}`}>
|
||||
{x.columns.length} columns
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{!isSchemaLocked && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center space-x-2"
|
||||
onClick={() =>
|
||||
router.push(`/project/${project?.ref}/editor/${x.id}`)
|
||||
}
|
||||
onMouseEnter={() =>
|
||||
prefetchEditorTablePage({ id: x.id ? String(x.id) : undefined })
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{entities.length === 0 && filterString.length > 0 && (
|
||||
<TableRow key={selectedSchema}>
|
||||
<TableCell colSpan={7}>
|
||||
<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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{entities.length > 0 &&
|
||||
entities.map((x) => (
|
||||
<TableRow key={x.id}>
|
||||
<TableCell className="!pl-5 !pr-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{x.type === ENTITY_TYPE.TABLE ? (
|
||||
<Table2
|
||||
size={15}
|
||||
strokeWidth={1.5}
|
||||
className="text-foreground-lighter"
|
||||
/>
|
||||
) : x.type === ENTITY_TYPE.VIEW ? (
|
||||
<Eye
|
||||
size={15}
|
||||
strokeWidth={1.5}
|
||||
className="text-foreground-lighter"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center text-xs h-4 w-4 rounded-[2px] font-bold',
|
||||
x.type === ENTITY_TYPE.FOREIGN_TABLE &&
|
||||
'text-yellow-900 bg-yellow-500',
|
||||
x.type === ENTITY_TYPE.MATERIALIZED_VIEW &&
|
||||
'text-purple-1000 bg-purple-500'
|
||||
// [Alaister]: tables endpoint doesn't distinguish between tables and partitioned tables
|
||||
// once we update the endpoint to include partitioned tables, we can uncomment this
|
||||
// x.type === ENTITY_TYPE.PARTITIONED_TABLE &&
|
||||
// 'text-foreground-light bg-border-stronger'
|
||||
)}
|
||||
>
|
||||
<Eye size={12} />
|
||||
<p>View in Table Editor</p>
|
||||
</DropdownMenuItem>
|
||||
{Object.entries(ENTITY_TYPE)
|
||||
.find(([, value]) => value === x.type)?.[0]?.[0]
|
||||
?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="capitalize">
|
||||
{formatTooltipText(x.type)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{/* only show tooltips if required, to reduce noise */}
|
||||
{x.name.length > 20 ? (
|
||||
<Tooltip disableHoverableContent={true}>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
className="max-w-[95%] overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<p>{x.name}</p>
|
||||
</TooltipTrigger>
|
||||
|
||||
{x.type === ENTITY_TYPE.TABLE && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemTooltip
|
||||
className="gap-x-2"
|
||||
disabled={!canUpdateTables}
|
||||
onClick={() => {
|
||||
if (canUpdateTables) onEditTable(x)
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'left',
|
||||
text: 'You need additional permissions to edit this table',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Edit size={12} />
|
||||
<p>Edit table</p>
|
||||
</DropdownMenuItemTooltip>
|
||||
<DropdownMenuItemTooltip
|
||||
key="duplicate-table"
|
||||
className="gap-x-2"
|
||||
disabled={!canUpdateTables}
|
||||
onClick={() => {
|
||||
if (canUpdateTables) onDuplicateTable(x)
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'left',
|
||||
text: 'You need additional permissions to duplicate tables',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Copy size={12} />
|
||||
<span>Duplicate Table</span>
|
||||
</DropdownMenuItemTooltip>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemTooltip
|
||||
disabled={!canUpdateTables || isSchemaLocked}
|
||||
className="gap-x-2"
|
||||
onClick={() => {
|
||||
if (canUpdateTables && !isSchemaLocked) {
|
||||
onDeleteTable({ ...x, schema: selectedSchema })
|
||||
}
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'left',
|
||||
text: 'You need additional permissions to delete tables',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Trash size={12} />
|
||||
<p>Delete table</p>
|
||||
</DropdownMenuItemTooltip>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent side="bottom">{x.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<p>{x.name}</p>
|
||||
)}
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell ">
|
||||
{x.comment !== null ? (
|
||||
<span className="lg:max-w-48 truncate inline-block" title={x.comment}>
|
||||
{x.comment}
|
||||
</span>
|
||||
) : (
|
||||
<p className="text-border-stronger">No description</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right xl:table-cell">
|
||||
{x.rows !== undefined ? x.rows.toLocaleString() : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right xl:table-cell">
|
||||
{x.size !== undefined ? <code className="text-xs">{x.size}</code> : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-cell text-center">
|
||||
{(realtimePublication?.tables ?? []).find(
|
||||
(table) => table.id === x.id
|
||||
) ? (
|
||||
<div className="flex justify-center">
|
||||
<Check size={18} strokeWidth={2} className="text-brand" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<X size={18} strokeWidth={2} className="text-foreground-lighter" />
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
iconRight={<Columns size={14} className="text-foreground-light" />}
|
||||
className="whitespace-nowrap hover:border-muted"
|
||||
style={{ paddingTop: 3, paddingBottom: 3 }}
|
||||
>
|
||||
<Link href={`/project/${ref}/database/tables/${x.id}`}>
|
||||
{x.columns.length} columns
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{!isSchemaLocked && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center space-x-2"
|
||||
onClick={() =>
|
||||
router.push(`/project/${project?.ref}/editor/${x.id}`)
|
||||
}
|
||||
onMouseEnter={() =>
|
||||
prefetchEditorTablePage({
|
||||
id: x.id ? String(x.id) : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Eye size={12} />
|
||||
<p>View in Table Editor</p>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{x.type === ENTITY_TYPE.TABLE && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemTooltip
|
||||
className="gap-x-2"
|
||||
disabled={!canUpdateTables}
|
||||
onClick={() => {
|
||||
if (canUpdateTables) onEditTable(x)
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'left',
|
||||
text: 'You need additional permissions to edit this table',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Edit size={12} />
|
||||
<p>Edit table</p>
|
||||
</DropdownMenuItemTooltip>
|
||||
<DropdownMenuItemTooltip
|
||||
key="duplicate-table"
|
||||
className="gap-x-2"
|
||||
disabled={!canUpdateTables}
|
||||
onClick={() => {
|
||||
if (canUpdateTables) onDuplicateTable(x)
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'left',
|
||||
text: 'You need additional permissions to duplicate tables',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Copy size={12} />
|
||||
<span>Duplicate Table</span>
|
||||
</DropdownMenuItemTooltip>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemTooltip
|
||||
disabled={!canUpdateTables || isSchemaLocked}
|
||||
className="gap-x-2"
|
||||
onClick={() => {
|
||||
if (canUpdateTables && !isSchemaLocked) {
|
||||
onDeleteTable({ ...x, schema: selectedSchema })
|
||||
}
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'left',
|
||||
text: 'You need additional permissions to delete tables',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Trash size={12} />
|
||||
<p>Delete table</p>
|
||||
</DropdownMenuItemTooltip>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from 'ui'
|
||||
import { generateTriggerCreateSQL } from './TriggerList.utils'
|
||||
|
||||
@@ -58,35 +60,35 @@ const TriggerList = ({
|
||||
|
||||
if (_triggers.length === 0 && filterString.length === 0) {
|
||||
return (
|
||||
<Table.tr key={schema}>
|
||||
<Table.td colSpan={7}>
|
||||
<TableRow key={schema}>
|
||||
<TableCell colSpan={7}>
|
||||
<p className="text-sm text-foreground">No triggers created yet</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are no triggers found in the schema "{schema}"
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
if (_triggers.length === 0 && filterString.length > 0) {
|
||||
return (
|
||||
<Table.tr key={schema}>
|
||||
<Table.td colSpan={7}>
|
||||
<TableRow key={schema}>
|
||||
<TableCell colSpan={7}>
|
||||
<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>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{_triggers.map((x: any) => (
|
||||
<Table.tr key={x.id}>
|
||||
<Table.td className="space-x-2">
|
||||
<TableRow key={x.id}>
|
||||
<TableCell className="space-x-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={() => editTrigger(x)}
|
||||
@@ -98,35 +100,35 @@ const TriggerList = ({
|
||||
{x.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Table.td>
|
||||
</TableCell>
|
||||
|
||||
<Table.td className="break-all">
|
||||
<TableCell className="break-all">
|
||||
<p title={x.table} className="truncate">
|
||||
{x.table}
|
||||
</p>
|
||||
</Table.td>
|
||||
</TableCell>
|
||||
|
||||
<Table.td className="space-x-2">
|
||||
<TableCell className="space-x-2">
|
||||
<p title={x.function_name} className="truncate">
|
||||
{x.function_name}
|
||||
</p>
|
||||
</Table.td>
|
||||
</TableCell>
|
||||
|
||||
<Table.td>
|
||||
<TableCell>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{x.events.map((event: string) => (
|
||||
<Badge key={event}>{`${x.activation} ${event}`}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Table.td>
|
||||
</TableCell>
|
||||
|
||||
<Table.td className="space-x-2">
|
||||
<TableCell className="space-x-2">
|
||||
<p title={x.orientation} className="truncate">
|
||||
{x.orientation}
|
||||
</p>
|
||||
</Table.td>
|
||||
</TableCell>
|
||||
|
||||
<Table.td>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center">
|
||||
{x.enabled_mode !== 'DISABLED' ? (
|
||||
<Check strokeWidth={2} className="text-brand" />
|
||||
@@ -134,9 +136,9 @@ const TriggerList = ({
|
||||
<X strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</Table.td>
|
||||
</TableCell>
|
||||
|
||||
<Table.td className="text-right">
|
||||
<TableCell className="text-right">
|
||||
{!isLocked && (
|
||||
<div className="flex items-center justify-end">
|
||||
{canUpdateTriggers ? (
|
||||
@@ -216,8 +218,8 @@ const TriggerList = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useState } from 'react'
|
||||
|
||||
import AlphaPreview from 'components/to-be-cleaned/AlphaPreview'
|
||||
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import SchemaSelector from 'components/ui/SchemaSelector'
|
||||
@@ -18,7 +17,17 @@ import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema, useProtectedSchemas } from 'hooks/useProtectedSchemas'
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
import { AiIconAnimation, Input } from 'ui'
|
||||
import {
|
||||
AiIconAnimation,
|
||||
Input,
|
||||
Card,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from 'ui'
|
||||
import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning'
|
||||
import TriggerList from './TriggerList'
|
||||
|
||||
@@ -181,30 +190,32 @@ const TriggersList = ({
|
||||
{isSchemaLocked && <ProtectedSchemaWarning schema={selectedSchema} entity="triggers" />}
|
||||
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
<Table
|
||||
head={
|
||||
<>
|
||||
<Table.th key="name">Name</Table.th>
|
||||
<Table.th key="table">Table</Table.th>
|
||||
<Table.th key="function">Function</Table.th>
|
||||
<Table.th key="events">Events</Table.th>
|
||||
<Table.th key="orientation">Orientation</Table.th>
|
||||
<Table.th key="enabled" className="w-20">
|
||||
Enabled
|
||||
</Table.th>
|
||||
<Table.th key="buttons" className="w-1/12"></Table.th>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<TriggerList
|
||||
schema={selectedSchema}
|
||||
filterString={filterString}
|
||||
isLocked={isSchemaLocked}
|
||||
editTrigger={editTrigger}
|
||||
deleteTrigger={deleteTrigger}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="name">Name</TableHead>
|
||||
<TableHead key="table">Table</TableHead>
|
||||
<TableHead key="function">Function</TableHead>
|
||||
<TableHead key="events">Events</TableHead>
|
||||
<TableHead key="orientation">Orientation</TableHead>
|
||||
<TableHead key="enabled" className="w-20">
|
||||
Enabled
|
||||
</TableHead>
|
||||
<TableHead key="buttons" className="w-1/12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TriggerList
|
||||
schema={selectedSchema}
|
||||
filterString={filterString}
|
||||
isLocked={isSchemaLocked}
|
||||
editTrigger={editTrigger}
|
||||
deleteTrigger={deleteTrigger}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,14 +5,11 @@ import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import ExtensionCard from 'components/interfaces/Database/Extensions/ExtensionCard'
|
||||
import GraphiQL from 'components/interfaces/GraphQL/GraphiQL'
|
||||
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 { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { API_URL, IS_PLATFORM } from 'lib/constants'
|
||||
import { getRoleImpersonationJWT } from 'lib/role-impersonation'
|
||||
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
|
||||
@@ -20,15 +17,8 @@ import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
|
||||
export const GraphiQLTab = () => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const currentTheme = resolvedTheme?.includes('dark') ? 'dark' : 'light'
|
||||
|
||||
const { data, isLoading: isExtensionsLoading } = useDatabaseExtensionsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const pgGraphqlExtension = (data ?? []).find((ext) => ext.name === 'pg_graphql')
|
||||
|
||||
const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM })
|
||||
|
||||
const { data: apiKeys, isFetched } = useAPIKeysQuery({ projectRef, reveal: true })
|
||||
@@ -80,29 +70,11 @@ export const GraphiQLTab = () => {
|
||||
}
|
||||
|
||||
return customFetcher
|
||||
}, [projectRef, getImpersonatedRoleState, jwtSecret, accessToken, serviceKey])
|
||||
}, [projectRef, getImpersonatedRoleState, jwtSecret, accessToken, serviceKey, secretKey?.api_key])
|
||||
|
||||
if ((IS_PLATFORM && !accessToken) || !isFetched || (isExtensionsLoading && !pgGraphqlExtension)) {
|
||||
if ((IS_PLATFORM && !accessToken) || !isFetched) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
if (pgGraphqlExtension?.installed_version === null) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-6">
|
||||
<h1 className="mt-8 mb-2">Enable the GraphQL Extension</h1>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Toggle the switch below to enable the GraphQL extension. You can then use the GraphQL
|
||||
API with your Supabase Database.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ExtensionCard extension={pgGraphqlExtension} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <GraphiQL fetcher={fetcher} theme={currentTheme} />
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
} from 'ui'
|
||||
|
||||
import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip'
|
||||
@@ -17,19 +16,22 @@ import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Edit3, Eye, EyeOff, Key, Loader, MoreVertical, Trash } from 'lucide-react'
|
||||
import type { VaultSecret } from 'types'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import EditSecretModal from './EditSecretModal'
|
||||
import type { SecretTableColumn } from './Secrets.utils'
|
||||
|
||||
interface SecretRowProps {
|
||||
secret: VaultSecret
|
||||
row: VaultSecret
|
||||
col: SecretTableColumn
|
||||
onSelectRemove: (secret: VaultSecret) => void
|
||||
}
|
||||
|
||||
const SecretRow = ({ secret, onSelectRemove }: SecretRowProps) => {
|
||||
const SecretRow = ({ row, col, onSelectRemove }: SecretRowProps) => {
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const [modal, setModal] = useState<string | null>(null)
|
||||
const [revealSecret, setRevealSecret] = useState(false)
|
||||
const name = secret?.name ?? 'No name provided'
|
||||
const name = row?.name ?? 'No name provided'
|
||||
|
||||
const { can: canManageSecrets } = useAsyncCheckProjectPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
@@ -40,69 +42,23 @@ const SecretRow = ({ secret, onSelectRemove }: SecretRowProps) => {
|
||||
{
|
||||
projectRef: ref!,
|
||||
connectionString: project?.connectionString,
|
||||
id: secret.id,
|
||||
id: row.id,
|
||||
},
|
||||
{
|
||||
enabled: !!(ref! && secret.id) && revealSecret,
|
||||
enabled: !!(ref! && row.id) && revealSecret,
|
||||
}
|
||||
)
|
||||
|
||||
const onCloseModal = () => setModal(null)
|
||||
|
||||
return (
|
||||
<div className="px-6 py-4 flex items-center space-x-4">
|
||||
<div className="space-y-1 min-w-[35%] max-w-[35%]">
|
||||
<div>
|
||||
<p className="text-sm text-foreground" title={name}>
|
||||
{name}
|
||||
</p>
|
||||
{secret.description !== undefined && (
|
||||
<p className="text-sm text-foreground-light" title={secret.description}>
|
||||
{secret.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 group">
|
||||
<Key size={14} strokeWidth={2} className="text-foreground-light transition" />
|
||||
<p className="text-foreground-light font-mono text-xs transition" title={secret.id}>
|
||||
{secret.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 w-[40%]">
|
||||
<Button
|
||||
type="text"
|
||||
className="px-1.5"
|
||||
icon={
|
||||
isFetching && revealedValue === undefined ? (
|
||||
<Loader className="animate-spin" size={16} strokeWidth={1.5} />
|
||||
) : !revealSecret ? (
|
||||
<Eye size={16} strokeWidth={1.5} />
|
||||
) : (
|
||||
<EyeOff size={16} strokeWidth={1.5} />
|
||||
)
|
||||
}
|
||||
onClick={() => setRevealSecret(!revealSecret)}
|
||||
/>
|
||||
<div className="flex-grow">
|
||||
{revealSecret && revealedValue ? (
|
||||
<Input copy size="small" className="font-mono" value={revealedValue} />
|
||||
) : (
|
||||
<p className="text-sm font-mono">••••••••••••••••••</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end w-[25%] space-x-4">
|
||||
<p className="text-sm text-foreground-light">
|
||||
{secret.updated_at === secret.created_at ? 'Added' : 'Updated'} on{' '}
|
||||
{dayjs(secret.updated_at).format('MMM D, YYYY')}
|
||||
</p>
|
||||
|
||||
if (col.id === 'actions') {
|
||||
return (
|
||||
<div className="flex items-center justify-end w-full" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button title="Manage Secret" type="text" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-32">
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-40">
|
||||
<DropdownMenuItemTooltip
|
||||
className="gap-x-2"
|
||||
disabled={!canManageSecrets}
|
||||
@@ -120,7 +76,7 @@ const SecretRow = ({ secret, onSelectRemove }: SecretRowProps) => {
|
||||
<DropdownMenuItemTooltip
|
||||
className="gap-x-2"
|
||||
disabled={!canManageSecrets}
|
||||
onClick={() => onSelectRemove(secret)}
|
||||
onClick={() => onSelectRemove(row)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'left',
|
||||
@@ -134,8 +90,71 @@ const SecretRow = ({ secret, onSelectRemove }: SecretRowProps) => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<EditSecretModal visible={modal === `edit`} secret={secret} onClose={onCloseModal} />
|
||||
<EditSecretModal visible={modal === `edit`} secret={row} onClose={onCloseModal} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (col.id === 'secret_value') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
type="text"
|
||||
className="px-1.5"
|
||||
icon={
|
||||
isFetching && revealedValue === undefined ? (
|
||||
<Loader className="animate-spin" size={16} strokeWidth={1.5} />
|
||||
) : !revealSecret ? (
|
||||
<Eye size={16} strokeWidth={1.5} />
|
||||
) : (
|
||||
<EyeOff size={16} strokeWidth={1.5} />
|
||||
)
|
||||
}
|
||||
onClick={() => setRevealSecret(!revealSecret)}
|
||||
/>
|
||||
<div className="flex-grow min-w-0">
|
||||
{revealSecret && revealedValue !== undefined ? (
|
||||
<Input copy readOnly size="tiny" className="font-mono" value={revealedValue} />
|
||||
) : (
|
||||
<p className="text-sm font-mono text-foreground">••••••••••••••••••</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (col.id === 'updated_at') {
|
||||
return (
|
||||
<div className="w-full flex items-center justify-start">
|
||||
<p className="text-xs text-foreground-light">
|
||||
{row.updated_at === row.created_at ? 'Added' : 'Updated'} on{' '}
|
||||
{dayjs(row.updated_at).format('MMM D, YYYY')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (col.id === 'id') {
|
||||
return (
|
||||
<div className="w-full flex items-center">
|
||||
<Key size={12} strokeWidth={2} className="text-foreground-light mr-2" />
|
||||
<p className="text-foreground-light text-xs font-mono truncate" title={row.id}>
|
||||
{row.id}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col justify-center">
|
||||
<p className="text-xs text-foreground truncate" title={name}>
|
||||
{name}
|
||||
</p>
|
||||
{row.description !== undefined && row.description !== '' && (
|
||||
<div>
|
||||
<p className="text-xs text-foreground-lighter w-full truncate">{row.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Column } from 'react-data-grid'
|
||||
|
||||
import type { VaultSecret } from 'types'
|
||||
import { cn } from 'ui'
|
||||
import SecretRow from './SecretRow'
|
||||
|
||||
export type SecretColumnId = 'secret' | 'id' | 'secret_value' | 'updated_at' | 'actions'
|
||||
|
||||
export interface SecretTableColumn {
|
||||
id: SecretColumnId
|
||||
name: string
|
||||
minWidth?: number
|
||||
width?: number
|
||||
maxWidth?: number
|
||||
}
|
||||
|
||||
export const SECRET_TABLE_COLUMNS: SecretTableColumn[] = [
|
||||
{ id: 'secret', name: 'Secret', minWidth: 300, width: 360 },
|
||||
{ id: 'id', name: 'ID', minWidth: 220, width: 260 },
|
||||
{ id: 'secret_value', name: 'Value', minWidth: 320, width: 420 },
|
||||
{ id: 'updated_at', name: 'Last updated', minWidth: 180 },
|
||||
{ id: 'actions', name: '', minWidth: 75, width: 75 },
|
||||
]
|
||||
|
||||
export const formatSecretColumns = ({
|
||||
onSelectRemove,
|
||||
}: {
|
||||
onSelectRemove: (secret: VaultSecret) => void
|
||||
}): Column<VaultSecret>[] => {
|
||||
return SECRET_TABLE_COLUMNS.map((col) => {
|
||||
const result: Column<VaultSecret> = {
|
||||
key: col.id,
|
||||
name: col.name,
|
||||
minWidth: col.minWidth ?? 100,
|
||||
maxWidth: col.maxWidth,
|
||||
width: col.width,
|
||||
resizable: false,
|
||||
sortable: false,
|
||||
draggable: false,
|
||||
headerCellClass: undefined,
|
||||
renderHeaderCell: () => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between font-normal text-xs w-full',
|
||||
col.id === 'secret' && 'ml-8'
|
||||
)}
|
||||
>
|
||||
<p className="!text-foreground">{col.name}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
renderCell: ({ row }) => <SecretRow row={row} col={col} onSelectRemove={onSelectRemove} />,
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
+141
-117
@@ -1,7 +1,8 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import DataGrid, { Row } from 'react-data-grid'
|
||||
import { sortBy } from 'lodash'
|
||||
import { Loader, Search, X } from 'lucide-react'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { Loader, RefreshCw, Search, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
@@ -13,16 +14,17 @@ import type { VaultSecret } from 'types'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
LoadingLine,
|
||||
cn,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
SelectValue_Shadcn_,
|
||||
Separator,
|
||||
SelectContent_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
} from 'ui'
|
||||
import AddNewSecretModal from './AddNewSecretModal'
|
||||
import DeleteSecretModal from './DeleteSecretModal'
|
||||
import SecretRow from './SecretRow'
|
||||
import { formatSecretColumns } from './Secrets.utils'
|
||||
|
||||
export const SecretsManagement = () => {
|
||||
const { search } = useParams()
|
||||
@@ -31,140 +33,162 @@ export const SecretsManagement = () => {
|
||||
const [searchValue, setSearchValue] = useState<string>('')
|
||||
const [showAddSecretModal, setShowAddSecretModal] = useState(false)
|
||||
const [selectedSecretToRemove, setSelectedSecretToRemove] = useState<VaultSecret>()
|
||||
const [selectedSort, setSelectedSort] = useState('updated_at')
|
||||
const [selectedSort, setSelectedSort] = useState<'updated_at' | 'name'>('updated_at')
|
||||
|
||||
const { can: canManageSecrets } = useAsyncCheckProjectPermissions(
|
||||
PermissionAction.TENANT_SQL_ADMIN_WRITE,
|
||||
'tables'
|
||||
)
|
||||
|
||||
const { data, isLoading } = useVaultSecretsQuery({
|
||||
const { data, isLoading, isRefetching, refetch, error, isError } = useVaultSecretsQuery({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const allSecrets = data || []
|
||||
const secrets = sortBy(
|
||||
searchValue.length > 0
|
||||
? allSecrets.filter(
|
||||
(secret) =>
|
||||
(secret?.name ?? '').toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
(secret?.id ?? '').toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
: allSecrets,
|
||||
(s) => {
|
||||
if (selectedSort === 'updated_at') {
|
||||
return Number(new Date(s.updated_at))
|
||||
} else {
|
||||
return s[selectedSort as keyof VaultSecret]
|
||||
}
|
||||
const allSecrets = useMemo(() => data || [], [data])
|
||||
const secrets = useMemo(() => {
|
||||
const filtered =
|
||||
searchValue.length > 0
|
||||
? allSecrets.filter(
|
||||
(secret) =>
|
||||
(secret?.name ?? '').toLowerCase().includes(searchValue.trim().toLowerCase()) ||
|
||||
(secret?.id ?? '').toLowerCase().includes(searchValue.trim().toLowerCase())
|
||||
)
|
||||
: allSecrets
|
||||
|
||||
if (selectedSort === 'updated_at') {
|
||||
return sortBy(filtered, (s) => Number(new Date(s.updated_at))).reverse()
|
||||
}
|
||||
)
|
||||
return sortBy(filtered, (s) => (s.name || '').toLowerCase())
|
||||
}, [allSecrets, searchValue, selectedSort])
|
||||
|
||||
useEffect(() => {
|
||||
if (search !== undefined) setSearchValue(search)
|
||||
}, [search])
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
formatSecretColumns({
|
||||
onSelectRemove: (secret) => setSelectedSecretToRemove(secret),
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 p-4 md:p-10">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-2">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-2">
|
||||
<Input
|
||||
className="md:w-52"
|
||||
size="tiny"
|
||||
placeholder="Search by name or key ID"
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
icon={<Search strokeWidth={2} size={16} />}
|
||||
actions={
|
||||
searchValue.length > 0
|
||||
? [
|
||||
<Button
|
||||
key="clear"
|
||||
size="tiny"
|
||||
type="text"
|
||||
icon={<X />}
|
||||
className="px-1"
|
||||
onClick={() => setSearchValue('')}
|
||||
/>,
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<Select_Shadcn_ value={selectedSort} onValueChange={setSelectedSort}>
|
||||
<SelectTrigger_Shadcn_ size="tiny" className="md:w-44">
|
||||
<SelectValue_Shadcn_ asChild>
|
||||
<>Sort by {selectedSort}</>
|
||||
</SelectValue_Shadcn_>
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="updated_at" className="text-xs">
|
||||
Updated at
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="name" className="text-xs">
|
||||
Name
|
||||
</SelectItem_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<DocsButton href="https://supabase.com/docs/guides/database/vault" />
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
disabled={!canManageSecrets}
|
||||
onClick={() => setShowAddSecretModal(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canManageSecrets
|
||||
? 'You need additional permissions to add secrets'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add new secret
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full w-full space-y-4">
|
||||
<div className="h-full w-full flex flex-col relative">
|
||||
<div className="bg-surface-200 py-3 px-10 flex items-center justify-between flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
size="tiny"
|
||||
className="w-52"
|
||||
placeholder="Search by name or key ID"
|
||||
icon={<Search size={14} />}
|
||||
value={searchValue ?? ''}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
actions={[
|
||||
searchValue && (
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
icon={<X />}
|
||||
onClick={() => {
|
||||
setSearchValue('')
|
||||
}}
|
||||
className="p-0 h-5 w-5"
|
||||
/>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Table of secrets */}
|
||||
<div className="border border-default rounded">
|
||||
{isLoading ? (
|
||||
<Select_Shadcn_ value={selectedSort} onValueChange={(v) => setSelectedSort(v as any)}>
|
||||
<SelectTrigger_Shadcn_ size="tiny" className="w-44">
|
||||
<SelectValue_Shadcn_ asChild>
|
||||
<>Sort by {selectedSort}</>
|
||||
</SelectValue_Shadcn_>
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="updated_at" className="text-xs">
|
||||
Updated at
|
||||
</SelectItem_Shadcn_>
|
||||
<SelectItem_Shadcn_ value="name" className="text-xs">
|
||||
Name
|
||||
</SelectItem_Shadcn_>
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
type="default"
|
||||
icon={<RefreshCw />}
|
||||
loading={isRefetching}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<DocsButton href="https://supabase.com/docs/guides/database/vault" />
|
||||
<ButtonTooltip
|
||||
type="primary"
|
||||
disabled={!canManageSecrets}
|
||||
onClick={() => setShowAddSecretModal(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canManageSecrets
|
||||
? 'You need additional permissions to add secrets'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Add new secret
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoadingLine loading={isLoading || isRefetching} />
|
||||
|
||||
{isError ? (
|
||||
<div className="px-6 py-6 space-x-2 flex items-center justify-center">
|
||||
<Loader className="animate-spin text-foreground-light" size={16} strokeWidth={1.5} />
|
||||
<p className="text-sm text-foreground">Loading secrets from the Vault</p>
|
||||
<p className="text-sm text-foreground">Failed to load secrets</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{secrets.map((secret, idx) => {
|
||||
return (
|
||||
<Fragment key={`secret-${idx}`}>
|
||||
<SecretRow secret={secret} onSelectRemove={setSelectedSecretToRemove} />
|
||||
{idx !== secrets.length - 1 && <Separator />}
|
||||
</Fragment>
|
||||
<DataGrid
|
||||
className="flex-grow border-t-0"
|
||||
rowHeight={52}
|
||||
headerRowHeight={36}
|
||||
columns={columns}
|
||||
rows={secrets}
|
||||
rowKeyGetter={(row: VaultSecret) => row.id}
|
||||
rowClass={() => {
|
||||
return cn(
|
||||
'cursor-pointer',
|
||||
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
|
||||
'[&>.rdg-cell:first-child>div]:pl-8'
|
||||
)
|
||||
})}
|
||||
{secrets.length === 0 && (
|
||||
<>
|
||||
{searchValue.length === 0 ? (
|
||||
<div className="px-6 py-6 space-y-1 flex flex-col items-center justify-center">
|
||||
<p className="text-sm text-foreground">No secrets added yet</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
The Vault allows you to store sensitive information like API keys
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-4 space-y-1">
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{searchValue}" did not return any results
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}}
|
||||
renderers={{
|
||||
renderRow(_, props) {
|
||||
return <Row key={(props.row as VaultSecret).id} {...props} />
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{secrets.length === 0 && !isLoading && !isError ? (
|
||||
<div className="absolute top-32 px-6 w-full">
|
||||
<div className="text-center text-sm flex flex-col gap-y-1">
|
||||
<p className="text-foreground">
|
||||
{searchValue ? 'No secrets found' : 'No secrets added yet'}
|
||||
</p>
|
||||
<p className="text-foreground-light">
|
||||
{searchValue
|
||||
? `There are currently no secrets based on the search "${searchValue}"`
|
||||
: 'The Vault allows you to store sensitive information like API keys'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ArrowRight, Check, Minus, User, X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import PartnerIcon from 'components/ui/PartnerIcon'
|
||||
import { ProfileImage } from 'components/ui/ProfileImage'
|
||||
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
|
||||
@@ -17,6 +16,8 @@ import {
|
||||
HoverCard_Shadcn_,
|
||||
ScrollArea,
|
||||
cn,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { isInviteExpired } from '../Organization.utils'
|
||||
@@ -68,8 +69,8 @@ export const MemberRow = ({ member }: MemberRowProps) => {
|
||||
}).length > 0
|
||||
|
||||
return (
|
||||
<Table.tr>
|
||||
<Table.td>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<ProfileImage
|
||||
alt={member.primary_email ?? member.username ?? ''}
|
||||
@@ -103,18 +104,18 @@ export const MemberRow = ({ member }: MemberRowProps) => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Table.td>
|
||||
</TableCell>
|
||||
|
||||
<Table.td>
|
||||
<TableCell>
|
||||
{isInvitedUser && member.invited_at && (
|
||||
<Badge variant={isInviteExpired(member.invited_at) ? 'destructive' : 'warning'}>
|
||||
{isInviteExpired(member.invited_at) ? 'Expired' : 'Invited'}
|
||||
</Badge>
|
||||
)}
|
||||
{member.is_sso_user && <Badge variant="default">SSO</Badge>}
|
||||
</Table.td>
|
||||
</TableCell>
|
||||
|
||||
<Table.td>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center">
|
||||
{member.mfa_enabled ? (
|
||||
<Check className="text-brand" strokeWidth={2} size={20} />
|
||||
@@ -122,9 +123,9 @@ export const MemberRow = ({ member }: MemberRowProps) => {
|
||||
<X className="text-foreground-light" strokeWidth={1.5} size={20} />
|
||||
)}
|
||||
</div>
|
||||
</Table.td>
|
||||
</TableCell>
|
||||
|
||||
<Table.td className="max-w-64">
|
||||
<TableCell className="max-w-64">
|
||||
{isLoadingRoles ? (
|
||||
<ShimmeringLoader className="w-32" />
|
||||
) : isObfuscated ? (
|
||||
@@ -201,11 +202,11 @@ export const MemberRow = ({ member }: MemberRowProps) => {
|
||||
)
|
||||
})
|
||||
)}
|
||||
</Table.td>
|
||||
</TableCell>
|
||||
|
||||
<Table.td>
|
||||
<TableCell>
|
||||
<MemberActions member={member} />
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AlertCircle, HelpCircle } from 'lucide-react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
|
||||
@@ -9,7 +8,20 @@ import { useOrganizationMembersQuery } from 'data/organizations/organization-mem
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { partition } from 'lodash'
|
||||
import { useMemo } from 'react'
|
||||
import { Button, Loading, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import {
|
||||
Button,
|
||||
Loading,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Card,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { MemberRow } from './MemberRow'
|
||||
|
||||
@@ -78,77 +90,86 @@ const MembersView = ({ searchString }: MembersViewProps) => {
|
||||
|
||||
{isSuccessMembers && (
|
||||
<div className="rounded w-full overflow-hidden overflow-x-scroll">
|
||||
<Loading active={!filteredMembers}>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="header-user">User</Table.th>,
|
||||
<Table.th key="header-status" className="w-24" />,
|
||||
<Table.th key="header-mfa" className="text-center w-32">
|
||||
Enabled MFA
|
||||
</Table.th>,
|
||||
<Table.th key="header-role" className="flex items-center space-x-1">
|
||||
<span>Role</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button asChild type="text" className="px-1">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://supabase.com/docs/guides/platform/access-control"
|
||||
>
|
||||
<HelpCircle size={14} className="text-foreground-light" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">How to configure access control?</TooltipContent>
|
||||
</Tooltip>
|
||||
</Table.th>,
|
||||
<Table.th key="header-action" />,
|
||||
]}
|
||||
body={[
|
||||
...(isSuccessRoles && isSuccessMembers && !isOrgScopedRole
|
||||
? [
|
||||
<Table.tr key="project-scope-notice">
|
||||
<Table.td colSpan={12} className="!p-0">
|
||||
<Admonition
|
||||
type="note"
|
||||
title="You are currently assigned with project scoped roles in this organization"
|
||||
description="All the members within the organization will not be visible to you"
|
||||
className="m-0 bg-alternative border-0 rounded-none"
|
||||
/>
|
||||
</Table.td>
|
||||
</Table.tr>,
|
||||
]
|
||||
: []),
|
||||
...(!!user ? [<MemberRow key={user.gotrue_id} member={user} />] : []),
|
||||
...sortedMembers.map((member) => (
|
||||
<MemberRow key={member.gotrue_id} member={member} />
|
||||
)),
|
||||
...(searchString.length > 0 && filteredMembers.length === 0
|
||||
? [
|
||||
<Table.tr key="no-results" className="bg-panel-secondary-light">
|
||||
<Table.td colSpan={12}>
|
||||
<div className="flex items-center space-x-3 opacity-75">
|
||||
<AlertCircle size={16} strokeWidth={2} />
|
||||
<p className="text-foreground-light">
|
||||
No users matched the search query "{searchString}"
|
||||
</p>
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>,
|
||||
]
|
||||
: []),
|
||||
<Table.tr key="footer" className="bg-panel-secondary-light">
|
||||
<Table.td colSpan={12}>
|
||||
<p className="text-foreground-light">
|
||||
{searchString ? `${filteredMembers.length} of ` : ''}
|
||||
{members.length || '0'} {members.length == 1 ? 'user' : 'users'}
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>,
|
||||
]}
|
||||
/>
|
||||
</Loading>
|
||||
<Card>
|
||||
<Loading active={!filteredMembers}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead key="header-user">User</TableHead>
|
||||
<TableHead key="header-status" className="w-24" />
|
||||
<TableHead key="header-mfa" className="text-center w-32">
|
||||
Enabled MFA
|
||||
</TableHead>
|
||||
<TableHead key="header-role" className="flex items-center space-x-1">
|
||||
<span>Role</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button asChild type="text" className="px-1">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://supabase.com/docs/guides/platform/access-control"
|
||||
>
|
||||
<HelpCircle size={14} className="text-foreground-light" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
How to configure access control?
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableHead>
|
||||
<TableHead key="header-action" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{[
|
||||
...(isSuccessRoles && isSuccessMembers && !isOrgScopedRole
|
||||
? [
|
||||
<TableRow key="project-scope-notice">
|
||||
<TableCell colSpan={12} className="!p-0">
|
||||
<Admonition
|
||||
type="note"
|
||||
title="You are currently assigned with project scoped roles in this organization"
|
||||
description="All the members within the organization will not be visible to you"
|
||||
className="m-0 bg-alternative border-0 rounded-none"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>,
|
||||
]
|
||||
: []),
|
||||
...(!!user ? [<MemberRow key={user.gotrue_id} member={user} />] : []),
|
||||
...sortedMembers.map((member) => (
|
||||
<MemberRow key={member.gotrue_id} member={member} />
|
||||
)),
|
||||
...(searchString.length > 0 && filteredMembers.length === 0
|
||||
? [
|
||||
<TableRow key="no-results" className="bg-panel-secondary-light">
|
||||
<TableCell colSpan={12}>
|
||||
<div className="flex items-center space-x-3 opacity-75">
|
||||
<AlertCircle size={16} strokeWidth={2} />
|
||||
<p className="text-foreground-light">
|
||||
No users matched the search query "{searchString}"
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>,
|
||||
]
|
||||
: []),
|
||||
<TableRow key="footer" className="bg-panel-secondary-light">
|
||||
<TableCell colSpan={12}>
|
||||
<p className="text-foreground-light">
|
||||
{searchString ? `${filteredMembers.length} of ` : ''}
|
||||
{members.length || '0'} {members.length == 1 ? 'user' : 'users'}
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>,
|
||||
]}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Loading>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
+11
-1
@@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { getTemporaryAPIKey } from 'data/api-keys/temp-api-keys-query'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import {
|
||||
@@ -52,7 +53,7 @@ export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPo
|
||||
setOpen(v)
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
const onSubmit = async () => {
|
||||
setOpen(false)
|
||||
sendEvent({
|
||||
action: 'realtime_inspector_listen_channel_clicked',
|
||||
@@ -61,8 +62,17 @@ export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPo
|
||||
organization: org?.slug ?? 'Unknown',
|
||||
},
|
||||
})
|
||||
|
||||
let token = config.token
|
||||
|
||||
// [Joshen] Refresh if starting to listen + using temp API key, since it has a low refresh rate
|
||||
if (token.startsWith('sb_temp')) {
|
||||
const data = await getTemporaryAPIKey({ projectRef: config.projectRef, expiry: 3600 })
|
||||
token = data.api_key
|
||||
}
|
||||
onChangeConfig({
|
||||
...config,
|
||||
token,
|
||||
channelName: form.getValues('channel'),
|
||||
isChannelPrivate: form.getValues('isPrivate'),
|
||||
enabled: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { getTemporaryAPIKey } from 'data/api-keys/temp-api-keys-query'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
@@ -38,8 +39,16 @@ export const Header = ({ config, onChangeConfig }: HeaderProps) => {
|
||||
className="rounded-l-none border-l-0"
|
||||
disabled={!canReadAPIKeys || config.channelName.length === 0}
|
||||
icon={config.enabled ? <StopCircle size="16" /> : <PlayCircle size="16" />}
|
||||
onClick={() => {
|
||||
onChangeConfig({ ...config, enabled: !config.enabled })
|
||||
onClick={async () => {
|
||||
// [Joshen] Refresh if starting to listen + using temp API key, since it has a low refresh rate
|
||||
if (!config.enabled && config.token.startsWith('sb_temp')) {
|
||||
const data = await getTemporaryAPIKey({ projectRef: config.projectRef, expiry: 3600 })
|
||||
const token = data.api_key
|
||||
onChangeConfig({ ...config, token, enabled: !config.enabled })
|
||||
} else {
|
||||
onChangeConfig({ ...config, enabled: !config.enabled })
|
||||
}
|
||||
|
||||
if (!config.enabled) {
|
||||
// the user has clicked to start listening
|
||||
sendEvent({
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokens
|
||||
.catch((err) => toast.error(`Failed to get JWT for role: ${err.message}`))
|
||||
} else {
|
||||
try {
|
||||
const data = await getTemporaryAPIKey({ projectRef: config.projectRef })
|
||||
const data = await getTemporaryAPIKey({ projectRef: config.projectRef, expiry: 3600 })
|
||||
token = data.api_key
|
||||
} catch (error) {
|
||||
token = publishableKey?.api_key
|
||||
|
||||
+17
-13
@@ -89,8 +89,15 @@ const GitHubIntegrationConnectionForm = ({
|
||||
enabled: Boolean(gitHubAuthorization),
|
||||
})
|
||||
|
||||
const { mutate: updateBranch } = useBranchUpdateMutation()
|
||||
const { mutate: updateBranch } = useBranchUpdateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Production branch settings successfully updated')
|
||||
},
|
||||
})
|
||||
const { mutate: createBranch } = useBranchCreateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Production branch settings successfully updated')
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to enable branching:', error)
|
||||
},
|
||||
@@ -105,7 +112,11 @@ const GitHubIntegrationConnectionForm = ({
|
||||
useCheckGithubBranchValidity({ onError: () => {} })
|
||||
|
||||
const { mutate: createConnection, isLoading: isCreatingConnection } =
|
||||
useGitHubConnectionCreateMutation()
|
||||
useGitHubConnectionCreateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('GitHub integration successfully updated')
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync: deleteConnection, isLoading: isDeletingConnection } =
|
||||
useGitHubConnectionDeleteMutation({
|
||||
@@ -245,7 +256,6 @@ const GitHubIntegrationConnectionForm = ({
|
||||
gitBranch: data.branchName,
|
||||
})
|
||||
}
|
||||
toast.success('GitHub integration successfully updated')
|
||||
}
|
||||
|
||||
const handleUpdateConnection = async (
|
||||
@@ -289,7 +299,6 @@ const GitHubIntegrationConnectionForm = ({
|
||||
})
|
||||
}
|
||||
|
||||
toast.success('GitHub integration successfully updated')
|
||||
setIsConfirmingBranchChange(false)
|
||||
}
|
||||
|
||||
@@ -344,15 +353,6 @@ const GitHubIntegrationConnectionForm = ({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRepository) {
|
||||
githubSettingsForm.setValue(
|
||||
'branchName',
|
||||
githubRepos.find((repo) => repo.id === selectedRepository.id)?.default_branch || 'main'
|
||||
)
|
||||
}
|
||||
}, [selectedRepository])
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
const hasGitBranch = Boolean(prodBranch?.git_branch?.trim())
|
||||
@@ -463,6 +463,10 @@ const GitHubIntegrationConnectionForm = ({
|
||||
onSelect={() => {
|
||||
field.onChange(repo.id)
|
||||
setRepoComboboxOpen(false)
|
||||
githubSettingsForm.setValue(
|
||||
'branchName',
|
||||
repo.default_branch || 'main'
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div className="bg-black shadow rounded p-1 w-5 h-5 flex justify-center items-center">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { noop } from 'lodash'
|
||||
import { Columns3, Edit2, MoreVertical, Trash, XCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import type { Bucket } from 'data/storage/buckets-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import EditBucketModal from 'components/interfaces/Storage/EditBucketModal'
|
||||
import DeleteBucketModal from 'components/interfaces/Storage/DeleteBucketModal'
|
||||
import EmptyBucketModal from 'components/interfaces/Storage/EmptyBucketModal'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -23,20 +26,15 @@ export interface BucketRowProps {
|
||||
bucket: Bucket
|
||||
projectRef?: string
|
||||
isSelected: boolean
|
||||
onSelectEmptyBucket: () => void
|
||||
onSelectDeleteBucket: () => void
|
||||
onSelectEditBucket: () => void
|
||||
}
|
||||
|
||||
const BucketRow = ({
|
||||
bucket,
|
||||
projectRef = '',
|
||||
isSelected = false,
|
||||
onSelectEmptyBucket = noop,
|
||||
onSelectDeleteBucket = noop,
|
||||
onSelectEditBucket = noop,
|
||||
}: BucketRowProps) => {
|
||||
const canUpdateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
const BucketRow = ({ bucket, projectRef = '', isSelected = false }: BucketRowProps) => {
|
||||
const { can: canUpdateBuckets } = useAsyncCheckProjectPermissions(
|
||||
PermissionAction.STORAGE_WRITE,
|
||||
'*'
|
||||
)
|
||||
const [modal, setModal] = useState<string | null>(null)
|
||||
const onClose = () => setModal(null)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -84,7 +82,7 @@ const BucketRow = ({
|
||||
<DropdownMenuItem
|
||||
key="toggle-private"
|
||||
className="space-x-2"
|
||||
onClick={() => onSelectEditBucket()}
|
||||
onClick={() => setModal(`edit`)}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
<p>Edit bucket</p>
|
||||
@@ -93,7 +91,7 @@ const BucketRow = ({
|
||||
<DropdownMenuItem
|
||||
key="empty-bucket"
|
||||
className="space-x-2"
|
||||
onClick={() => onSelectEmptyBucket()}
|
||||
onClick={() => setModal(`empty`)}
|
||||
>
|
||||
<XCircle size={14} />
|
||||
<p>Empty bucket</p>
|
||||
@@ -103,7 +101,7 @@ const BucketRow = ({
|
||||
<DropdownMenuItem
|
||||
key="delete-bucket"
|
||||
className="space-x-2"
|
||||
onClick={() => onSelectDeleteBucket()}
|
||||
onClick={() => setModal(`delete`)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p>Delete bucket</p>
|
||||
@@ -113,6 +111,10 @@ const BucketRow = ({
|
||||
) : (
|
||||
<div className="w-7 mr-1" />
|
||||
)}
|
||||
|
||||
<EditBucketModal visible={modal === `edit`} bucket={bucket} onClose={onClose} />
|
||||
<EmptyBucketModal visible={modal === `empty`} bucket={bucket} onClose={onClose} />
|
||||
<DeleteBucketModal visible={modal === `delete`} bucket={bucket} onClose={onClose} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { snakeCase } from 'lodash'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { ChevronDown, Edit } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import z from 'zod'
|
||||
@@ -24,28 +24,39 @@ import {
|
||||
AlertTitle_Shadcn_,
|
||||
Button,
|
||||
cn,
|
||||
Collapsible,
|
||||
Collapsible_Shadcn_,
|
||||
CollapsibleContent_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Label_Shadcn_,
|
||||
Listbox,
|
||||
Modal,
|
||||
RadioGroupStacked,
|
||||
RadioGroupStackedItem,
|
||||
Toggle,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
SelectValue_Shadcn_,
|
||||
Switch,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils'
|
||||
|
||||
export interface CreateBucketModalProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
@@ -84,13 +95,20 @@ const FormSchema = z
|
||||
}
|
||||
})
|
||||
|
||||
const formId = 'create-storage-bucket-form'
|
||||
|
||||
export type CreateBucketForm = z.infer<typeof FormSchema>
|
||||
|
||||
const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
|
||||
const CreateBucketModal = () => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const { ref } = useParams()
|
||||
const router = useRouter()
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
const router = useRouter()
|
||||
const { can: canCreateBuckets } = useAsyncCheckProjectPermissions(
|
||||
PermissionAction.STORAGE_WRITE,
|
||||
'*'
|
||||
)
|
||||
|
||||
const { mutateAsync: createBucket, isLoading: isCreating } = useBucketCreateMutation({
|
||||
// [Joshen] Silencing the error here as it's being handled in onSubmit
|
||||
@@ -103,7 +121,7 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
|
||||
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
|
||||
const formattedGlobalUploadLimit = `${value} ${unit}`
|
||||
|
||||
const [selectedUnit, setSelectedUnit] = useState<StorageSizeUnits>(StorageSizeUnits.BYTES)
|
||||
const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.BYTES)
|
||||
const [showConfiguration, setShowConfiguration] = useState(false)
|
||||
|
||||
const form = useForm<CreateBucketForm>({
|
||||
@@ -124,6 +142,7 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
|
||||
const hasFileSizeLimit = form.watch('has_file_size_limit')
|
||||
const formattedSizeLimit = form.watch('formatted_size_limit')
|
||||
const icebergWrapperExtensionState = useIcebergWrapperExtension()
|
||||
const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled
|
||||
|
||||
const onSubmit: SubmitHandler<CreateBucketForm> = async (values) => {
|
||||
if (!ref) return console.error('Project ref is required')
|
||||
@@ -137,7 +156,7 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
|
||||
|
||||
try {
|
||||
const fileSizeLimit = values.has_file_size_limit
|
||||
? convertToBytes(values.formatted_size_limit, selectedUnit)
|
||||
? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits)
|
||||
: undefined
|
||||
|
||||
const allowedMimeTypes =
|
||||
@@ -162,105 +181,129 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
|
||||
if (values.type === 'ANALYTICS' && icebergWrapperExtensionState === 'installed') {
|
||||
await createIcebergWrapper({ bucketName: values.name })
|
||||
}
|
||||
toast.success(`Successfully created bucket ${values.name}`)
|
||||
router.push(`/project/${ref}/storage/buckets/${values.name}`)
|
||||
onClose()
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to create bucket: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
form.reset()
|
||||
setSelectedUnit(StorageSizeUnits.BYTES)
|
||||
setShowConfiguration(false)
|
||||
setVisible(false)
|
||||
toast.success(`Successfully created bucket ${values.name}`)
|
||||
router.push(`/project/${ref}/storage/buckets/${values.name}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error('Failed to create bucket')
|
||||
}
|
||||
}, [visible, form])
|
||||
}
|
||||
|
||||
const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled
|
||||
const handleClose = () => {
|
||||
form.reset()
|
||||
setSelectedUnit(StorageSizeUnits.BYTES)
|
||||
setShowConfiguration(false)
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
hideFooter
|
||||
visible={visible}
|
||||
size="medium"
|
||||
header="Create storage bucket"
|
||||
onCancel={() => onClose()}
|
||||
<Dialog
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Modal.Content>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="vertical"
|
||||
label="Name of bucket"
|
||||
labelOptional="Buckets cannot be renamed once created."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="Enter bucket name" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-y-2 mt-2">
|
||||
<DialogTrigger asChild>
|
||||
<ButtonTooltip
|
||||
block
|
||||
type="default"
|
||||
icon={<Edit />}
|
||||
disabled={!canCreateBuckets}
|
||||
style={{ justifyContent: 'start' }}
|
||||
onClick={() => setVisible(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canCreateBuckets
|
||||
? 'You need additional permissions to create buckets'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
New bucket
|
||||
</ButtonTooltip>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create storage bucket</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
key="name"
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="name"
|
||||
label="Name of bucket"
|
||||
labelOptional="Buckets cannot be renamed once created."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ id="name" {...field} placeholder="Enter bucket name" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField_Shadcn_
|
||||
key="type"
|
||||
name="type"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Bucket type">
|
||||
<FormControl_Shadcn_>
|
||||
<RadioGroupStacked
|
||||
id="type"
|
||||
onValueChange={(v) => field.onChange(v)}
|
||||
value={field.value}
|
||||
onValueChange={(v) => field.onChange(v)}
|
||||
>
|
||||
<RadioGroupStackedItem
|
||||
value="STANDARD"
|
||||
id="STANDARD"
|
||||
value="STANDARD"
|
||||
label="Standard bucket"
|
||||
description="Compatible with S3 buckets."
|
||||
showIndicator={false}
|
||||
>
|
||||
<div className="flex gap-x-5">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-foreground-light text-left">
|
||||
Compatible with S3 buckets.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupStackedItem>
|
||||
/>
|
||||
{IS_PLATFORM && (
|
||||
<RadioGroupStackedItem
|
||||
value="ANALYTICS"
|
||||
id="ANALYTICS"
|
||||
value="ANALYTICS"
|
||||
label="Analytics bucket"
|
||||
showIndicator={false}
|
||||
disabled={!icebergCatalogEnabled}
|
||||
>
|
||||
<div className="flex gap-x-5">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-foreground-light text-left">
|
||||
Stores Iceberg files and is optimized for analytical workloads.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!icebergCatalogEnabled && (
|
||||
<div className="w-full flex gap-x-2 py-2 items-center">
|
||||
<WarningIcon />
|
||||
<span className="text-xs text-left text-foreground-lighter">
|
||||
This is currently in alpha and not enabled for your project. Sign
|
||||
up{' '}
|
||||
<InlineLink href="https://forms.supabase.com/analytics-buckets">
|
||||
here
|
||||
</InlineLink>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<p className="text-foreground-light text-left">
|
||||
Stores Iceberg files and is optimized for analytical workloads.
|
||||
</p>
|
||||
|
||||
{icebergCatalogEnabled ? null : (
|
||||
<div className="w-full flex gap-x-2 py-2 items-center">
|
||||
<WarningIcon />
|
||||
<span className="text-xs text-left">
|
||||
This feature is currently in alpha and not yet enabled for your
|
||||
project. Sign up{' '}
|
||||
<InlineLink href="https://forms.supabase.com/analytics-buckets">
|
||||
here
|
||||
</InlineLink>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</RadioGroupStackedItem>
|
||||
)}
|
||||
</RadioGroupStacked>
|
||||
@@ -268,26 +311,28 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
{isStandardBucket ? (
|
||||
<>
|
||||
<Modal.Content className="!px-0 !pb-0">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
|
||||
<DialogSectionSeparator />
|
||||
|
||||
{isStandardBucket ? (
|
||||
<>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
key="public"
|
||||
name="public"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout className="px-5">
|
||||
<FormItemLayout
|
||||
name="public"
|
||||
label="Public bucket"
|
||||
description="Anyone can read any object without any authorization"
|
||||
layout="flex"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Toggle
|
||||
<Switch
|
||||
id="public"
|
||||
size="large"
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
layout="flex"
|
||||
label="Public bucket"
|
||||
descriptionText="Anyone can read any object without any authorization"
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
@@ -296,235 +341,240 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
|
||||
{isPublicBucket && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="rounded-none border-x-0 border-b-0 mb-0 [&>div>p]:!leading-normal"
|
||||
className="rounded-none border-x-0 border-b-0 mb-0 pb-0 px-0 [&>svg]:left-0 [&>div>p]:!leading-normal"
|
||||
title="Public buckets are not protected"
|
||||
>
|
||||
<p className="mb-2">
|
||||
Users can read objects in public buckets without any authorization.
|
||||
</p>
|
||||
<p>
|
||||
Row level security (RLS) policies are still required for other operations
|
||||
such as object uploads and deletes.
|
||||
</p>
|
||||
</Admonition>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Collapsible
|
||||
open={showConfiguration}
|
||||
onOpenChange={() => setShowConfiguration(!showConfiguration)}
|
||||
>
|
||||
<Collapsible.Trigger asChild>
|
||||
<div className="w-full cursor-pointer py-3 px-5 flex items-center justify-between">
|
||||
<p className="text-sm">Additional restrictions</p>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
className={cn('text-foreground-light', showConfiguration && 'rotate-180')}
|
||||
description={
|
||||
<>
|
||||
<p className="mb-2">
|
||||
Users can read objects in public buckets without any authorization.
|
||||
</p>
|
||||
<p>
|
||||
Row level security (RLS) policies are still required for other
|
||||
operations such as object uploads and deletes.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="py-4">
|
||||
<div className="w-full space-y-5 px-5">
|
||||
<div className="space-y-5">
|
||||
)}
|
||||
<Collapsible_Shadcn_
|
||||
open={showConfiguration}
|
||||
onOpenChange={() => setShowConfiguration(!showConfiguration)}
|
||||
>
|
||||
<CollapsibleTrigger_Shadcn_ asChild>
|
||||
<button className="w-full cursor-pointer py-3 flex items-center justify-between border-t border-default">
|
||||
<p className="text-sm">Additional configuration</p>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
className={cn('text-foreground-light', showConfiguration && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_ className="py-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<FormField_Shadcn_
|
||||
key="has_file_size_limit"
|
||||
name="has_file_size_limit"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="has_file_size_limit"
|
||||
label="Restrict file upload size for bucket"
|
||||
description="Prevent uploading of file sizes greater than a specified limit"
|
||||
layout="flex"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
id="has_file_size_limit"
|
||||
size="large"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
{hasFileSizeLimit && (
|
||||
<div className="grid grid-cols-12 col-span-12 gap-x-2 gap-y-1">
|
||||
<div className="col-span-8">
|
||||
<FormField_Shadcn_
|
||||
key="formatted_size_limit"
|
||||
name="formatted_size_limit"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="formatted_size_limit"
|
||||
description={`Equivalent to ${convertToBytes(
|
||||
formattedSizeLimit,
|
||||
selectedUnit as StorageSizeUnits
|
||||
).toLocaleString()} bytes.`}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
id="formatted_size_limit"
|
||||
aria-label="File size limit"
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Select_Shadcn_ value={selectedUnit} onValueChange={setSelectedUnit}>
|
||||
<SelectTrigger_Shadcn_
|
||||
aria-label="File size limit unit"
|
||||
size="small"
|
||||
className="col-span-4"
|
||||
>
|
||||
<SelectValue_Shadcn_ asChild>
|
||||
<>{selectedUnit}</>
|
||||
</SelectValue_Shadcn_>
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{Object.values(StorageSizeUnits).map((unit: string) => (
|
||||
<SelectItem_Shadcn_ key={unit} value={unit} className="text-xs">
|
||||
<div>{unit}</div>
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
{IS_PLATFORM && (
|
||||
<div className="col-span-12">
|
||||
<p className="text-foreground-light text-sm">
|
||||
Note: Individual bucket uploads will still be capped at the{' '}
|
||||
<Link
|
||||
href={`/project/${ref}/settings/storage`}
|
||||
className="font-bold underline"
|
||||
>
|
||||
global upload limit
|
||||
</Link>{' '}
|
||||
of {formattedGlobalUploadLimit}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FormField_Shadcn_
|
||||
key="allowed_mime_types"
|
||||
name="allowed_mime_types"
|
||||
control={form.control}
|
||||
name="has_file_size_limit"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout>
|
||||
<FormItemLayout
|
||||
name="allowed_mime_types"
|
||||
label="Allowed MIME types"
|
||||
labelOptional="Comma separated values"
|
||||
description="Wildcards are allowed, e.g. image/*. Leave blank to allow any MIME type."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Toggle
|
||||
id="has_file_size_limit"
|
||||
checked={field.value}
|
||||
onChange={field.onChange}
|
||||
layout="flex"
|
||||
label="Restrict file upload size for bucket"
|
||||
descriptionText="Prevent uploading of file sizes greater than a specified limit"
|
||||
<Input_Shadcn_
|
||||
id="allowed_mime_types"
|
||||
{...field}
|
||||
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
{hasFileSizeLimit && (
|
||||
<div className="grid grid-cols-12 col-span-12 gap-x-2 gap-y-1">
|
||||
<div className="col-span-8">
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="formatted_size_limit"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
type="number"
|
||||
step={1}
|
||||
{...field}
|
||||
onKeyPress={(event) => {
|
||||
if (event.charCode < 48 || event.charCode > 57) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
<span className="text-foreground-light text-xs">
|
||||
Equivalent to{' '}
|
||||
{convertToBytes(
|
||||
formattedSizeLimit,
|
||||
selectedUnit
|
||||
).toLocaleString()}{' '}
|
||||
bytes.
|
||||
</span>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Listbox
|
||||
id="size_limit_units"
|
||||
value={selectedUnit}
|
||||
onChange={setSelectedUnit}
|
||||
>
|
||||
{Object.values(StorageSizeUnits).map((unit: string) => (
|
||||
<Listbox.Option key={unit} label={unit} value={unit}>
|
||||
<div>{unit}</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
{IS_PLATFORM && (
|
||||
<div className="col-span-12">
|
||||
<p className="text-foreground-light text-sm">
|
||||
Note: Individual bucket uploads will still be capped at the{' '}
|
||||
<Link
|
||||
href={`/project/${ref}/storage/settings`}
|
||||
className="font-bold underline"
|
||||
>
|
||||
global upload limit
|
||||
</Link>{' '}
|
||||
of {formattedGlobalUploadLimit}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="allowed_mime_types"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Allowed MIME types"
|
||||
labelOptional="Comma separated values"
|
||||
description="Wildcards are allowed, e.g. image/*. Leave blank to allow any MIME type."
|
||||
layout="vertical"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</>
|
||||
) : (
|
||||
<Modal.Content>
|
||||
{icebergWrapperExtensionState === 'installed' ? (
|
||||
<Label_Shadcn_ className="text-foreground-lighter leading-1 flex flex-col gap-y-2">
|
||||
<p>
|
||||
<span>Supabase will setup a </span>
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${ref}/integrations/iceberg_wrapper/overview`}
|
||||
target="_blank"
|
||||
className="underline text-foreground-light"
|
||||
>
|
||||
foreign data wrapper
|
||||
{bucketName && <span className="text-brand"> {`${bucketName}_fdw`}</span>}
|
||||
</a>
|
||||
<span>
|
||||
{' '}
|
||||
for easier access to the data. This action will also create{' '}
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${ref}/storage/access-keys`}
|
||||
target="_blank"
|
||||
className="underline text-foreground-light"
|
||||
>
|
||||
S3 Access Keys
|
||||
{bucketName && (
|
||||
<>
|
||||
{' '}
|
||||
named <span className="text-brand"> {`${bucketName}_keys`}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
<span> and </span>
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${ref}/integrations/vault/secrets`}
|
||||
target="_blank"
|
||||
className="underline text-foreground-light"
|
||||
>
|
||||
four Vault Secrets
|
||||
{bucketName && (
|
||||
<>
|
||||
{' '}
|
||||
prefixed with{' '}
|
||||
<span className="text-brand"> {`${bucketName}_vault_`}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
As a final step, you'll need to create an{' '}
|
||||
<span className="text-foreground-light">Iceberg namespace</span> before you
|
||||
connect the Iceberg data to your database.
|
||||
</p>
|
||||
</Label_Shadcn_>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
</>
|
||||
) : (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
You need to install the Iceberg wrapper extension to connect your Analytic
|
||||
bucket to your database.
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="flex flex-col gap-y-2">
|
||||
<p>
|
||||
You need to install the <span className="text-brand">wrappers</span> extension
|
||||
(with the minimum version of <span>0.5.3</span>) if you want to connect your
|
||||
Analytics bucket to your database.
|
||||
</p>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
<>
|
||||
{icebergWrapperExtensionState === 'installed' ? (
|
||||
<Label_Shadcn_ className="text-foreground-lighter leading-1 flex flex-col gap-y-2">
|
||||
<p>
|
||||
<span>Supabase will setup a </span>
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${ref}/integrations/iceberg_wrapper/overview`}
|
||||
target="_blank"
|
||||
className="underline text-foreground-light"
|
||||
>
|
||||
foreign data wrapper
|
||||
{bucketName && <span className="text-brand"> {`${bucketName}_fdw`}</span>}
|
||||
</a>
|
||||
<span>
|
||||
{' '}
|
||||
for easier access to the data. This action will also create{' '}
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${ref}/storage/access-keys`}
|
||||
target="_blank"
|
||||
className="underline text-foreground-light"
|
||||
>
|
||||
S3 Access Keys
|
||||
{bucketName && (
|
||||
<>
|
||||
{' '}
|
||||
named <span className="text-brand"> {`${bucketName}_keys`}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
<span> and </span>
|
||||
<a
|
||||
href={`${BASE_PATH}/project/${ref}/integrations/vault/secrets`}
|
||||
target="_blank"
|
||||
className="underline text-foreground-light"
|
||||
>
|
||||
four Vault Secrets
|
||||
{bucketName && (
|
||||
<>
|
||||
{' '}
|
||||
prefixed with{' '}
|
||||
<span className="text-brand"> {`${bucketName}_vault_`}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
As a final step, you'll need to create an{' '}
|
||||
<span className="text-foreground-light">Iceberg namespace</span> before you
|
||||
connect the Iceberg data to your database.
|
||||
</p>
|
||||
</Label_Shadcn_>
|
||||
) : (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
You need to install the Iceberg wrapper extension to connect your Analytic
|
||||
bucket to your database.
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="flex flex-col gap-y-2">
|
||||
<p>
|
||||
You need to install the <span className="text-brand">wrappers</span>{' '}
|
||||
extension (with the minimum version of <span>0.5.3</span>) if you want to
|
||||
connect your Analytics bucket to your database.
|
||||
</p>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal.Content>
|
||||
)}
|
||||
<Modal.Separator />
|
||||
<Modal.Content className="flex items-center space-x-2 justify-end">
|
||||
<Button
|
||||
type="default"
|
||||
htmlType="button"
|
||||
disabled={isCreating || isCreatingIcebergWrapper}
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isCreating || isCreatingIcebergWrapper}
|
||||
disabled={isCreating || isCreatingIcebergWrapper}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</Modal>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</DialogSection>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={isCreating || isCreatingIcebergWrapper}
|
||||
onClick={() => setVisible(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
form={formId}
|
||||
htmlType="submit"
|
||||
loading={isCreating || isCreatingIcebergWrapper}
|
||||
disabled={isCreating || isCreatingIcebergWrapper}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,58 @@
|
||||
import { useParams } from 'common'
|
||||
import { get as _get, find } from 'lodash'
|
||||
import { useRouter } from 'next/router'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import z from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
|
||||
import { useDatabasePolicyDeleteMutation } from 'data/database-policies/database-policy-delete-mutation'
|
||||
import { useBucketDeleteMutation } from 'data/storage/bucket-delete-mutation'
|
||||
import { Bucket, useBucketsQuery } from 'data/storage/buckets-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
|
||||
import { formatPoliciesForStorage } from './Storage.utils'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Label_Shadcn_,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
export interface DeleteBucketModalProps {
|
||||
visible: boolean
|
||||
bucket?: Bucket
|
||||
bucket: Bucket
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const DeleteBucketModal = ({ visible = false, bucket, onClose }: DeleteBucketModalProps) => {
|
||||
const formId = `delete-storage-bucket-form`
|
||||
|
||||
export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModalProps) => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const schema = z.object({
|
||||
confirm: z.literal(bucket.name, {
|
||||
errorMap: () => ({ message: `Please enter "${bucket.name}" to confirm` }),
|
||||
}),
|
||||
})
|
||||
|
||||
const form = useForm<z.infer<typeof schema>>({
|
||||
resolver: zodResolver(schema),
|
||||
})
|
||||
|
||||
const { data } = useBucketsQuery({ projectRef })
|
||||
const { data: policies } = useDatabasePoliciesQuery({
|
||||
projectRef: project?.ref,
|
||||
@@ -30,7 +61,7 @@ const DeleteBucketModal = ({ visible = false, bucket, onClose }: DeleteBucketMod
|
||||
})
|
||||
const { mutateAsync: deletePolicy } = useDatabasePolicyDeleteMutation()
|
||||
|
||||
const { mutate: deleteBucket, isLoading: isDeleting } = useBucketDeleteMutation({
|
||||
const { mutate: deleteBucket, isLoading } = useBucketDeleteMutation({
|
||||
onSuccess: async () => {
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
@@ -41,7 +72,7 @@ const DeleteBucketModal = ({ visible = false, bucket, onClose }: DeleteBucketMod
|
||||
storageObjectsPolicies
|
||||
)
|
||||
const bucketPolicies = _get(
|
||||
find(formattedStorageObjectPolicies, { name: bucket!.name }),
|
||||
find(formattedStorageObjectPolicies, { name: bucket.name }),
|
||||
['policies'],
|
||||
[]
|
||||
)
|
||||
@@ -57,12 +88,12 @@ const DeleteBucketModal = ({ visible = false, bucket, onClose }: DeleteBucketMod
|
||||
)
|
||||
)
|
||||
|
||||
toast.success(`Successfully deleted bucket ${bucket?.name}`)
|
||||
toast.success(`Successfully deleted bucket ${bucket.name}`)
|
||||
router.push(`/project/${projectRef}/storage/buckets`)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
toast.success(
|
||||
`Successfully deleted bucket ${bucket?.name}. However, there was a problem deleting the policies tied to the bucket. Please review them in the storage policies section`
|
||||
`Successfully deleted bucket ${bucket.name}. However, there was a problem deleting the policies tied to the bucket. Please review them in the storage policies section`
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -70,34 +101,83 @@ const DeleteBucketModal = ({ visible = false, bucket, onClose }: DeleteBucketMod
|
||||
|
||||
const buckets = data ?? []
|
||||
|
||||
const onDeleteBucket = async () => {
|
||||
const onSubmit: SubmitHandler<z.infer<typeof schema>> = async () => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (!bucket) return console.error('No bucket is selected')
|
||||
deleteBucket({ projectRef, id: bucket.id, type: bucket.type })
|
||||
}
|
||||
|
||||
return (
|
||||
<TextConfirmModal
|
||||
variant={'destructive'}
|
||||
visible={visible}
|
||||
title={`Confirm deletion of ${bucket?.name}`}
|
||||
confirmPlaceholder="Type in name of bucket"
|
||||
onConfirm={onDeleteBucket}
|
||||
onCancel={onClose}
|
||||
confirmString={bucket?.name ?? ''}
|
||||
loading={isDeleting}
|
||||
text={
|
||||
<>
|
||||
Your bucket <span className="font-bold text-foreground">{bucket?.name}</span> and all its
|
||||
contents will be permanently deleted.
|
||||
</>
|
||||
}
|
||||
alert={{
|
||||
title: 'You cannot recover this bucket once deleted.',
|
||||
description: 'All bucket data will be lost.',
|
||||
<Dialog
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
confirmLabel="Delete bucket"
|
||||
/>
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Confirm deletion of ${bucket.name}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection className="flex flex-col gap-4">
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="You cannot recover this bucket once deleted."
|
||||
description="All bucket data will be lost."
|
||||
/>
|
||||
<p>
|
||||
Your bucket <span className="font-bold text-foreground">{bucket.name}</span> and all its
|
||||
contents will be permanently deleted.
|
||||
</p>
|
||||
</DialogSection>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
key="confirm"
|
||||
name="confirm"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="confirm"
|
||||
label={
|
||||
<>
|
||||
Type <span className="font-bold text-foreground">{bucket.name}</span> to
|
||||
confirm.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
id="confirm"
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
placeholder="Type in name of bucket"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</DialogSection>
|
||||
<DialogFooter>
|
||||
<Button type="default" disabled={isLoading} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button form={formId} htmlType="submit" type="danger" loading={isLoading}>
|
||||
Delete Bucket
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { useParams } from 'common'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui'
|
||||
import { type SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
Button,
|
||||
CollapsibleContent_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
Collapsible_Shadcn_,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSectionSeparator,
|
||||
DialogSection,
|
||||
DialogTitle,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
SelectItem_Shadcn_,
|
||||
SelectTrigger_Shadcn_,
|
||||
SelectValue_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
Switch,
|
||||
cn,
|
||||
} from 'ui'
|
||||
|
||||
import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants'
|
||||
import {
|
||||
@@ -16,256 +42,333 @@ import { useBucketUpdateMutation } from 'data/storage/bucket-update-mutation'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { Bucket } from 'data/storage/buckets-query'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { isNonNullable } from 'lib/isNonNullable'
|
||||
|
||||
export interface EditBucketModalProps {
|
||||
visible: boolean
|
||||
bucket?: Bucket
|
||||
bucket: Bucket
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => {
|
||||
const BucketSchema = z.object({
|
||||
name: z.string(),
|
||||
public: z.boolean().default(false),
|
||||
has_file_size_limit: z.boolean().default(false),
|
||||
formatted_size_limit: z.coerce
|
||||
.number()
|
||||
.min(0, 'File size upload limit has to be at least 0')
|
||||
.default(0),
|
||||
allowed_mime_types: z.string().trim().default(''),
|
||||
})
|
||||
|
||||
const formId = 'edit-storage-bucket-form'
|
||||
|
||||
export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => {
|
||||
const { ref } = useParams()
|
||||
|
||||
const { mutate: updateBucket, isLoading: isUpdating } = useBucketUpdateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully updated bucket "${bucket?.name}"`)
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
const { data } = useProjectStorageConfigQuery(
|
||||
{ projectRef: ref },
|
||||
{ enabled: IS_PLATFORM && visible }
|
||||
)
|
||||
const { mutate: updateBucket, isLoading: isUpdating } = useBucketUpdateMutation()
|
||||
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
|
||||
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
|
||||
const formattedGlobalUploadLimit = `${value} ${unit}`
|
||||
|
||||
const [selectedUnit, setSelectedUnit] = useState<StorageSizeUnits>(StorageSizeUnits.BYTES)
|
||||
const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.BYTES)
|
||||
const [showConfiguration, setShowConfiguration] = useState(false)
|
||||
const { value: fileSizeLimit } = convertFromBytes(bucket?.file_size_limit ?? 0)
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors = {} as any
|
||||
if (values.has_file_size_limit && values.formatted_size_limit < 0) {
|
||||
errors.formatted_size_limit = 'File size upload limit has to be at least 0'
|
||||
}
|
||||
return errors
|
||||
}
|
||||
const form = useForm<z.infer<typeof BucketSchema>>({
|
||||
resolver: zodResolver(BucketSchema),
|
||||
defaultValues: {
|
||||
name: bucket?.name ?? '',
|
||||
public: bucket?.public,
|
||||
has_file_size_limit: isNonNullable(bucket?.file_size_limit),
|
||||
formatted_size_limit: fileSizeLimit ?? 0,
|
||||
allowed_mime_types: (bucket?.allowed_mime_types ?? []).join(', '),
|
||||
},
|
||||
values: {
|
||||
name: bucket?.name ?? '',
|
||||
public: bucket?.public,
|
||||
has_file_size_limit: isNonNullable(bucket?.file_size_limit),
|
||||
formatted_size_limit: fileSizeLimit ?? 0,
|
||||
allowed_mime_types: (bucket?.allowed_mime_types ?? []).join(', '),
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
const onSubmit = async (values: any) => {
|
||||
const isPublicBucket = form.watch('public')
|
||||
const hasFileSizeLimit = form.watch('has_file_size_limit')
|
||||
const formattedSizeLimit = form.watch('formatted_size_limit')
|
||||
const isChangingBucketVisibility = bucket?.public !== isPublicBucket
|
||||
const isMakingBucketPrivate = bucket?.public && !isPublicBucket
|
||||
const isMakingBucketPublic = !bucket?.public && isPublicBucket
|
||||
|
||||
const onSubmit: SubmitHandler<z.infer<typeof BucketSchema>> = async (values) => {
|
||||
if (bucket === undefined) return console.error('Bucket is required')
|
||||
if (ref === undefined) return console.error('Project ref is required')
|
||||
|
||||
updateBucket({
|
||||
projectRef: ref,
|
||||
id: bucket.id,
|
||||
isPublic: values.public,
|
||||
file_size_limit: values.has_file_size_limit
|
||||
? convertToBytes(values.formatted_size_limit, selectedUnit)
|
||||
: null,
|
||||
allowed_mime_types:
|
||||
values.allowed_mime_types.length > 0
|
||||
? values.allowed_mime_types.split(',').map((x: string) => x.trim())
|
||||
updateBucket(
|
||||
{
|
||||
projectRef: ref,
|
||||
id: bucket.id,
|
||||
isPublic: values.public,
|
||||
file_size_limit: values.has_file_size_limit
|
||||
? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits)
|
||||
: null,
|
||||
})
|
||||
allowed_mime_types:
|
||||
values.allowed_mime_types.length > 0
|
||||
? values.allowed_mime_types.split(',').map((x: string) => x.trim())
|
||||
: null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully updated bucket "${bucket?.name}"`)
|
||||
onClose()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const { unit } = convertFromBytes(bucket?.file_size_limit ?? 0)
|
||||
setSelectedUnit(unit)
|
||||
setShowConfiguration(false)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
hideFooter
|
||||
visible={visible}
|
||||
size="medium"
|
||||
header={`Edit bucket "${bucket?.name}"`}
|
||||
onCancel={onClose}
|
||||
<Dialog
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
form.reset()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form validateOnBlur={false} initialValues={{}} validate={validate} onSubmit={onSubmit}>
|
||||
{({ values, resetForm }: { values: any; resetForm: any }) => {
|
||||
const isChangingBucketVisibility = bucket?.public !== values.public
|
||||
const isMakingBucketPrivate = bucket?.public && !values.public
|
||||
const isMakingBucketPublic = !bucket?.public && values.public
|
||||
|
||||
// [Alaister] although this "technically" is breaking the rules of React hooks
|
||||
// it won't error because the hooks are always rendered in the same order
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
if (visible && bucket !== undefined) {
|
||||
const { value: fileSizeLimit } = convertFromBytes(bucket.file_size_limit ?? 0)
|
||||
|
||||
const values = {
|
||||
name: bucket.name ?? '',
|
||||
public: bucket.public,
|
||||
file_size_limit: bucket.file_size_limit,
|
||||
allowed_mime_types: (bucket.allowed_mime_types ?? []).join(', '),
|
||||
|
||||
has_file_size_limit: bucket.file_size_limit !== null,
|
||||
formatted_size_limit: fileSizeLimit ?? 0,
|
||||
}
|
||||
resetForm({ values, initialValues: values })
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Content className={cn('!px-0', isChangingBucketVisibility && '!pb-0')}>
|
||||
<Input
|
||||
disabled
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
className="w-full px-5"
|
||||
layout="vertical"
|
||||
label="Name of bucket"
|
||||
labelOptional="Buckets cannot be renamed once created."
|
||||
/>
|
||||
<div className={cn('flex flex-col gap-y-2 mt-6')}>
|
||||
<Toggle
|
||||
id="public"
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Edit bucket "${bucket?.name}"`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
key="name"
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="name"
|
||||
label="Name of bucket"
|
||||
labelOptional="Buckets cannot be renamed once created."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ id="name" {...field} disabled />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
key="public"
|
||||
name="public"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="public"
|
||||
layout="flex"
|
||||
label="Public bucket"
|
||||
className="px-5"
|
||||
descriptionText="Anyone can read any object without any authorization"
|
||||
/>
|
||||
{isChangingBucketVisibility && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="rounded-none border-x-0 border-b-0 mb-0 [&>div>p]:!leading-normal"
|
||||
title={
|
||||
isMakingBucketPublic
|
||||
? 'Warning: Making bucket public'
|
||||
: isMakingBucketPrivate
|
||||
? 'Warning: Making bucket private'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<p>
|
||||
{isMakingBucketPublic
|
||||
? `This will make all objects in your bucket publicly accessible.`
|
||||
: isMakingBucketPrivate
|
||||
? `All objects in your bucket will be private and only accessible via signed URLs, or downloaded with the right authorisation headers.`
|
||||
: ''}
|
||||
</p>
|
||||
description="Anyone can read any object without any authorization"
|
||||
layout="flex"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
id="public"
|
||||
size="large"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
{isChangingBucketVisibility && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="rounded-none border-x-0 border-b-0 mb-0 pb-0 px-0 [&>svg]:left-0 [&>div>p]:!leading-normal"
|
||||
title={
|
||||
isMakingBucketPublic
|
||||
? 'Warning: Making bucket public'
|
||||
: isMakingBucketPrivate
|
||||
? 'Warning: Making bucket private'
|
||||
: ''
|
||||
}
|
||||
description={
|
||||
<>
|
||||
{isMakingBucketPublic ? (
|
||||
<p>`This will make all objects in your bucket publicly accessible.`</p>
|
||||
) : isMakingBucketPrivate ? (
|
||||
<p>
|
||||
`All objects in your bucket will be private and only accessible via signed
|
||||
URLs, or downloaded with the right authorisation headers.`
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isMakingBucketPrivate && (
|
||||
<p>
|
||||
Assets cached in the CDN may still be publicly accessible. You can
|
||||
consider{' '}
|
||||
{
|
||||
'Assets cached in the CDN may still be publicly accessible. You can consider '
|
||||
}
|
||||
<InlineLink href="https://supabase.com/docs/guides/storage/cdn/smart-cdn#cache-eviction">
|
||||
purging the cache
|
||||
</InlineLink>{' '}
|
||||
or moving your assets to a new bucket.
|
||||
</InlineLink>
|
||||
{' or moving your assets to a new bucket.'}
|
||||
</p>
|
||||
)}
|
||||
</Admonition>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Collapsible
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Collapsible_Shadcn_
|
||||
open={showConfiguration}
|
||||
onOpenChange={() => setShowConfiguration(!showConfiguration)}
|
||||
>
|
||||
<Collapsible.Trigger asChild>
|
||||
<div className="w-full cursor-pointer py-3 px-5 flex items-center justify-between border-t border-default">
|
||||
<CollapsibleTrigger_Shadcn_ asChild>
|
||||
<button className="w-full cursor-pointer py-3 flex items-center justify-between border-t border-default">
|
||||
<p className="text-sm">Additional configuration</p>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
className={cn('text-foreground-light', showConfiguration && 'rotate-180')}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="py-4">
|
||||
<div className="w-full space-y-4 px-5">
|
||||
<div className="space-y-2">
|
||||
<Toggle
|
||||
id="has_file_size_limit"
|
||||
name="has_file_size_limit"
|
||||
layout="flex"
|
||||
label="Restrict file upload size for bucket"
|
||||
descriptionText="Prevent uploading of file sizes greater than a specified limit"
|
||||
/>
|
||||
{values.has_file_size_limit && (
|
||||
<div className="grid grid-cols-12 col-span-12 gap-x-2 gap-y-1">
|
||||
<div className="col-span-8">
|
||||
<Input
|
||||
type="number"
|
||||
step={1}
|
||||
id="formatted_size_limit"
|
||||
name="formatted_size_limit"
|
||||
disabled={false}
|
||||
onKeyPress={(event) => {
|
||||
if (event.charCode < 48 || event.charCode > 57) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
descriptionText={`Equivalent to ${convertToBytes(
|
||||
values.formatted_size_limit,
|
||||
selectedUnit
|
||||
).toLocaleString()} bytes.`}
|
||||
</button>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_ className="py-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<FormField_Shadcn_
|
||||
key="has_file_size_limit"
|
||||
name="has_file_size_limit"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="has_file_size_limit"
|
||||
label="Restrict file upload size for bucket"
|
||||
description="Prevent uploading of file sizes greater than a specified limit"
|
||||
layout="flex"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
id="has_file_size_limit"
|
||||
size="large"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Listbox
|
||||
id="size_limit_units"
|
||||
disabled={false}
|
||||
value={selectedUnit}
|
||||
onChange={setSelectedUnit}
|
||||
>
|
||||
{Object.values(StorageSizeUnits).map((unit: string) => (
|
||||
<Listbox.Option key={unit} label={unit} value={unit}>
|
||||
<div>{unit}</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
{IS_PLATFORM && (
|
||||
<div className="col-span-12 mt-2">
|
||||
<p className="text-foreground-light text-sm">
|
||||
Note: Individual bucket upload will still be capped at the{' '}
|
||||
<Link
|
||||
href={`/project/${ref}/storage/settings`}
|
||||
className="font-bold underline"
|
||||
>
|
||||
global upload limit
|
||||
</Link>{' '}
|
||||
of {formattedGlobalUploadLimit}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="allowed_mime_types"
|
||||
name="allowed_mime_types"
|
||||
layout="vertical"
|
||||
label="Allowed MIME types"
|
||||
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
|
||||
labelOptional="Comma separated values"
|
||||
descriptionText="Wildcards are allowed, e.g. image/*. Leave blank to allow any MIME type."
|
||||
/>
|
||||
{hasFileSizeLimit && (
|
||||
<div className="grid grid-cols-12 col-span-12 gap-x-2 gap-y-1">
|
||||
<div className="col-span-8">
|
||||
<FormField_Shadcn_
|
||||
key="formatted_size_limit"
|
||||
name="formatted_size_limit"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="formatted_size_limit"
|
||||
description={`Equivalent to ${convertToBytes(
|
||||
formattedSizeLimit,
|
||||
selectedUnit as StorageSizeUnits
|
||||
).toLocaleString()} bytes.`}
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
id="formatted_size_limit"
|
||||
aria-label="File size limit"
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Select_Shadcn_ value={selectedUnit} onValueChange={setSelectedUnit}>
|
||||
<SelectTrigger_Shadcn_
|
||||
aria-label="File size limit unit"
|
||||
size="small"
|
||||
className="col-span-4"
|
||||
>
|
||||
<SelectValue_Shadcn_ asChild>
|
||||
<>{selectedUnit}</>
|
||||
</SelectValue_Shadcn_>
|
||||
</SelectTrigger_Shadcn_>
|
||||
<SelectContent_Shadcn_>
|
||||
{Object.values(StorageSizeUnits).map((unit: string) => (
|
||||
<SelectItem_Shadcn_ key={unit} value={unit} className="text-xs">
|
||||
<div>{unit}</div>
|
||||
</SelectItem_Shadcn_>
|
||||
))}
|
||||
</SelectContent_Shadcn_>
|
||||
</Select_Shadcn_>
|
||||
{IS_PLATFORM && (
|
||||
<div className="col-span-12 mt-2">
|
||||
<p className="text-foreground-light text-sm">
|
||||
Note: Individual bucket upload will still be capped at the{' '}
|
||||
<Link
|
||||
href={`/project/${ref}/settings/storage`}
|
||||
className="font-bold underline"
|
||||
>
|
||||
global upload limit
|
||||
</Link>{' '}
|
||||
of {formattedGlobalUploadLimit}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
<Modal.Separator />
|
||||
<Modal.Content className="flex items-center space-x-2 justify-end">
|
||||
<Button type="default" disabled={isUpdating} onClick={() => onClose()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" loading={isUpdating} disabled={isUpdating}>
|
||||
Save
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
</Modal>
|
||||
<FormField_Shadcn_
|
||||
key="allowed_mime_types"
|
||||
name="allowed_mime_types"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="allowed_mime_types"
|
||||
label="Allowed MIME types"
|
||||
labelOptional="Comma separated values"
|
||||
description="Wildcards are allowed, e.g. image/*. Leave blank to allow any MIME type."
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
id="allowed_mime_types"
|
||||
{...field}
|
||||
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</DialogSection>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={isUpdating}
|
||||
onClick={() => {
|
||||
form.reset()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button form={formId} htmlType="submit" loading={isUpdating}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,17 @@ import { toast } from 'sonner'
|
||||
import { useBucketEmptyMutation } from 'data/storage/bucket-empty-mutation'
|
||||
import type { Bucket } from 'data/storage/buckets-query'
|
||||
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogFooter,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
|
||||
export interface EmptyBucketModalProps {
|
||||
visible: boolean
|
||||
@@ -12,7 +22,7 @@ export interface EmptyBucketModalProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const EmptyBucketModal = ({ visible = false, bucket, onClose }: EmptyBucketModalProps) => {
|
||||
export const EmptyBucketModal = ({ visible, bucket, onClose }: EmptyBucketModalProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { fetchFolderContents } = useStorageExplorerStateSnapshot()
|
||||
|
||||
@@ -37,21 +47,38 @@ export const EmptyBucketModal = ({ visible = false, bucket, onClose }: EmptyBuck
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
variant={'destructive'}
|
||||
size="small"
|
||||
title={`Confirm to delete all contents from ${bucket?.name}`}
|
||||
confirmLabel="Empty bucket"
|
||||
visible={visible}
|
||||
loading={isLoading}
|
||||
onCancel={() => onClose()}
|
||||
onConfirm={onEmptyBucket}
|
||||
alert={{
|
||||
title: 'This action cannot be undone',
|
||||
description: 'The contents of your bucket cannot be recovered once deleted',
|
||||
<Dialog
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-sm">Are you sure you want to empty the bucket "{bucket?.name}"?</p>
|
||||
</ConfirmationModal>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`Confirm to delete all contents from ${bucket?.name}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection className="flex flex-col gap-4">
|
||||
<Admonition
|
||||
type="destructive"
|
||||
title="This action cannot be undone"
|
||||
description="The contents of your bucket cannot be recovered once deleted."
|
||||
/>
|
||||
<p className="text-sm">Are you sure you want to empty the bucket "{bucket?.name}"?</p>
|
||||
</DialogSection>
|
||||
<DialogFooter>
|
||||
<Button type="default" disabled={isLoading} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="danger" loading={isLoading} onClick={onEmptyBucket}>
|
||||
Empty Bucket
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyBucketModal
|
||||
@@ -1,18 +1,11 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Edit } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { DeleteBucketModal } from 'components/interfaces/Storage'
|
||||
import CreateBucketModal from 'components/interfaces/Storage/CreateBucketModal'
|
||||
import EditBucketModal from 'components/interfaces/Storage/EditBucketModal'
|
||||
import { EmptyBucketModal } from 'components/interfaces/Storage/EmptyBucketModal'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import { Bucket, useBucketsQuery } from 'data/storage/buckets-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useBucketsQuery } from 'data/storage/buckets-query'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
|
||||
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Menu } from 'ui'
|
||||
@@ -28,16 +21,11 @@ import BucketRow from './BucketRow'
|
||||
const StorageMenu = () => {
|
||||
const router = useRouter()
|
||||
const { ref, bucketId } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { data: projectDetails } = useSelectedProjectQuery()
|
||||
const snap = useStorageExplorerStateSnapshot()
|
||||
const isBranch = project?.parent_project_ref !== undefined
|
||||
const isBranch = projectDetails?.parent_project_ref !== undefined
|
||||
|
||||
const [searchText, setSearchText] = useState<string>('')
|
||||
const [showCreateBucketModal, setShowCreateBucketModal] = useState(false)
|
||||
const [selectedBucketToEdit, setSelectedBucketToEdit] = useState<Bucket>()
|
||||
const [selectedBucketToEmpty, setSelectedBucketToEmpty] = useState<Bucket>()
|
||||
const [selectedBucketToDelete, setSelectedBucketToDelete] = useState<Bucket>()
|
||||
const canCreateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
|
||||
|
||||
const page = router.pathname.split('/')[4] as
|
||||
| undefined
|
||||
@@ -69,24 +57,7 @@ const StorageMenu = () => {
|
||||
<>
|
||||
<Menu type="pills" className="mt-6 flex flex-grow flex-col">
|
||||
<div className="mb-6 mx-5 flex flex-col gap-y-1.5">
|
||||
<ButtonTooltip
|
||||
block
|
||||
type="default"
|
||||
icon={<Edit />}
|
||||
disabled={!canCreateBuckets}
|
||||
style={{ justifyContent: 'start' }}
|
||||
onClick={() => setShowCreateBucketModal(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canCreateBuckets
|
||||
? 'You need additional permissions to create buckets'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
New bucket
|
||||
</ButtonTooltip>
|
||||
<CreateBucketModal />
|
||||
|
||||
<InnerSideBarFilters className="px-0">
|
||||
<InnerSideBarFilterSearchInput
|
||||
@@ -170,9 +141,6 @@ const StorageMenu = () => {
|
||||
bucket={bucket}
|
||||
projectRef={ref}
|
||||
isSelected={isSelected}
|
||||
onSelectEmptyBucket={() => setSelectedBucketToEmpty(bucket)}
|
||||
onSelectDeleteBucket={() => setSelectedBucketToDelete(bucket)}
|
||||
onSelectEditBucket={() => setSelectedBucketToEdit(bucket)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -195,29 +163,6 @@ const StorageMenu = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Menu>
|
||||
|
||||
<CreateBucketModal
|
||||
visible={showCreateBucketModal}
|
||||
onClose={() => setShowCreateBucketModal(false)}
|
||||
/>
|
||||
|
||||
<EditBucketModal
|
||||
visible={selectedBucketToEdit !== undefined}
|
||||
bucket={selectedBucketToEdit}
|
||||
onClose={() => setSelectedBucketToEdit(undefined)}
|
||||
/>
|
||||
|
||||
<EmptyBucketModal
|
||||
visible={selectedBucketToEmpty !== undefined}
|
||||
bucket={selectedBucketToEmpty}
|
||||
onClose={() => setSelectedBucketToEmpty(undefined)}
|
||||
/>
|
||||
|
||||
<DeleteBucketModal
|
||||
visible={selectedBucketToDelete !== undefined}
|
||||
bucket={selectedBucketToDelete}
|
||||
onClose={() => setSelectedBucketToDelete(undefined)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { addAPIMock } from 'tests/lib/msw'
|
||||
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { render } from 'tests/helpers'
|
||||
import { routerMock } from 'tests/lib/route-mock'
|
||||
import CreateBucketModal from '../CreateBucketModal'
|
||||
|
||||
describe(`CreateBucketModal`, () => {
|
||||
beforeEach(() => {
|
||||
vi.mock(`hooks/misc/useCheckPermissions`, () => ({
|
||||
useCheckPermissions: vi.fn(),
|
||||
useAsyncCheckProjectPermissions: vi.fn().mockImplementation(() => ({ can: true })),
|
||||
}))
|
||||
// useParams
|
||||
routerMock.setCurrentUrl(`/project/default/storage/buckets`)
|
||||
// useSelectedProject -> Project
|
||||
addAPIMock({
|
||||
method: `get`,
|
||||
path: `/platform/projects/:ref`,
|
||||
// @ts-expect-error
|
||||
response: {
|
||||
cloud_provider: 'localhost',
|
||||
id: 1,
|
||||
inserted_at: '2021-08-02T06:40:40.646Z',
|
||||
name: 'Default Project',
|
||||
organization_id: 1,
|
||||
ref: 'default',
|
||||
region: 'local',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
},
|
||||
})
|
||||
// useBucketCreateMutation
|
||||
addAPIMock({
|
||||
method: `post`,
|
||||
path: `/platform/storage/:ref/buckets`,
|
||||
})
|
||||
})
|
||||
|
||||
it(`renders a dialog with a form`, async () => {
|
||||
render(
|
||||
<ProjectContextProvider projectRef="default">
|
||||
<CreateBucketModal />
|
||||
</ProjectContextProvider>
|
||||
)
|
||||
|
||||
const dialogTrigger = screen.getByRole(`button`, { name: `New bucket` })
|
||||
await userEvent.click(dialogTrigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole(`dialog`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const nameInput = screen.getByLabelText(`Name of bucket`)
|
||||
await userEvent.type(nameInput, `test`)
|
||||
|
||||
const standardOption = screen.getByLabelText(`Standard bucket`)
|
||||
await userEvent.click(standardOption)
|
||||
|
||||
const publicToggle = screen.getByLabelText(`Public bucket`)
|
||||
expect(publicToggle).not.toBeChecked()
|
||||
await userEvent.click(publicToggle)
|
||||
expect(publicToggle).toBeChecked()
|
||||
|
||||
const detailsTrigger = screen.getByRole(`button`, { name: `Additional configuration` })
|
||||
expect(detailsTrigger).toHaveAttribute(`data-state`, `closed`)
|
||||
await userEvent.click(detailsTrigger)
|
||||
expect(detailsTrigger).toHaveAttribute(`data-state`, `open`)
|
||||
|
||||
const sizeLimitToggle = screen.getByLabelText(`Restrict file upload size for bucket`)
|
||||
expect(sizeLimitToggle).not.toBeChecked()
|
||||
await userEvent.click(sizeLimitToggle)
|
||||
expect(sizeLimitToggle).toBeChecked()
|
||||
|
||||
const sizeLimitInput = screen.getByLabelText(`File size limit`)
|
||||
expect(sizeLimitInput).toHaveValue(0)
|
||||
await userEvent.type(sizeLimitInput, `25`)
|
||||
|
||||
const sizeLimitUnitSelect = screen.getByLabelText(`File size limit unit`)
|
||||
expect(sizeLimitUnitSelect).toHaveTextContent(`bytes`)
|
||||
await userEvent.click(sizeLimitUnitSelect)
|
||||
const mbOption = screen.getByRole(`option`, { name: `MB` })
|
||||
await userEvent.click(mbOption)
|
||||
expect(sizeLimitUnitSelect).toHaveTextContent(`MB`)
|
||||
|
||||
const mimeTypeInput = screen.getByLabelText(`Allowed MIME types`)
|
||||
expect(mimeTypeInput).toHaveValue(``)
|
||||
await userEvent.type(mimeTypeInput, `image/jpeg, image/png`)
|
||||
|
||||
const submitButton = screen.getByRole(`button`, { name: `Create` })
|
||||
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(routerMock.asPath).toStrictEqual(`/project/default/storage/buckets/test`)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { faker } from '@faker-js/faker'
|
||||
|
||||
import { addAPIMock } from 'tests/lib/msw'
|
||||
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { Bucket } from 'data/storage/buckets-query'
|
||||
import DeleteBucketModal from '../DeleteBucketModal'
|
||||
import { render } from 'tests/helpers'
|
||||
import { routerMock } from 'tests/lib/route-mock'
|
||||
|
||||
const bucket: Bucket = {
|
||||
id: faker.string.uuid(),
|
||||
name: `test`,
|
||||
owner: faker.string.uuid(),
|
||||
public: faker.datatype.boolean(),
|
||||
allowed_mime_types: faker.helpers.multiple(() => faker.system.mimeType(), {
|
||||
count: { min: 1, max: 5 },
|
||||
}),
|
||||
file_size_limit: faker.number.int({ min: 0, max: 25165824 }),
|
||||
type: faker.helpers.arrayElement(['STANDARD', 'ANALYTICS', undefined]),
|
||||
created_at: faker.date.recent().toISOString(),
|
||||
updated_at: faker.date.recent().toISOString(),
|
||||
}
|
||||
|
||||
const Page = ({ onClose }: { onClose: () => void }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<ProjectContextProvider projectRef="default">
|
||||
<button onClick={() => setOpen(true)}>Open</button>
|
||||
|
||||
<DeleteBucketModal
|
||||
visible={open}
|
||||
bucket={bucket}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
</ProjectContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe(`DeleteBucketModal`, () => {
|
||||
beforeEach(() => {
|
||||
// useParams
|
||||
routerMock.setCurrentUrl(`/project/default/storage/buckets/test`)
|
||||
// useProjectContext
|
||||
addAPIMock({
|
||||
method: `get`,
|
||||
path: `/platform/projects/:ref`,
|
||||
// @ts-expect-error
|
||||
response: {
|
||||
cloud_provider: 'localhost',
|
||||
id: 1,
|
||||
inserted_at: '2021-08-02T06:40:40.646Z',
|
||||
name: 'Default Project',
|
||||
organization_id: 1,
|
||||
ref: 'default',
|
||||
region: 'local',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
},
|
||||
})
|
||||
// useBucketsQuery
|
||||
addAPIMock({
|
||||
method: `get`,
|
||||
path: `/platform/storage/:ref/buckets`,
|
||||
response: [bucket],
|
||||
})
|
||||
// useDatabasePoliciesQuery
|
||||
addAPIMock({
|
||||
method: `get`,
|
||||
path: `/platform/pg-meta/:ref/policies`,
|
||||
response: [
|
||||
{
|
||||
id: faker.number.int({ min: 1 }),
|
||||
name: faker.word.noun(),
|
||||
action: faker.helpers.arrayElement(['PERMISSIVE', 'RESTRICTIVE']),
|
||||
command: faker.helpers.arrayElement(['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'ALL']),
|
||||
table: faker.word.noun(),
|
||||
table_id: faker.number.int({ min: 1 }),
|
||||
check: null,
|
||||
definition: null,
|
||||
schema: faker.lorem.sentence(),
|
||||
roles: faker.helpers.multiple(() => faker.word.noun(), {
|
||||
count: { min: 1, max: 5 },
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
// useBucketDeleteMutation
|
||||
addAPIMock({
|
||||
method: `post`,
|
||||
path: `/platform/storage/:ref/buckets/:id/empty`,
|
||||
})
|
||||
// useDatabasePolicyDeleteMutation
|
||||
addAPIMock({
|
||||
method: `delete`,
|
||||
path: `/platform/storage/:ref/buckets/:id`,
|
||||
})
|
||||
})
|
||||
|
||||
it(`renders a confirmation dialog`, async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<Page onClose={onClose} />)
|
||||
|
||||
const openButton = screen.getByRole(`button`, { name: `Open` })
|
||||
await userEvent.click(openButton)
|
||||
await screen.findByRole(`dialog`)
|
||||
|
||||
const input = screen.getByLabelText(/Type/)
|
||||
await userEvent.type(input, `test`)
|
||||
|
||||
const confirmButton = screen.getByRole(`button`, { name: `Delete Bucket` })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
|
||||
expect(routerMock.asPath).toStrictEqual(`/project/default/storage/buckets`)
|
||||
})
|
||||
|
||||
it(`prevents submission when the input doesn't match the bucket name`, async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<Page onClose={onClose} />)
|
||||
|
||||
const openButton = screen.getByRole(`button`, { name: `Open` })
|
||||
await userEvent.click(openButton)
|
||||
await screen.findByRole(`dialog`)
|
||||
|
||||
const input = screen.getByLabelText(/Type/)
|
||||
await userEvent.type(input, `invalid`)
|
||||
|
||||
const confirmButton = screen.getByRole(`button`, { name: `Delete Bucket` })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Please enter/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { faker } from '@faker-js/faker'
|
||||
|
||||
import { addAPIMock } from 'tests/lib/msw'
|
||||
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { Bucket } from 'data/storage/buckets-query'
|
||||
import { render } from 'tests/helpers'
|
||||
import { routerMock } from 'tests/lib/route-mock'
|
||||
import EditBucketModal from '../EditBucketModal'
|
||||
|
||||
const bucket: Bucket = {
|
||||
id: faker.string.uuid(),
|
||||
name: `test`,
|
||||
owner: faker.string.uuid(),
|
||||
public: false,
|
||||
allowed_mime_types: [],
|
||||
file_size_limit: undefined,
|
||||
type: 'STANDARD',
|
||||
created_at: faker.date.recent().toISOString(),
|
||||
updated_at: faker.date.recent().toISOString(),
|
||||
}
|
||||
|
||||
const Page = ({ onClose }: { onClose: () => void }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<ProjectContextProvider projectRef="default">
|
||||
<button onClick={() => setOpen(true)}>Open</button>
|
||||
|
||||
<EditBucketModal
|
||||
visible={open}
|
||||
bucket={bucket}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
</ProjectContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe(`EditBucketModal`, () => {
|
||||
beforeEach(() => {
|
||||
// useParams
|
||||
routerMock.setCurrentUrl(`/project/default/storage/buckets/test`)
|
||||
// useSelectedProject -> Project
|
||||
addAPIMock({
|
||||
method: `get`,
|
||||
path: `/platform/projects/:ref`,
|
||||
// @ts-expect-error
|
||||
response: {
|
||||
cloud_provider: 'localhost',
|
||||
id: 1,
|
||||
inserted_at: '2021-08-02T06:40:40.646Z',
|
||||
name: 'Default Project',
|
||||
organization_id: 1,
|
||||
ref: 'default',
|
||||
region: 'local',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
},
|
||||
})
|
||||
// useBucketUpdateMutation
|
||||
addAPIMock({
|
||||
method: `patch`,
|
||||
path: `/platform/storage/:ref/buckets/:id`,
|
||||
})
|
||||
})
|
||||
|
||||
it(`renders a dialog with a form`, async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<Page onClose={onClose} />)
|
||||
|
||||
const openButton = screen.getByRole(`button`, { name: `Open` })
|
||||
await userEvent.click(openButton)
|
||||
await screen.findByRole(`dialog`)
|
||||
|
||||
const nameInput = screen.getByLabelText(`Name of bucket`)
|
||||
expect(nameInput).toHaveValue(`test`)
|
||||
expect(nameInput).toBeDisabled()
|
||||
|
||||
const publicToggle = screen.getByLabelText(`Public bucket`)
|
||||
expect(publicToggle).not.toBeChecked()
|
||||
await userEvent.click(publicToggle)
|
||||
expect(publicToggle).toBeChecked()
|
||||
|
||||
const detailsTrigger = screen.getByRole(`button`, { name: `Additional configuration` })
|
||||
expect(detailsTrigger).toHaveAttribute(`data-state`, `closed`)
|
||||
await userEvent.click(detailsTrigger)
|
||||
expect(detailsTrigger).toHaveAttribute(`data-state`, `open`)
|
||||
|
||||
const sizeLimitToggle = screen.getByLabelText(`Restrict file upload size for bucket`)
|
||||
expect(sizeLimitToggle).not.toBeChecked()
|
||||
await userEvent.click(sizeLimitToggle)
|
||||
expect(sizeLimitToggle).toBeChecked()
|
||||
|
||||
const sizeLimitInput = screen.getByLabelText(`File size limit`)
|
||||
expect(sizeLimitInput).toHaveValue(0)
|
||||
await userEvent.type(sizeLimitInput, `25`)
|
||||
|
||||
const sizeLimitUnitSelect = screen.getByLabelText(`File size limit unit`)
|
||||
expect(sizeLimitUnitSelect).toHaveTextContent(`bytes`)
|
||||
await userEvent.click(sizeLimitUnitSelect)
|
||||
const mbOption = screen.getByRole(`option`, { name: `MB` })
|
||||
await userEvent.click(mbOption)
|
||||
expect(sizeLimitUnitSelect).toHaveTextContent(`MB`)
|
||||
|
||||
const mimeTypeInput = screen.getByLabelText(`Allowed MIME types`)
|
||||
expect(mimeTypeInput).toHaveValue(``)
|
||||
await userEvent.type(mimeTypeInput, `image/jpeg, image/png`)
|
||||
|
||||
const confirmButton = screen.getByRole(`button`, { name: `Save` })
|
||||
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { faker } from '@faker-js/faker'
|
||||
|
||||
import { addAPIMock } from 'tests/lib/msw'
|
||||
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { Bucket } from 'data/storage/buckets-query'
|
||||
import EmptyBucketModal from '../EmptyBucketModal'
|
||||
import { render } from 'tests/helpers'
|
||||
import { routerMock } from 'tests/lib/route-mock'
|
||||
|
||||
const bucket: Bucket = {
|
||||
id: faker.string.uuid(),
|
||||
name: `test`,
|
||||
owner: faker.string.uuid(),
|
||||
public: faker.datatype.boolean(),
|
||||
allowed_mime_types: faker.helpers.multiple(() => faker.system.mimeType(), {
|
||||
count: { min: 1, max: 5 },
|
||||
}),
|
||||
file_size_limit: faker.number.int({ min: 0, max: 25165824 }),
|
||||
type: faker.helpers.arrayElement(['STANDARD', 'ANALYTICS', undefined]),
|
||||
created_at: faker.date.recent().toISOString(),
|
||||
updated_at: faker.date.recent().toISOString(),
|
||||
}
|
||||
|
||||
const Page = ({ onClose }: { onClose: () => void }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<ProjectContextProvider projectRef="default">
|
||||
<button onClick={() => setOpen(true)}>Open</button>
|
||||
|
||||
<EmptyBucketModal
|
||||
visible={open}
|
||||
bucket={bucket}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
</ProjectContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe(`EmptyBucketModal`, () => {
|
||||
beforeEach(() => {
|
||||
// useParams
|
||||
routerMock.setCurrentUrl(`/project/default/storage/buckets/test`)
|
||||
// useSelectedProject -> Project
|
||||
addAPIMock({
|
||||
method: `get`,
|
||||
path: `/platform/projects/:ref`,
|
||||
// @ts-expect-error
|
||||
response: {
|
||||
cloud_provider: 'localhost',
|
||||
id: 1,
|
||||
inserted_at: '2021-08-02T06:40:40.646Z',
|
||||
name: 'Default Project',
|
||||
organization_id: 1,
|
||||
ref: 'default',
|
||||
region: 'local',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
},
|
||||
})
|
||||
// useBucketEmptyMutation
|
||||
addAPIMock({
|
||||
method: `post`,
|
||||
path: `/platform/storage/:ref/buckets/:id/empty`,
|
||||
})
|
||||
// Called by useStorageExplorerStateSnapshot but seems
|
||||
// to be unnecessary for succesful test?
|
||||
//
|
||||
// useProjectSettingsV2Query -> ProjectSettings
|
||||
// GET /platform/projects/:ref/settings
|
||||
// useAPIKeysQuery -> APIKey[]
|
||||
// GET /v1/projects/:ref/api-keys
|
||||
// listBucketObjects -> ListBucketObjectsData
|
||||
// POST /platform/storage/:ref/buckets/:id/objects/list
|
||||
})
|
||||
|
||||
it(`renders a confirmation dialog`, async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<Page onClose={onClose} />)
|
||||
|
||||
const openButton = screen.getByRole(`button`, { name: `Open` })
|
||||
await userEvent.click(openButton)
|
||||
await screen.findByRole(`dialog`)
|
||||
|
||||
const confirmButton = screen.getByRole(`button`, { name: `Empty Bucket` })
|
||||
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
|
||||
})
|
||||
})
|
||||
@@ -3,9 +3,10 @@ import { ExternalLink } from 'lucide-react'
|
||||
import { useVercelRedirectQuery } from 'data/integrations/vercel-redirect-query'
|
||||
import { Alert_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui'
|
||||
import PartnerIcon from './PartnerIcon'
|
||||
import { MANAGED_BY, ManagedBy } from 'lib/constants/infrastructure'
|
||||
|
||||
interface PartnerManagedResourceProps {
|
||||
partner: 'vercel-marketplace' | 'aws-marketplace'
|
||||
partner: ManagedBy
|
||||
resource: string
|
||||
cta?: {
|
||||
installationId?: string
|
||||
@@ -15,11 +16,13 @@ interface PartnerManagedResourceProps {
|
||||
}
|
||||
|
||||
export const PARTNER_TO_NAME = {
|
||||
'vercel-marketplace': 'Vercel Marketplace',
|
||||
'aws-marketplace': 'AWS Marketplace',
|
||||
[MANAGED_BY.VERCEL_MARKETPLACE]: 'Vercel Marketplace',
|
||||
[MANAGED_BY.AWS_MARKETPLACE]: 'AWS Marketplace',
|
||||
[MANAGED_BY.SUPABASE]: 'Supabase',
|
||||
} as const
|
||||
|
||||
function PartnerManagedResource({ partner, resource, cta }: PartnerManagedResourceProps) {
|
||||
const isManagedBySupabase = partner === MANAGED_BY.SUPABASE
|
||||
const ctaEnabled = cta !== undefined
|
||||
|
||||
const { data, isLoading, isError } = useVercelRedirectQuery(
|
||||
@@ -27,10 +30,12 @@ function PartnerManagedResource({ partner, resource, cta }: PartnerManagedResour
|
||||
installationId: cta?.installationId,
|
||||
},
|
||||
{
|
||||
enabled: ctaEnabled,
|
||||
enabled: ctaEnabled && !isManagedBySupabase,
|
||||
}
|
||||
)
|
||||
|
||||
if (isManagedBySupabase) return null
|
||||
|
||||
const ctaUrl = (data?.url ?? '') + (cta?.path ?? '')
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Code, Play } from 'lucide-react'
|
||||
import { DragEvent, ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import { Bar, BarChart, CartesianGrid, Cell, Tooltip, XAxis, YAxis } from 'recharts'
|
||||
@@ -8,10 +9,9 @@ import { ReportBlockContainer } from 'components/interfaces/Reports/ReportBlock/
|
||||
import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
|
||||
import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results'
|
||||
import { usePrimaryDatabase } from 'data/read-replicas/replicas-query'
|
||||
import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
|
||||
import dayjs from 'dayjs'
|
||||
import { Parameter, parseParameters } from 'lib/sql-parameters'
|
||||
import { Dashboards } from 'types'
|
||||
import { type QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
|
||||
import { type Parameter, parseParameters } from 'lib/sql-parameters'
|
||||
import type { Dashboards } from 'types'
|
||||
import { ChartContainer, ChartTooltipContent, cn, CodeBlock, SQL_ICON } from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
import { ButtonTooltip } from '../ButtonTooltip'
|
||||
@@ -178,9 +178,10 @@ export const QueryBlock = ({
|
||||
setQueryError(undefined)
|
||||
},
|
||||
onError: (error) => {
|
||||
const readOnlyTransaction = /cannot execute .+ in a read-only transaction/.test(error.message)
|
||||
const permissionDenied = error.message.includes('permission denied')
|
||||
const notOwner = error.message.includes('must be owner')
|
||||
if (permissionDenied || notOwner) {
|
||||
if (readOnlyTransaction || permissionDenied || notOwner) {
|
||||
setReadOnlyError(true)
|
||||
if (showRunButtonIfNotReadOnly) setShowWarning('hasWriteOperation')
|
||||
} else {
|
||||
|
||||
@@ -2,12 +2,14 @@ import { handleError, post } from 'data/fetchers'
|
||||
|
||||
interface getTemporaryAPIKeyVariables {
|
||||
projectRef?: string
|
||||
/** In seconds, max: 3600 (an hour) */
|
||||
expiry?: number
|
||||
}
|
||||
|
||||
// [Joshen] This one specifically shouldn't need a useQuery hook since the expiry is meant to be short lived
|
||||
// Used in storage explorer and realtime inspector.
|
||||
export async function getTemporaryAPIKey(
|
||||
{ projectRef }: getTemporaryAPIKeyVariables,
|
||||
{ projectRef, expiry = 300 }: getTemporaryAPIKeyVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
@@ -15,7 +17,10 @@ export async function getTemporaryAPIKey(
|
||||
const { data, error } = await post('/platform/projects/{ref}/api-keys/temporary', {
|
||||
params: {
|
||||
path: { ref: projectRef },
|
||||
query: { authorization_exp: '300', claims: JSON.stringify({ role: 'service_role' }) },
|
||||
query: {
|
||||
authorization_exp: expiry.toString(),
|
||||
claims: JSON.stringify({ role: 'service_role' }),
|
||||
},
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { get, handleError } from 'data/fetchers'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import type { Organization, ResponseError } from 'types'
|
||||
import { organizationKeys } from './keys'
|
||||
import { MANAGED_BY, ManagedBy } from 'lib/constants/infrastructure'
|
||||
|
||||
export type OrganizationBase = components['schemas']['OrganizationResponse']
|
||||
|
||||
@@ -12,11 +13,25 @@ export function castOrganizationResponseToOrganization(org: OrganizationBase): O
|
||||
return {
|
||||
...org,
|
||||
billing_email: org.billing_email ?? 'Unknown',
|
||||
managed_by: org.slug.startsWith('vercel_icfg_') ? 'vercel-marketplace' : 'supabase',
|
||||
managed_by: getManagedBy(org),
|
||||
partner_id: org.slug.startsWith('vercel_') ? org.slug.replace('vercel_', '') : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function getManagedBy(org: OrganizationBase): ManagedBy {
|
||||
switch (org.billing_partner) {
|
||||
case 'vercel_marketplace':
|
||||
return MANAGED_BY.VERCEL_MARKETPLACE
|
||||
// TODO(ignacio): Uncomment this when we've deployed the AWS Marketplace new slug
|
||||
// case 'aws_marketplace':
|
||||
// return MANAGED_BY.AWS_MARKETPLACE
|
||||
case 'aws':
|
||||
return MANAGED_BY.AWS_MARKETPLACE
|
||||
default:
|
||||
return MANAGED_BY.SUPABASE
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrganizations({
|
||||
signal,
|
||||
headers,
|
||||
|
||||
@@ -31,7 +31,7 @@ export const usePermissionsQuery = <TData = PermissionsData>({
|
||||
{
|
||||
...options,
|
||||
enabled: IS_PLATFORM && enabled && isLoggedIn,
|
||||
staleTime: 30 * 60 * 1000,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import { useQuery, useQueryClient, type UseQueryOptions } from '@tanstack/react-
|
||||
import { sortBy } from 'lodash'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants'
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { tableKeys } from './keys'
|
||||
import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants'
|
||||
|
||||
export type TablesVariables = {
|
||||
projectRef?: string
|
||||
|
||||
@@ -63,8 +63,12 @@ Sentry.init({
|
||||
const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes(
|
||||
`Failed to construct 'URL': Invalid URL`
|
||||
)
|
||||
// [Joshen] Similar behaviour for this error from SessionTimeoutModal to control the quota usage
|
||||
const isSessionTimeoutEvent = (hint.originalException as any)?.message.includes(
|
||||
'Session error detected'
|
||||
)
|
||||
|
||||
if (isInvalidUrlEvent && Math.random() > 0.01) {
|
||||
if ((isInvalidUrlEvent || isSessionTimeoutEvent) && Math.random() > 0.01) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,14 @@ export const AWS_REGIONS_DEFAULT =
|
||||
// TO DO, change default to US region for prod
|
||||
export const FLY_REGIONS_DEFAULT = FLY_REGIONS.SOUTHEAST_ASIA
|
||||
|
||||
export const MANAGED_BY = {
|
||||
VERCEL_MARKETPLACE: 'vercel-marketplace',
|
||||
AWS_MARKETPLACE: 'aws-marketplace',
|
||||
SUPABASE: 'supabase',
|
||||
}
|
||||
|
||||
export type ManagedBy = (typeof MANAGED_BY)[keyof typeof MANAGED_BY]
|
||||
|
||||
export const PRICING_TIER_LABELS_ORG = {
|
||||
FREE: 'Free - $0/month',
|
||||
PRO: 'Pro - $25/month',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isNonNullable } from './isNonNullable.js'
|
||||
|
||||
describe(`isNonNullable`, () => {
|
||||
it.each([
|
||||
[null, false],
|
||||
[undefined, false],
|
||||
// void
|
||||
[(() => {})(), false],
|
||||
// Truthy
|
||||
[`string`, true],
|
||||
[1, true],
|
||||
[true, true],
|
||||
// Falsy
|
||||
[``, true],
|
||||
[NaN, true],
|
||||
[0, true],
|
||||
[0, true],
|
||||
[false, true],
|
||||
// Type coercion
|
||||
[[], true],
|
||||
[{}, true],
|
||||
])(`correctly matches against nullish values`, (val, expected) => {
|
||||
expect(isNonNullable(val)).toStrictEqual(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
export type Maybe<T> = T | null | undefined
|
||||
|
||||
/**
|
||||
* Used to test whether a `Maybe` typed value is `null` or `undefined`.
|
||||
*
|
||||
* When called, the given value's type is narrowed to `NonNullable<T>`.
|
||||
*
|
||||
* ### Example Usage:
|
||||
*
|
||||
* ```ts
|
||||
* const fn = (str: Maybe<string>) => {
|
||||
* if (!isNonNullable(str)) {
|
||||
* // typeof str = null | undefined
|
||||
* // ...
|
||||
* }
|
||||
* // typeof str = string
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const isNonNullable = <T extends Maybe<unknown>>(val?: T): val is NonNullable<T> =>
|
||||
typeof val !== `undefined` && val !== null
|
||||
@@ -4,7 +4,7 @@ import { Extensions } from 'components/interfaces/Database'
|
||||
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
@@ -18,14 +18,17 @@ const DatabaseExtensions: NextPageWithLayout = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection>
|
||||
<div className="col-span-12">
|
||||
<FormHeader title="Database Extensions" />
|
||||
<PageLayout
|
||||
size="large"
|
||||
title="Database Extensions"
|
||||
subtitle="Manage what extensions are installed in your database"
|
||||
>
|
||||
<ScaffoldContainer size="large">
|
||||
<ScaffoldSection isFullWidth>
|
||||
<Extensions />
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { PublicationsList } from 'components/interfaces/Database/Publications/PublicationsList'
|
||||
import { PublicationsTables } from 'components/interfaces/Database/Publications/PublicationsTables'
|
||||
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
// [Joshen] Technically, best that we have these as separate URLs
|
||||
// makes it easier to manage state, but foresee that this page might
|
||||
// be consolidated somewhere else eventually for better UX
|
||||
|
||||
const DatabasePublications: NextPageWithLayout = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const { data } = useDatabasePublicationsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const publications = data ?? []
|
||||
|
||||
const { can: canViewPublications, isSuccess: isPermissionsLoaded } =
|
||||
useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'publications')
|
||||
|
||||
const [selectedPublicationId, setSelectedPublicationId] = useState<number>()
|
||||
const selectedPublication = publications.find((pub) => pub.id === selectedPublicationId)
|
||||
|
||||
if (isPermissionsLoaded && !canViewPublications) {
|
||||
return <NoPermission isFullPage resourceText="view database publications" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection>
|
||||
<div className="col-span-12">
|
||||
<FormHeader title="Database Publications" />
|
||||
{selectedPublication === undefined ? (
|
||||
<PublicationsList onSelectPublication={setSelectedPublicationId} />
|
||||
) : (
|
||||
<PublicationsTables
|
||||
selectedPublication={selectedPublication}
|
||||
onSelectBack={() => setSelectedPublicationId(undefined)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
DatabasePublications.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<DatabaseLayout title="Database">{page}</DatabaseLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default DatabasePublications
|
||||
@@ -0,0 +1,37 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { PublicationsTables } from 'components/interfaces/Database/Publications/PublicationsTables'
|
||||
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const DatabasePublications: NextPageWithLayout = () => {
|
||||
const { can: canViewPublications, isSuccess: isPermissionsLoaded } =
|
||||
useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'publications')
|
||||
|
||||
if (isPermissionsLoaded && !canViewPublications) {
|
||||
return <NoPermission isFullPage resourceText="view database publications" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<PublicationsTables />
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
DatabasePublications.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<DatabaseLayout title="Database">
|
||||
<PageLayout title="Database Publications">{page}</PageLayout>
|
||||
</DatabaseLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default DatabasePublications
|
||||
@@ -0,0 +1,37 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { PublicationsList } from 'components/interfaces/Database/Publications/PublicationsList'
|
||||
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const DatabasePublications: NextPageWithLayout = () => {
|
||||
const { can: canViewPublications, isSuccess: isPermissionsLoaded } =
|
||||
useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'publications')
|
||||
|
||||
if (isPermissionsLoaded && !canViewPublications) {
|
||||
return <NoPermission isFullPage resourceText="view database publications" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<PublicationsList />
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
DatabasePublications.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<DatabaseLayout title="Database">
|
||||
<PageLayout title="Database Publications">{page}</PageLayout>
|
||||
</DatabaseLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default DatabasePublications
|
||||
@@ -61,8 +61,8 @@ export const createTableEditorTableState = ({
|
||||
const gridColumns = getInitialGridColumns(
|
||||
getGridColumns(supaTable, {
|
||||
tableId: table.id,
|
||||
editable,
|
||||
onAddColumn: editable ? onAddColumn : undefined,
|
||||
editable: state.editable,
|
||||
onAddColumn: state.editable ? onAddColumn : undefined,
|
||||
onExpandJSONEditor,
|
||||
onExpandTextEditor,
|
||||
}),
|
||||
|
||||
@@ -2,9 +2,10 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { OrganizationBase } from 'data/organizations/organizations-query'
|
||||
import { PlanId } from 'data/subscriptions/types'
|
||||
import jsonLogic from 'json-logic-js'
|
||||
import { ManagedBy } from 'lib/constants/infrastructure'
|
||||
|
||||
export interface Organization extends OrganizationBase {
|
||||
managed_by: 'supabase' | 'vercel-marketplace' | 'aws-marketplace'
|
||||
managed_by: ManagedBy
|
||||
partner_id?: string
|
||||
plan: { id: PlanId; name: string }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { configDefaults, defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
@@ -26,9 +26,11 @@ export default defineConfig({
|
||||
environment: 'jsdom', // TODO(kamil): This should be set per test via header in .tsx files only
|
||||
setupFiles: [
|
||||
resolve(dirname, './tests/vitestSetup.ts'),
|
||||
resolve(dirname, './tests/setup/polyfills.js'),
|
||||
resolve(dirname, './tests/setup/polyfills.ts'),
|
||||
resolve(dirname, './tests/setup/radix.js'),
|
||||
],
|
||||
// Don't look for tests in the nextjs output directory
|
||||
exclude: [...configDefaults.exclude, `.next/*`],
|
||||
reporters: [['default']],
|
||||
coverage: {
|
||||
reporter: ['lcov'],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export interface LumaGeoAddressJson {
|
||||
@@ -115,6 +116,7 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error('Error fetching meetups from Luma:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_EMAIL_ABUSE_URL as string
|
||||
const supabaseServiceKey = process.env.EMAIL_ABUSE_SERVICE_KEY as string
|
||||
@@ -78,7 +79,9 @@ export async function POST(req: NextRequest, props: { params: Promise<{ ref: str
|
||||
.from('manual_reports')
|
||||
.insert([{ project_ref: ref, reason, email }])
|
||||
|
||||
if (supabaseError) throw new Error(`Supabase error: ${supabaseError.message}`)
|
||||
if (supabaseError) {
|
||||
throw new Error(`Supabase error: ${supabaseError.message}`)
|
||||
}
|
||||
|
||||
const response = await fetch(process.env.EMAIL_REPORT_SLACK_WEBHOOK as string, {
|
||||
method: 'POST',
|
||||
@@ -93,6 +96,7 @@ export async function POST(req: NextRequest, props: { params: Promise<{ ref: str
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
const errorMessage = (error as Error).message
|
||||
return NextResponse.json(
|
||||
{ error: `Failure: Could not send post to Slack. Error: ${errorMessage}` },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
@@ -92,6 +94,7 @@ export async function POST(req: Request) {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
Sentry.captureException(errorData)
|
||||
return new Response(JSON.stringify({ message: errorData.message }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: response.status,
|
||||
@@ -103,6 +106,7 @@ export async function POST(req: Request) {
|
||||
status: 200,
|
||||
})
|
||||
} catch (error: any) {
|
||||
Sentry.captureException(error)
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 500,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
@@ -56,6 +58,7 @@ export async function POST(req: Request) {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
Sentry.captureException(errorData)
|
||||
return new Response(JSON.stringify({ message: errorData.message }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: response.status,
|
||||
@@ -67,6 +70,7 @@ export async function POST(req: Request) {
|
||||
status: 200,
|
||||
})
|
||||
} catch (error: any) {
|
||||
Sentry.captureException(error)
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 500,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
@@ -91,6 +93,7 @@ export async function POST(req: Request) {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
Sentry.captureException(errorData)
|
||||
return new Response(JSON.stringify({ message: errorData.message }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: response.status,
|
||||
@@ -102,6 +105,7 @@ export async function POST(req: Request) {
|
||||
status: 200,
|
||||
})
|
||||
} catch (error: any) {
|
||||
Sentry.captureException(error)
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 500,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { ImageResponse } from '@vercel/og'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { ImageResponse } from '@vercel/og'
|
||||
import useTicketBg from 'components/LaunchWeek/15/hooks/use-ticket-bg'
|
||||
|
||||
export const runtime = 'edge' // 'nodejs' is the default
|
||||
@@ -501,6 +501,9 @@ export async function GET(req: Request, res: Response) {
|
||||
|
||||
return await fetch(`${STORAGE_URL}/og/${ticketType}/${username}.png?t=${NEW_TIMESTAMP}`)
|
||||
} catch (error: any) {
|
||||
Sentry.captureException(error)
|
||||
await Sentry.flush(2000)
|
||||
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 400,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
await import('./sentry.server.config')
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === 'edge') {
|
||||
await import('./sentry.edge.config')
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError
|
||||
@@ -1,5 +1,6 @@
|
||||
import bundleAnalyzer from '@next/bundle-analyzer'
|
||||
import nextMdx from '@next/mdx'
|
||||
import { withSentryConfig } from '@sentry/nextjs'
|
||||
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
@@ -124,11 +125,37 @@ const nextConfig = {
|
||||
}
|
||||
|
||||
// next.config.js.
|
||||
export default () => {
|
||||
const configExport = () => {
|
||||
const plugins = [withContentlayer, withMDX, withBundleAnalyzer]
|
||||
return plugins.reduce((acc, next) => next(acc), nextConfig)
|
||||
}
|
||||
|
||||
export default withSentryConfig(configExport, {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
|
||||
org: 'supabase',
|
||||
project: 'www',
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
// This can increase your server load as well as your hosting bill.
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
// side errors will fail.
|
||||
// tunnelRoute: "/monitoring",
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
})
|
||||
|
||||
function getAssetPrefix() {
|
||||
// If not force enabled, but not production env, disable CDN
|
||||
if (process.env.FORCE_ASSET_CDN !== '1' && process.env.VERCEL_ENV !== 'production') {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@octokit/plugin-paginate-graphql": "^4.0.0",
|
||||
"@octokit/rest": "^21.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@sentry/nextjs": "^10",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@vercel/og": "^0.6.2",
|
||||
"ai-commands": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
||||
// The config you add here will be used whenever one of the edge features is loaded.
|
||||
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Enable logs to be sent to Sentry
|
||||
enableLogs: true,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
// This file configures the initialization of Sentry on the server.
|
||||
// The config you add here will be used whenever the server handles a request.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Enable logs to be sent to Sentry
|
||||
enableLogs: true,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
})
|
||||
@@ -6,7 +6,10 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-surface-100 text-card-foreground shadow-sm', className)}
|
||||
className={cn(
|
||||
'overflow-hidden rounded-lg border bg-surface-100 text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b [&>tr]:bg-200', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
@@ -64,7 +64,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle heading-meta [&:has([role=checkbox])]:pr-0',
|
||||
'h-10 px-4 text-left align-middle heading-meta text-foreground-lighter [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Generated
+49
-3
@@ -1553,6 +1553,9 @@ importers:
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.0.5
|
||||
version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@sentry/nextjs':
|
||||
specifier: ^10
|
||||
version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.3(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.25.2))
|
||||
'@supabase/supabase-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.49.3
|
||||
@@ -24785,6 +24788,32 @@ snapshots:
|
||||
|
||||
'@sentry/core@10.3.0': {}
|
||||
|
||||
'@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.3(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.25.2))':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/semantic-conventions': 1.36.0
|
||||
'@rollup/plugin-commonjs': 28.0.1(rollup@4.38.0)
|
||||
'@sentry-internal/browser-utils': 10.3.0
|
||||
'@sentry/core': 10.3.0
|
||||
'@sentry/node': 10.3.0(supports-color@8.1.1)
|
||||
'@sentry/opentelemetry': 10.3.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)
|
||||
'@sentry/react': 10.3.0(react@18.3.1)
|
||||
'@sentry/vercel-edge': 10.3.0
|
||||
'@sentry/webpack-plugin': 4.0.2(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.25.2))
|
||||
chalk: 3.0.0
|
||||
next: 15.3.3(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
|
||||
resolve: 1.22.8
|
||||
rollup: 4.38.0
|
||||
stacktrace-parser: 0.1.10
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/context-async-hooks'
|
||||
- '@opentelemetry/core'
|
||||
- '@opentelemetry/sdk-trace-base'
|
||||
- encoding
|
||||
- react
|
||||
- supports-color
|
||||
- webpack
|
||||
|
||||
'@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.3(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -24864,6 +24893,15 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@sentry/opentelemetry@10.3.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/semantic-conventions': 1.36.0
|
||||
'@sentry/core': 10.3.0
|
||||
|
||||
'@sentry/opentelemetry@10.3.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -24886,6 +24924,16 @@ snapshots:
|
||||
'@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0)
|
||||
'@sentry/core': 10.3.0
|
||||
|
||||
'@sentry/webpack-plugin@4.0.2(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.25.2))':
|
||||
dependencies:
|
||||
'@sentry/bundler-plugin-core': 4.0.2(encoding@0.1.13)(supports-color@8.1.1)
|
||||
unplugin: 1.0.1
|
||||
uuid: 9.0.1
|
||||
webpack: 5.94.0(esbuild@0.25.2)
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
'@sentry/webpack-plugin@4.0.2(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.94.0)':
|
||||
dependencies:
|
||||
'@sentry/bundler-plugin-core': 4.0.2(encoding@0.1.13)(supports-color@8.1.1)
|
||||
@@ -26786,7 +26834,7 @@ snapshots:
|
||||
'@types/pg@8.15.4':
|
||||
dependencies:
|
||||
'@types/node': 22.13.14
|
||||
pg-protocol: 1.7.1
|
||||
pg-protocol: 1.10.3
|
||||
pg-types: 2.2.0
|
||||
|
||||
'@types/phoenix@1.6.4': {}
|
||||
@@ -37040,7 +37088,6 @@ snapshots:
|
||||
webpack: 5.94.0(esbuild@0.25.2)
|
||||
optionalDependencies:
|
||||
esbuild: 0.25.2
|
||||
optional: true
|
||||
|
||||
terser-webpack-plugin@5.3.14(webpack@5.94.0):
|
||||
dependencies:
|
||||
@@ -38409,7 +38456,6 @@ snapshots:
|
||||
- '@swc/core'
|
||||
- esbuild
|
||||
- uglify-js
|
||||
optional: true
|
||||
|
||||
whatwg-encoding@2.0.0:
|
||||
dependencies:
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"NEXT_PUBLIC_HCAPTCHA_SITE_KEY",
|
||||
"HCAPTCHA_SECRET_KEY",
|
||||
"NODE_ENV",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
// These envs are used in the packages
|
||||
"NEXT_PUBLIC_STORAGE_KEY",
|
||||
"NEXT_PUBLIC_AUTH_DEBUG_KEY",
|
||||
|
||||
Reference in new issue
Block a user