diff --git a/apps/docs/content/guides/functions/websockets.mdx b/apps/docs/content/guides/functions/websockets.mdx index b9741bb7c60..e4d15d7ae9d 100644 --- a/apps/docs/content/guides/functions/websockets.mdx +++ b/apps/docs/content/guides/functions/websockets.mdx @@ -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 }) } diff --git a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx index a6633706faa..14690e27cc3 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx @@ -59,12 +59,12 @@ hideToc: true - - + + - 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. @@ -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
{JSON.stringify(instruments, null, 2)}
- } - ``` - ```ts name=utils/supabase/server.ts import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' @@ -122,6 +111,36 @@ hideToc: true
+ + + 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. + + + + + + <$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
{JSON.stringify(instruments, null, 2)}
+ } + ``` + + + +
+ +
+ + Run the development server, go to http://localhost:3000/instruments in a browser and you should see the list of instruments. diff --git a/apps/docs/spec/supabase_dart_v2.yml b/apps/docs/spec/supabase_dart_v2.yml index 2264e2544e1..82e4ab3e1ce 100644 --- a/apps/docs/spec/supabase_dart_v2.yml +++ b/apps/docs/spec/supabase_dart_v2.yml @@ -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: '', 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: '', + type: FactorType.phone, + totp: null, + phone: PhoneEnrollment( + phone: '+1234567890', + ), ); ``` - id: mfa-challenge diff --git a/apps/docs/spec/supabase_js_v2.yml b/apps/docs/spec/supabase_js_v2.yml index a9834a43420..6eb80d4b262 100644 --- a/apps/docs/spec/supabase_js_v2.yml +++ b/apps/docs/spec/supabase_js_v2.yml @@ -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' diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx index 0f8007c0484..9cbb12638b1 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx @@ -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 && ( - Schema, - Name, - Values, - , - ]} - body={ - <> - {filteredEnumeratedTypes.length === 0 && search.length === 0 && ( - - -

No enumerated types created yet

-

- There are no enumerated types found in the schema "{selectedSchema}" -

-
-
- )} - {filteredEnumeratedTypes.length === 0 && search.length > 0 && ( - - -

No results found

-

- Your search for "{search}" did not return any results -

-
-
- )} - {filteredEnumeratedTypes.length > 0 && - filteredEnumeratedTypes.map((type) => ( - - -

{type.schema}

-
- {type.name} - {type.enums.join(', ')} - - {!isSchemaLocked && ( -
- - -
- )} -
-
- ))} - - } - /> + +
+ + + Schema + Name + Values + + + + + <> + {filteredEnumeratedTypes.length === 0 && search.length === 0 && ( + + +

No enumerated types created yet

+

+ There are no enumerated types found in the schema "{selectedSchema}" +

+
+
+ )} + {filteredEnumeratedTypes.length === 0 && search.length > 0 && ( + + +

No results found

+

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

+
+
+ )} + {filteredEnumeratedTypes.length > 0 && + filteredEnumeratedTypes.map((type) => ( + + +

{type.schema}

+
+ {type.name} + {type.enums.join(', ')} + + {!isSchemaLocked && ( +
+ + +
+ )} +
+
+ ))} + +
+
+ )} { +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 ( <> -
-
-
-

+ + +
+ {extension.name} -

-

- {extension?.installed_version ?? extension.default_version} -

+ + {extensionMeta?.deprecated && extensionMeta?.deprecated.length > 0 && ( + } + className="rounded-full" + tooltip={{ + content: { + text: `The extension is deprecated and will be removed in ${extensionMeta.deprecated.join(', ')}.`, + }, + }} + > + Deprecated + + )}
+ + + {extension?.installed_version ?? extension.default_version} + + + {isOn ? extension.schema : '-'} + + +

+ {extension.comment} +

+
+ + + {extensionMeta?.product ? ( +
+ {extensionMeta.product_url ? ( + + {extensionMeta.product} + + ) : ( + {extensionMeta.product} + )} + {!isOn && ( + + Install extension to use {extensionMeta.product} + + )} +
+ ) : ( + - + )} +
+ + + {extensionMeta?.github_url && ( + + )} + {docsUrl !== undefined && ( + + )} + + + {isDisabling ? ( ) : ( @@ -98,88 +170,8 @@ const ExtensionCard = ({ extension }: ExtensionCardProps) => { )} )} -
- - {isOn && ( -
-

- Installed in {extension.schema} schema -

-
- )} - -
-

{extension.comment}

