From 43af9c865882acfb9378d52bac8852d47a3654c8 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Fri, 16 Aug 2024 23:10:19 +0700 Subject: [PATCH] feat: add keys & bucket permissions management --- src/app/router.tsx | 5 + src/components/containers/sidebar.tsx | 57 +++--- src/components/layouts/main-layout.tsx | 5 +- src/components/ui/button.tsx | 10 +- src/components/ui/checkbox.tsx | 64 +++++++ src/components/ui/input.tsx | 44 +++++ src/context/page-context.tsx | 4 +- src/hooks/useDisclosure.ts | 32 ++++ src/lib/api.ts | 4 +- src/lib/utils.ts | 3 +- src/pages/buckets/components/bucket-card.tsx | 20 ++- .../components/create-bucket-dialog.tsx | 71 ++++++++ src/pages/buckets/hooks.ts | 16 +- .../manage/components/allow-key-dialog.tsx | 168 ++++++++++++++++++ .../buckets/manage/components/menu-button.tsx | 42 +++++ .../manage/components/overview-aliases.tsx | 2 +- .../manage/components/overview-quota.tsx | 2 +- .../manage/components/overview-tab.tsx | 6 +- .../components/overview-website-access.tsx | 2 +- .../manage/components/permissions-tab.tsx | 103 +++++++++++ src/pages/buckets/manage/hooks.ts | 62 ++++++- src/pages/buckets/manage/page.tsx | 16 +- src/pages/buckets/manage/schema.ts | 15 ++ src/pages/buckets/page.tsx | 9 +- src/pages/buckets/schema.ts | 7 + .../keys/components/create-key-dialog.tsx | 94 ++++++++++ src/pages/keys/hooks.ts | 38 ++++ src/pages/keys/page.tsx | 70 ++++++++ src/pages/keys/schema.ts | 24 +++ src/pages/keys/types.ts | 6 + 30 files changed, 948 insertions(+), 53 deletions(-) create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/hooks/useDisclosure.ts create mode 100644 src/pages/buckets/components/create-bucket-dialog.tsx create mode 100644 src/pages/buckets/manage/components/allow-key-dialog.tsx create mode 100644 src/pages/buckets/manage/components/menu-button.tsx create mode 100644 src/pages/buckets/manage/components/permissions-tab.tsx create mode 100644 src/pages/buckets/schema.ts create mode 100644 src/pages/keys/components/create-key-dialog.tsx create mode 100644 src/pages/keys/hooks.ts create mode 100644 src/pages/keys/page.tsx create mode 100644 src/pages/keys/schema.ts create mode 100644 src/pages/keys/types.ts diff --git a/src/app/router.tsx b/src/app/router.tsx index 455b821..ba87390 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -7,6 +7,7 @@ const ClusterPage = lazy(() => import("@/pages/cluster/page")); const HomePage = lazy(() => import("@/pages/home/page")); const BucketsPage = lazy(() => import("@/pages/buckets/page")); const ManageBucketPage = lazy(() => import("@/pages/buckets/manage/page")); +const KeysPage = lazy(() => import("@/pages/keys/page")); const router = createBrowserRouter([ { @@ -32,6 +33,10 @@ const router = createBrowserRouter([ { path: ":id", Component: ManageBucketPage }, ], }, + { + path: "keys", + Component: KeysPage, + }, ], }, ]); diff --git a/src/components/containers/sidebar.tsx b/src/components/containers/sidebar.tsx index 9267aff..4e46c49 100644 --- a/src/components/containers/sidebar.tsx +++ b/src/components/containers/sidebar.tsx @@ -1,8 +1,23 @@ -import { Cylinder, HardDrive, LayoutDashboard } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + ArchiveIcon, + HardDrive, + KeySquare, + LayoutDashboard, +} from "lucide-react"; import { Menu } from "react-daisyui"; -import { Link } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; + +const pages = [ + { icon: LayoutDashboard, title: "Dashboard", path: "/", exact: true }, + { icon: HardDrive, title: "Cluster", path: "/cluster" }, + { icon: ArchiveIcon, title: "Buckets", path: "/buckets" }, + { icon: KeySquare, title: "Keys", path: "/keys" }, +]; const Sidebar = () => { + const { pathname } = useLocation(); + return ( ); diff --git a/src/components/layouts/main-layout.tsx b/src/components/layouts/main-layout.tsx index ea87ca6..da5d9bc 100644 --- a/src/components/layouts/main-layout.tsx +++ b/src/components/layouts/main-layout.tsx @@ -40,7 +40,10 @@ const Header = () => { ) : null} -

