From cfc99d7d4ab5500332f1fc6342a8ae3eb9eaeee9 Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Thu, 14 Aug 2025 13:32:10 -0300 Subject: [PATCH 1/3] chore: add BillingManagedBy constant (#37919) chore/managed-by --- .../components/ui/PartnerManagedResource.tsx | 13 +++++++++---- .../data/organizations/organizations-query.ts | 17 ++++++++++++++++- apps/studio/lib/constants/infrastructure.ts | 8 ++++++++ apps/studio/types/base.ts | 3 ++- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/studio/components/ui/PartnerManagedResource.tsx b/apps/studio/components/ui/PartnerManagedResource.tsx index c5c28b3844c59..2acd744ed9a41 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/data/organizations/organizations-query.ts b/apps/studio/data/organizations/organizations-query.ts index 1e6b8d1c35d0d..3ddbac115d30f 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/lib/constants/infrastructure.ts b/apps/studio/lib/constants/infrastructure.ts index 78a765217538b..4d2d4ffbf66a4 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/types/base.ts b/apps/studio/types/base.ts index 53d471ae05542..04847b64c66f5 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 } } From b6b906cff4f20083bb3212ecc451dc0cd42a08a3 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 14 Aug 2025 15:03:01 -0300 Subject: [PATCH 2/3] docs: add flutter phone factor reference doc and example (#37828) * docs: add flutter phone factor reference doc and example * Update apps/docs/spec/supabase_dart_v2.yml Co-authored-by: Chris Chinchilla * Update apps/docs/spec/supabase_js_v2.yml Co-authored-by: Chris Chinchilla * Update apps/docs/spec/supabase_dart_v2.yml Co-authored-by: Chris Chinchilla * Update apps/docs/spec/supabase_dart_v2.yml Co-authored-by: Chris Chinchilla --------- Co-authored-by: Chris Chinchilla --- apps/docs/spec/supabase_dart_v2.yml | 31 ++++++++++++++++++++++++++--- apps/docs/spec/supabase_js_v2.yml | 2 ++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/docs/spec/supabase_dart_v2.yml b/apps/docs/spec/supabase_dart_v2.yml index 2264e2544e164..82e4ab3e1ce7c 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 a9834a4342066..6eb80d4b26233 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' From ca4b3bc624eccf61bfe7de1a7974700626a6fd64 Mon Sep 17 00:00:00 2001 From: Drake Costa Date: Thu, 14 Aug 2025 12:23:08 -0700 Subject: [PATCH 3/3] Refactor Storage Create/Edit/Empty/Delete Modals to use Shadcn components (#37517) * Refactor `StorageMenu` modals to replace deprecated patterns * add test for `DeleteBucketModal` and update test setup Note: Because this component uses `useParams`, it's necessary to have the dynamic route segment passed to `next-router-mock`'s `createDynamicRouteParser`. In order not to have to manually list all of these as the application grows, I added a glob utility that uses the `pages/` directory to automatically generate an array of dynamic route paths in this case. * add test for `EmptyBucketModal` * add test for `EditBucketModal` and add `isNonNullable` utility function * add test for `CreateBucketModal` * implement requested changes * implement visual fixes --- .../interfaces/Storage/BucketRow.tsx | 36 +- .../interfaces/Storage/CreateBucketModal.tsx | 680 ++++++++++-------- .../interfaces/Storage/DeleteBucketModal.tsx | 138 +++- .../interfaces/Storage/EditBucketModal.tsx | 521 ++++++++------ .../interfaces/Storage/EmptyBucketModal.tsx | 59 +- .../interfaces/Storage/StorageMenu.tsx | 65 +- .../__tests__/CreateBucketModal.test.tsx | 100 +++ .../__tests__/DeleteBucketModal.test.tsx | 141 ++++ .../__tests__/EditBucketModal.test.tsx | 119 +++ .../__tests__/EmptyBucketModal.test.tsx | 96 +++ apps/studio/lib/isNonNullable.test.ts | 26 + apps/studio/lib/isNonNullable.ts | 22 + apps/studio/vitest.config.ts | 6 +- 13 files changed, 1361 insertions(+), 648 deletions(-) create mode 100644 apps/studio/components/interfaces/Storage/__tests__/CreateBucketModal.test.tsx create mode 100644 apps/studio/components/interfaces/Storage/__tests__/DeleteBucketModal.test.tsx create mode 100644 apps/studio/components/interfaces/Storage/__tests__/EditBucketModal.test.tsx create mode 100644 apps/studio/components/interfaces/Storage/__tests__/EmptyBucketModal.test.tsx create mode 100644 apps/studio/lib/isNonNullable.test.ts create mode 100644 apps/studio/lib/isNonNullable.ts diff --git a/apps/studio/components/interfaces/Storage/BucketRow.tsx b/apps/studio/components/interfaces/Storage/BucketRow.tsx index 78ef08e09c0b0..4023d5ab35605 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 b02df4a2a4e68..db5181a618d0c 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 0515641d0749c..509b1969d462b 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 ba9cb5b9a51be..975f340279ba0 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 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 = async (values: any) => { + 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 77696bc4862e3..18e0e901eb9b8 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 44e88926c8184..88a9d629784e3 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 0000000000000..b0c4c7fc7b935 --- /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 0000000000000..ea63c932272ad --- /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 0000000000000..4824277d55735 --- /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 0000000000000..8576210874584 --- /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/lib/isNonNullable.test.ts b/apps/studio/lib/isNonNullable.test.ts new file mode 100644 index 0000000000000..6c26736d19f38 --- /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 0000000000000..2fe7efdb6b5e6 --- /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/vitest.config.ts b/apps/studio/vitest.config.ts index 964b7ff991af5..4091d3e7cadb3 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'],