-
- {extensionMeta?.github_url && ( - - )} - {docsUrl !== undefined && ( - - )} - {extensionMeta?.deprecated && extensionMeta?.deprecated.length > 0 && ( - } - className="rounded-full" - tooltip={{ - content: { - text: `The extension is deprecated and will be removed in ${extensionMeta.deprecated.join(', ')}.`, - }, - }} - > - Deprecated - - )} -
-
- - {extensionMeta?.product && ( -
-
- -
-
-

- {extension.name} is used by{' '} - {extensionMeta.product_url ? ( - - {extensionMeta.product} - - ) : ( - extensionMeta.product - )} -

- {!isOn && ( -

- Install extension to use {extensionMeta.product} -

- )} -
-
- )} -
+ + { ) } -export default ExtensionCard +export default ExtensionRow diff --git a/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx b/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx index b9e629dcda1..4aba0c0fa61 100644 --- a/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx +++ b/apps/studio/components/interfaces/Database/Extensions/Extensions.tsx @@ -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 ? ( -
-
- - -
- {Array.from({ length: 6 }).map((_, index) => ( - - ))} -
-
-
+ ) : ( - <> - {extensions.length === 0 && ( - setFilterString('')} - /> - )} - -
- {enabledExtensions.length > 0 && ( -
-

Enabled extensions

-
- {enabledExtensions.map((extension) => ( - - ))} -
-
- )} - - {disabledExtensions.length > 0 && ( -
-

Available extensions

-
- {disabledExtensions.map((extension) => ( - - ))} -
-
- )} -
- +
+ + + + + Name + Version + Schema + Description + Used by + Links + + Enabled + + + + + {[...enabledExtensions, ...disabledExtensions].map((extension) => ( + + ))} + {extensions.length === 0 && ( + + + setFilterString('')} + /> + + + )} + +
+
+
)} ) diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx index 4d72806bd16..680755b6c32 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx @@ -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 ( - - + +

No functions created yet

There are no functions found in the schema "{schema}"

-
-
+ + ) } if (_functions.length === 0 && filterString.length > 0) { return ( - - + +

No results found

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

-
-
+ + ) } @@ -87,8 +88,8 @@ const FunctionList = ({ const isApiDocumentAvailable = schema == 'public' && x.return_type !== 'trigger' return ( - - + + - - + +

{x.argument_types || '-'}

-
- + +

{x.return_type}

-
- {x.security_definer ? 'Definer' : 'Invoker'} - + + + {x.security_definer ? 'Definer' : 'Invoker'} + + {!isLocked && (
{canUpdateFunctions ? ( @@ -189,8 +192,8 @@ const FunctionList = ({ )}
)} -
-
+ + ) })} diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index 9f478304989..bc8f73b826a 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -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 = ({ {isSchemaLocked && } - - - Name - - Arguments - - - Return type - - - Security - - - - } - body={ - - } - /> + +
+ + + Name + + Arguments + + + Return type + + + Security + + + + + + + +
+ )} diff --git a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx index 3fe2c0dff97..8cabd076c5c 100644 --- a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx @@ -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 && ( -
- Schema, - Table, - Name, - , - ]} - body={ - <> +
+ +
+ + + Schema + Table + Name + + + + {sortedIndexes.length === 0 && search.length === 0 && ( - - + +

No indexes created yet

There are no indexes found in the schema "{selectedSchema}"

-
-
+ + )} {sortedIndexes.length === 0 && search.length > 0 && ( - - + +

No results found

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

-
-
+ + )} {indexes.length > 0 && indexes.map((index) => ( - - + +

{index.schema}

-
- + +

{index.table}

-
- + +

{index.name}

-
- + +
-
-
+ + ))} - - } - /> +
+
+
)} diff --git a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx index 0d7206b0fa7..ffe4a03f960 100644 --- a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx @@ -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={} /> - - Version - , - Name, - Inserted at (UTC), - , - ]} - body={ - migrations.length > 0 ? ( - migrations.map((migration) => { - // [Joshen] LEFT OFF HERE - const insertedAt = dayjs(migration.version, 'YYYYMMDDHHmmss').format( - 'DD MMM YYYY, HH:mm:ss' - ) + +
+ + + + Version + + Name + Inserted at (UTC) + + + + + {migrations.length > 0 ? ( + migrations.map((migration) => { + // [Joshen] LEFT OFF HERE + const insertedAt = dayjs(migration.version, 'YYYYMMDDHHmmss').format( + 'DD MMM YYYY, HH:mm:ss' + ) - return ( - - {migration.version} - - {migration?.name ?? 'Name not available'} - - {insertedAt} - - - - - ) - }) - ) : ( - - -

No results found

-

- Your search for "{search}" did not return any results -

-
-
- ) - } - /> + {migration?.name ?? 'Name not available'} + + {insertedAt} + + + + + ) + }) + ) : ( + + +

No results found

+

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

+
+
+ )} +
+
+ )} diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationSkeleton.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationSkeleton.tsx index c6774d0803c..70def772d88 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationSkeleton.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationSkeleton.tsx @@ -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 ( - - + + - - + + - + {Array.from({ length: 4 }).map((_, i) => ( - - - + + + ))} - +
-
-
+ + ) } - -export default PublicationSkeleton diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx index f24078292b2..deb947dd863 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx @@ -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('') - 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
} - placeholder={'Filter'} + icon={} + 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
- Name, - System ID, - Insert, - Update, - Delete, - Truncate, - - Source - , - ]} - body={ - isLoading - ? Array.from({ length: 5 }).map((_, i) => ) - : publications.map((x) => ( - - {x.name} - {x.id} + +
+ + + Name + System ID + Insert + Update + Delete + Truncate + + + + + {isLoading && + Array.from({ length: 2 }).map((_, i) => )} + + {isError && ( + + + + + + )} + + {isSuccess && + publications.map((x) => ( + + + {x.name} + {/* [Joshen] Making this tooltip very specific for these 2 publications */} + {['supabase_realtime', 'supabase_realtime_messages_publication'].includes( + x.name + ) && ( + + + + + + {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} + + + )} + + {x.id} {publicationEvents.map((event) => ( - - + { + onClick={() => { setToggleListenEventValue({ publication: x, event, @@ -130,27 +180,24 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis }) }} /> - + ))} - -
-
-
- - )) - } - /> + +
+ ))} +
+
+
{!isLoading && publications.length === 0 && ( diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx index 0ae7b75c7f9..b7a8425e002 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx @@ -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.name} - {table.schema} - + + {table.name} + {table.schema} + {table.comment} - - + +
{enabledForAllTables ? ( @@ -84,19 +90,24 @@ const PublicationsTableItem = ({ table, selectedPublication }: PublicationsTable  for all tables ) : ( - toggleReplicationForTable(table, selectedPublication)} - /> + + + toggleReplicationForTable(table, selectedPublication)} + /> + + {isProtected && ( + + This table belongs to a protected schema, and its publication cannot be toggled + + )} + )}
-
-
+ + ) } - -export default PublicationsTableItem diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index ec191ac0ce7..fe55650d79a 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -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('') 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 ( <>
-
@@ -94,40 +95,42 @@ export const PublicationsTables = ({ (tables.length === 0 ? ( ) : ( -
- Name, - Schema, - - Description - , - - {/* Temporarily disable All tables toggle for publications. See https://github.com/supabase/supabase/pull/7233. -
-
- All Tables -
- toggleReplicationForAllTables(publication, enabledForAllTables)} - /> -
*/} -
, - ]} - body={tables.map((table) => ( - - ))} - /> - + +
+ + + Name + Schema + Description + {/* + We've disabled All tables toggle for publications. + See https://github.com/supabase/supabase/pull/7233. + */} + + + + + {!!selectedPublication ? ( + tables.map((table) => ( + + )) + ) : ( + + +

The selected publication with ID {id} cannot be found

+

+ Head back to the list of publications to select one from there +

+
+
+ )} +
+
+ ))} ) diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 68e4ead2a14..5d08596a521 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -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 && (
- , - Name, - - Description - , - - Rows (Estimated) - , - - Size (Estimated) - , - - Realtime Enabled - , - , - ]} - body={ - <> - {entities.length === 0 && filterString.length === 0 && ( - - - {visibleTypes.length === 0 ? ( - <> -

- Please select at least one entity type to filter with -

-

- There are currently no results based on the filter that you have applied -

- - ) : ( - <> -

No tables created yet

-

- 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}" -

- - )} -
-
- )} - {entities.length === 0 && filterString.length > 0 && ( - - -

No results found

-

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

-
-
- )} - {entities.length > 0 && - entities.map((x) => ( - - - - - {x.type === ENTITY_TYPE.TABLE ? ( - - ) : x.type === ENTITY_TYPE.VIEW ? ( - - ) : ( -
- {Object.entries(ENTITY_TYPE) - .find(([, value]) => value === x.type)?.[0]?.[0] - ?.toUpperCase()} -
- )} -
- - {formatTooltipText(x.type)} - -
-
- - {/* only show tooltips if required, to reduce noise */} - {x.name.length > 20 ? ( - - -

{x.name}

-
- - {x.name} -
+ +
+ + + + Name + + Description + + + Rows (Estimated) + + + Size (Estimated) + + + Realtime Enabled + + + + + + <> + {entities.length === 0 && filterString.length === 0 && ( + + + {visibleTypes.length === 0 ? ( + <> +

+ Please select at least one entity type to filter with +

+

+ There are currently no results based on the filter that you have + applied +

+ ) : ( -

{x.name}

+ <> +

No tables created yet

+

+ 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}" +

+ )} - - - {x.comment !== null ? ( - - {x.comment} - - ) : ( -

No description

- )} -
- - {x.rows !== undefined ? x.rows.toLocaleString() : '-'} - - - {x.size !== undefined ? {x.size} : '-'} - - - {(realtimePublication?.tables ?? []).find((table) => table.id === x.id) ? ( -
- -
- ) : ( -
- -
- )} -
- -
- - - {!isSchemaLocked && ( - - -
-
- - ))} - - } - /> +
+ + {x.comment !== null ? ( + + {x.comment} + + ) : ( +

No description

+ )} +
+ + {x.rows !== undefined ? x.rows.toLocaleString() : '-'} + + + {x.size !== undefined ? {x.size} : '-'} + + + {(realtimePublication?.tables ?? []).find( + (table) => table.id === x.id + ) ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ +
+ + + {!isSchemaLocked && ( + + +
+
+
+ ))} + +
+
+
)}
diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx index cc3f4b5283f..f78e526252b 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx @@ -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 ( - - + +

No triggers created yet

There are no triggers found in the schema "{schema}"

-
-
+ + ) } if (_triggers.length === 0 && filterString.length > 0) { return ( - - + +

No results found

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

-
-
+ + ) } return ( <> {_triggers.map((x: any) => ( - - + + editTrigger(x)} @@ -98,35 +100,35 @@ const TriggerList = ({ {x.name} - + - +

{x.table}

-
+ - +

{x.function_name}

-
+ - +
{x.events.map((event: string) => ( {`${x.activation} ${event}`} ))}
-
+ - +

{x.orientation}

-
+ - +
{x.enabled_mode !== 'DISABLED' ? ( @@ -134,9 +136,9 @@ const TriggerList = ({ )}
-
+ - + {!isLocked && (
{canUpdateTriggers ? ( @@ -216,8 +218,8 @@ const TriggerList = ({ )}
)} -
-
+ + ))} ) diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx index 2978d9d3074..a9097f7190d 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx @@ -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 && }
- - Name - Table - Function - Events - Orientation - - Enabled - - - - } - body={ - - } - /> + +
+ + + Name + Table + Function + Events + Orientation + + Enabled + + + + + + + +
+
)} diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx index 5ea6f0cfb08..2478f55f7cd 100644 --- a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx +++ b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx @@ -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 } - if (pgGraphqlExtension?.installed_version === null) { - return ( -
-
-
-

Enable the GraphQL Extension

-

- Toggle the switch below to enable the GraphQL extension. You can then use the GraphQL - API with your Supabase Database. -

-
- - -
-
- ) - } - return } diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx index b69576d5454..d15afd50960 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretRow.tsx @@ -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(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 ( -
-
-
-

- {name} -

- {secret.description !== undefined && ( -

- {secret.description} -

- )} -
-
- -

- {secret.id} -

-
-
-
-
-
-

- {secret.updated_at === secret.created_at ? 'Added' : 'Updated'} on{' '} - {dayjs(secret.updated_at).format('MMM D, YYYY')} -

- + if (col.id === 'actions') { + return ( +
e.stopPropagation()}>
+ ) + } + + if (col.id === 'secret_value') { + return ( +
e.stopPropagation()}> +
+ ) + } + + if (col.id === 'updated_at') { + return ( +
+

+ {row.updated_at === row.created_at ? 'Added' : 'Updated'} on{' '} + {dayjs(row.updated_at).format('MMM D, YYYY')} +

+
+ ) + } + + if (col.id === 'id') { + return ( +
+ +

+ {row.id} +

+
+ ) + } + + return ( +
+

+ {name} +

+ {row.description !== undefined && row.description !== '' && ( +
+

{row.description}

+
+ )}
) } diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/Secrets.utils.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/Secrets.utils.tsx new file mode 100644 index 00000000000..a1bec3a55db --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/Secrets.utils.tsx @@ -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[] => { + return SECRET_TABLE_COLUMNS.map((col) => { + const result: Column = { + 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 ( +
+

{col.name}

+
+ ) + }, + renderCell: ({ row }) => , + } + return result + }) +} diff --git a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx index e1278afbbb8..05bd8a8f726 100644 --- a/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx +++ b/apps/studio/components/interfaces/Integrations/Vault/Secrets/SecretsManagement.tsx @@ -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('') const [showAddSecretModal, setShowAddSecretModal] = useState(false) const [selectedSecretToRemove, setSelectedSecretToRemove] = useState() - 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 ( <> -
-
-
- setSearchValue(event.target.value)} - icon={} - actions={ - searchValue.length > 0 - ? [ -
-
- - setShowAddSecretModal(true)} - tooltip={{ - content: { - side: 'bottom', - text: !canManageSecrets - ? 'You need additional permissions to add secrets' - : undefined, - }, - }} - > - Add new secret - -
-
+
+
+
+
+ } + value={searchValue ?? ''} + onChange={(e) => setSearchValue(e.target.value)} + actions={[ + searchValue && ( + + + setShowAddSecretModal(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canManageSecrets + ? 'You need additional permissions to add secrets' + : undefined, + }, + }} + > + Add new secret + +
+
+ + + + {isError ? (
- -

Loading secrets from the Vault

+

Failed to load secrets

) : ( - <> - {secrets.map((secret, idx) => { - return ( - - - {idx !== secrets.length - 1 && } - + 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 ? ( -
-

No secrets added yet

-

- The Vault allows you to store sensitive information like API keys -

-
- ) : ( -
-

No results found

-

- Your search for "{searchValue}" did not return any results -

-
- )} - - )} - + }} + renderers={{ + renderRow(_, props) { + return + }, + }} + /> )} + + {secrets.length === 0 && !isLoading && !isError ? ( +
+
+

+ {searchValue ? 'No secrets found' : 'No secrets added yet'} +

+

+ {searchValue + ? `There are currently no secrets based on the search "${searchValue}"` + : 'The Vault allows you to store sensitive information like API keys'} +

+
+
+ ) : null}
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx index ea047d76d18..ce683309f80 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx @@ -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 ( - - + +
{ /> )}
-
+ - + {isInvitedUser && member.invited_at && ( {isInviteExpired(member.invited_at) ? 'Expired' : 'Invited'} )} {member.is_sso_user && SSO} - + - +
{member.mfa_enabled ? ( @@ -122,9 +123,9 @@ export const MemberRow = ({ member }: MemberRowProps) => { )}
-
+ - + {isLoadingRoles ? ( ) : isObfuscated ? ( @@ -201,11 +202,11 @@ export const MemberRow = ({ member }: MemberRowProps) => { ) }) )} - + - + - -
+ + ) } diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index 3ecc3b3de90..22b3d069982 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -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 && (
- - User, - , - - Enabled MFA - , - - Role - - - - - How to configure access control? - - , - , - ]} - body={[ - ...(isSuccessRoles && isSuccessMembers && !isOrgScopedRole - ? [ - - - - - , - ] - : []), - ...(!!user ? [] : []), - ...sortedMembers.map((member) => ( - - )), - ...(searchString.length > 0 && filteredMembers.length === 0 - ? [ - - -
- -

- No users matched the search query "{searchString}" -

-
-
-
, - ] - : []), - - -

- {searchString ? `${filteredMembers.length} of ` : ''} - {members.length || '0'} {members.length == 1 ? 'user' : 'users'} -

-
-
, - ]} - /> - + + +
+ + + User + + + Enabled MFA + + + Role + + + + + + How to configure access control? + + + + + + + + + {[ + ...(isSuccessRoles && isSuccessMembers && !isOrgScopedRole + ? [ + + + + + , + ] + : []), + ...(!!user ? [] : []), + ...sortedMembers.map((member) => ( + + )), + ...(searchString.length > 0 && filteredMembers.length === 0 + ? [ + + +
+ +

+ No users matched the search query "{searchString}" +

+
+
+
, + ] + : []), + + +

+ {searchString ? `${filteredMembers.length} of ` : ''} + {members.length || '0'} {members.length == 1 ? 'user' : 'users'} +

+
+
, + ]} +
+
+
+
)} diff --git a/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx index e45ead06f10..4851d41d6ea 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx @@ -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, diff --git a/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx b/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx index 867438923c1..08f008921b1 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx @@ -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 ? : } - 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({ diff --git a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx index ca059755665..2ed1e7515e7 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx @@ -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 diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx index 9d7255035e5..2c604636847 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx @@ -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' + ) }} >
diff --git a/apps/studio/components/interfaces/Storage/BucketRow.tsx b/apps/studio/components/interfaces/Storage/BucketRow.tsx index 78ef08e09c0..4023d5ab356 100644 --- a/apps/studio/components/interfaces/Storage/BucketRow.tsx +++ b/apps/studio/components/interfaces/Storage/BucketRow.tsx @@ -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(null) + const onClose = () => setModal(null) return (
onSelectEditBucket()} + onClick={() => setModal(`edit`)} >

Edit bucket

@@ -93,7 +91,7 @@ const BucketRow = ({ onSelectEmptyBucket()} + onClick={() => setModal(`empty`)} >

Empty bucket

@@ -103,7 +101,7 @@ const BucketRow = ({ onSelectDeleteBucket()} + onClick={() => setModal(`delete`)} >

Delete bucket

@@ -113,6 +111,10 @@ const BucketRow = ({ ) : (
)} + + + +
) } diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index b02df4a2a4e..db5181a618d 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -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 -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.BYTES) + const [selectedUnit, setSelectedUnit] = useState(StorageSizeUnits.BYTES) const [showConfiguration, setShowConfiguration] = useState(false) const form = useForm({ @@ -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 = 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 ( - onClose()} + { + if (!open) { + handleClose() + } + }} > - -
- - ( - - - - - - )} - /> - -
+ + } + disabled={!canCreateBuckets} + style={{ justifyContent: 'start' }} + onClick={() => setVisible(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canCreateBuckets + ? 'You need additional permissions to create buckets' + : undefined, + }, + }} + > + New bucket + + + + + Create storage bucket + + + + + ( + + + + + + )} + /> + + ( field.onChange(v)} value={field.value} + onValueChange={(v) => field.onChange(v)} > -
-
-