{page?.title || "Dashboard"}

+ +

{page?.title || "Dashboard"}

+ + {page?.actions} ); }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 69180bc..13d08c2 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,3 +1,4 @@ +import { LucideIcon } from "lucide-react"; import { ComponentPropsWithoutRef, forwardRef } from "react"; import { Button as BaseButton } from "react-daisyui"; import { Link } from "react-router-dom"; @@ -5,17 +6,22 @@ import { Link } from "react-router-dom"; type ButtonProps = ComponentPropsWithoutRef & { href?: string; target?: "_blank" | "_self" | "_parent" | "_top"; + icon?: LucideIcon; }; const Button = forwardRef( - ({ href, ...props }, ref) => { + ({ href, children, icon: Icon, shape, ...props }, ref) => { return ( + > + {Icon ? : null} + {children} + ); } ); diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..981619d --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,64 @@ +import { cn } from "@/lib/utils"; +import React, { forwardRef } from "react"; +import { Checkbox as BaseCheckbox } from "react-daisyui"; +import FormControl from "./form-control"; +import { FieldValues } from "react-hook-form"; + +type CheckboxProps = Omit< + React.ComponentPropsWithoutRef, + "form" +> & { + label?: string; + inputClassName?: string; +}; + +const Checkbox = forwardRef( + ({ label, className, inputClassName, ...props }, ref) => { + return ( + + ); + } +); + +type CheckboxFieldProps = Omit< + React.ComponentPropsWithoutRef>, + "render" +> & + CheckboxProps; + +export const CheckboxField = ({ + form, + name, + ...props +}: CheckboxFieldProps) => { + return ( + ( + field.onChange(e.target.checked)} + /> + )} + /> + ); +}; + +export default Checkbox; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..50c6c8b --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,44 @@ +import React, { forwardRef } from "react"; +import { Input as BaseInput } from "react-daisyui"; +import FormControl from "./form-control"; +import { FieldValues } from "react-hook-form"; + +type InputProps = Omit< + React.ComponentPropsWithoutRef, + "form" +>; + +const Input = forwardRef(({ ...props }, ref) => { + return ; +}); + +type InputFieldProps = Omit< + React.ComponentPropsWithoutRef>, + "render" +> & + InputProps & { + inputClassName?: string; + }; + +export const InputField = ({ + form, + name, + title, + className, + inputClassName, + ...props +}: InputFieldProps) => { + return ( + ( + + )} + /> + ); +}; + +export default Input; diff --git a/src/context/page-context.tsx b/src/context/page-context.tsx index 578d643..444518f 100644 --- a/src/context/page-context.tsx +++ b/src/context/page-context.tsx @@ -1,4 +1,4 @@ -import { +import React, { createContext, memo, PropsWithChildren, @@ -11,6 +11,7 @@ import { type PageContextValues = { title: string | null; prev: string | null; + actions: React.ReactNode | null; }; export const PageContext = createContext< @@ -23,6 +24,7 @@ export const PageContext = createContext< const initialValues: PageContextValues = { title: null, prev: null, + actions: null, }; export const PageContextProvider = ({ children }: PropsWithChildren) => { diff --git a/src/hooks/useDisclosure.ts b/src/hooks/useDisclosure.ts new file mode 100644 index 0000000..453fe91 --- /dev/null +++ b/src/hooks/useDisclosure.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef, useState } from "react"; + +export const useDisclosure = () => { + const dialogRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [data, setData] = useState(null); + + useEffect(() => { + const dlg = dialogRef.current; + if (!dlg || !isOpen) return; + + const onDialogClose = () => { + setIsOpen(false); + }; + + dlg.addEventListener("close", onDialogClose); + return () => dlg.removeEventListener("close", onDialogClose); + }, [dialogRef, isOpen]); + + return { + dialogRef, + isOpen, + data, + onOpen: (data?: T | null) => { + setIsOpen(true); + setData(data); + }, + onClose: () => { + setIsOpen(false); + }, + }; +}; diff --git a/src/lib/api.ts b/src/lib/api.ts index 2496e48..6330b18 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -29,7 +29,9 @@ const api = { }); if (!res.ok) { - throw new Error(res.statusText); + const json = await res.json().catch(() => {}); + const message = json?.message || res.statusText; + throw new Error(message); } const isJson = res.headers diff --git a/src/lib/utils.ts b/src/lib/utils.ts index fae2937..0a5cba6 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -14,8 +14,7 @@ export const readableBytes = (bytes?: number | null, divider = 1024) => { if (bytes == null || Number.isNaN(bytes)) return "n/a"; const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; - if (bytes === 0) return "n/a"; - const i = Math.floor(Math.log(bytes) / Math.log(divider)); + const i = Math.max(0, Math.floor(Math.log(bytes) / Math.log(divider))); return `${(bytes / Math.pow(divider, i)).toFixed(1)} ${sizes[i]}`; }; diff --git a/src/pages/buckets/components/bucket-card.tsx b/src/pages/buckets/components/bucket-card.tsx index 7de1b76..449e201 100644 --- a/src/pages/buckets/components/bucket-card.tsx +++ b/src/pages/buckets/components/bucket-card.tsx @@ -10,33 +10,35 @@ type Props = { const BucketCard = ({ data }: Props) => { return (
-
- +
+
+ -
-

+

{data.globalAliases?.join(", ")}

-
+

Usage

-

{readableBytes(data.bytes)}

+

+ {readableBytes(data.bytes)} +

-
+

Objects

-

{data.objects}

+

{data.objects}

-
+
{/* */}
diff --git a/src/pages/buckets/components/create-bucket-dialog.tsx b/src/pages/buckets/components/create-bucket-dialog.tsx new file mode 100644 index 0000000..82e1e07 --- /dev/null +++ b/src/pages/buckets/components/create-bucket-dialog.tsx @@ -0,0 +1,71 @@ +import Button from "@/components/ui/button"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus } from "lucide-react"; +import { Modal } from "react-daisyui"; +import { useForm } from "react-hook-form"; +import { useDisclosure } from "@/hooks/useDisclosure"; +import { createBucketSchema, CreateBucketSchema } from "../schema"; +import { InputField } from "@/components/ui/input"; +import { useCreateBucket } from "../hooks"; +import { useQueryClient } from "@tanstack/react-query"; +import { handleError } from "@/lib/utils"; +import { toast } from "sonner"; +import { useEffect } from "react"; + +const CreateBucketDialog = () => { + const { dialogRef, isOpen, onOpen, onClose } = useDisclosure(); + const form = useForm({ + resolver: zodResolver(createBucketSchema), + defaultValues: { globalAlias: "" }, + }); + const queryClient = useQueryClient(); + + useEffect(() => { + if (isOpen) form.setFocus("globalAlias"); + }, [isOpen]); + + const createBucket = useCreateBucket({ + onSuccess: () => { + onClose(); + queryClient.invalidateQueries({ queryKey: ["buckets"] }); + toast.success("Bucket created!"); + }, + onError: handleError, + }); + + const onSubmit = form.handleSubmit((values) => { + createBucket.mutate(values); + }); + + return ( + <> + + + + Create New Bucket + +

Enter the details of the bucket you wish to create.

+ +
+ + +
+ + + + + +
+ + ); +}; + +export default CreateBucketDialog; diff --git a/src/pages/buckets/hooks.ts b/src/pages/buckets/hooks.ts index e9b4018..9e57fde 100644 --- a/src/pages/buckets/hooks.ts +++ b/src/pages/buckets/hooks.ts @@ -1,6 +1,11 @@ import api from "@/lib/api"; -import { useQuery } from "@tanstack/react-query"; +import { + useMutation, + UseMutationOptions, + useQuery, +} from "@tanstack/react-query"; import { GetBucketRes } from "./types"; +import { CreateBucketSchema } from "./schema"; export const useBuckets = () => { return useQuery({ @@ -8,3 +13,12 @@ export const useBuckets = () => { queryFn: () => api.get("/buckets"), }); }; + +export const useCreateBucket = ( + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (body) => api.post("/v1/bucket", { body }), + ...options, + }); +}; diff --git a/src/pages/buckets/manage/components/allow-key-dialog.tsx b/src/pages/buckets/manage/components/allow-key-dialog.tsx new file mode 100644 index 0000000..ce9e6cc --- /dev/null +++ b/src/pages/buckets/manage/components/allow-key-dialog.tsx @@ -0,0 +1,168 @@ +import Button from "@/components/ui/button"; +import { useKeys } from "@/pages/keys/hooks"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus } from "lucide-react"; +import { useEffect } from "react"; +import { Checkbox, Modal, Table } from "react-daisyui"; +import { useFieldArray, useForm } from "react-hook-form"; +import { AllowKeysSchema, allowKeysSchema } from "../schema"; +import { useDisclosure } from "@/hooks/useDisclosure"; +import { CheckboxField } from "@/components/ui/checkbox"; +import { useAllowKey } from "../hooks"; +import { toast } from "sonner"; +import { handleError } from "@/lib/utils"; +import { useQueryClient } from "@tanstack/react-query"; + +type Props = { + id?: string; + currentKeys?: string[]; +}; + +const AllowKeyDialog = ({ id, currentKeys }: Props) => { + const { dialogRef, isOpen, onOpen, onClose } = useDisclosure(); + const { data: keys } = useKeys(); + const form = useForm({ + resolver: zodResolver(allowKeysSchema), + }); + const { fields: keyFields } = useFieldArray({ + control: form.control, + name: "keys", + }); + const queryClient = useQueryClient(); + + const allowKey = useAllowKey(id, { + onSuccess: () => { + form.reset(); + onClose(); + toast.success("Key allowed!"); + queryClient.invalidateQueries({ queryKey: ["bucket", id] }); + }, + onError: handleError, + }); + + useEffect(() => { + const _keys = keys + ?.filter((key) => !currentKeys?.includes(key.id)) + ?.map((key) => ({ + checked: false, + keyId: key.id, + name: key.name, + read: false, + write: false, + owner: false, + })); + + form.setValue("keys", _keys || []); + }, [keys, currentKeys]); + + const onToggleAll = ( + e: React.ChangeEvent, + field: keyof AllowKeysSchema["keys"][number] + ) => { + const curValues = form.getValues("keys"); + const newValues = curValues.map((item) => ({ + ...item, + [field]: e.target.checked, + })); + form.setValue("keys", newValues); + }; + + const onSubmit = form.handleSubmit((values) => { + const data = values.keys + .filter((key) => key.checked) + .map((key) => ({ + keyId: key.keyId, + permissions: { read: key.read, write: key.write, owner: key.owner }, + })); + allowKey.mutate(data); + }); + + return ( + <> + + + + Allow Key + +

Enter the key you want to allow access to.

+ +
+ + + + + + + + + + {!keyFields.length ? ( + + + + ) : null} + {keyFields.map((field, index) => ( + + + + + + + ))} + +
+ No keys found +
+
+
+ + + + + +
+ + ); +}; + +export default AllowKeyDialog; diff --git a/src/pages/buckets/manage/components/menu-button.tsx b/src/pages/buckets/manage/components/menu-button.tsx new file mode 100644 index 0000000..6d5efdb --- /dev/null +++ b/src/pages/buckets/manage/components/menu-button.tsx @@ -0,0 +1,42 @@ +import Button from "@/components/ui/button"; +import { EllipsisVertical, Trash } from "lucide-react"; +import { Dropdown } from "react-daisyui"; +import { useNavigate, useParams } from "react-router-dom"; +import { useRemoveBucket } from "../hooks"; +import { toast } from "sonner"; +import { handleError } from "@/lib/utils"; + +const MenuButton = () => { + const { id } = useParams(); + const navigate = useNavigate(); + + const removeBucket = useRemoveBucket({ + onSuccess: () => { + toast.success("Bucket removed!"); + navigate("/buckets", { replace: true }); + }, + onError: handleError, + }); + + const onRemove = () => { + if (window.confirm("Are you sure you want to remove this bucket?")) { + removeBucket.mutate(id!); + } + }; + + return ( + + + +
diff --git a/src/pages/buckets/schema.ts b/src/pages/buckets/schema.ts new file mode 100644 index 0000000..a714bd3 --- /dev/null +++ b/src/pages/buckets/schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const createBucketSchema = z.object({ + globalAlias: z.string().min(1, "Bucket Name is required"), +}); + +export type CreateBucketSchema = z.infer; diff --git a/src/pages/keys/components/create-key-dialog.tsx b/src/pages/keys/components/create-key-dialog.tsx new file mode 100644 index 0000000..3ce474c --- /dev/null +++ b/src/pages/keys/components/create-key-dialog.tsx @@ -0,0 +1,94 @@ +import Button from "@/components/ui/button"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus } from "lucide-react"; +import { Modal } from "react-daisyui"; +import { useForm, useWatch } from "react-hook-form"; +import { useDisclosure } from "@/hooks/useDisclosure"; +import { createKeySchema, CreateKeySchema } from "../schema"; +import { InputField } from "@/components/ui/input"; +import { CheckboxField } from "@/components/ui/checkbox"; +import { useCreateKey } from "../hooks"; +import { useQueryClient } from "@tanstack/react-query"; +import { handleError } from "@/lib/utils"; +import { toast } from "sonner"; +import { useEffect } from "react"; + +const CreateKeyDialog = () => { + const { dialogRef, isOpen, onOpen, onClose } = useDisclosure(); + const form = useForm({ + resolver: zodResolver(createKeySchema), + defaultValues: { name: "" }, + }); + const isImport = useWatch({ control: form.control, name: "isImport" }); + const queryClient = useQueryClient(); + + useEffect(() => { + if (isOpen) form.setFocus("name"); + }, [isOpen]); + + const createKey = useCreateKey({ + onSuccess: () => { + onClose(); + queryClient.invalidateQueries({ queryKey: ["keys"] }); + toast.success("Key created!"); + }, + onError: handleError, + }); + + const onSubmit = form.handleSubmit((values) => { + createKey.mutate(values); + }); + + return ( + <> + + + + Create New Key + +

Enter the details of the key you wish to create.

+ +
+ + + + {isImport && ( + <> + + + + )} + +
+ + + + + +
+ + ); +}; + +export default CreateKeyDialog; diff --git a/src/pages/keys/hooks.ts b/src/pages/keys/hooks.ts new file mode 100644 index 0000000..c819220 --- /dev/null +++ b/src/pages/keys/hooks.ts @@ -0,0 +1,38 @@ +import api from "@/lib/api"; +import { + useMutation, + UseMutationOptions, + useQuery, +} from "@tanstack/react-query"; +import { Key } from "./types"; +import { CreateKeySchema } from "./schema"; + +export const useKeys = () => { + return useQuery({ + queryKey: ["keys"], + queryFn: () => api.get("/v1/key?list"), + }); +}; + +export const useCreateKey = ( + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: async (body) => { + if (body.isImport) { + return api.post("/v1/key/import", { body }); + } + return api.post("/v1/key", { body }); + }, + ...options, + }); +}; + +export const useRemoveKey = ( + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (id) => api.delete("/v1/key", { params: { id } }), + ...options, + }); +}; diff --git a/src/pages/keys/page.tsx b/src/pages/keys/page.tsx new file mode 100644 index 0000000..fa04764 --- /dev/null +++ b/src/pages/keys/page.tsx @@ -0,0 +1,70 @@ +import Button from "@/components/ui/button"; +import Page from "@/context/page-context"; +import { Trash } from "lucide-react"; +import { Card, Input, Table } from "react-daisyui"; +import { useKeys, useRemoveKey } from "./hooks"; +import CreateKeyDialog from "./components/create-key-dialog"; +import { toast } from "sonner"; +import { handleError } from "@/lib/utils"; + +const KeysPage = () => { + const { data, refetch } = useKeys(); + + const removeKey = useRemoveKey({ + onSuccess: () => { + refetch(); + toast.success("Key removed!"); + }, + onError: handleError, + }); + + const onRemove = (id: string) => { + if (window.confirm("Are you sure you want to remove this key?")) { + removeKey.mutate(id); + } + }; + + return ( +
+ + +
+ +
+ +
+ + +
+ + + # + Key ID + Name + + + + + {data?.map((key, idx) => ( + + {idx + 1} + {key.id} + {key.name} + +
+
+
+
+ ); +}; + +export default KeysPage; diff --git a/src/pages/keys/schema.ts b/src/pages/keys/schema.ts new file mode 100644 index 0000000..60d78fa --- /dev/null +++ b/src/pages/keys/schema.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const createKeySchema = z + .object({ + name: z + .string() + .min(1, "Key Name is required") + .regex(/^[a-zA-Z0-9_-]+$/, "Key Name invalid"), + isImport: z.boolean().nullish(), + accessKeyId: z.string().nullish(), + secretAccessKey: z.string().nullish(), + }) + .refine( + (v) => !v.isImport || (v.accessKeyId != null && v.accessKeyId.length > 0), + { message: "Access key ID is required", path: ["accessKeyId"] } + ) + .refine( + (v) => + !v.isImport || + (v.secretAccessKey != null && v.secretAccessKey.length > 0), + { message: "Secret access key is required", path: ["secretAccessKey"] } + ); + +export type CreateKeySchema = z.infer; diff --git a/src/pages/keys/types.ts b/src/pages/keys/types.ts new file mode 100644 index 0000000..ded910b --- /dev/null +++ b/src/pages/keys/types.ts @@ -0,0 +1,6 @@ +// + +export type Key = { + id: string; + name: string; +};