mirror of
https://github.com/khairul169/garage-webui.git
synced 2025-11-30 04:51:04 +07:00
feat: added cors listing & form actions
This commit is contained in:
parent
bd35a5ece1
commit
9344247d19
@ -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);
|
||||
|
||||
@ -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<BucketCors[]>(`/buckets/${bucketName}/cors`),
|
||||
enabled: !!bucketName,
|
||||
});
|
||||
};
|
||||
|
||||
export const useBucketCorsMutation = (
|
||||
options?: MutationOptions<any, Error, BucketCorsSchema>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (data: BucketCorsSchema) =>
|
||||
api.put(`/buckets/${data.bucketName}/cors`, {
|
||||
body: { rules: data.rules },
|
||||
}),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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 = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
{data && cors && (
|
||||
<div className="container">
|
||||
<BucketContext.Provider
|
||||
value={{ bucket: data, refetch, bucketName: name || "" }}
|
||||
value={{
|
||||
bucket: data,
|
||||
cors: cors ?? [],
|
||||
refetch,
|
||||
bucketName: name || "",
|
||||
}}
|
||||
>
|
||||
<TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
|
||||
</BucketContext.Provider>
|
||||
|
||||
96
src/pages/buckets/manage/permissions/access-keys.tsx
Normal file
96
src/pages/buckets/manage/permissions/access-keys.tsx
Normal file
@ -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 (
|
||||
<Card className="card-body">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Card.Title className="flex-1 truncate">Access Keys</Card.Title>
|
||||
<AllowKeyDialog currentKeys={keys?.map((key) => key.accessKeyId)} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table zebra size="sm">
|
||||
<Table.Head>
|
||||
<span>#</span>
|
||||
<span>Key</span>
|
||||
<span>Aliases</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>{key.bucketLocalAliases?.join(", ") || "-"}</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessKeyPermissions;
|
||||
231
src/pages/buckets/manage/permissions/cors-configuration.tsx
Normal file
231
src/pages/buckets/manage/permissions/cors-configuration.tsx
Normal file
@ -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<BucketCorsSchema>({
|
||||
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 (
|
||||
<Card className="card-body">
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Card.Title className="flex-1 truncate">
|
||||
Cors Configuration
|
||||
</Card.Title>
|
||||
<Button
|
||||
icon={CheckCircle}
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{fields.map((rule, idx) => (
|
||||
<div
|
||||
className="grid md:grid-cols-2 xl:grid-cols-5 gap-5 mt-5"
|
||||
key={rule.id}
|
||||
>
|
||||
<FormControl
|
||||
form={form}
|
||||
name={`rules.${idx}.allowedHeaders`}
|
||||
title={"Allowed Headers"}
|
||||
render={(field) => (
|
||||
<CorsRulesChips
|
||||
form={form}
|
||||
fieldName={`rules.${idx}.allowedHeaders`}
|
||||
values={(field.value as string[]) ?? []}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormControl
|
||||
form={form}
|
||||
name={`rules.${idx}.allowedMethods`}
|
||||
title={"Allowed Methods"}
|
||||
render={(field) => (
|
||||
<CorsRulesChips
|
||||
form={form}
|
||||
fieldName={`rules.${idx}.allowedMethods`}
|
||||
values={(field.value as string[]) ?? []}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormControl
|
||||
form={form}
|
||||
name={`rules.${idx}.allowedOrigins`}
|
||||
title={"Allowed Origins"}
|
||||
render={(field) => (
|
||||
<CorsRulesChips
|
||||
form={form}
|
||||
fieldName={`rules.${idx}.allowedOrigins`}
|
||||
values={(field.value as string[]) ?? []}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormControl
|
||||
key={rule.id}
|
||||
form={form}
|
||||
name={`rules.${idx}.exposeHeaders`}
|
||||
title={"Expose Headers"}
|
||||
render={(field) => (
|
||||
<CorsRulesChips
|
||||
form={form}
|
||||
fieldName={`rules.${idx}.exposeHeaders`}
|
||||
values={(field.value as string[]) ?? []}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<InputField
|
||||
form={form}
|
||||
name={`rules.${idx}.maxAgeSeconds`}
|
||||
title="Max age seconds"
|
||||
placeholder="0000"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface CorsRulesChipsProps {
|
||||
form: UseFormReturn<BucketCorsSchema>;
|
||||
fieldName: Path<BucketCorsSchema>;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
function CorsRulesChips({ fieldName, form, values }: CorsRulesChipsProps) {
|
||||
function onRemove(value: string) {
|
||||
const currentValues =
|
||||
(form.getValues(fieldName as Path<BucketCorsSchema>) as string[]) ?? [];
|
||||
|
||||
form.setValue(
|
||||
fieldName,
|
||||
currentValues.filter((v) => v !== value)
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap gap-2 mt-2">
|
||||
{values.map((value) => (
|
||||
<Chips key={value} onRemove={() => onRemove(value)}>
|
||||
{value}
|
||||
</Chips>
|
||||
))}
|
||||
<AddRuleDialog form={form} fieldName={fieldName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddRuleDialogProps {
|
||||
form: UseFormReturn<BucketCorsSchema>;
|
||||
fieldName: Path<BucketCorsSchema>;
|
||||
}
|
||||
|
||||
const AddRuleDialog = ({ form, fieldName }: AddRuleDialogProps) => {
|
||||
const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
|
||||
const inputRef = useRef<HTMLInputElement>(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<BucketCorsSchema>) as string[]) ?? [];
|
||||
|
||||
form.setValue(
|
||||
fieldName as Path<BucketCorsSchema>,
|
||||
[...currentValues, value],
|
||||
{ shouldDirty: true, shouldValidate: true }
|
||||
);
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="button" size="sm" onClick={onOpen} icon={Plus}>
|
||||
Add Rule
|
||||
</Button>
|
||||
|
||||
<Modal ref={dialogRef} open={isOpen}>
|
||||
<Modal.Header>Add Alias</Modal.Header>
|
||||
|
||||
<Modal.Body>
|
||||
<Input ref={inputRef} className="w-full" />
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Actions>
|
||||
<Button type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" color="primary" onClick={onSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CorsConfiguration;
|
||||
@ -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 (
|
||||
<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 currentKeys={keys?.map((key) => key.accessKeyId)} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table zebra size="sm">
|
||||
<Table.Head>
|
||||
<span>#</span>
|
||||
<span>Key</span>
|
||||
<span>Aliases</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>{key.bucketLocalAliases?.join(", ") || "-"}</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 className="space-y-5">
|
||||
<AccessKeyPermissions />
|
||||
<CorsConfiguration />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -37,3 +37,18 @@ export const allowKeysSchema = z.object({
|
||||
});
|
||||
|
||||
export type AllowKeysSchema = z.infer<typeof allowKeysSchema>;
|
||||
|
||||
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<typeof bucketCorsSchema>;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user