From 9344247d19c6c19cdde5c64b2a1b9e517c706028 Mon Sep 17 00:00:00 2001 From: jpedro-cf Date: Fri, 7 Nov 2025 16:21:45 -0300 Subject: [PATCH] feat: added cors listing & form actions --- src/pages/buckets/manage/context.ts | 3 +- src/pages/buckets/manage/hooks.ts | 23 +- src/pages/buckets/manage/page.tsx | 12 +- .../manage/permissions/access-keys.tsx | 96 ++++++++ .../manage/permissions/cors-configuration.tsx | 231 ++++++++++++++++++ .../manage/permissions/permissions-tab.tsx | 98 +------- src/pages/buckets/manage/schema.ts | 15 ++ src/pages/buckets/types.ts | 8 + 8 files changed, 388 insertions(+), 98 deletions(-) create mode 100644 src/pages/buckets/manage/permissions/access-keys.tsx create mode 100644 src/pages/buckets/manage/permissions/cors-configuration.tsx diff --git a/src/pages/buckets/manage/context.ts b/src/pages/buckets/manage/context.ts index 2d2cbd2..641c025 100644 --- a/src/pages/buckets/manage/context.ts +++ b/src/pages/buckets/manage/context.ts @@ -1,8 +1,9 @@ import { createContext, useContext } from "react"; -import { Bucket } from "../types"; +import { Bucket, BucketCors } from "../types"; export const BucketContext = createContext<{ bucket: Bucket; + cors: BucketCors[]; refetch: () => void; bucketName: string; } | null>(null); diff --git a/src/pages/buckets/manage/hooks.ts b/src/pages/buckets/manage/hooks.ts index 837c8ad..dd589f4 100644 --- a/src/pages/buckets/manage/hooks.ts +++ b/src/pages/buckets/manage/hooks.ts @@ -5,7 +5,8 @@ import { UseMutationOptions, useQuery, } from "@tanstack/react-query"; -import { Bucket, Permissions } from "../types"; +import { Bucket, BucketCors, Permissions } from "../types"; +import { BucketCorsSchema } from "./schema"; export const useBucket = (id?: string | null) => { return useQuery({ @@ -110,3 +111,23 @@ export const useRemoveBucket = ( ...options, }); }; + +export const useBucketCors = (bucketName?: string) => { + return useQuery({ + queryKey: ["bucket_cors", bucketName], + queryFn: () => api.get(`/buckets/${bucketName}/cors`), + enabled: !!bucketName, + }); +}; + +export const useBucketCorsMutation = ( + options?: MutationOptions +) => { + return useMutation({ + mutationFn: (data: BucketCorsSchema) => + api.put(`/buckets/${data.bucketName}/cors`, { + body: { rules: data.rules }, + }), + ...options, + }); +}; diff --git a/src/pages/buckets/manage/page.tsx b/src/pages/buckets/manage/page.tsx index ec2081f..21b8db2 100644 --- a/src/pages/buckets/manage/page.tsx +++ b/src/pages/buckets/manage/page.tsx @@ -1,5 +1,5 @@ import { useParams } from "react-router-dom"; -import { useBucket } from "./hooks"; +import { useBucket, useBucketCors } from "./hooks"; import Page from "@/context/page-context"; import TabView, { Tab } from "@/components/containers/tab-view"; import { @@ -39,6 +39,7 @@ const tabs: Tab[] = [ const ManageBucketPage = () => { const { id } = useParams(); const { data, error, isLoading, refetch } = useBucket(id); + const { data: cors } = useBucketCors(data?.globalAliases[0]); const name = data?.globalAliases[0]; @@ -62,10 +63,15 @@ const ManageBucketPage = () => { )} - {data && ( + {data && cors && (
diff --git a/src/pages/buckets/manage/permissions/access-keys.tsx b/src/pages/buckets/manage/permissions/access-keys.tsx new file mode 100644 index 0000000..6630fa2 --- /dev/null +++ b/src/pages/buckets/manage/permissions/access-keys.tsx @@ -0,0 +1,96 @@ +import { useDenyKey } from "../hooks"; +import { Card, Checkbox, Table } from "react-daisyui"; +import Button from "@/components/ui/button"; +import { Trash } from "lucide-react"; +import AllowKeyDialog from "./allow-key-dialog"; +import { useMemo } from "react"; +import { toast } from "sonner"; +import { handleError } from "@/lib/utils"; +import { useBucketContext } from "../context"; + +const AccessKeyPermissions = () => { + const { bucket, refetch } = useBucketContext(); + + const denyKey = useDenyKey(bucket.id, { + onSuccess: () => { + toast.success("Key removed!"); + refetch(); + }, + onError: handleError, + }); + + const keys = useMemo(() => { + return bucket?.keys.filter( + (key) => + key.permissions.read !== false || + key.permissions.write !== false || + key.permissions.owner !== false + ); + }, [bucket?.keys]); + + const onRemove = (id: string) => { + if (window.confirm("Are you sure you want to remove this key?")) { + denyKey.mutate({ + keyId: id, + permissions: { read: true, write: true, owner: true }, + }); + } + }; + + return ( + +
+ Access Keys + key.accessKeyId)} /> +
+ +
+ + + # + Key + Aliases + Read + Write + Owner + + + + + {keys?.map((key, idx) => ( + + {idx + 1} + {key.name || key.accessKeyId?.substring(0, 8)} + {key.bucketLocalAliases?.join(", ") || "-"} + + + + + + + + + +
+
+
+ ); +}; + +export default AccessKeyPermissions; diff --git a/src/pages/buckets/manage/permissions/cors-configuration.tsx b/src/pages/buckets/manage/permissions/cors-configuration.tsx new file mode 100644 index 0000000..c45ac02 --- /dev/null +++ b/src/pages/buckets/manage/permissions/cors-configuration.tsx @@ -0,0 +1,231 @@ +import { Card, Modal } from "react-daisyui"; +import Button from "@/components/ui/button"; +import { CheckCircle, Plus } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { useBucketContext } from "../context"; +import { bucketCorsSchema, BucketCorsSchema } from "../schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Path, useFieldArray, useForm, UseFormReturn } from "react-hook-form"; +import Input, { InputField } from "@/components/ui/input"; +import FormControl from "@/components/ui/form-control"; +import { useDisclosure } from "@/hooks/useDisclosure"; +import Chips from "@/components/ui/chips"; +import { useBucketCorsMutation } from "../hooks"; +import { handleError } from "@/lib/utils"; +import { useQueryClient } from "@tanstack/react-query"; + +const CorsConfiguration = () => { + const queryClient = useQueryClient(); + const { bucketName, cors } = useBucketContext(); + + const { mutate, isPending } = useBucketCorsMutation({ + onSuccess: () => { + toast.success("CORS saved!"); + queryClient.invalidateQueries({ queryKey: ["bucket_cors", bucketName] }); + }, + onError: handleError, + }); + + const form = useForm({ + resolver: zodResolver(bucketCorsSchema), + defaultValues: { + bucketName: bucketName, + rules: + cors.length > 0 + ? cors + : [ + { + allowedHeaders: [], + allowedMethods: [], + allowedOrigins: [], + exposeHeaders: [], + maxAgeSeconds: null, + }, + ], + }, + }); + + const { fields } = useFieldArray({ + control: form.control, + name: "rules", + }); + + function handleSubmit(data: BucketCorsSchema) { + mutate(data); + } + + return ( + +
+
+ + Cors Configuration + + +
+ {fields.map((rule, idx) => ( +
+ ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + +
+ ))} +
+
+ ); +}; + +interface CorsRulesChipsProps { + form: UseFormReturn; + fieldName: Path; + values: string[]; +} + +function CorsRulesChips({ fieldName, form, values }: CorsRulesChipsProps) { + function onRemove(value: string) { + const currentValues = + (form.getValues(fieldName as Path) as string[]) ?? []; + + form.setValue( + fieldName, + currentValues.filter((v) => v !== value) + ); + } + return ( +
+ {values.map((value) => ( + onRemove(value)}> + {value} + + ))} + +
+ ); +} + +interface AddRuleDialogProps { + form: UseFormReturn; + fieldName: Path; +} + +const AddRuleDialog = ({ form, fieldName }: AddRuleDialogProps) => { + const { dialogRef, isOpen, onOpen, onClose } = useDisclosure(); + const inputRef = useRef(null); + + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + inputRef.current.value = ""; + } + }, [isOpen]); + + function onSubmit() { + const value = inputRef.current?.value?.trim(); + if (!value) { + onClose(); + return; + } + + const currentValues = + (form.getValues(fieldName as Path) as string[]) ?? []; + + form.setValue( + fieldName as Path, + [...currentValues, value], + { shouldDirty: true, shouldValidate: true } + ); + + onClose(); + } + + return ( + <> + + + + Add Alias + + + + + + + + + + + + ); +}; + +export default CorsConfiguration; diff --git a/src/pages/buckets/manage/permissions/permissions-tab.tsx b/src/pages/buckets/manage/permissions/permissions-tab.tsx index fa48699..31b21da 100644 --- a/src/pages/buckets/manage/permissions/permissions-tab.tsx +++ b/src/pages/buckets/manage/permissions/permissions-tab.tsx @@ -1,99 +1,11 @@ -import { useDenyKey } from "../hooks"; -import { Card, Checkbox, Table } from "react-daisyui"; -import Button from "@/components/ui/button"; -import { Trash } from "lucide-react"; -import AllowKeyDialog from "./allow-key-dialog"; -import { useMemo } from "react"; -import { toast } from "sonner"; -import { handleError } from "@/lib/utils"; -import { useBucketContext } from "../context"; +import AccessKeyPermissions from "./access-keys"; +import CorsConfiguration from "./cors-configuration"; const PermissionsTab = () => { - const { bucket, refetch } = useBucketContext(); - - const denyKey = useDenyKey(bucket.id, { - onSuccess: () => { - toast.success("Key removed!"); - refetch(); - }, - onError: handleError, - }); - - const keys = useMemo(() => { - return bucket?.keys.filter( - (key) => - key.permissions.read !== false || - key.permissions.write !== false || - key.permissions.owner !== false - ); - }, [bucket?.keys]); - - const onRemove = (id: string) => { - if (window.confirm("Are you sure you want to remove this key?")) { - denyKey.mutate({ - keyId: id, - permissions: { read: true, write: true, owner: true }, - }); - } - }; - return ( -
- -
- Access Keys - key.accessKeyId)} /> -
- -
- - - # - Key - Aliases - Read - Write - Owner - - - - - {keys?.map((key, idx) => ( - - {idx + 1} - {key.name || key.accessKeyId?.substring(0, 8)} - {key.bucketLocalAliases?.join(", ") || "-"} - - - - - - - - - -
-
-
+
+ +
); }; diff --git a/src/pages/buckets/manage/schema.ts b/src/pages/buckets/manage/schema.ts index 0338e17..9ff51ee 100644 --- a/src/pages/buckets/manage/schema.ts +++ b/src/pages/buckets/manage/schema.ts @@ -37,3 +37,18 @@ export const allowKeysSchema = z.object({ }); export type AllowKeysSchema = z.infer; + +export const bucketCorsSchema = z.object({ + bucketName: z.string(), + rules: z.array( + z.object({ + allowedMethods: z.array(z.string()).nullable(), + allowedOrigins: z.array(z.string()).nullable(), + allowedHeaders: z.array(z.string()).nullable(), + exposeHeaders: z.array(z.string()).nullable(), + maxAgeSeconds: z.coerce.number().nullable(), + }) + ), +}); + +export type BucketCorsSchema = z.infer; diff --git a/src/pages/buckets/types.ts b/src/pages/buckets/types.ts index df4b6d7..68d62d5 100644 --- a/src/pages/buckets/types.ts +++ b/src/pages/buckets/types.ts @@ -18,6 +18,14 @@ export type Bucket = { quotas: Quotas; }; +export type BucketCors = { + allowedMethods: string[] | null; + allowedOrigins: string[] | null; + allowedHeaders: string[] | null; + exposeHeaders: string[] | null; + maxAgeSeconds: number | null; +}; + export type LocalAlias = { accessKeyId: string; alias: string;