From 69a696cad3bd77a2bfe9b9faf1af9fd285e35bd0 Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Mon, 13 Nov 2023 10:47:03 -0500 Subject: [PATCH] feat: renterd bucket policy and read access --- .changeset/gentle-cats-love.md | 5 + .../components/Files/BucketContextMenu.tsx | 12 +- .../Files/FilesBucketPolicyDialog.tsx | 133 ++++++++++++++++++ apps/renterd/contexts/dialog.tsx | 6 + apps/renterd/contexts/files/columns.tsx | 41 +++++- apps/renterd/contexts/files/dataset.tsx | 21 +-- apps/renterd/contexts/files/types.ts | 12 +- 7 files changed, 216 insertions(+), 14 deletions(-) create mode 100644 .changeset/gentle-cats-love.md create mode 100644 apps/renterd/components/Files/FilesBucketPolicyDialog.tsx diff --git a/.changeset/gentle-cats-love.md b/.changeset/gentle-cats-love.md new file mode 100644 index 000000000..9d380d3d4 --- /dev/null +++ b/.changeset/gentle-cats-love.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The bucket context menu now allows you to edit the bucket policy and toggle the read access between public and private. diff --git a/apps/renterd/components/Files/BucketContextMenu.tsx b/apps/renterd/components/Files/BucketContextMenu.tsx index 0902a58af..72a9d8c0c 100644 --- a/apps/renterd/components/Files/BucketContextMenu.tsx +++ b/apps/renterd/components/Files/BucketContextMenu.tsx @@ -5,7 +5,7 @@ import { DropdownMenuLeftSlot, DropdownMenuLabel, } from '@siafoundation/design-system' -import { Delete16, BucketIcon } from '@siafoundation/react-icons' +import { Delete16, BucketIcon, Rule16 } from '@siafoundation/react-icons' import { useDialog } from '../../contexts/dialog' type Props = { @@ -24,6 +24,16 @@ export function BucketContextMenu({ name }: Props) { contentProps={{ align: 'start' }} > Actions + { + openDialog('filesBucketPolicy', name) + }} + > + + + + Change policy + { diff --git a/apps/renterd/components/Files/FilesBucketPolicyDialog.tsx b/apps/renterd/components/Files/FilesBucketPolicyDialog.tsx new file mode 100644 index 000000000..9aa3d8fc3 --- /dev/null +++ b/apps/renterd/components/Files/FilesBucketPolicyDialog.tsx @@ -0,0 +1,133 @@ +import { + Paragraph, + Dialog, + triggerErrorToast, + triggerSuccessToast, + ConfigFields, + useOnInvalid, + FormSubmitButton, + FieldSelect, +} from '@siafoundation/design-system' +import { useCallback, useEffect, useMemo } from 'react' +import { useForm } from 'react-hook-form' +import { useDialog } from '../../contexts/dialog' +import { useBucket, useBucketPolicyUpdate } from '@siafoundation/react-renterd' + +const defaultValues = { + visibility: 'public', +} + +function getFields(name: string): ConfigFields { + return { + visibility: { + type: 'text', + title: 'Read Access', + placeholder: name, + validation: { + required: 'required', + }, + options: [ + { + label: 'Public', + value: 'public', + }, + { + label: 'Private', + value: 'private', + }, + ], + }, + } +} + +type Props = { + trigger?: React.ReactNode + open: boolean + onOpenChange: (val: boolean) => void +} + +export function FilesBucketPolicyDialog({ + trigger, + open, + onOpenChange, +}: Props) { + const { id: name, closeDialog } = useDialog() + const bucket = useBucket({ + disabled: !open, + params: { + name: name, + }, + config: { + swr: { + revalidateOnFocus: false, + }, + }, + }) + + const policyUpdate = useBucketPolicyUpdate() + const form = useForm({ + mode: 'all', + defaultValues, + }) + + useEffect(() => { + form.reset({ + visibility: bucket.data?.policy?.publicReadAccess ? 'public' : 'private', + }) + }, [form, bucket.data]) + + const onSubmit = useCallback( + async (values: typeof defaultValues) => { + const response = await policyUpdate.put({ + params: { + name, + }, + payload: { + policy: { + publicReadAccess: values.visibility === 'public', + }, + }, + }) + if (response.error) { + triggerErrorToast(response.error) + } else { + triggerSuccessToast('Bucket policy has been updated.') + form.reset() + closeDialog() + } + }, + [form, name, policyUpdate, closeDialog] + ) + + const fields = useMemo(() => getFields(name), [name]) + + const onInvalid = useOnInvalid(fields) + + return ( + { + if (!val) { + form.reset(defaultValues) + } + onOpenChange(val) + }} + contentVariants={{ + className: 'w-[400px]', + }} + onSubmit={form.handleSubmit(onSubmit, onInvalid)} + > +
+ + {`Update the bucket's policy to set read access to either private or public. Files in public read access buckets can be accessed without authentication via the S3 API.`} + + + + Update policy + +
+
+ ) +} diff --git a/apps/renterd/contexts/dialog.tsx b/apps/renterd/contexts/dialog.tsx index b3a35f97a..c2c482e5e 100644 --- a/apps/renterd/contexts/dialog.tsx +++ b/apps/renterd/contexts/dialog.tsx @@ -18,6 +18,7 @@ import { RenterdTransactionDetailsDialog } from '../dialogs/RenterdTransactionDe import { AlertsDialog } from '../dialogs/AlertsDialog' import { HostsFilterPublicKeyDialog } from '../components/Hosts/HostsFilterPublicKeyDialog' import { FilesBucketDeleteDialog } from '../components/Files/FilesBucketDeleteDialog' +import { FilesBucketPolicyDialog } from '../components/Files/FilesBucketPolicyDialog' import { FilesBucketCreateDialog } from '../components/Files/FilesBucketCreateDialog' export type DialogType = @@ -38,6 +39,7 @@ export type DialogType = | 'filesCreateBucket' | 'filesDeleteBucket' | 'filesCreateDirectory' + | 'filesBucketPolicy' | 'filesSearch' | 'alerts' | 'confirm' @@ -161,6 +163,10 @@ export function Dialogs() { open={dialog === 'filesDeleteBucket'} onOpenChange={(val) => (val ? openDialog(dialog) : closeDialog())} /> + (val ? openDialog(dialog) : closeDialog())} + /> (val ? openDialog(dialog) : closeDialog())} diff --git a/apps/renterd/contexts/files/columns.tsx b/apps/renterd/contexts/files/columns.tsx index 2b5dae510..5ff12fac3 100644 --- a/apps/renterd/contexts/files/columns.tsx +++ b/apps/renterd/contexts/files/columns.tsx @@ -1,11 +1,19 @@ import { + Badge, Button, LoadingDots, TableColumn, Text, + Tooltip, ValueNum, } from '@siafoundation/design-system' -import { Document16, FolderIcon } from '@siafoundation/react-icons' +import { + Document16, + Earth16, + FolderIcon, + Globe16, + Locked16, +} from '@siafoundation/react-icons' import { humanBytes } from '@siafoundation/sia-js' import { FileContextMenu } from '../../components/Files/FileContextMenu' import { DirectoryContextMenu } from '../../components/Files/DirectoryContextMenu' @@ -130,6 +138,37 @@ export const columns: FilesTableColumn[] = [ ) }, }, + { + id: 'readAccess', + label: 'read access', + contentClassName: 'justify-center', + render: function ReadAccessColumn({ data }) { + if (data.name === '..') { + return null + } + const isPublic = data.bucket?.policy?.publicReadAccess + return ( + +
+ +
+
+ ) + }, + }, { id: 'size', label: 'size', diff --git a/apps/renterd/contexts/files/dataset.tsx b/apps/renterd/contexts/files/dataset.tsx index 0052de070..d82e0bf28 100644 --- a/apps/renterd/contexts/files/dataset.tsx +++ b/apps/renterd/contexts/files/dataset.tsx @@ -28,9 +28,10 @@ export function useDataset({ activeDirectoryPath, uploadsList }: Props) { const router = useRouter() const limit = Number(router.query.limit || defaultLimit) const offset = Number(router.query.offset || 0) - const bucket = getBucketFromPath(activeDirectoryPath) + const activeBucketName = getBucketFromPath(activeDirectoryPath) + const activeBucket = buckets.data?.find((b) => b.name === activeBucketName) const response = useObjectDirectory({ - disabled: !bucket, + disabled: !activeBucketName, params: { ...bucketAndKeyParamsFromPath(activeDirectoryPath), offset, @@ -53,32 +54,32 @@ export function useDataset({ activeDirectoryPath, uploadsList }: Props) { uploadsList, allContracts, buckets.data, - bucket, + activeBucketName, activeDirectoryPath, ], () => { const dataMap: Record = {} - if (!bucket) { - buckets.data?.forEach(({ name }) => { - const bucket = name - const path = getDirPath(bucket, '') + if (!activeBucket) { + buckets.data?.forEach((bucket) => { + const name = bucket.name + const path = getDirPath(name, '') dataMap[name] = { id: path, path, bucket, size: 0, health: 0, - name: name, + name, type: 'bucket', } }) } else if (response.data) { response.data.entries?.forEach(({ name: key, size, health }) => { - const path = bucketAndResponseKeyToFilePath(bucket, key) + const path = bucketAndResponseKeyToFilePath(activeBucketName, key) dataMap[path] = { id: path, path, - bucket, + bucket: activeBucket, size, health, name: getFilename(key), diff --git a/apps/renterd/contexts/files/types.ts b/apps/renterd/contexts/files/types.ts index faff1d47d..9e070d119 100644 --- a/apps/renterd/contexts/files/types.ts +++ b/apps/renterd/contexts/files/types.ts @@ -1,3 +1,4 @@ +import { Bucket } from '@siafoundation/react-renterd' import { FullPath } from './paths' export type ObjectType = 'bucket' | 'directory' | 'file' @@ -7,7 +8,7 @@ export type ObjectData = { // path is exacty bucket + returned key // eg: default + /path/to/file.txt = default/path/to/file.txt path: FullPath - bucket: string + bucket: Bucket name: string health?: number size: number @@ -16,11 +17,18 @@ export type ObjectData = { loaded?: number } -export type TableColumnId = 'actions' | 'type' | 'name' | 'size' | 'health' +export type TableColumnId = + | 'actions' + | 'type' + | 'name' + | 'readAccess' + | 'size' + | 'health' export const columnsDefaultVisible: TableColumnId[] = [ 'type', 'name', + 'readAccess', 'size', 'health', ]