Merge branch 'master' into feature/homepage

This commit is contained in:
Saxon Fletcher committed 2025-08-15 15:37:30 +10:00
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.
+28 -3
View File
@@ -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
+2
View File
@@ -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
@@ -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>
)}
</>
)
@@ -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>
)
})}
</>
@@ -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">&nbsp;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
})
}
@@ -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>
)}
</>
@@ -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({
@@ -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
@@ -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,
}
)
}
+1 -1
View File
@@ -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
+5 -1
View File
@@ -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',
+26
View File
@@ -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)
})
})
+22
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -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 }
}
+4 -2
View File
@@ -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(
{
+6 -2
View File
@@ -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,
+5 -2
View File
@@ -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,
+13
View File
@@ -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
+28 -1
View File
@@ -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') {
+1
View File
@@ -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:*",
+16
View File
@@ -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,
})
+15
View File
@@ -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}
+49 -3
View File
@@ -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:
+1
View File
@@ -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",