mirror of
https://github.com/khairul169/garage-webui.git
synced 2025-04-28 14:59:31 +07:00
feat: add keys & bucket permissions management
This commit is contained in:
parent
dfb4e30e23
commit
43af9c8658
@ -7,6 +7,7 @@ const ClusterPage = lazy(() => import("@/pages/cluster/page"));
|
|||||||
const HomePage = lazy(() => import("@/pages/home/page"));
|
const HomePage = lazy(() => import("@/pages/home/page"));
|
||||||
const BucketsPage = lazy(() => import("@/pages/buckets/page"));
|
const BucketsPage = lazy(() => import("@/pages/buckets/page"));
|
||||||
const ManageBucketPage = lazy(() => import("@/pages/buckets/manage/page"));
|
const ManageBucketPage = lazy(() => import("@/pages/buckets/manage/page"));
|
||||||
|
const KeysPage = lazy(() => import("@/pages/keys/page"));
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -32,6 +33,10 @@ const router = createBrowserRouter([
|
|||||||
{ path: ":id", Component: ManageBucketPage },
|
{ path: ":id", Component: ManageBucketPage },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "keys",
|
||||||
|
Component: KeysPage,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
@ -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 { 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 Sidebar = () => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="bg-base-100 border-r border-base-300/30 w-[220px] overflow-y-auto">
|
<aside className="bg-base-100 border-r border-base-300/30 w-[220px] overflow-y-auto">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
@ -14,24 +29,26 @@ const Sidebar = () => {
|
|||||||
<p className="text-sm font-medium text-center">WebUI</p>
|
<p className="text-sm font-medium text-center">WebUI</p>
|
||||||
</div>
|
</div>
|
||||||
<Menu className="gap-y-1">
|
<Menu className="gap-y-1">
|
||||||
<Menu.Item>
|
{pages.map((page) => {
|
||||||
<Link to="/">
|
const isActive = page.exact
|
||||||
<LayoutDashboard />
|
? pathname === page.path
|
||||||
<p>Dashboard</p>
|
: pathname.startsWith(page.path);
|
||||||
</Link>
|
return (
|
||||||
</Menu.Item>
|
<Menu.Item key={page.path}>
|
||||||
<Menu.Item>
|
<Link
|
||||||
<Link to="/cluster">
|
to={page.path}
|
||||||
<HardDrive />
|
className={cn(
|
||||||
<p>Cluster</p>
|
"h-12 flex items-center px-6",
|
||||||
</Link>
|
isActive &&
|
||||||
</Menu.Item>
|
"bg-primary text-primary-content hover:bg-primary/60 focus:bg-primary"
|
||||||
<Menu.Item>
|
)}
|
||||||
<Link to="/buckets">
|
>
|
||||||
<Cylinder />
|
<page.icon size={18} />
|
||||||
<p>Buckets</p>
|
<p>{page.title}</p>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Menu>
|
</Menu>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
@ -40,7 +40,10 @@ const Header = () => {
|
|||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<h1 className="text-xl">{page?.title || "Dashboard"}</h1>
|
|
||||||
|
<h1 className="text-xl flex-1 truncate">{page?.title || "Dashboard"}</h1>
|
||||||
|
|
||||||
|
{page?.actions}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
import { ComponentPropsWithoutRef, forwardRef } from "react";
|
import { ComponentPropsWithoutRef, forwardRef } from "react";
|
||||||
import { Button as BaseButton } from "react-daisyui";
|
import { Button as BaseButton } from "react-daisyui";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@ -5,17 +6,22 @@ import { Link } from "react-router-dom";
|
|||||||
type ButtonProps = ComponentPropsWithoutRef<typeof BaseButton> & {
|
type ButtonProps = ComponentPropsWithoutRef<typeof BaseButton> & {
|
||||||
href?: string;
|
href?: string;
|
||||||
target?: "_blank" | "_self" | "_parent" | "_top";
|
target?: "_blank" | "_self" | "_parent" | "_top";
|
||||||
|
icon?: LucideIcon;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ href, ...props }, ref) => {
|
({ href, children, icon: Icon, shape, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<BaseButton
|
<BaseButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
tag={href ? Link : undefined}
|
tag={href ? Link : undefined}
|
||||||
|
shape={Icon && !children ? "circle" : shape}
|
||||||
{...props}
|
{...props}
|
||||||
{...(href ? { to: href } : {})}
|
{...(href ? { to: href } : {})}
|
||||||
/>
|
>
|
||||||
|
{Icon ? <Icon size={18} className={children ? "-ml-1" : ""} /> : null}
|
||||||
|
{children}
|
||||||
|
</BaseButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
64
src/components/ui/checkbox.tsx
Normal file
64
src/components/ui/checkbox.tsx
Normal file
@ -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<typeof BaseCheckbox>,
|
||||||
|
"form"
|
||||||
|
> & {
|
||||||
|
label?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||||
|
({ label, className, inputClassName, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
"label label-text inline-flex items-center justify-start gap-2 cursor-pointer",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BaseCheckbox
|
||||||
|
ref={ref}
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
className={inputClassName}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type CheckboxFieldProps<T extends FieldValues> = Omit<
|
||||||
|
React.ComponentPropsWithoutRef<typeof FormControl<T>>,
|
||||||
|
"render"
|
||||||
|
> &
|
||||||
|
CheckboxProps;
|
||||||
|
|
||||||
|
export const CheckboxField = <T extends FieldValues>({
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
...props
|
||||||
|
}: CheckboxFieldProps<T>) => {
|
||||||
|
return (
|
||||||
|
<FormControl
|
||||||
|
form={form}
|
||||||
|
name={name}
|
||||||
|
render={(field) => (
|
||||||
|
<Checkbox
|
||||||
|
{...props}
|
||||||
|
{...field}
|
||||||
|
checked={field.value}
|
||||||
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Checkbox;
|
44
src/components/ui/input.tsx
Normal file
44
src/components/ui/input.tsx
Normal file
@ -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<typeof BaseInput>,
|
||||||
|
"form"
|
||||||
|
>;
|
||||||
|
|
||||||
|
const Input = forwardRef<HTMLInputElement, InputProps>(({ ...props }, ref) => {
|
||||||
|
return <BaseInput ref={ref} {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
type InputFieldProps<T extends FieldValues> = Omit<
|
||||||
|
React.ComponentPropsWithoutRef<typeof FormControl<T>>,
|
||||||
|
"render"
|
||||||
|
> &
|
||||||
|
InputProps & {
|
||||||
|
inputClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InputField = <T extends FieldValues>({
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
inputClassName,
|
||||||
|
...props
|
||||||
|
}: InputFieldProps<T>) => {
|
||||||
|
return (
|
||||||
|
<FormControl
|
||||||
|
form={form}
|
||||||
|
name={name}
|
||||||
|
title={title}
|
||||||
|
className={className}
|
||||||
|
render={(field) => (
|
||||||
|
<Input {...props} {...field} className={inputClassName} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Input;
|
@ -1,4 +1,4 @@
|
|||||||
import {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
memo,
|
memo,
|
||||||
PropsWithChildren,
|
PropsWithChildren,
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
type PageContextValues = {
|
type PageContextValues = {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
prev: string | null;
|
prev: string | null;
|
||||||
|
actions: React.ReactNode | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageContext = createContext<
|
export const PageContext = createContext<
|
||||||
@ -23,6 +24,7 @@ export const PageContext = createContext<
|
|||||||
const initialValues: PageContextValues = {
|
const initialValues: PageContextValues = {
|
||||||
title: null,
|
title: null,
|
||||||
prev: null,
|
prev: null,
|
||||||
|
actions: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageContextProvider = ({ children }: PropsWithChildren) => {
|
export const PageContextProvider = ({ children }: PropsWithChildren) => {
|
||||||
|
32
src/hooks/useDisclosure.ts
Normal file
32
src/hooks/useDisclosure.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export const useDisclosure = <T = any>() => {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [data, setData] = useState<T | null | undefined>(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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -29,7 +29,9 @@ const api = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
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
|
const isJson = res.headers
|
||||||
|
@ -14,8 +14,7 @@ export const readableBytes = (bytes?: number | null, divider = 1024) => {
|
|||||||
if (bytes == null || Number.isNaN(bytes)) return "n/a";
|
if (bytes == null || Number.isNaN(bytes)) return "n/a";
|
||||||
|
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
if (bytes === 0) return "n/a";
|
const i = Math.max(0, Math.floor(Math.log(bytes) / Math.log(divider)));
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(divider));
|
|
||||||
|
|
||||||
return `${(bytes / Math.pow(divider, i)).toFixed(1)} ${sizes[i]}`;
|
return `${(bytes / Math.pow(divider, i)).toFixed(1)} ${sizes[i]}`;
|
||||||
};
|
};
|
||||||
|
@ -10,33 +10,35 @@ type Props = {
|
|||||||
const BucketCard = ({ data }: Props) => {
|
const BucketCard = ({ data }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className="card card-body p-6">
|
<div className="card card-body p-6">
|
||||||
<div className="flex flex-row items-start gap-4 p-2 pb-0">
|
<div className="grid grid-cols-2 md:grid-cols-3 items-start gap-4 p-2 pb-0">
|
||||||
<ArchiveIcon size={28} />
|
<div className="flex flex-row items-start gap-x-3 col-span-2 md:col-span-1">
|
||||||
|
<ArchiveIcon size={28} className="shrink-0" />
|
||||||
|
|
||||||
<div className="flex-1">
|
<p className="text-xl font-medium truncate">
|
||||||
<p className="text-xl font-medium">
|
|
||||||
{data.globalAliases?.join(", ")}
|
{data.globalAliases?.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<p className="text-sm flex items-center gap-1">
|
<p className="text-sm flex items-center gap-1">
|
||||||
<ChartPie className="inline" size={16} />
|
<ChartPie className="inline" size={16} />
|
||||||
Usage
|
Usage
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-medium">{readableBytes(data.bytes)}</p>
|
<p className="text-xl font-medium mt-1">
|
||||||
|
{readableBytes(data.bytes)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<p className="text-sm flex items-center gap-1">
|
<p className="text-sm flex items-center gap-1">
|
||||||
<ChartScatter className="inline" size={16} />
|
<ChartScatter className="inline" size={16} />
|
||||||
Objects
|
Objects
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-medium">{data.objects}</p>
|
<p className="text-xl font-medium mt-1">{data.objects}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-row justify-end gap-4">
|
<div className="mt-1 flex flex-row justify-end gap-4">
|
||||||
<Button href={`/buckets/${data.id}`}>Manage</Button>
|
<Button href={`/buckets/${data.id}`}>Manage</Button>
|
||||||
{/* <Button color="primary">Browse</Button> */}
|
{/* <Button color="primary">Browse</Button> */}
|
||||||
</div>
|
</div>
|
||||||
|
71
src/pages/buckets/components/create-bucket-dialog.tsx
Normal file
71
src/pages/buckets/components/create-bucket-dialog.tsx
Normal file
@ -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<CreateBucketSchema>({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Button icon={Plus} color="primary" onClick={onOpen}>
|
||||||
|
Create Bucket
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal ref={dialogRef} backdrop open={isOpen}>
|
||||||
|
<Modal.Header className="mb-1">Create New Bucket</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p>Enter the details of the bucket you wish to create.</p>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<InputField form={form} name="globalAlias" title="Bucket Name" />
|
||||||
|
</form>
|
||||||
|
</Modal.Body>
|
||||||
|
|
||||||
|
<Modal.Actions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
disabled={createBucket.isPending}
|
||||||
|
onClick={onSubmit}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Modal.Actions>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateBucketDialog;
|
@ -1,6 +1,11 @@
|
|||||||
import api from "@/lib/api";
|
import api from "@/lib/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import {
|
||||||
|
useMutation,
|
||||||
|
UseMutationOptions,
|
||||||
|
useQuery,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
import { GetBucketRes } from "./types";
|
import { GetBucketRes } from "./types";
|
||||||
|
import { CreateBucketSchema } from "./schema";
|
||||||
|
|
||||||
export const useBuckets = () => {
|
export const useBuckets = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@ -8,3 +13,12 @@ export const useBuckets = () => {
|
|||||||
queryFn: () => api.get<GetBucketRes>("/buckets"),
|
queryFn: () => api.get<GetBucketRes>("/buckets"),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useCreateBucket = (
|
||||||
|
options?: UseMutationOptions<any, Error, CreateBucketSchema>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body) => api.post("/v1/bucket", { body }),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
168
src/pages/buckets/manage/components/allow-key-dialog.tsx
Normal file
168
src/pages/buckets/manage/components/allow-key-dialog.tsx
Normal file
@ -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<AllowKeysSchema>({
|
||||||
|
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<HTMLInputElement>,
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Button icon={Plus} color="primary" onClick={onOpen}>
|
||||||
|
Allow Key
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal ref={dialogRef} backdrop open={isOpen}>
|
||||||
|
<Modal.Header className="mb-1">Allow Key</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p>Enter the key you want to allow access to.</p>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto mt-4">
|
||||||
|
<Table>
|
||||||
|
<Table.Head>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
onChange={(e) => onToggleAll(e, "checked")}
|
||||||
|
/>
|
||||||
|
Key
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
onChange={(e) => onToggleAll(e, "read")}
|
||||||
|
/>
|
||||||
|
Read
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
onChange={(e) => onToggleAll(e, "write")}
|
||||||
|
/>
|
||||||
|
Write
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
onChange={(e) => onToggleAll(e, "owner")}
|
||||||
|
/>
|
||||||
|
Owner
|
||||||
|
</label>
|
||||||
|
</Table.Head>
|
||||||
|
|
||||||
|
<Table.Body>
|
||||||
|
{!keyFields.length ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="text-center">
|
||||||
|
No keys found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{keyFields.map((field, index) => (
|
||||||
|
<Table.Row key={field.id}>
|
||||||
|
<CheckboxField
|
||||||
|
form={form}
|
||||||
|
name={`keys.${index}.checked`}
|
||||||
|
label={field.name || field.keyId?.substring(0, 8)}
|
||||||
|
/>
|
||||||
|
<CheckboxField form={form} name={`keys.${index}.read`} />
|
||||||
|
<CheckboxField form={form} name={`keys.${index}.write`} />
|
||||||
|
<CheckboxField form={form} name={`keys.${index}.owner`} />
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
|
||||||
|
<Modal.Actions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
disabled={allowKey.isPending}
|
||||||
|
onClick={onSubmit}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Modal.Actions>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AllowKeyDialog;
|
42
src/pages/buckets/manage/components/menu-button.tsx
Normal file
42
src/pages/buckets/manage/components/menu-button.tsx
Normal file
@ -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 (
|
||||||
|
<Dropdown end>
|
||||||
|
<Dropdown.Toggle button={false}>
|
||||||
|
<Button icon={EllipsisVertical} />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item onClick={onRemove} className="bg-error/10 text-error">
|
||||||
|
<Trash /> Remove
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MenuButton;
|
@ -4,7 +4,7 @@ import Chips from "@/components/ui/chips";
|
|||||||
import { Bucket } from "../../types";
|
import { Bucket } from "../../types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: Bucket;
|
data?: Bucket;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AliasesSection = ({ data }: Props) => {
|
const AliasesSection = ({ data }: Props) => {
|
||||||
|
@ -9,7 +9,7 @@ import { useUpdateBucket } from "../hooks";
|
|||||||
import { Bucket } from "../../types";
|
import { Bucket } from "../../types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: Bucket;
|
data?: Bucket;
|
||||||
};
|
};
|
||||||
|
|
||||||
const QuotaSection = ({ data }: Props) => {
|
const QuotaSection = ({ data }: Props) => {
|
||||||
|
@ -12,8 +12,8 @@ const OverviewTab = () => {
|
|||||||
const { data } = useBucket(id);
|
const { data } = useBucket(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-8 items-start">
|
||||||
<Card className="card-body gap-0 items-start">
|
<Card className="card-body gap-0 items-start order-2 md:order-1">
|
||||||
<Card.Title>Summary</Card.Title>
|
<Card.Title>Summary</Card.Title>
|
||||||
|
|
||||||
<AliasesSection data={data} />
|
<AliasesSection data={data} />
|
||||||
@ -21,7 +21,7 @@ const OverviewTab = () => {
|
|||||||
<QuotaSection data={data} />
|
<QuotaSection data={data} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="card-body">
|
<Card className="card-body order-1 md:order-2">
|
||||||
<Card.Title>Usage</Card.Title>
|
<Card.Title>Usage</Card.Title>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||||
|
@ -12,7 +12,7 @@ import Button from "@/components/ui/button";
|
|||||||
import { Bucket } from "../../types";
|
import { Bucket } from "../../types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: Bucket;
|
data?: Bucket;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WebsiteAccessSection = ({ data }: Props) => {
|
const WebsiteAccessSection = ({ data }: Props) => {
|
||||||
|
103
src/pages/buckets/manage/components/permissions-tab.tsx
Normal file
103
src/pages/buckets/manage/components/permissions-tab.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useBucket, 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";
|
||||||
|
|
||||||
|
const PermissionsTab = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { data, refetch } = useBucket(id);
|
||||||
|
|
||||||
|
const denyKey = useDenyKey(id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Key removed!");
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: handleError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const keys = useMemo(() => {
|
||||||
|
return data?.keys.filter(
|
||||||
|
(key) =>
|
||||||
|
key.permissions.read !== false ||
|
||||||
|
key.permissions.write !== false ||
|
||||||
|
key.permissions.owner !== false
|
||||||
|
);
|
||||||
|
}, [data?.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 (
|
||||||
|
<div>
|
||||||
|
<Card className="card-body">
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<Card.Title className="flex-1 truncate">Access Keys</Card.Title>
|
||||||
|
<AllowKeyDialog
|
||||||
|
id={id}
|
||||||
|
currentKeys={keys?.map((key) => key.accessKeyId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table zebra size="sm">
|
||||||
|
<Table.Head>
|
||||||
|
<span>#</span>
|
||||||
|
<span>Key</span>
|
||||||
|
<span>Read</span>
|
||||||
|
<span>Write</span>
|
||||||
|
<span>Owner</span>
|
||||||
|
<span />
|
||||||
|
</Table.Head>
|
||||||
|
|
||||||
|
<Table.Body>
|
||||||
|
{keys?.map((key, idx) => (
|
||||||
|
<Table.Row>
|
||||||
|
<span>{idx + 1}</span>
|
||||||
|
<span>{key.name || key.accessKeyId?.substring(0, 8)}</span>
|
||||||
|
<span>
|
||||||
|
<Checkbox
|
||||||
|
checked={key.permissions?.read}
|
||||||
|
color="primary"
|
||||||
|
className="cursor-default"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Checkbox
|
||||||
|
checked={key.permissions?.write}
|
||||||
|
color="primary"
|
||||||
|
className="cursor-default"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Checkbox
|
||||||
|
checked={key.permissions?.owner}
|
||||||
|
color="primary"
|
||||||
|
className="cursor-default"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
icon={Trash}
|
||||||
|
onClick={() => onRemove(key.accessKeyId)}
|
||||||
|
/>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PermissionsTab;
|
@ -1,6 +1,6 @@
|
|||||||
import api from "@/lib/api";
|
import api from "@/lib/api";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { MutationOptions, useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Bucket } from "../types";
|
import { Bucket, Permissions } from "../types";
|
||||||
|
|
||||||
export const useBucket = (id?: string | null) => {
|
export const useBucket = (id?: string | null) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@ -17,3 +17,61 @@ export const useUpdateBucket = (id?: string | null) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useAllowKey = (
|
||||||
|
bucketId?: string | null,
|
||||||
|
options?: MutationOptions<
|
||||||
|
any,
|
||||||
|
Error,
|
||||||
|
{ keyId: string; permissions: Permissions }[]
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload) => {
|
||||||
|
const promises = payload.map(async (key) => {
|
||||||
|
console.log("test", key);
|
||||||
|
return api.post("/v1/bucket/allow", {
|
||||||
|
body: {
|
||||||
|
bucketId,
|
||||||
|
accessKeyId: key.keyId,
|
||||||
|
permissions: key.permissions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const result = await Promise.all(promises);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDenyKey = (
|
||||||
|
bucketId?: string | null,
|
||||||
|
options?: MutationOptions<
|
||||||
|
any,
|
||||||
|
Error,
|
||||||
|
{ keyId: string; permissions: Permissions }
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload) => {
|
||||||
|
return api.post("/v1/bucket/deny", {
|
||||||
|
body: {
|
||||||
|
bucketId,
|
||||||
|
accessKeyId: payload.keyId,
|
||||||
|
permissions: payload.permissions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRemoveBucket = (
|
||||||
|
options?: MutationOptions<any, Error, string>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id) => api.delete("/v1/bucket", { params: { id } }),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
@ -2,8 +2,10 @@ import { useParams } from "react-router-dom";
|
|||||||
import { useBucket } from "./hooks";
|
import { useBucket } from "./hooks";
|
||||||
import Page from "@/context/page-context";
|
import Page from "@/context/page-context";
|
||||||
import TabView, { Tab } from "@/components/containers/tab-view";
|
import TabView, { Tab } from "@/components/containers/tab-view";
|
||||||
import { ChartLine, FolderSearch } from "lucide-react";
|
import { ChartLine, LockKeyhole } from "lucide-react";
|
||||||
import OverviewTab from "./components/overview-tab";
|
import OverviewTab from "./components/overview-tab";
|
||||||
|
import PermissionsTab from "./components/permissions-tab";
|
||||||
|
import MenuButton from "./components/menu-button";
|
||||||
|
|
||||||
const tabs: Tab[] = [
|
const tabs: Tab[] = [
|
||||||
{
|
{
|
||||||
@ -12,6 +14,12 @@ const tabs: Tab[] = [
|
|||||||
icon: ChartLine,
|
icon: ChartLine,
|
||||||
Component: OverviewTab,
|
Component: OverviewTab,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "permissions",
|
||||||
|
title: "Permissions",
|
||||||
|
icon: LockKeyhole,
|
||||||
|
Component: PermissionsTab,
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// name: "browse",
|
// name: "browse",
|
||||||
// title: "Browse",
|
// title: "Browse",
|
||||||
@ -27,7 +35,11 @@ const ManageBucketPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<Page title={name || "Manage Bucket"} prev="/buckets" />
|
<Page
|
||||||
|
title={name || "Manage Bucket"}
|
||||||
|
prev="/buckets"
|
||||||
|
actions={<MenuButton />}
|
||||||
|
/>
|
||||||
<TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
|
<TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -16,3 +16,18 @@ export const quotaSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type QuotaSchema = z.infer<typeof quotaSchema>;
|
export type QuotaSchema = z.infer<typeof quotaSchema>;
|
||||||
|
|
||||||
|
export const allowKeysSchema = z.object({
|
||||||
|
keys: z
|
||||||
|
.object({
|
||||||
|
checked: z.boolean(),
|
||||||
|
keyId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
read: z.boolean(),
|
||||||
|
write: z.boolean(),
|
||||||
|
owner: z.boolean(),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AllowKeysSchema = z.infer<typeof allowKeysSchema>;
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import Page from "@/context/page-context";
|
import Page from "@/context/page-context";
|
||||||
import { useBuckets } from "./hooks";
|
import { useBuckets } from "./hooks";
|
||||||
import { Button, Input } from "react-daisyui";
|
import { Input } from "react-daisyui";
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
import BucketCard from "./components/bucket-card";
|
import BucketCard from "./components/bucket-card";
|
||||||
|
import CreateBucketDialog from "./components/create-bucket-dialog";
|
||||||
|
|
||||||
const BucketsPage = () => {
|
const BucketsPage = () => {
|
||||||
const { data } = useBuckets();
|
const { data } = useBuckets();
|
||||||
@ -15,10 +15,7 @@ const BucketsPage = () => {
|
|||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Input placeholder="Search..." />
|
<Input placeholder="Search..." />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button color="primary">
|
<CreateBucketDialog />
|
||||||
<Plus />
|
|
||||||
Create Bucket
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-8 items-stretch mt-4 md:mt-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-8 items-stretch mt-4 md:mt-8">
|
||||||
|
7
src/pages/buckets/schema.ts
Normal file
7
src/pages/buckets/schema.ts
Normal file
@ -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<typeof createBucketSchema>;
|
94
src/pages/keys/components/create-key-dialog.tsx
Normal file
94
src/pages/keys/components/create-key-dialog.tsx
Normal file
@ -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<CreateKeySchema>({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Button icon={Plus} color="primary" onClick={onOpen}>
|
||||||
|
Create Key
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal ref={dialogRef} backdrop open={isOpen}>
|
||||||
|
<Modal.Header className="mb-1">Create New Key</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p>Enter the details of the key you wish to create.</p>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<InputField form={form} name="name" title="Key Name" />
|
||||||
|
<CheckboxField
|
||||||
|
form={form}
|
||||||
|
name="isImport"
|
||||||
|
label="Import existing"
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isImport && (
|
||||||
|
<>
|
||||||
|
<InputField
|
||||||
|
form={form}
|
||||||
|
name="accessKeyId"
|
||||||
|
title="Access Key ID"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
form={form}
|
||||||
|
name="secretAccessKey"
|
||||||
|
title="Secret Access Key"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Modal.Body>
|
||||||
|
|
||||||
|
<Modal.Actions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
disabled={createKey.isPending}
|
||||||
|
onClick={onSubmit}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Modal.Actions>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateKeyDialog;
|
38
src/pages/keys/hooks.ts
Normal file
38
src/pages/keys/hooks.ts
Normal file
@ -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<Key[]>("/v1/key?list"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateKey = (
|
||||||
|
options?: UseMutationOptions<any, Error, CreateKeySchema>
|
||||||
|
) => {
|
||||||
|
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<any, Error, string>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id) => api.delete("/v1/key", { params: { id } }),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
70
src/pages/keys/page.tsx
Normal file
70
src/pages/keys/page.tsx
Normal file
@ -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 (
|
||||||
|
<div className="container">
|
||||||
|
<Page title="Keys" />
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<Input placeholder="Search..." />
|
||||||
|
<div className="flex-1" />
|
||||||
|
<CreateKeyDialog />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="card-body mt-4 md:mt-8 p-4">
|
||||||
|
<div className="w-full overflow-x-auto">
|
||||||
|
<Table zebra>
|
||||||
|
<Table.Head>
|
||||||
|
<span>#</span>
|
||||||
|
<span>Key ID</span>
|
||||||
|
<span>Name</span>
|
||||||
|
<span />
|
||||||
|
</Table.Head>
|
||||||
|
|
||||||
|
<Table.Body>
|
||||||
|
{data?.map((key, idx) => (
|
||||||
|
<Table.Row key={key.id}>
|
||||||
|
<span>{idx + 1}</span>
|
||||||
|
<span className="truncate">{key.id}</span>
|
||||||
|
<span>{key.name}</span>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
color="ghost"
|
||||||
|
icon={Trash}
|
||||||
|
onClick={() => onRemove(key.id)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KeysPage;
|
24
src/pages/keys/schema.ts
Normal file
24
src/pages/keys/schema.ts
Normal file
@ -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<typeof createKeySchema>;
|
6
src/pages/keys/types.ts
Normal file
6
src/pages/keys/types.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
//
|
||||||
|
|
||||||
|
export type Key = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user