diff --git a/.changeset/mean-ghosts-beam.md b/.changeset/mean-ghosts-beam.md new file mode 100644 index 0000000..7748d07 --- /dev/null +++ b/.changeset/mean-ghosts-beam.md @@ -0,0 +1,5 @@ +--- +"saleor-app-products-feed": patch +--- + +Added validation for AWS credentials. If provided configuration for S3 Bucket is invalid, it won't be saved. diff --git a/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts b/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts index f34d441..68ed6cb 100644 --- a/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts +++ b/apps/products-feed/src/modules/app-configuration/app-configuration.router.ts @@ -5,6 +5,9 @@ import { createLogger } from "@saleor/apps-shared"; import { updateCacheForConfigurations } from "../metadata-cache/update-cache-for-configurations"; import { AppConfigSchema } from "./app-config"; import { z } from "zod"; +import { createS3ClientFromConfiguration } from "../file-storage/s3/create-s3-client-from-configuration"; +import { checkBucketAccess } from "../file-storage/s3/check-bucket-access"; +import { TRPCError } from "@trpc/server"; export const appConfigurationRouter = router({ /** @@ -17,13 +20,54 @@ export const appConfigurationRouter = router({ return c.getRootConfig(); }); }), + testS3BucketConfiguration: protectedClientProcedure + .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + .input(AppConfigSchema.s3Bucket) + .mutation(async ({ ctx: { saleorApiUrl, getConfig, appConfigMetadataManager }, input }) => { + const logger = createLogger({ saleorApiUrl: saleorApiUrl }); + + logger.debug("Validate the credentials"); + + const s3Client = createS3ClientFromConfiguration(input); + + try { + await checkBucketAccess({ + bucketName: input.bucketName, + s3Client, + }); + } catch { + logger.debug("Validation failed"); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Could not access the S3 bucket using the provided credentials", + }); + } + }), + setS3BucketConfiguration: protectedClientProcedure .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(AppConfigSchema.s3Bucket) .mutation(async ({ ctx: { saleorApiUrl, getConfig, appConfigMetadataManager }, input }) => { const logger = createLogger({ saleorApiUrl: saleorApiUrl }); - logger.debug(input, "Input"); + logger.debug("Validate credentials"); + + const s3Client = createS3ClientFromConfiguration(input); + + try { + await checkBucketAccess({ + bucketName: input.bucketName, + s3Client, + }); + } catch { + logger.debug("Validation failed"); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Could not access the S3 bucket using the provided credentials", + }); + } + + logger.debug("Credentials validated, saving"); const config = await getConfig(); diff --git a/apps/products-feed/src/modules/app-configuration/s3-configuration-form.tsx b/apps/products-feed/src/modules/app-configuration/s3-configuration-form.tsx index e9d0af7..d1b2287 100644 --- a/apps/products-feed/src/modules/app-configuration/s3-configuration-form.tsx +++ b/apps/products-feed/src/modules/app-configuration/s3-configuration-form.tsx @@ -14,10 +14,11 @@ type S3BucketConfiguration = Exclude; type Props = { initialData: S3BucketConfiguration; onSubmit(data: S3BucketConfiguration): Promise; + onValidate(data: S3BucketConfiguration): Promise; }; export const S3ConfigurationForm = (props: Props) => { - const { handleSubmit, control } = useForm({ + const { handleSubmit, control, getValues } = useForm({ defaultValues: props.initialData, resolver: zodResolver(AppConfigSchema.s3Bucket), }); @@ -53,9 +54,14 @@ export const S3ConfigurationForm = (props: Props) => { placeholder={"eu-west-1"} /> - + + + + ); }; @@ -67,10 +73,25 @@ export const ConnectedS3ConfigurationForm = () => { onSuccess() { notifySuccess("Success", "Updated S3 configuration"); }, - onError() { + onError({ message }) { + if (message) { + notifyError("Error", message); + return; + } notifyError("Error", "Failed to update, please refresh and try again"); }, }); + + const { mutate: testConfigurationMutate } = + trpcClient.appConfiguration.testS3BucketConfiguration.useMutation({ + onSuccess() { + notifySuccess("Configuration is valid"); + }, + onError({ message }) { + notifyError("Error", message); + }, + }); + const { data, isLoading } = trpcClient.appConfiguration.fetch.useQuery(); const handleSubmit = useCallback( @@ -80,6 +101,13 @@ export const ConnectedS3ConfigurationForm = () => { [mutate] ); + const handleValidate = useCallback( + async (data: S3BucketConfiguration) => { + testConfigurationMutate(data); + }, + [testConfigurationMutate] + ); + const formData: S3BucketConfiguration = useMemo(() => { if (data?.s3) { return data.s3; @@ -97,5 +125,11 @@ export const ConnectedS3ConfigurationForm = () => { return Loading...; } - return ; + return ( + + ); }; diff --git a/apps/products-feed/src/modules/file-storage/s3/check-bucket-access.ts b/apps/products-feed/src/modules/file-storage/s3/check-bucket-access.ts new file mode 100644 index 0000000..97469f2 --- /dev/null +++ b/apps/products-feed/src/modules/file-storage/s3/check-bucket-access.ts @@ -0,0 +1,15 @@ +import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3"; + +interface checkBucketAccessArgs { + s3Client: S3Client; + bucketName: string; +} + +// Check if client can access the bucket. Throws an error otherwise +export const checkBucketAccess = async ({ s3Client, bucketName }: checkBucketAccessArgs) => { + await s3Client.send( + new HeadBucketCommand({ + Bucket: bucketName, + }) + ); +};