feat: add keys & bucket permissions management

This commit is contained in:
2024-08-16 23:10:19 +07:00
parent dfb4e30e23
commit 43af9c8658
30 changed files with 948 additions and 53 deletions
+11 -9
View File
@@ -10,33 +10,35 @@ type Props = {
const BucketCard = ({ data }: Props) => {
return (
<div className="card card-body p-6">
<div className="flex flex-row items-start gap-4 p-2 pb-0">
<ArchiveIcon size={28} />
<div className="grid grid-cols-2 md:grid-cols-3 items-start gap-4 p-2 pb-0">
<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">
<p className="text-xl font-medium truncate">
{data.globalAliases?.join(", ")}
</p>
</div>
<div className="flex-1">
<div>
<p className="text-sm flex items-center gap-1">
<ChartPie className="inline" size={16} />
Usage
</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 className="flex-1">
<div>
<p className="text-sm flex items-center gap-1">
<ChartScatter className="inline" size={16} />
Objects
</p>
<p className="text-2xl font-medium">{data.objects}</p>
<p className="text-xl font-medium mt-1">{data.objects}</p>
</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 color="primary">Browse</Button> */}
</div>
@@ -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;
+15 -1
View File
@@ -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<GetBucketRes>("/buckets"),
});
};
export const useCreateBucket = (
options?: UseMutationOptions<any, Error, CreateBucketSchema>
) => {
return useMutation({
mutationFn: (body) => api.post("/v1/bucket", { body }),
...options,
});
};
@@ -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;
@@ -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";
type Props = {
data: Bucket;
data?: Bucket;
};
const AliasesSection = ({ data }: Props) => {
@@ -9,7 +9,7 @@ import { useUpdateBucket } from "../hooks";
import { Bucket } from "../../types";
type Props = {
data: Bucket;
data?: Bucket;
};
const QuotaSection = ({ data }: Props) => {
@@ -12,8 +12,8 @@ const OverviewTab = () => {
const { data } = useBucket(id);
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-8">
<Card className="card-body gap-0 items-start">
<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 order-2 md:order-1">
<Card.Title>Summary</Card.Title>
<AliasesSection data={data} />
@@ -21,7 +21,7 @@ const OverviewTab = () => {
<QuotaSection data={data} />
</Card>
<Card className="card-body">
<Card className="card-body order-1 md:order-2">
<Card.Title>Usage</Card.Title>
<div className="grid grid-cols-2 gap-4 mt-4">
@@ -12,7 +12,7 @@ import Button from "@/components/ui/button";
import { Bucket } from "../../types";
type Props = {
data: Bucket;
data?: Bucket;
};
const WebsiteAccessSection = ({ data }: Props) => {
@@ -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;
+60 -2
View File
@@ -1,6 +1,6 @@
import api from "@/lib/api";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Bucket } from "../types";
import { MutationOptions, useMutation, useQuery } from "@tanstack/react-query";
import { Bucket, Permissions } from "../types";
export const useBucket = (id?: string | null) => {
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,
});
};
+14 -2
View File
@@ -2,8 +2,10 @@ import { useParams } from "react-router-dom";
import { useBucket } from "./hooks";
import Page from "@/context/page-context";
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 PermissionsTab from "./components/permissions-tab";
import MenuButton from "./components/menu-button";
const tabs: Tab[] = [
{
@@ -12,6 +14,12 @@ const tabs: Tab[] = [
icon: ChartLine,
Component: OverviewTab,
},
{
name: "permissions",
title: "Permissions",
icon: LockKeyhole,
Component: PermissionsTab,
},
// {
// name: "browse",
// title: "Browse",
@@ -27,7 +35,11 @@ const ManageBucketPage = () => {
return (
<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" />
</div>
);
+15
View File
@@ -16,3 +16,18 @@ export const quotaSchema = z.object({
});
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>;
+3 -6
View File
@@ -1,8 +1,8 @@
import Page from "@/context/page-context";
import { useBuckets } from "./hooks";
import { Button, Input } from "react-daisyui";
import { Plus } from "lucide-react";
import { Input } from "react-daisyui";
import BucketCard from "./components/bucket-card";
import CreateBucketDialog from "./components/create-bucket-dialog";
const BucketsPage = () => {
const { data } = useBuckets();
@@ -15,10 +15,7 @@ const BucketsPage = () => {
<div className="flex flex-row items-center gap-2">
<Input placeholder="Search..." />
<div className="flex-1" />
<Button color="primary">
<Plus />
Create Bucket
</Button>
<CreateBucketDialog />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-8 items-stretch mt-4 md:mt-8">
+7
View 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>;
@@ -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
View 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
View 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
View 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
View File
@@ -0,0 +1,6 @@
//
export type Key = {
id: string;
name: string;
};