- Compatible with S3 buckets. -

-
-
-
+ /> {IS_PLATFORM && ( -
-
-

- Stores Iceberg files and is optimized for analytical workloads. -

-
-
- {!icebergCatalogEnabled && ( -
- - - This is currently in alpha and not enabled for your project. Sign - up{' '} - - here - - . - -
- )} + <> +

+ Stores Iceberg files and is optimized for analytical workloads. +

+ + {icebergCatalogEnabled ? null : ( +
+ + + This feature is currently in alpha and not yet enabled for your + project. Sign up{' '} + + here + + . + +
+ )} +
)}
@@ -268,26 +311,28 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
)} /> -
-
- - {isStandardBucket ? ( - <> - -
+ + + + {isStandardBucket ? ( + <> ( - + - @@ -296,235 +341,240 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { {isPublicBucket && ( -

- Users can read objects in public buckets without any authorization. -

-

- Row level security (RLS) policies are still required for other operations - such as object uploads and deletes. -

-
- )} -
-
- - setShowConfiguration(!showConfiguration)} - > - -
-

Additional restrictions

- +

+ Users can read objects in public buckets without any authorization. +

+

+ Row level security (RLS) policies are still required for other + operations such as object uploads and deletes. +

+ + } /> -
-
- -
-
+ )} + setShowConfiguration(!showConfiguration)} + > + + + + +
+ ( + + + + + + )} + /> + {hasFileSizeLimit && ( +
+
+ ( + + + + + + )} + /> +
+ + + + <>{selectedUnit} + + + + {Object.values(StorageSizeUnits).map((unit: string) => ( + +
{unit}
+
+ ))} +
+
+ {IS_PLATFORM && ( +
+

+ Note: Individual bucket uploads will still be capped at the{' '} + + global upload limit + {' '} + of {formattedGlobalUploadLimit} +

+
+ )} +
+ )} +
( - + - )} /> - {hasFileSizeLimit && ( -
-
- ( - - - { - if (event.charCode < 48 || event.charCode > 57) { - event.preventDefault() - } - }} - /> - - - Equivalent to{' '} - {convertToBytes( - formattedSizeLimit, - selectedUnit - ).toLocaleString()}{' '} - bytes. - - - )} - /> -
-
- - {Object.values(StorageSizeUnits).map((unit: string) => ( - -
{unit}
-
- ))} -
-
- {IS_PLATFORM && ( -
-

- Note: Individual bucket uploads will still be capped at the{' '} - - global upload limit - {' '} - of {formattedGlobalUploadLimit} -

-
- )} -
- )} -
- ( - - - - - - )} - /> -
-
-
- - ) : ( - - {icebergWrapperExtensionState === 'installed' ? ( - -

- Supabase will setup a - - foreign data wrapper - {bucketName && {`${bucketName}_fdw`}} - - - {' '} - for easier access to the data. This action will also create{' '} - - S3 Access Keys - {bucketName && ( - <> - {' '} - named {`${bucketName}_keys`} - - )} - - and - - four Vault Secrets - {bucketName && ( - <> - {' '} - prefixed with{' '} - {`${bucketName}_vault_`} - - )} - - . - -

-

- As a final step, you'll need to create an{' '} - Iceberg namespace before you - connect the Iceberg data to your database. -

-
+ + + ) : ( - - - - You need to install the Iceberg wrapper extension to connect your Analytic - bucket to your database. - - -

- You need to install the wrappers extension - (with the minimum version of 0.5.3) if you want to connect your - Analytics bucket to your database. -

-
-
+ <> + {icebergWrapperExtensionState === 'installed' ? ( + +

+ Supabase will setup a + + foreign data wrapper + {bucketName && {`${bucketName}_fdw`}} + + + {' '} + for easier access to the data. This action will also create{' '} + + S3 Access Keys + {bucketName && ( + <> + {' '} + named {`${bucketName}_keys`} + + )} + + and + + four Vault Secrets + {bucketName && ( + <> + {' '} + prefixed with{' '} + {`${bucketName}_vault_`} + + )} + + . + +

+

+ As a final step, you'll need to create an{' '} + Iceberg namespace before you + connect the Iceberg data to your database. +

+
+ ) : ( + + + + You need to install the Iceberg wrapper extension to connect your Analytic + bucket to your database. + + +

+ You need to install the wrappers{' '} + extension (with the minimum version of 0.5.3) if you want to + connect your Analytics bucket to your database. +

+
+
+ )} + )} -
- )} - - - - - - -
-
+ + + + + + + + + ) } diff --git a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx index 0515641d074..509b1969d46 100644 --- a/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/DeleteBucketModal.tsx @@ -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>({ + 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> = 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 ( - - Your bucket {bucket?.name} and all its - contents will be permanently deleted. - - } - alert={{ - title: 'You cannot recover this bucket once deleted.', - description: 'All bucket data will be lost.', + { + if (!open) { + onClose() + } }} - confirmLabel="Delete bucket" - /> + > + + + {`Confirm deletion of ${bucket.name}`} + + + + +

+ Your bucket {bucket.name} and all its + contents will be permanently deleted. +

+
+ + + +
+ ( + + Type {bucket.name} to + confirm. + + } + > + + + + + )} + /> + +
+
+ + + + +
+
) } diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx index ba9cb5b9a51..975f340279b 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx @@ -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.BYTES) + const [selectedUnit, setSelectedUnit] = useState(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>({ + 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> = 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 ( - { + if (!open) { + form.reset() + onClose() + } + }} > -
- {({ 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 ( - <> - - -
- + + {`Edit bucket "${bucket?.name}"`} + + + + + + ( + + + + + + )} + /> + ( + - {isChangingBucketVisibility && ( - -

- {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.` - : ''} -

+ description="Anyone can read any object without any authorization" + layout="flex" + > + + + +
+ )} + /> + {isChangingBucketVisibility && ( + + {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.` +

+ ) : null} + {isMakingBucketPrivate && (

- 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 ' + } purging the cache - {' '} - or moving your assets to a new bucket. + + {' or moving your assets to a new bucket.'}

)} -
- )} -
-
- + } + /> + )} + setShowConfiguration(!showConfiguration)} > - -
+ +
-
- -
-
- - {values.has_file_size_limit && ( -
-
- { - if (event.charCode < 48 || event.charCode > 57) { - event.preventDefault() - } - }} - descriptionText={`Equivalent to ${convertToBytes( - values.formatted_size_limit, - selectedUnit - ).toLocaleString()} bytes.`} + + + +
+ ( + + + -
-
- - {Object.values(StorageSizeUnits).map((unit: string) => ( - -
{unit}
-
- ))} -
-
- {IS_PLATFORM && ( -
-

- Note: Individual bucket upload will still be capped at the{' '} - - global upload limit - {' '} - of {formattedGlobalUploadLimit} -

-
- )} -
+ + )} -
- + {hasFileSizeLimit && ( +
+
+ ( + + + + + + )} + /> +
+ + + + <>{selectedUnit} + + + + {Object.values(StorageSizeUnits).map((unit: string) => ( + +
{unit}
+
+ ))} +
+
+ {IS_PLATFORM && ( +
+

+ Note: Individual bucket upload will still be capped at the{' '} + + global upload limit + {' '} + of {formattedGlobalUploadLimit} +

+
+ )} +
+ )}
- - - - - - - - - ) - }} - - + ( + + + + + + )} + /> + + + + + + + + + + + ) } diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx index 77696bc4862..18e0e901eb9 100644 --- a/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx @@ -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 ( - onClose()} - onConfirm={onEmptyBucket} - alert={{ - title: 'This action cannot be undone', - description: 'The contents of your bucket cannot be recovered once deleted', + { + if (!open) { + onClose() + } }} > -

Are you sure you want to empty the bucket "{bucket?.name}"?

-
+ + + {`Confirm to delete all contents from ${bucket?.name}`} + + + + +

Are you sure you want to empty the bucket "{bucket?.name}"?

+
+ + + + +
+ ) } + +export default EmptyBucketModal diff --git a/apps/studio/components/interfaces/Storage/StorageMenu.tsx b/apps/studio/components/interfaces/Storage/StorageMenu.tsx index 44e88926c81..88a9d629784 100644 --- a/apps/studio/components/interfaces/Storage/StorageMenu.tsx +++ b/apps/studio/components/interfaces/Storage/StorageMenu.tsx @@ -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('') - const [showCreateBucketModal, setShowCreateBucketModal] = useState(false) - const [selectedBucketToEdit, setSelectedBucketToEdit] = useState() - const [selectedBucketToEmpty, setSelectedBucketToEmpty] = useState() - const [selectedBucketToDelete, setSelectedBucketToDelete] = useState() - const canCreateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const page = router.pathname.split('/')[4] as | undefined @@ -69,24 +57,7 @@ const StorageMenu = () => { <>
- } - disabled={!canCreateBuckets} - style={{ justifyContent: 'start' }} - onClick={() => setShowCreateBucketModal(true)} - tooltip={{ - content: { - side: 'bottom', - text: !canCreateBuckets - ? 'You need additional permissions to create buckets' - : undefined, - }, - }} - > - New bucket - + { bucket={bucket} projectRef={ref} isSelected={isSelected} - onSelectEmptyBucket={() => setSelectedBucketToEmpty(bucket)} - onSelectDeleteBucket={() => setSelectedBucketToDelete(bucket)} - onSelectEditBucket={() => setSelectedBucketToEdit(bucket)} /> ) })} @@ -195,29 +163,6 @@ const StorageMenu = () => {
- - setShowCreateBucketModal(false)} - /> - - setSelectedBucketToEdit(undefined)} - /> - - setSelectedBucketToEmpty(undefined)} - /> - - setSelectedBucketToDelete(undefined)} - /> ) } diff --git a/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx new file mode 100644 index 00000000000..b0c4c7fc7b9 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx @@ -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( + + + + ) + + 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`) + ) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx new file mode 100644 index 00000000000..ea63c932272 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx @@ -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 ( + + + + { + setOpen(false) + onClose() + }} + /> + + ) +} + +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() + + 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() + + 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() + }) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx new file mode 100644 index 00000000000..4824277d557 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx @@ -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 ( + + + + { + setOpen(false) + onClose() + }} + /> + + ) +} + +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() + + 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()) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/__tests__/EmptyBucketModal.test.tsx b/apps/studio/components/interfaces/Storage/__tests__/EmptyBucketModal.test.tsx new file mode 100644 index 00000000000..85762108745 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/__tests__/EmptyBucketModal.test.tsx @@ -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 ( + + + + { + setOpen(false) + onClose() + }} + /> + + ) +} + +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() + + 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()) + }) +}) diff --git a/apps/studio/components/ui/PartnerManagedResource.tsx b/apps/studio/components/ui/PartnerManagedResource.tsx index c5c28b3844c..2acd744ed9a 100644 --- a/apps/studio/components/ui/PartnerManagedResource.tsx +++ b/apps/studio/components/ui/PartnerManagedResource.tsx @@ -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 ( diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx index 925b57f72cf..3262c661b2d 100644 --- a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx +++ b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx @@ -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 { diff --git a/apps/studio/data/api-keys/temp-api-keys-query.ts b/apps/studio/data/api-keys/temp-api-keys-query.ts index 4939727c236..5a15846d269 100644 --- a/apps/studio/data/api-keys/temp-api-keys-query.ts +++ b/apps/studio/data/api-keys/temp-api-keys-query.ts @@ -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, }) diff --git a/apps/studio/data/organizations/organizations-query.ts b/apps/studio/data/organizations/organizations-query.ts index 1e6b8d1c35d..3ddbac115d3 100644 --- a/apps/studio/data/organizations/organizations-query.ts +++ b/apps/studio/data/organizations/organizations-query.ts @@ -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, diff --git a/apps/studio/data/permissions/permissions-query.ts b/apps/studio/data/permissions/permissions-query.ts index 5de290221cc..975786dd73b 100644 --- a/apps/studio/data/permissions/permissions-query.ts +++ b/apps/studio/data/permissions/permissions-query.ts @@ -31,7 +31,7 @@ export const usePermissionsQuery = ({ { ...options, enabled: IS_PLATFORM && enabled && isLoggedIn, - staleTime: 30 * 60 * 1000, + staleTime: 5 * 60 * 1000, } ) } diff --git a/apps/studio/data/tables/tables-query.ts b/apps/studio/data/tables/tables-query.ts index b7da827a5bb..af715ea5b60 100644 --- a/apps/studio/data/tables/tables-query.ts +++ b/apps/studio/data/tables/tables-query.ts @@ -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 diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts index b1268e0a534..06eca90031d 100644 --- a/apps/studio/instrumentation-client.ts +++ b/apps/studio/instrumentation-client.ts @@ -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 } diff --git a/apps/studio/lib/constants/infrastructure.ts b/apps/studio/lib/constants/infrastructure.ts index 78a76521753..4d2d4ffbf66 100644 --- a/apps/studio/lib/constants/infrastructure.ts +++ b/apps/studio/lib/constants/infrastructure.ts @@ -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', diff --git a/apps/studio/lib/isNonNullable.test.ts b/apps/studio/lib/isNonNullable.test.ts new file mode 100644 index 00000000000..6c26736d19f --- /dev/null +++ b/apps/studio/lib/isNonNullable.test.ts @@ -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) + }) +}) diff --git a/apps/studio/lib/isNonNullable.ts b/apps/studio/lib/isNonNullable.ts new file mode 100644 index 00000000000..2fe7efdb6b5 --- /dev/null +++ b/apps/studio/lib/isNonNullable.ts @@ -0,0 +1,22 @@ +export type Maybe = 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`. + * + * ### Example Usage: + * + * ```ts + * const fn = (str: Maybe) => { + * if (!isNonNullable(str)) { + * // typeof str = null | undefined + * // ... + * } + * // typeof str = string + * // ... + * } + * ``` + */ +export const isNonNullable = >(val?: T): val is NonNullable => + typeof val !== `undefined` && val !== null diff --git a/apps/studio/pages/project/[ref]/database/extensions.tsx b/apps/studio/pages/project/[ref]/database/extensions.tsx index fd663c2a53f..4239872f36a 100644 --- a/apps/studio/pages/project/[ref]/database/extensions.tsx +++ b/apps/studio/pages/project/[ref]/database/extensions.tsx @@ -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 ( - - -
- + + + -
-
-
+ + + ) } diff --git a/apps/studio/pages/project/[ref]/database/publications.tsx b/apps/studio/pages/project/[ref]/database/publications.tsx deleted file mode 100644 index fb3d57b3060..00000000000 --- a/apps/studio/pages/project/[ref]/database/publications.tsx +++ /dev/null @@ -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() - const selectedPublication = publications.find((pub) => pub.id === selectedPublicationId) - - if (isPermissionsLoaded && !canViewPublications) { - return - } - - return ( - - -
- - {selectedPublication === undefined ? ( - - ) : ( - setSelectedPublicationId(undefined)} - /> - )} -
-
-
- ) -} - -DatabasePublications.getLayout = (page) => ( - - {page} - -) - -export default DatabasePublications diff --git a/apps/studio/pages/project/[ref]/database/publications/[id].tsx b/apps/studio/pages/project/[ref]/database/publications/[id].tsx new file mode 100644 index 00000000000..81fec4d623a --- /dev/null +++ b/apps/studio/pages/project/[ref]/database/publications/[id].tsx @@ -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 + } + + return ( + + + + + + ) +} + +DatabasePublications.getLayout = (page) => ( + + + {page} + + +) + +export default DatabasePublications diff --git a/apps/studio/pages/project/[ref]/database/publications/index.tsx b/apps/studio/pages/project/[ref]/database/publications/index.tsx new file mode 100644 index 00000000000..b5ee05c33dc --- /dev/null +++ b/apps/studio/pages/project/[ref]/database/publications/index.tsx @@ -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 + } + + return ( + + + + + + ) +} + +DatabasePublications.getLayout = (page) => ( + + + {page} + + +) + +export default DatabasePublications diff --git a/apps/studio/state/table-editor-table.tsx b/apps/studio/state/table-editor-table.tsx index 6824c3da674..16634adcd7c 100644 --- a/apps/studio/state/table-editor-table.tsx +++ b/apps/studio/state/table-editor-table.tsx @@ -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, }), diff --git a/apps/studio/types/base.ts b/apps/studio/types/base.ts index 53d471ae055..04847b64c66 100644 --- a/apps/studio/types/base.ts +++ b/apps/studio/types/base.ts @@ -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 } } diff --git a/apps/studio/vitest.config.ts b/apps/studio/vitest.config.ts index 964b7ff991a..4091d3e7cad 100644 --- a/apps/studio/vitest.config.ts +++ b/apps/studio/vitest.config.ts @@ -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'], diff --git a/apps/www/app/api-v2/luma-events/route.tsx b/apps/www/app/api-v2/luma-events/route.tsx index 9d7fc28ddc6..ffd30e14daa 100644 --- a/apps/www/app/api-v2/luma-events/route.tsx +++ b/apps/www/app/api-v2/luma-events/route.tsx @@ -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( { diff --git a/apps/www/app/api-v2/opt-out/[ref]/route.ts b/apps/www/app/api-v2/opt-out/[ref]/route.ts index 9028a6934b2..8cc41ee481d 100644 --- a/apps/www/app/api-v2/opt-out/[ref]/route.ts +++ b/apps/www/app/api-v2/opt-out/[ref]/route.ts @@ -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}` }, diff --git a/apps/www/app/api-v2/submit-form-contact-sales/route.tsx b/apps/www/app/api-v2/submit-form-contact-sales/route.tsx index 4f2e1aed08e..a8e2ef53720 100644 --- a/apps/www/app/api-v2/submit-form-contact-sales/route.tsx +++ b/apps/www/app/api-v2/submit-form-contact-sales/route.tsx @@ -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, diff --git a/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx b/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx index 856845ad620..ed9c6a668eb 100644 --- a/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx +++ b/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx @@ -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, diff --git a/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx b/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx index b41b9064300..a4d65619280 100644 --- a/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx +++ b/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx @@ -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, diff --git a/apps/www/app/api-v2/ticket-og/route.tsx b/apps/www/app/api-v2/ticket-og/route.tsx index 61f4cc0fdeb..6c9ba7807a3 100644 --- a/apps/www/app/api-v2/ticket-og/route.tsx +++ b/apps/www/app/api-v2/ticket-og/route.tsx @@ -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, diff --git a/apps/www/instrumentation.ts b/apps/www/instrumentation.ts new file mode 100644 index 00000000000..3063091e693 --- /dev/null +++ b/apps/www/instrumentation.ts @@ -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 diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index a04689a8a98..88390c9889d 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -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') { diff --git a/apps/www/package.json b/apps/www/package.json index a35b815912b..6f63357f1c5 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -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:*", diff --git a/apps/www/sentry.edge.config.ts b/apps/www/sentry.edge.config.ts new file mode 100644 index 00000000000..9bc4d3e1e64 --- /dev/null +++ b/apps/www/sentry.edge.config.ts @@ -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, +}) diff --git a/apps/www/sentry.server.config.ts b/apps/www/sentry.server.config.ts new file mode 100644 index 00000000000..89dc5a230bf --- /dev/null +++ b/apps/www/sentry.server.config.ts @@ -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, +}) diff --git a/packages/ui/src/components/shadcn/ui/card.tsx b/packages/ui/src/components/shadcn/ui/card.tsx index 59529534d41..e72a26a4066 100644 --- a/packages/ui/src/components/shadcn/ui/card.tsx +++ b/packages/ui/src/components/shadcn/ui/card.tsx @@ -6,7 +6,10 @@ const Card = React.forwardRef (
) diff --git a/packages/ui/src/components/shadcn/ui/table.tsx b/packages/ui/src/components/shadcn/ui/table.tsx index 186fadfe9e6..ef716e89d09 100644 --- a/packages/ui/src/components/shadcn/ui/table.tsx +++ b/packages/ui/src/components/shadcn/ui/table.tsx @@ -19,7 +19,7 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( - + tr]:bg-200', className)} {...props} /> )) TableHeader.displayName = 'TableHeader' @@ -64,7 +64,7 @@ const TableHead = React.forwardRef<