diff --git a/.changeset/fair-steaks-film.md b/.changeset/fair-steaks-film.md new file mode 100644 index 000000000..fb5313dec --- /dev/null +++ b/.changeset/fair-steaks-film.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +S3 authentication keypairs can now be created and managed directly from the UI. Closes https://github.com/SiaFoundation/web/issues/430 diff --git a/apps/renterd/components/CmdRoot/index.tsx b/apps/renterd/components/CmdRoot/index.tsx index 324ad5868..cfdb2ada8 100644 --- a/apps/renterd/components/CmdRoot/index.tsx +++ b/apps/renterd/components/CmdRoot/index.tsx @@ -22,6 +22,7 @@ import { FilesCmd } from '../Files/FilesCmd' import { useHosts } from '../../contexts/hosts' import { useDebounce } from 'use-debounce' import { CmdEmptyDefault } from './CmdEmpty' +import { KeysCmd } from '../Keys/KeysCmd' type Props = { panel?: boolean @@ -131,6 +132,19 @@ export function CmdRoot({ panel }: Props) { afterSelect() }} /> + { + beforeSelect() + }} + afterSelect={() => { + if (!router.pathname.startsWith(routes.keys.index)) { + router.push(routes.keys.index) + } + afterSelect() + }} + /> diff --git a/apps/renterd/components/Keys/KeyContextMenu.tsx b/apps/renterd/components/Keys/KeyContextMenu.tsx new file mode 100644 index 000000000..246b8218f --- /dev/null +++ b/apps/renterd/components/Keys/KeyContextMenu.tsx @@ -0,0 +1,97 @@ +import { + DropdownMenu, + DropdownMenuItem, + Button, + DropdownMenuLeftSlot, + DropdownMenuLabel, + Text, + Paragraph, + triggerSuccessToast, + triggerErrorToast, + truncate, +} from '@siafoundation/design-system' +import { Draggable16, Delete16 } from '@siafoundation/react-icons' +import { useSettingUpdate } from '@siafoundation/react-renterd' +import { useS3AuthenticationSettings } from '../../hooks/useS3AuthenticationSettings' +import { useCallback } from 'react' +import { omit } from '@technically/lodash' +import { useDialog } from '../../contexts/dialog' + +type Props = { + s3Key: string + contentProps?: React.ComponentProps['contentProps'] + buttonProps?: React.ComponentProps +} + +export function KeyContextMenu({ s3Key, contentProps, buttonProps }: Props) { + const { openConfirmDialog } = useDialog() + const s3AuthenticationSettings = useS3AuthenticationSettings() + const update = useSettingUpdate() + const deleteKey = useCallback(async () => { + const newKeys = omit(s3AuthenticationSettings.data?.v4Keypairs, s3Key) + const response = await update.put({ + params: { + key: 's3authentication', + }, + payload: { + v4Keypairs: newKeys, + }, + }) + if (response.error) { + triggerErrorToast(`Failed to delete key: ${response.error}`) + } else { + triggerSuccessToast(`Key ${s3Key} removed.`) + } + }, [s3AuthenticationSettings.data, s3Key, update]) + + return ( + + + + } + contentProps={{ + align: 'start', + ...contentProps, + onClick: (e) => { + e.stopPropagation() + }, + }} + > +
+ + Key {s3Key.slice(0, 24)}... + +
+ Actions + { + openConfirmDialog({ + title: `Delete key ${truncate(s3Key, 15)}`, + action: 'Remove', + variant: 'red', + body: ( +
+ + Are you sure you would like to remove the following key? + + + {truncate(s3Key, 80)} + +
+ ), + onConfirm: async () => { + deleteKey() + }, + }) + }} + > + + + + Delete key +
+
+ ) +} diff --git a/apps/renterd/components/Keys/KeysActionsMenu.tsx b/apps/renterd/components/Keys/KeysActionsMenu.tsx new file mode 100644 index 000000000..b86fc1822 --- /dev/null +++ b/apps/renterd/components/Keys/KeysActionsMenu.tsx @@ -0,0 +1,17 @@ +import { Button } from '@siafoundation/design-system' +import { KeysViewDropdownMenu } from './KeysViewDropdownMenu' +import { Add16 } from '@siafoundation/react-icons' +import { useDialog } from '../../contexts/dialog' + +export function KeysActionsMenu() { + const { openDialog } = useDialog() + return ( +
+ + +
+ ) +} diff --git a/apps/renterd/components/Keys/KeysCmd/index.tsx b/apps/renterd/components/Keys/KeysCmd/index.tsx new file mode 100644 index 000000000..cf6e1d7f4 --- /dev/null +++ b/apps/renterd/components/Keys/KeysCmd/index.tsx @@ -0,0 +1,65 @@ +import { + CommandGroup, + CommandItemNav, + CommandItemSearch, +} from '../../CmdRoot/Item' +import { Page } from '../../CmdRoot/types' +import { useRouter } from 'next/router' +import { useDialog } from '../../../contexts/dialog' +import { routes } from '../../../config/routes' + +export const commandPage = { + namespace: 'keys', + label: 'S3 authentication keys', +} + +export function KeysCmd({ + currentPage, + parentPage, + pushPage, +}: { + currentPage: Page + parentPage?: Page + beforeSelect?: () => void + afterSelect?: () => void + pushPage: (page: Page) => void +}) { + const router = useRouter() + const { closeDialog, openDialog } = useDialog() + return ( + <> + { + pushPage(commandPage) + }} + > + {commandPage.label} + + + { + router.push(routes.keys.index) + closeDialog() + }} + > + View keys + + { + router.push(routes.keys.index) + openDialog('keysCreate') + }} + > + Create new S3 authentication keypair + + + + ) +} diff --git a/apps/renterd/components/Keys/KeysCreateDialog.tsx b/apps/renterd/components/Keys/KeysCreateDialog.tsx new file mode 100644 index 000000000..5d7bea5d2 --- /dev/null +++ b/apps/renterd/components/Keys/KeysCreateDialog.tsx @@ -0,0 +1,201 @@ +import { + Dialog, + triggerErrorToast, + triggerSuccessToast, + FieldText, + FormSubmitButton, + ConfigFields, + useOnInvalid, + Paragraph, + Button, +} from '@siafoundation/design-system' +import { useCallback, useMemo } from 'react' +import { useForm } from 'react-hook-form' +import { useDialog } from '../../contexts/dialog' +import { useS3AuthenticationSettings } from '../../hooks/useS3AuthenticationSettings' +import { useSettingUpdate } from '@siafoundation/react-renterd' +import { Reset16 } from '@carbon/icons-react' + +type Props = { + trigger?: React.ReactNode + open: boolean + onOpenChange: (val: boolean) => void +} + +function getDefaultValues() { + return { + name: generateAccessKeyId(), + secret: generateSecretAccessKey(), + } +} + +type Values = ReturnType + +function getFields({ + existingKeys, + regenerateAccessKey, + regenerateSecretKey, +}: { + existingKeys: string[] + regenerateAccessKey: () => void + regenerateSecretKey: () => void +}): ConfigFields { + return { + name: { + type: 'text', + title: 'Access key ID', + placeholder: 'my secure key', + actions: ( + + ), + validation: { + required: 'required', + validate: { + minLength: (value) => + value?.length >= 16 || 'must be at least 16 characters', + maxLength: (value) => + value?.length <= 128 || 'must be at most 128 characters', + unique: (value) => + !existingKeys.includes(value) || 'Name must be unique', + }, + }, + }, + secret: { + type: 'text', + title: 'Secret access key', + placeholder: '', + actions: ( + + ), + validation: { + required: 'required', + validate: { + exactLength: (value) => + value?.length === 40 || 'must be exactly 40 characters', + }, + }, + }, + } +} + +export function KeysCreateDialog({ trigger, open, onOpenChange }: Props) { + const { closeDialog } = useDialog() + const s3AuthenticationSettings = useS3AuthenticationSettings() + const update = useSettingUpdate() + + const form = useForm({ + mode: 'all', + defaultValues: getDefaultValues(), + }) + + const onSubmit = useCallback( + async (values: Values) => { + const v4Keypairs = { + ...s3AuthenticationSettings.data?.v4Keypairs, + [values.name]: values.secret, + } + const response = await update.put({ + params: { + key: 's3authentication', + }, + payload: { + v4Keypairs, + }, + }) + if (response.error) { + triggerErrorToast(response.error) + } else { + triggerSuccessToast('New key created.') + form.reset(getDefaultValues()) + closeDialog() + } + }, + [form, closeDialog, update, s3AuthenticationSettings.data] + ) + + const fields = useMemo( + () => + getFields({ + regenerateAccessKey: () => { + form.setValue('name', generateAccessKeyId()) + }, + regenerateSecretKey: () => { + form.setValue('secret', generateSecretAccessKey()) + }, + existingKeys: Object.keys( + s3AuthenticationSettings.data?.v4Keypairs || {} + ), + }), + [s3AuthenticationSettings.data, form] + ) + + const onInvalid = useOnInvalid(fields) + + return ( + { + if (!val) { + form.reset(getDefaultValues()) + } + onOpenChange(val) + }} + contentVariants={{ + className: 'w-[400px]', + }} + onSubmit={form.handleSubmit(onSubmit, onInvalid)} + controls={ +
+ Create +
+ } + > +
+ Create a new S3 authentication key. +
+ + +
+
+
+ ) +} + +function generateAccessKeyId() { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + let result = '' + for (let i = 0; i < 20; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)) + } + return result +} + +function generateSecretAccessKey() { + const characters = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + let result = '' + for (let i = 0; i < 40; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)) + } + return result +} diff --git a/apps/renterd/components/Keys/KeysViewDropdownMenu.tsx b/apps/renterd/components/Keys/KeysViewDropdownMenu.tsx new file mode 100644 index 000000000..195641cf5 --- /dev/null +++ b/apps/renterd/components/Keys/KeysViewDropdownMenu.tsx @@ -0,0 +1,132 @@ +import { + Button, + Select, + PoolCombo, + Label, + Popover, + MenuItemRightSlot, + BaseMenuItem, + MenuSeparator, + MenuSectionLabelToggleAll, + Option, +} from '@siafoundation/design-system' +import { + CaretDown16, + SettingsAdjust16, + Reset16, +} from '@siafoundation/react-icons' +import { sortOptions, SortField } from '../../contexts/keys/types' +import { groupBy } from '@technically/lodash' +import { useKeys } from '../../contexts/keys' + +export function KeysViewDropdownMenu() { + const { + configurableColumns, + toggleColumnVisibility, + resetDefaultColumnVisibility, + setColumnsVisible, + setColumnsHidden, + sortField, + setSortField, + sortDirection, + setSortDirection, + enabledColumns, + } = useKeys() + + const generalColumns = configurableColumns + .filter((c) => c.category === 'general') + .map((column) => ({ + label: column.label, + value: column.id, + })) + return ( + + + View + + + } + contentProps={{ + align: 'end', + className: 'max-w-[300px]', + }} + > + + + + + + + + + + + + + + + + + + + + c.value)} + enabled={enabledColumns} + setColumnsVisible={setColumnsVisible} + setColumnsHidden={setColumnsHidden} + /> + + toggleColumnVisibility(value)} + /> + + + ) +} diff --git a/apps/renterd/components/Keys/StateError.tsx b/apps/renterd/components/Keys/StateError.tsx new file mode 100644 index 000000000..87e815f48 --- /dev/null +++ b/apps/renterd/components/Keys/StateError.tsx @@ -0,0 +1,16 @@ +import { Text } from '@siafoundation/design-system' +import { MisuseOutline32 } from '@siafoundation/react-icons' + +export function StateError() { + const message = 'Error fetching keys.' + return ( +
+ + + + + {message} + +
+ ) +} diff --git a/apps/renterd/components/Keys/StateNoneMatching.tsx b/apps/renterd/components/Keys/StateNoneMatching.tsx new file mode 100644 index 000000000..9341327d0 --- /dev/null +++ b/apps/renterd/components/Keys/StateNoneMatching.tsx @@ -0,0 +1,15 @@ +import { Text } from '@siafoundation/design-system' +import { Filter32 } from '@siafoundation/react-icons' + +export function StateNoneMatching() { + return ( +
+ + + + + No keys matching filters. + +
+ ) +} diff --git a/apps/renterd/components/Keys/StateNoneYet.tsx b/apps/renterd/components/Keys/StateNoneYet.tsx new file mode 100644 index 000000000..e50f52808 --- /dev/null +++ b/apps/renterd/components/Keys/StateNoneYet.tsx @@ -0,0 +1,27 @@ +import { Button, Text } from '@siafoundation/design-system' +import { KeyIcon } from '@siafoundation/react-icons' +import { useDialog } from '../../contexts/dialog' + +export function StateNoneYet() { + const { openDialog } = useDialog() + return ( +
+ + + +
+ + There are no S3 authentication keypairs yet. Create one to get + started. + + +
+
+ ) +} diff --git a/apps/renterd/components/Keys/index.tsx b/apps/renterd/components/Keys/index.tsx new file mode 100644 index 000000000..6430d13ac --- /dev/null +++ b/apps/renterd/components/Keys/index.tsx @@ -0,0 +1,59 @@ +import { RenterdSidenav } from '../RenterdSidenav' +import { routes } from '../../config/routes' +import { Table } from '@siafoundation/design-system' +import { useDialog } from '../../contexts/dialog' +import { RenterdAuthedLayout } from '../RenterdAuthedLayout' +import { StateNoneMatching } from './StateNoneMatching' +import { StateNoneYet } from './StateNoneYet' +import { KeysActionsMenu } from './KeysActionsMenu' +import { StateError } from './StateError' +import { useKeys } from '../../contexts/keys' + +export function Keys() { + const { openDialog } = useDialog() + const { + columns, + datasetPage, + sortField, + sortDirection, + sortableColumns, + toggleSort, + limit, + dataState, + cellContext, + } = useKeys() + + return ( + } + openSettings={() => openDialog('settings')} + actions={} + > +
+ + ) : dataState === 'noneYet' ? ( + + ) : dataState === 'error' ? ( + + ) : null + } + sortableColumns={sortableColumns} + pageSize={limit} + data={datasetPage} + columns={columns} + sortDirection={sortDirection} + sortField={sortField} + toggleSort={toggleSort} + rowSize="default" + /> + + + ) +} diff --git a/apps/renterd/components/RenterdSidenav.tsx b/apps/renterd/components/RenterdSidenav.tsx index 02e84faea..50a293884 100644 --- a/apps/renterd/components/RenterdSidenav.tsx +++ b/apps/renterd/components/RenterdSidenav.tsx @@ -5,6 +5,7 @@ import { FileContractIcon, BarsProgressIcon, BellIcon, + KeyIcon, } from '@siafoundation/react-icons' import { useAlerts } from '@siafoundation/react-renterd' import { cx } from 'class-variance-authority' @@ -34,6 +35,9 @@ export function RenterdSidenav() { + + +
{!!alertCount && onlyInfoAlerts && (
- {/* this is here so that dialogs can use all the other providers, + + {/* this is here so that dialogs can use all the other providers, and the other providers can trigger dialogs */} - - - - {children} + + + + {children} + diff --git a/apps/renterd/config/routes.ts b/apps/renterd/config/routes.ts index 474b59b54..cf3c32d52 100644 --- a/apps/renterd/config/routes.ts +++ b/apps/renterd/config/routes.ts @@ -24,6 +24,9 @@ export const routes = { wallet: { view: '/wallet', }, + keys: { + index: '/keys', + }, node: { index: '/node', }, diff --git a/apps/renterd/contexts/dialog.tsx b/apps/renterd/contexts/dialog.tsx index 58423bb5a..1e7dd9352 100644 --- a/apps/renterd/contexts/dialog.tsx +++ b/apps/renterd/contexts/dialog.tsx @@ -21,6 +21,7 @@ import { HostsFilterPublicKeyDialog } from '../components/Hosts/HostsFilterPubli import { FilesBucketDeleteDialog } from '../components/Files/FilesBucketDeleteDialog' import { FilesBucketPolicyDialog } from '../components/Files/FilesBucketPolicyDialog' import { FilesBucketCreateDialog } from '../components/Files/FilesBucketCreateDialog' +import { KeysCreateDialog } from '../components/Keys/KeysCreateDialog' export type DialogType = | 'cmdk' @@ -43,6 +44,7 @@ export type DialogType = | 'filesCreateDirectory' | 'filesBucketPolicy' | 'filesSearch' + | 'keysCreate' | 'alerts' | 'confirm' @@ -206,6 +208,10 @@ export function Dialogs() { open={dialog === 'alerts'} onOpenChange={(val) => (val ? openDialog(dialog) : closeDialog())} /> + (val ? openDialog(dialog) : closeDialog())} + /> & { + fixed?: boolean + category?: string +} + +export const columns: KeysTableColumn[] = [ + { + id: 'actions', + label: '', + fixed: true, + cellClassName: 'w-[50px] !pl-2 !pr-4 [&+*]:!pl-0', + render: ({ data: { key } }) => , + }, + { + id: 'key', + label: 'key', + category: 'general', + render: ({ data: { key } }) => { + return + }, + }, + { + id: 'secret', + label: 'secret', + contentClassName: 'w-[120px]', + category: 'general', + render: ({ data: { secret } }) => { + return ( + + ) + }, + }, +] diff --git a/apps/renterd/contexts/keys/index.tsx b/apps/renterd/contexts/keys/index.tsx new file mode 100644 index 000000000..7ccf10009 --- /dev/null +++ b/apps/renterd/contexts/keys/index.tsx @@ -0,0 +1,148 @@ +import { + useTableState, + useDatasetEmptyState, + useClientFilters, + useClientFilteredDataset, + minutesInMilliseconds, +} from '@siafoundation/design-system' +import { useRouter } from 'next/router' +import { createContext, useContext, useMemo } from 'react' +import { + KeyData, + columnsDefaultVisible, + defaultSortField, + sortOptions, +} from './types' +import { columns } from './columns' +import { useS3AuthenticationSettings } from '../../hooks/useS3AuthenticationSettings' + +const defaultLimit = 50 + +function useKeysMain() { + const router = useRouter() + const limit = Number(router.query.limit || defaultLimit) + const offset = Number(router.query.offset || 0) + const response = useS3AuthenticationSettings({ + config: { + swr: { + refreshInterval: minutesInMilliseconds(1), + }, + }, + }) + + const dataset = useMemo(() => { + if (!response.data) { + return null + } + console.log(response.data?.v4Keypairs) + const data: KeyData[] = + Object.entries(response.data?.v4Keypairs || {}).map(([key, secret]) => { + return { + id: key, + key, + secret, + } + }) || [] + return data + }, [response.data]) + + const { filters, setFilter, removeFilter, removeLastFilter, resetFilters } = + useClientFilters() + + const { + configurableColumns, + enabledColumns, + sortableColumns, + toggleColumnVisibility, + setColumnsVisible, + setColumnsHidden, + toggleSort, + setSortDirection, + setSortField, + sortField, + sortDirection, + resetDefaultColumnVisibility, + } = useTableState('renterd/v0/keys', { + columns, + columnsDefaultVisible, + sortOptions, + defaultSortField, + }) + + const datasetFiltered = useClientFilteredDataset({ + dataset, + filters, + sortField, + sortDirection, + }) + + const datasetPage = useMemo(() => { + if (!datasetFiltered) { + return null + } + return datasetFiltered.slice(offset, offset + limit) + }, [datasetFiltered, offset, limit]) + + const filteredTableColumns = useMemo( + () => + columns.filter( + (column) => column.fixed || enabledColumns.includes(column.id) + ), + [enabledColumns] + ) + + const dataState = useDatasetEmptyState( + datasetFiltered, + response.isValidating, + response.error, + filters + ) + + const cellContext = useMemo(() => ({}), []) + + return { + dataState, + limit, + offset, + isLoading: response.isLoading, + error: response.error, + pageCount: datasetPage?.length || 0, + datasetCount: dataset?.length || 0, + datasetFilteredCount: datasetFiltered?.length || 0, + columns: filteredTableColumns, + dataset, + cellContext, + datasetPage, + configurableColumns, + enabledColumns, + sortableColumns, + toggleColumnVisibility, + setColumnsVisible, + setColumnsHidden, + toggleSort, + setSortDirection, + setSortField, + sortField, + filters, + setFilter, + removeFilter, + removeLastFilter, + resetFilters, + sortDirection, + resetDefaultColumnVisibility, + } +} + +type State = ReturnType + +const KeysContext = createContext({} as State) +export const useKeys = () => useContext(KeysContext) + +type Props = { + children: React.ReactNode +} + +export function KeysProvider({ children }: Props) { + const state = useKeysMain() + return {children} +} diff --git a/apps/renterd/contexts/keys/types.ts b/apps/renterd/contexts/keys/types.ts new file mode 100644 index 000000000..6981dc5c1 --- /dev/null +++ b/apps/renterd/contexts/keys/types.ts @@ -0,0 +1,30 @@ +export type KeyData = { + id: string + key: string + secret: string +} + +export type TableColumnId = 'actions' | 'key' | 'secret' + +export const columnsDefaultVisible: TableColumnId[] = ['key', 'secret'] + +export type SortField = 'key' | 'secret' + +export const defaultSortField: SortField = 'key' + +export const sortOptions: { + id: SortField + label: string + category: string +}[] = [ + { + id: 'key', + label: 'key', + category: 'general', + }, + { + id: 'secret', + label: 'secret', + category: 'general', + }, +] diff --git a/apps/renterd/pages/keys/index.tsx b/apps/renterd/pages/keys/index.tsx new file mode 100644 index 000000000..5e8cbd6e4 --- /dev/null +++ b/apps/renterd/pages/keys/index.tsx @@ -0,0 +1,5 @@ +import { Keys } from '../../components/Keys' + +export default function KeysPage() { + return +} diff --git a/libs/design-system/src/components/ValueCopyable.tsx b/libs/design-system/src/components/ValueCopyable.tsx index a326c258f..f58f7a8fa 100644 --- a/libs/design-system/src/components/ValueCopyable.tsx +++ b/libs/design-system/src/components/ValueCopyable.tsx @@ -28,6 +28,7 @@ type Props = { href?: string size?: React.ComponentProps['size'] weight?: React.ComponentProps['weight'] + font?: React.ComponentProps['font'] scaleSize?: React.ComponentProps['scaleSize'] maxLength?: number color?: React.ComponentProps['color'] @@ -45,6 +46,7 @@ export function ValueCopyable({ size, scaleSize, weight, + font, color = 'contrast', className, siascanUrl, @@ -68,6 +70,7 @@ export function ValueCopyable({ scaleSize={scaleSize} color={color} weight={weight} + font={font} ellipsis > {text} @@ -78,6 +81,7 @@ export function ValueCopyable({ scaleSize={scaleSize} color={color} weight={weight} + font={font} ellipsis > {text} diff --git a/libs/react-icons/src/KeyIcon.tsx b/libs/react-icons/src/KeyIcon.tsx new file mode 100644 index 000000000..a175a9b67 --- /dev/null +++ b/libs/react-icons/src/KeyIcon.tsx @@ -0,0 +1,19 @@ +type Props = { + size?: number + className?: string +} + +export function KeyIcon({ size = 24, className }: Props) { + return ( + + + + ) +} diff --git a/libs/react-icons/src/index.ts b/libs/react-icons/src/index.ts index c7e7186cf..4caa6b9ed 100644 --- a/libs/react-icons/src/index.ts +++ b/libs/react-icons/src/index.ts @@ -23,3 +23,4 @@ export * from './UsbIcon' export * from './HidIcon' export * from './VerifyIcon' export * from './SeedIcon' +export * from './KeyIcon'