diff --git a/package.json b/package.json index 4df0565b..09e42ae1 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@mui/material": "^7.3.6", "@mui/x-data-grid": "^8.14.1", "@oceanprotocol/contracts": "2.6.0", - "@oceanprotocol/lib": "8.0.3", + "@oceanprotocol/lib": "8.0.6", "@ramp-network/ramp-instant-sdk": "^6.2.0", "@tanstack/react-query": "^5.28.4", "@wagmi/core": "^2.15.0", diff --git a/src/components/Navigation/profile-button.tsx b/src/components/Navigation/profile-button.tsx index 0b2ceb85..33bc5f43 100644 --- a/src/components/Navigation/profile-button.tsx +++ b/src/components/Navigation/profile-button.tsx @@ -1,10 +1,12 @@ import Avatar from '@/components/avatar/avatar'; import Menu from '@/components/menu/menu'; +import EditAccessListModal from '@/components/node-storage/edit-access-list-modal'; import { useProfileContext } from '@/context/profile-context'; import { useOceanAccount } from '@/lib/use-ocean-account'; import { GrantStatus } from '@/types/grant'; import { formatWalletAddress } from '@/utils/formatters'; import { useAuthModal, useLogout } from '@account-kit/react'; +import ListAltIcon from '@mui/icons-material/ListAlt'; import LogoutIcon from '@mui/icons-material/Logout'; import PersonIcon from '@mui/icons-material/Person'; import RedeemIcon from '@mui/icons-material/Redeem'; @@ -16,18 +18,20 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import Button from '../button/button'; import styles from './navigation.module.css'; -const ProfileButton = () => { +const ProfileButton: React.FC = () => { const router = useRouter(); const { closeAuthModal, isOpen: isAuthModalOpen, openAuthModal } = useAuthModal(); const { isLoggingOut, logout } = useLogout(); - const { account } = useOceanAccount(); + const { account, provider } = useOceanAccount(); const { ensName, ensProfile, grantStatus } = useProfileContext(); const [anchorEl, setAnchorEl] = useState(null); const [isClient, setIsClient] = useState(false); + const [isAccessListModalOpen, setIsAccessListModalOpen] = useState(false); + const buttonRef = useRef(null); // This is a workaround for the modal not closing after connecting @@ -136,6 +140,20 @@ const ProfileButton = () => { Convert to COMPY + {provider && ( + { + setIsAccessListModalOpen(true); + handleCloseMenu(); + }} + > + + + + Edit access list + + )} { Log out + setIsAccessListModalOpen(false)} + /> ) : ( ); }; diff --git a/src/components/modal/confirm-modal.tsx b/src/components/modal/confirm-modal.tsx new file mode 100644 index 00000000..aa1abab9 --- /dev/null +++ b/src/components/modal/confirm-modal.tsx @@ -0,0 +1,36 @@ +import Button from '@/components/button/button'; +import Modal from '@/components/modal/modal'; + +type ConfirmModalProps = { + confirmLabel?: string; + isOpen: boolean; + message: string; + onCancel: () => void; + onConfirm: () => void; + title?: string; +}; + +const ConfirmModal: React.FC = ({ + confirmLabel = 'Confirm', + isOpen, + message, + onCancel, + onConfirm, + title = 'Confirm', +}) => { + return ( + +

{message}

+
+ + +
+
+ ); +}; + +export default ConfirmModal; diff --git a/src/components/modal/modal.module.css b/src/components/modal/modal.module.css index fea51954..d1298677 100644 --- a/src/components/modal/modal.module.css +++ b/src/components/modal/modal.module.css @@ -1,9 +1,33 @@ /* Header */ .header { align-items: start; + border-bottom: 1px solid var(--border-glass); + box-shadow: var(--drop-shadow-black); display: flex; gap: 24px; justify-content: space-between; + padding: 16px; + position: sticky; + top: 0; + z-index: 1; + + @media (min-width: 576px) { + padding: 16px 24px; + } +} + +/* Body */ +.body { + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; + padding: 16px; + + @media (min-width: 576px) { + gap: 24px; + padding: 24px; + } } /* Title */ diff --git a/src/components/modal/modal.tsx b/src/components/modal/modal.tsx index 80c58af2..3a41dd8d 100644 --- a/src/components/modal/modal.tsx +++ b/src/components/modal/modal.tsx @@ -16,14 +16,12 @@ const StyledDialog = styled(Dialog)(({ theme }) => ({ color: 'var(--text-primary)', display: 'flex', flexDirection: 'column', - gap: 24, - padding: 24, + overflow: 'hidden', + padding: 0, [theme.breakpoints.down('sm')]: { borderRadius: 16, - gap: 16, margin: 16, - padding: 16, width: 'calc(100% - 32px)', }, }, @@ -49,7 +47,7 @@ const Modal = ({ children, fullWidth, hideCloseButton, isOpen, onClose, title, w )} - {children} +
{children}
); diff --git a/src/components/node-details/node-details-page-layout.tsx b/src/components/node-details/node-details-page-layout.tsx new file mode 100644 index 00000000..692d7ec9 --- /dev/null +++ b/src/components/node-details/node-details-page-layout.tsx @@ -0,0 +1,71 @@ +import Container from '@/components/container/container'; +import SectionTitle from '@/components/section-title/section-title'; +import TabBar from '@/components/tab-bar/tab-bar'; +import { CircularProgress } from '@mui/material'; +import React from 'react'; + +type TabKey = 'info' | 'storage'; + +type NodePageLayoutProps = { + activeTab: TabKey; + children?: React.ReactNode; + isWalletConnected?: boolean; + loading?: boolean; + nodeId?: string; + notFound?: boolean; + subtitle: string; +}; + +const NodeDetailsPageLayout: React.FC = ({ + activeTab, + children, + isWalletConnected, + loading, + nodeId, + notFound, + subtitle, +}) => { + if (loading) { + return ( + + + + Retrieving node details... + + } + /> + + ); + } + + if (notFound) { + return ( + + + + ); + } + + const tabs: { key: TabKey; label: string; href: string }[] = [ + { key: 'info', label: 'Node info', href: `/nodes/${nodeId}` }, + { key: 'storage' as TabKey, label: 'Remote storage', href: `/nodes/${nodeId}/storage` }, + ]; + + return ( + + : null} + moreReadable + subTitle={subtitle} + title="Node details" + /> +
{children}
+
+ ); +}; + +export default NodeDetailsPageLayout; diff --git a/src/components/node-details/node-details-page.tsx b/src/components/node-details/node-details-page.tsx index 48318930..01ce1436 100644 --- a/src/components/node-details/node-details-page.tsx +++ b/src/components/node-details/node-details-page.tsx @@ -1,22 +1,23 @@ -import Container from '@/components/container/container'; import BenchmarkJobs from '@/components/node-details/benchmark-jobs'; import Environments from '@/components/node-details/environments'; import JobsRevenueStats from '@/components/node-details/jobs-revenue-stats'; +import NodeDetailsPageLayout from '@/components/node-details/node-details-page-layout'; import NodeInfo from '@/components/node-details/node-info'; import UnbanRequests from '@/components/node-details/unban-requests'; -import SectionTitle from '@/components/section-title/section-title'; import { useNodesContext } from '@/context/nodes-context'; import { useUnbanRequestsContext } from '@/context/unban-requests-context'; import { useP2P } from '@/contexts/P2PContext'; import { directNodeCommand } from '@/lib/direct-node-command'; +import { useOceanAccount } from '@/lib/use-ocean-account'; import { ComputeEnvironment } from '@/types/environments'; -import { CircularProgress } from '@mui/material'; import { useParams } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; -const NodeDetailsPage = () => { +const NodeDetailsPage: React.FC = () => { const params = useParams<{ nodeId: string }>(); + const { account } = useOceanAccount(); + const { getEnvs: getEnvsP2P, isReady: isP2PReady, sendCommand } = useP2P(); const { selectedNode, fetchNode, loadingFetchNode } = useNodesContext(); @@ -79,52 +80,31 @@ const NodeDetailsPage = () => { } }, [fetchUnbanRequests, node]); - if (loadingFetchNode) { - return ( - - - - Retrieving node details... - - } - /> - - ); - } - - if (!node) { - return ( - - - - ); - } - return ( - - -
- - - - - {node.banned === false && unbanRequests?.length === 0 ? null : } -
-
+ + {node ? ( + <> + + + + + {node.banned === false && unbanRequests?.length === 0 ? null : } + + ) : null} + ); }; diff --git a/src/components/node-storage/access-list-editor.module.css b/src/components/node-storage/access-list-editor.module.css new file mode 100644 index 00000000..d106ce56 --- /dev/null +++ b/src/components/node-storage/access-list-editor.module.css @@ -0,0 +1,36 @@ +.walletEditor { + display: flex; + flex-direction: column; + gap: 16px; +} + +.walletList { + align-items: center; + align-self: flex-start; + display: grid; + font-size: 12px; + gap: 4px 16px; + grid-template-columns: auto auto; + justify-items: flex-start; + + .walletAddress { + font-family: monospace; + word-break: break-all; + } + + .walletYou { + padding: 0 8px; + } +} + +.addRow { + align-items: flex-start; + display: grid; + gap: 8px; + grid-template-columns: 1fr; + + @media (min-width: 320px) { + align-items: center; + grid-template-columns: 1fr auto; + } +} diff --git a/src/components/node-storage/access-list-editor.tsx b/src/components/node-storage/access-list-editor.tsx new file mode 100644 index 00000000..521079c2 --- /dev/null +++ b/src/components/node-storage/access-list-editor.tsx @@ -0,0 +1,113 @@ +'use client'; + +import Button from '@/components/button/button'; +import Input from '@/components/input/input'; +import { CHAIN_ID } from '@/constants/chains'; +import { formatChainLabel } from '@/utils/formatters'; +import classNames from 'classnames'; +import { isAddress } from 'ethers'; +import React, { useState } from 'react'; +import styles from './access-list-editor.module.css'; + +type AccessListEditorProps = { + currentAccount?: string; + error?: string; + loading?: boolean; + /** Called with just the wallet being added */ + onAdd?: (wallet: string) => void; + /** Called with the full updated list */ + onChange?: (wallets: string[]) => void; + /** Called with just the wallet being removed */ + onRemove?: (wallet: string) => void; + wallets: string[]; +}; + +const AccessListEditor: React.FC = ({ + currentAccount, + error, + loading, + onAdd, + onChange, + onRemove, + wallets, +}) => { + const [newWallet, setNewWallet] = useState(''); + const [inputError, setInputError] = useState(null); + + function handleAdd() { + const trimmed = newWallet.trim(); + if (!trimmed) { + return; + } + if (!isAddress(trimmed)) { + setInputError('Invalid Ethereum address'); + return; + } + if (wallets.some((w) => w.toLowerCase() === trimmed.toLowerCase())) { + setInputError('Address already in list'); + return; + } + setInputError(null); + onAdd?.(trimmed); + onChange?.([...wallets, trimmed]); + setNewWallet(''); + } + + function handleRemove(wallet: string) { + onRemove?.(wallet); + onChange?.(wallets.filter((w) => w !== wallet)); + } + + return ( +
+ {wallets.length > 0 && ( +
+ {wallets.map((wallet) => { + const isYou = currentAccount && wallet.toLowerCase() === currentAccount.toLowerCase(); + return ( + + {wallet} + {isYou ? ( + you + ) : ( + + )} + + ); + })} +
+ )} + {error && wallets.length === 0 ? {error} : null} +
+ { + setNewWallet(e.target.value); + setInputError(null); + }} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + placeholder="0x..." + size="sm" + type="text" + value={newWallet} + /> + +
+
+ ); +}; + +export default AccessListEditor; diff --git a/src/components/node-storage/bucket-access.module.css b/src/components/node-storage/bucket-access.module.css new file mode 100644 index 00000000..dd930281 --- /dev/null +++ b/src/components/node-storage/bucket-access.module.css @@ -0,0 +1,40 @@ +.section { + display: flex; + flex-direction: column; + gap: 4px; + + .sectionTitle { + font-size: 14px; + font-weight: 600; + margin-left: 16px; + } + + .option { + padding: 8px 16px 16px; + + .optionContent { + padding-left: 0; + + @media (min-width: 576px) { + padding-left: 36px; + } + } + + .optionDesc { + color: var(--text-secondary); + font-size: 13px; + font-weight: 400; + } + + .optionExtra { + display: flex; + flex-direction: column; + gap: 16px; + padding-top: 16px; + + &:not(:first-of-type) { + margin-top: 8px; + } + } + } +} diff --git a/src/components/node-storage/bucket-access.tsx b/src/components/node-storage/bucket-access.tsx new file mode 100644 index 00000000..ea2acbd6 --- /dev/null +++ b/src/components/node-storage/bucket-access.tsx @@ -0,0 +1,122 @@ +'use client'; + +import Card from '@/components/card/card'; +import Checkbox from '@/components/checkbox/checkbox'; +import Input from '@/components/input/input'; +import AccessListEditor from '@/components/node-storage/access-list-editor'; +import { CHAIN_ID } from '@/constants/chains'; +import { BucketAccessState, BucketAccessStateType } from '@/types/node-storage'; +import { formatChainLabel } from '@/utils/formatters'; +import { Collapse } from '@mui/material'; +import classNames from 'classnames'; +import React from 'react'; +import { TransitionGroup } from 'react-transition-group'; +import styles from './bucket-access.module.css'; + +type BucketAccessProps = { + value: BucketAccessState; + onChange: (value: BucketAccessState) => void; + currentAccount?: string; + error?: string; +}; + +const BucketAccess: React.FC = ({ value, onChange, currentAccount, error }) => { + function handleModeChange(mode: BucketAccessStateType) { + switch (mode) { + case 'existing': { + onChange({ mode: 'existing', address: '' }); + break; + } + case 'none': { + onChange({ mode: 'none' }); + break; + } + case 'new': { + onChange({ mode: 'new', wallets: currentAccount ? [currentAccount] : [] }); + break; + } + } + } + + return ( +
+ Access list + + {/* New access list */} + + handleModeChange('new')} + type="single" + /> +
+ Deploy a new access list contract with allowed wallet addresses +
+ + {value.mode === 'new' ? ( + +
+ onChange({ mode: 'new', wallets })} + wallets={value.wallets} + /> +
+
+ ) : null} +
+
+ + {/* Existing access list */} + + handleModeChange('existing')} + type="single" + /> +
+ Use an already deployed access list contract +
+ + {value.mode === 'existing' ? ( + +
+ onChange({ mode: 'existing', address: e.target.value })} + placeholder="0x..." + size="sm" + type="text" + value={value.address} + /> +
+
+ ) : null} +
+
+ + {/* No access list */} + + handleModeChange('none')} + type="single" + /> +
+ Only you can access this bucket. It cannot be shared later. +
+
+
+ ); +}; + +export default BucketAccess; diff --git a/src/components/node-storage/bucket-files-page.tsx b/src/components/node-storage/bucket-files-page.tsx new file mode 100644 index 00000000..e394b11b --- /dev/null +++ b/src/components/node-storage/bucket-files-page.tsx @@ -0,0 +1,50 @@ +import Container from '@/components/container/container'; +import BucketFiles from '@/components/node-storage/bucket-files'; +import SectionTitle from '@/components/section-title/section-title'; +import { useNodesContext } from '@/context/nodes-context'; +import { CircularProgress } from '@mui/material'; +import { useParams } from 'next/navigation'; +import { useEffect, useMemo } from 'react'; + +const BucketFilesPage: React.FC = () => { + const params = useParams<{ nodeId: string; bucketId: string }>(); + const nodeId = params?.nodeId; + const bucketId = params?.bucketId; + + const { selectedNode, fetchNode, loadingFetchNode } = useNodesContext(); + + const node = useMemo(() => { + if (nodeId === selectedNode?.id || nodeId === selectedNode?.nodeId) { + return selectedNode; + } + return null; + }, [nodeId, selectedNode]); + + useEffect(() => { + if (!node && nodeId) { + fetchNode(nodeId); + } + }, [nodeId, fetchNode, node]); + + return ( + + + + Retrieving node details... + + ) : null + } + title="Bucket files" + /> +
+ {node && bucketId ? : null} +
+
+ ); +}; + +export default BucketFilesPage; diff --git a/src/components/node-storage/bucket-files.module.css b/src/components/node-storage/bucket-files.module.css new file mode 100644 index 00000000..6a8c33b2 --- /dev/null +++ b/src/components/node-storage/bucket-files.module.css @@ -0,0 +1,15 @@ +.infoRow { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: 8px; + word-break: break-word; +} + +.accessListsContainer { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + word-break: break-word; +} diff --git a/src/components/node-storage/bucket-files.tsx b/src/components/node-storage/bucket-files.tsx new file mode 100644 index 00000000..0088ec3d --- /dev/null +++ b/src/components/node-storage/bucket-files.tsx @@ -0,0 +1,250 @@ +'use client'; + +import Button from '@/components/button/button'; +import Card from '@/components/card/card'; +import Input from '@/components/input/input'; +import ConfirmModal from '@/components/modal/confirm-modal'; +import EditBucketAccessModal from '@/components/node-storage/edit-bucket-access-modal'; +import { Table } from '@/components/table/table'; +import { TableTypeEnum } from '@/components/table/table-type'; +import { useNodeStorage } from '@/contexts/node-storage-context'; +import { useOceanAccount } from '@/lib/use-ocean-account'; +import { Node } from '@/types/nodes'; +import { formatAccessLists, formatError } from '@/utils/formatters'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import CachedIcon from '@mui/icons-material/Cached'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import SearchIcon from '@mui/icons-material/Search'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; +import { PersistentStorageFileEntry } from '@oceanprotocol/lib'; +import { useRouter } from 'next/router'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'react-toastify'; +import styles from './bucket-files.module.css'; + +type BucketFilesProps = { + bucketId: string; + node: Node; +}; + +const BucketFiles: React.FC = ({ bucketId, node }) => { + const router = useRouter(); + + const { account } = useOceanAccount(); + + const { buckets, bucketFiles, fetchingFiles, uploadingFile, deletingFile, fetchBucketFiles, uploadFile, deleteFile } = + useNodeStorage(); + + const nodeId = node.id ?? node.nodeId ?? ''; + + const bucket = useMemo( + () => (buckets[nodeId] ?? []).find((b) => b.bucketId === bucketId) ?? null, + [buckets, nodeId, bucketId] + ); + + const accessListLabels = useMemo(() => { + if (!bucket || bucket.accessLists.length === 0) { + return null; + } + const labels = formatAccessLists(bucket.accessLists, { shortenAddresses: bucket.accessLists.length > 1 }); + return labels.length > 0 ? labels : null; + }, [bucket]); + + const [alreadyLoaded, setAlreadyLoaded] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [pendingDelete, setPendingDelete] = useState(null); + const [editAccessOpen, setEditAccessOpen] = useState(false); + + const fileInputRef = useRef(null); + + const nodeUri = useMemo( + () => (node.currentAddrs?.length ? node.currentAddrs : (node.id ?? node.nodeId ?? '')), + [node] + ); + + const loading = fetchingFiles[bucketId] ?? false; + const uploading = uploadingFile[bucketId] ?? false; + + const loadFiles = useCallback(async () => { + try { + await fetchBucketFiles({ bucketId, nodeId, nodeUri }); + } catch (e: any) { + toast.error(formatError({ error: e, fallback: 'The files could not be loaded.' })); + } + }, [nodeId, bucketId, nodeUri, fetchBucketFiles]); + + useEffect(() => { + if (!(bucketId in bucketFiles) && !alreadyLoaded) { + setAlreadyLoaded(true); + loadFiles(); + } + }, [alreadyLoaded, bucketFiles, bucketId, loadFiles]); + + const handleUploadClick = () => fileInputRef.current?.click(); + + const handleFileSelected = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) { + return; + } + try { + await uploadFile({ bucketId, file, nodeId, nodeUri }); + toast.success(`${file.name} uploaded`); + } catch (err: any) { + toast.error(formatError({ error: err, fallback: 'Your file could not be uploaded.' })); + } + }; + + const handleDelete = async () => { + if (!pendingDelete) { + return; + } + const fileName = pendingDelete; + setPendingDelete(null); + try { + await deleteFile({ bucketId, fileName, nodeId, nodeUri }); + toast.success(`${fileName} deleted`); + } catch (err: any) { + toast.error(formatError({ error: err, fallback: 'Your file could not be deleted.' })); + } + }; + + const filteredFiles = useMemo(() => { + const files = bucketFiles[bucketId] ?? []; + const term = searchTerm.trim().toLowerCase(); + if (!term) { + return files; + } + return files.filter((f) => f.name.toLowerCase().includes(term)); + }, [bucketFiles, bucketId, searchTerm]); + + return ( + +
+ + +
+ +
+
Node:
+ {node.friendlyName ? ( +
+ {node.friendlyName} +
{node.id}
+
+ ) : ( +
{nodeId}
+ )} +
+
+
Bucket:
+ {bucketId} +
+
+
Access:
+ {accessListLabels ? ( +
+
{accessListLabels.join(', ')}
+ +
+ ) : ( + Owner-only (no access list) + )} +
+ + + + } + onChange={(e) => setSearchTerm(e.target.value)} + placeholder="Search files..." + type="text" + size="sm" + value={searchTerm} + /> + + autoHeight + actionsColumn={(params) => { + const deleting = deletingFile[`${bucketId}:${params.row.name}`] ?? false; + return ( + + ); + }} + loading={loading} + paginationType="none" + tableType={TableTypeEnum.NODE_STORAGE_FILES} + data={filteredFiles} + getRowId={(row) => row.name} + /> + + setPendingDelete(null)} + onConfirm={handleDelete} + title="Delete file" + /> + {bucket && account?.address && editAccessOpen && ( + setEditAccessOpen(false)} + /> + )} +
+ ); +}; + +export default BucketFiles; diff --git a/src/components/node-storage/create-bucket-modal.module.css b/src/components/node-storage/create-bucket-modal.module.css new file mode 100644 index 00000000..0c7e43cf --- /dev/null +++ b/src/components/node-storage/create-bucket-modal.module.css @@ -0,0 +1,13 @@ +.form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.infoRow { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: 8px; + word-break: break-word; +} diff --git a/src/components/node-storage/create-bucket-modal.tsx b/src/components/node-storage/create-bucket-modal.tsx new file mode 100644 index 00000000..f78d394a --- /dev/null +++ b/src/components/node-storage/create-bucket-modal.tsx @@ -0,0 +1,159 @@ +'use client'; + +import Button from '@/components/button/button'; +import Modal from '@/components/modal/modal'; +import BucketAccess from '@/components/node-storage/bucket-access'; +import { useNodeStorage } from '@/contexts/node-storage-context'; +import { useOceanAccount } from '@/lib/use-ocean-account'; +import { BucketAccessState } from '@/types/node-storage'; +import { Node } from '@/types/nodes'; +import { formatError } from '@/utils/formatters'; +import { isAddress } from 'ethers'; +import { useFormik } from 'formik'; +import React from 'react'; +import { toast } from 'react-toastify'; +import * as Yup from 'yup'; +import styles from './create-bucket-modal.module.css'; + +type CreateBucketModalProps = { + isOpen: boolean; + node: Node; + onClose: () => void; + onSave?: () => void; +}; + +type CreateBucketFormValues = { + access: BucketAccessState; +}; + +const CreateBucketModalInner: React.FC = ({ node, onClose, onSave }) => { + const { account, provider } = useOceanAccount(); + const { createBucket } = useNodeStorage(); + + const nodeId = node.id ?? node.nodeId ?? ''; + const friendlyName = node.friendlyName ?? nodeId; + const nodeUri = node.currentAddrs?.length ? node.currentAddrs : nodeId; + + const formik = useFormik({ + initialValues: { + access: { mode: 'new', wallets: [account.address!] }, + }, + validationSchema: Yup.object({ + access: Yup.mixed() + .required() + .test('access-valid', 'Access list contract address is required', (value) => { + if (!value) { + return false; + } + if (value.mode === 'existing') { + return Boolean(value.address.trim()); + } + if (value.mode === 'new') { + return value.wallets.length > 0; + } + if (value.mode === 'none') { + return true; + } + return false; + }) + .test('access-existing-format', 'Invalid Ethereum address', (value) => { + if (!value || value.mode !== 'existing') { + return true; + } + const addr = value.address.trim(); + return !addr || isAddress(addr); + }) + .test('access-existing-contract', 'Address is not a deployed contract', async (value) => { + if (!value || value.mode !== 'existing') { + return true; + } + const addr = value.address.trim(); + if (!addr || !isAddress(addr)) { + return true; + } + if (!provider) { + return true; + } + try { + const code = await provider.getCode(addr); + return code !== '0x'; + } catch { + return true; + } + }) + .test('access-new-wallets', 'Add at least one wallet address', (value) => { + if (!value) { + return false; + } + if (value.mode === 'new') { + return value.wallets.length > 0; + } + return true; + }), + }), + validateOnBlur: true, + validateOnChange: false, + onSubmit: async (values) => { + try { + await createBucket({ nodeId, nodeUri, access: values.access }); + toast.success('Bucket created'); + onClose(); + onSave?.(); + } catch (e: any) { + toast.error(formatError({ error: e, fallback: 'Your bucket could not be created.' })); + } + }, + }); + + const accessError = formik.touched.access && formik.errors.access ? (formik.errors.access as string) : undefined; + + return ( +
+
+
Node:
+ {friendlyName ? ( +
+ {friendlyName} +
{nodeId}
+
+ ) : ( +
{nodeId}
+ )} +
+ { + formik.setFieldValue('access', v); + formik.setFieldTouched('access', true, false); + }} + currentAccount={account?.address} + error={accessError} + /> +
+ + +
+ + ); +}; + +const CreateBucketModal: React.FC = ({ isOpen, node, onClose, onSave }) => { + return ( + + + + ); +}; + +export default CreateBucketModal; diff --git a/src/components/node-storage/edit-access-list-modal.module.css b/src/components/node-storage/edit-access-list-modal.module.css new file mode 100644 index 00000000..cc243bc1 --- /dev/null +++ b/src/components/node-storage/edit-access-list-modal.module.css @@ -0,0 +1,10 @@ +.searchRow { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + + @media (min-width: 768px) { + align-items: center; + grid-template-columns: 1fr auto; + } +} diff --git a/src/components/node-storage/edit-access-list-modal.tsx b/src/components/node-storage/edit-access-list-modal.tsx new file mode 100644 index 00000000..2f86debc --- /dev/null +++ b/src/components/node-storage/edit-access-list-modal.tsx @@ -0,0 +1,152 @@ +'use client'; + +import Button from '@/components/button/button'; +import Card from '@/components/card/card'; +import Input from '@/components/input/input'; +import Modal from '@/components/modal/modal'; +import AccessListEditor from '@/components/node-storage/access-list-editor'; +import { CHAIN_ID } from '@/constants/chains'; +import { useNodeStorage } from '@/contexts/node-storage-context'; +import { formatChainLabel, formatError } from '@/utils/formatters'; +import { Collapse } from '@mui/material'; +import { isAddress } from 'ethers'; +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import { TransitionGroup } from 'react-transition-group'; +import styles from './edit-access-list-modal.module.css'; + +type EditAccessListModalProps = { + currentAccount?: string; + isOpen: boolean; + onClose: () => void; +}; + +const EditAccessListModalInner: React.FC = ({ currentAccount, onClose }) => { + const { getAccessListAddresses, addToAccessList, removeFromAccessList } = useNodeStorage(); + + const [contractAddress, setContractAddress] = useState(''); + const [contractAddressError, setContractAddressError] = useState(null); + const [wallets, setWallets] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [editing, setEditing] = useState(false); + + async function handleEdit() { + const trimmed = contractAddress.trim(); + if (!trimmed) { + setContractAddressError('Address required'); + return; + } + if (!isAddress(trimmed)) { + setContractAddressError('Invalid contract address'); + return; + } + setContractAddressError(null); + setLoading(true); + try { + const result = await getAccessListAddresses(trimmed); + setWallets(result); + setEditing(true); + } catch (e: any) { + toast.error(formatError({ error: e, fallback: 'The access list could not be loaded.' })); + } finally { + setLoading(false); + } + } + + async function handleAdd(wallet: string) { + setSaving(true); + try { + await addToAccessList({ contractAddress: contractAddress.trim(), wallet }); + setWallets((prev) => [...prev, wallet]); + } catch (e: any) { + toast.error(formatError({ error: e, fallback: 'The wallet address could not be added to the access list.' })); + } finally { + setSaving(false); + } + } + + async function handleRemove(wallet: string) { + setSaving(true); + try { + await removeFromAccessList({ contractAddress: contractAddress.trim(), wallet }); + setWallets((prev) => prev.filter((w) => w !== wallet)); + } catch (e: any) { + toast.error(formatError({ error: e, fallback: 'The wallet address could not be removed from the access list.' })); + } finally { + setSaving(false); + } + } + + return ( + <> +
+
+ { + setContractAddress(e.target.value); + setContractAddressError(null); + }} + onKeyDown={(e) => !editing && e.key === 'Enter' && handleEdit()} + placeholder="0x contract address..." + size="sm" + type="text" + value={contractAddress} + /> + {editing ? ( + + ) : ( + + )} +
+ + {editing ? ( + + + Wallet addresses in this access list: + + + + ) : null} + +
+ +
+
+ + ); +}; + +const EditAccessListModal: React.FC = ({ currentAccount, isOpen, onClose }) => { + return ( + + + + ); +}; + +export default EditAccessListModal; diff --git a/src/components/node-storage/edit-bucket-access-modal.module.css b/src/components/node-storage/edit-bucket-access-modal.module.css new file mode 100644 index 00000000..c30bc8cc --- /dev/null +++ b/src/components/node-storage/edit-bucket-access-modal.module.css @@ -0,0 +1,61 @@ +.infoRow { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: 8px; + word-break: break-word; +} + +.accessSection { + border: 1px solid var(--border); + border-radius: 16px; + overflow: hidden; + + .sectionHeader { + align-items: center; + background: var(--background-glass); + border: none; + color: inherit; + cursor: pointer; + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 14px; + text-align: left; + width: 100%; + word-break: break-word; + + &:hover { + background: color-mix(in srgb, var(--accent1) 6%, transparent); + } + + .expandIcon { + color: var(--text-secondary); + flex-shrink: 0; + transition: transform 200ms ease; + } + } + + .sectionBody { + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + + .sectionBodyHeader { + align-items: center; + display: flex; + flex-direction: row; + gap: 16px; + flex-wrap: wrap-reverse; + justify-content: space-between; + } + + .loadingRow { + align-items: center; + display: flex; + gap: 8px; + } + } +} diff --git a/src/components/node-storage/edit-bucket-access-modal.tsx b/src/components/node-storage/edit-bucket-access-modal.tsx new file mode 100644 index 00000000..b7df459c --- /dev/null +++ b/src/components/node-storage/edit-bucket-access-modal.tsx @@ -0,0 +1,214 @@ +'use client'; + +import Button from '@/components/button/button'; +import CopyButton from '@/components/button/copy-button'; +import Modal from '@/components/modal/modal'; +import AccessListEditor from '@/components/node-storage/access-list-editor'; +import { useNodeStorage } from '@/contexts/node-storage-context'; +import { Node } from '@/types/nodes'; +import { formatChainLabel, formatError } from '@/utils/formatters'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { CircularProgress, Collapse } from '@mui/material'; +import { PersistentStorageBucket } from '@oceanprotocol/lib'; +import { useState } from 'react'; +import { toast } from 'react-toastify'; +import styles from './edit-bucket-access-modal.module.css'; + +type EditBucketAccessModalProps = { + bucket: PersistentStorageBucket; + currentAccount: string; + isOpen: boolean; + node: Node; + onClose: () => void; +}; + +type AccessListState = { + chainId: string; + contractAddress: string; + loading: boolean; + open: boolean; + saving: boolean; + wallets: string[]; +}; + +function buildAccessListsStates(bucket: PersistentStorageBucket): Record { + const accessListStates: Record = {}; + for (const entry of bucket.accessLists) { + for (const [chainId, addresses] of Object.entries(entry)) { + for (const contractAddress of addresses) { + accessListStates[contractAddress] = { + chainId, + contractAddress, + loading: false, + open: false, + saving: false, + wallets: [], + }; + } + } + } + return accessListStates; +} + +const EditBucketAccessModal: React.FC = ({ + bucket, + currentAccount, + isOpen, + node, + onClose, +}) => { + const { getAccessListAddresses, addToAccessList, removeFromAccessList } = useNodeStorage(); + + const nodeId = node.id ?? node.nodeId; + const friendlyName = node.friendlyName ?? nodeId; + + const [accessListStates, setAccessListStates] = useState>(() => + buildAccessListsStates(bucket) + ); + + const updateAccessListState = (contractAddress: string, updates: Partial) => { + setAccessListStates((prev) => ({ + ...prev, + [contractAddress]: { ...prev[contractAddress], ...updates }, + })); + }; + + async function toggleSection(contractAddress: string) { + const accessListState = accessListStates[contractAddress]; + if (accessListState.open) { + updateAccessListState(contractAddress, { open: false }); + return; + } + updateAccessListState(contractAddress, { open: true, loading: true }); + try { + if (accessListState.wallets.length > 0) { + updateAccessListState(contractAddress, { loading: false }); + } else { + const wallets = await getAccessListAddresses(accessListState.contractAddress); + updateAccessListState(contractAddress, { wallets, loading: false }); + } + } catch (e: any) { + updateAccessListState(contractAddress, { loading: false }); + toast.error( + formatError({ error: e, fallback: 'The wallet addresses for this access list could not be loaded.' }) + ); + } + } + + async function handleAdd(contractAddress: string, wallet: string) { + const accessListState = accessListStates[contractAddress]; + updateAccessListState(contractAddress, { saving: true }); + try { + await addToAccessList({ contractAddress, wallet }); + updateAccessListState(contractAddress, { wallets: [...accessListState.wallets, wallet], saving: false }); + } catch (e: any) { + updateAccessListState(contractAddress, { saving: false }); + toast.error(formatError({ error: e, fallback: 'The wallet address could not be added to the access list.' })); + } + } + + async function handleRemove(contractAddress: string, wallet: string) { + const accessListState = accessListStates[contractAddress]; + updateAccessListState(contractAddress, { saving: true }); + try { + await removeFromAccessList({ contractAddress, wallet }); + updateAccessListState(contractAddress, { + wallets: accessListState.wallets.filter((w) => w !== wallet), + saving: false, + }); + } catch (e: any) { + updateAccessListState(contractAddress, { saving: false }); + toast.error(formatError({ error: e, fallback: 'The wallet address could not be removed from the access list.' })); + } + } + + return ( + +
+
+
+
Node:
+ {friendlyName ? ( +
+ {friendlyName} +
{nodeId}
+
+ ) : ( +
{nodeId}
+ )} +
+
+
Bucket:
+ {bucket.bucketId} +
+
+ +
+ {Object.values(accessListStates).length === 0 && ( + + No access lists configured. + + )} + {Object.values(accessListStates).map((accessListState) => ( +
+ + +
+ {accessListState.loading ? ( +
+ + + Loading addresses… + +
+ ) : ( + <> +
+ Wallet addresses in this access list: + +
+ handleAdd(accessListState.contractAddress, wallet)} + onRemove={(wallet) => handleRemove(accessListState.contractAddress, wallet)} + wallets={accessListState.wallets} + /> + + )} +
+
+
+ ))} +
+
+ +
+
+
+ ); +}; + +export default EditBucketAccessModal; diff --git a/src/components/node-storage/my-buckets.module.css b/src/components/node-storage/my-buckets.module.css new file mode 100644 index 00000000..57afb9a5 --- /dev/null +++ b/src/components/node-storage/my-buckets.module.css @@ -0,0 +1,7 @@ +.header { + align-items: center; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; +} diff --git a/src/components/node-storage/my-buckets.tsx b/src/components/node-storage/my-buckets.tsx new file mode 100644 index 00000000..45c53822 --- /dev/null +++ b/src/components/node-storage/my-buckets.tsx @@ -0,0 +1,135 @@ +import Button from '@/components/button/button'; +import Card from '@/components/card/card'; +import Input from '@/components/input/input'; +import CreateBucketModal from '@/components/node-storage/create-bucket-modal'; +import EditBucketAccessModal from '@/components/node-storage/edit-bucket-access-modal'; +import { Table } from '@/components/table/table'; +import { TableTypeEnum } from '@/components/table/table-type'; +import { useNodeStorage } from '@/contexts/node-storage-context'; +import { useOceanAccount } from '@/lib/use-ocean-account'; +import { Node } from '@/types'; +import { formatError } from '@/utils/formatters'; +import CachedIcon from '@mui/icons-material/Cached'; +import EditIcon from '@mui/icons-material/Edit'; +import SearchIcon from '@mui/icons-material/Search'; +import { PersistentStorageBucket } from '@oceanprotocol/lib'; +import { useRouter } from 'next/router'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'react-toastify'; +import styles from './my-buckets.module.css'; + +type MyBucketsProps = { + node: Node; +}; + +const MyBuckets: React.FC = ({ node }) => { + const router = useRouter(); + + const { account } = useOceanAccount(); + + const { buckets, fetchingBuckets, fetchBuckets } = useNodeStorage(); + + const nodeId = node.id ?? node.nodeId ?? ''; + const nodeUri = node.currentAddrs?.length ? node.currentAddrs : nodeId; + + const [alreadyLoaded, setAlreadyLoaded] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [editBucket, setEditBucket] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + const loading = fetchingBuckets[nodeId] ?? false; + + const loadBuckets = useCallback(async () => { + try { + await fetchBuckets({ nodeId, nodeUri }); + } catch (e: any) { + toast.error(formatError({ error: e, fallback: 'The buckets could not be loaded.' })); + } + }, [nodeId, nodeUri, fetchBuckets]); + + useEffect(() => { + if (!account.address || !nodeId) { + return; + } + if (!(nodeId in buckets) && !alreadyLoaded) { + setAlreadyLoaded(true); + loadBuckets(); + } + }, [nodeId, buckets, loadBuckets, alreadyLoaded, account.address]); + + const filteredBuckets = useMemo(() => { + const myBuckets = buckets[nodeId] ?? []; + const term = searchTerm.trim(); + if (!term) { + return myBuckets; + } + return myBuckets.filter((b) => b.bucketId.toLowerCase().includes(term.toLowerCase())); + }, [buckets, nodeId, searchTerm]); + + return ( + +
+

My Buckets

+ +
+ } + onChange={(e) => setSearchTerm(e.target.value)} + placeholder="Search..." + type="text" + size="sm" + value={searchTerm} + /> + + autoHeight + actionsColumn={(params) => + params.row.accessLists.length > 0 ? ( + + ) : null + } + loading={loading} + onRowClick={({ row }) => router.push(`/nodes/${nodeId}/storage/${row.bucketId}/files`)} + paginationType="none" + tableType={TableTypeEnum.NODE_STORAGE_MY_BUCKETS} + data={filteredBuckets} + getRowId={(row) => row.bucketId} + /> + + setCreateOpen(false)} onSave={loadBuckets} /> + {editBucket && account?.address && ( + setEditBucket(null)} + /> + )} +
+ ); +}; + +export default MyBuckets; diff --git a/src/components/node-storage/node-storage-page.tsx b/src/components/node-storage/node-storage-page.tsx new file mode 100644 index 00000000..f61bc50a --- /dev/null +++ b/src/components/node-storage/node-storage-page.tsx @@ -0,0 +1,41 @@ +import NodeDetailsPageLayout from '@/components/node-details/node-details-page-layout'; +import MyBuckets from '@/components/node-storage/my-buckets'; +import { useNodesContext } from '@/context/nodes-context'; +import { useOceanAccount } from '@/lib/use-ocean-account'; +import { useParams } from 'next/navigation'; +import { useEffect, useMemo } from 'react'; + +const NodeStoragePage: React.FC = () => { + const params = useParams<{ nodeId: string }>(); + + const { account } = useOceanAccount(); + + const { selectedNode, fetchNode, loadingFetchNode } = useNodesContext(); + + const node = useMemo(() => { + if (params?.nodeId === selectedNode?.id || params?.nodeId === selectedNode?.nodeId) { + return selectedNode; + } + return null; + }, [params?.nodeId, selectedNode]); + + useEffect(() => { + if (!node) { + fetchNode(params?.nodeId); + } + }, [params?.nodeId, fetchNode, node]); + + return ( + + {node ? : null} + + ); +}; + +export default NodeStoragePage; diff --git a/src/components/table/columns.tsx b/src/components/table/columns.tsx index bd5a1bcd..5ee117a4 100644 --- a/src/components/table/columns.tsx +++ b/src/components/table/columns.tsx @@ -6,61 +6,16 @@ import { BenchmarkJobHistory, ComputeJob } from '@/types/jobs'; import { GPUPopularity, Node } from '@/types/nodes'; import { UnbanRequest } from '@/types/unban-requests'; import { calculateTotalBenchmarkScore } from '@/utils/benchmark-score'; -import { formatDateTime, formatNumber } from '@/utils/formatters'; -import CheckCircleOutlinedIcon from '@mui/icons-material/CheckCircleOutlined'; +import { formatAccessLists, formatBytes, formatDateTime, formatNumber, formatWalletAddress } from '@/utils/formatters'; import ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined'; import HighlightOffOutlinedIcon from '@mui/icons-material/HighlightOffOutlined'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import VerifiedIcon from '@mui/icons-material/Verified'; import { Tooltip } from '@mui/material'; import { getGridNumericOperators, getGridStringOperators, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; +import { PersistentStorageBucket, PersistentStorageFileEntry } from '@oceanprotocol/lib'; import classNames from 'classnames'; -function getEligibleCheckbox(eligible = false, banned = false, eligibilityCauseStr?: string) { - if (banned) { - return ( - <> - - Banned - - ); - } - if (eligible) { - return ( - <> - - Eligible - - ); - } else { - switch (eligibilityCauseStr) { - case 'Invalid status response': - return ( - <> - - Not eligible - - ); - - case 'No peer data': - return ( - <> - - Not eligible - - ); - - default: - return ( - <> - - Not eligible - - ); - } - } -} - function getUnbanAttemptResult(result: any) { if (result === null || result === undefined || result === '') { return ( @@ -104,6 +59,15 @@ function getUnbanAttemptStatus(status: string) { ); } +export const actionsColumnProps: GridColDef = { + align: 'right', + field: '_actions', + filterable: false, + headerAlign: 'center', + headerName: 'Actions', + sortable: false, +}; + export const nodesLeaderboardColumns: GridColDef[] = [ { align: 'center', @@ -774,3 +738,87 @@ export const topNodesByJobsColumns: GridColDef[] = [ sortable: false, }, ]; + +export const nodeStorageMyBucketsColumns: GridColDef[] = [ + { + field: 'bucketId', + filterable: true, + flex: 1, + headerName: 'Bucket ID', + sortable: false, + }, + { + field: 'createdAt', + filterable: false, + headerName: 'Created', + sortable: true, + width: 160, + renderCell: ({ value }) => formatDateTime(value), + }, + { + field: 'accessLists', + filterable: false, + flex: 1, + headerName: 'Access list', + sortable: false, + renderCell: ({ value }) => { + if (!value?.length) { + return Owner-only (no access list); + } + return formatAccessLists(value, { shortenAddresses: true }).join(', '); + }, + }, +]; + +export const nodeStorageSharedBucketsColumns: GridColDef[] = [ + { + field: 'bucketId', + filterable: true, + flex: 1, + headerName: 'Bucket ID', + sortable: false, + }, + { + field: 'createdAt', + filterable: false, + headerName: 'Created', + sortable: true, + width: 160, + renderCell: ({ value }) => formatDateTime(value), + }, + { + field: 'owner', + filterable: false, + headerName: 'Owner', + sortable: false, + width: 160, + renderCell: ({ value }) => formatWalletAddress(value), + }, +]; + +export const nodeStorageFilesColumns: GridColDef[] = [ + { + field: 'name', + filterable: false, + flex: 1, + headerName: 'File name', + sortable: false, + }, + { + field: 'size', + filterable: false, + headerName: 'File size', + sortable: false, + align: 'right', + headerAlign: 'right', + renderCell: ({ value }) => formatBytes(value as number), + }, + { + field: 'lastModified', + filterable: false, + headerName: 'Last modified', + sortable: true, + width: 160, + renderCell: ({ value }) => formatDateTime(value / 1000), + }, +]; diff --git a/src/components/table/table-type.ts b/src/components/table/table-type.ts index 2e795053..b72a9b1e 100644 --- a/src/components/table/table-type.ts +++ b/src/components/table/table-type.ts @@ -2,6 +2,9 @@ export enum TableTypeEnum { BENCHMARK_JOBS = 'benchmark-jobs', MY_JOBS = 'my-jobs', MY_NODES = 'my-nodes', + NODE_STORAGE_FILES = 'node-storage-files', + NODE_STORAGE_MY_BUCKETS = 'node-storage-my-buckets', + NODE_STORAGE_SHARED_BUCKETS = 'node-storage-shared-buckets', NODES_LEADERBOARD = 'nodes-leaderboard', NODES_LEADERBOARD_HOME = 'nodes-leaderboard-home', NODES_TOP_JOBS = 'nodes-top-jobs', diff --git a/src/components/table/table.tsx b/src/components/table/table.tsx index caf345aa..9eef7812 100644 --- a/src/components/table/table.tsx +++ b/src/components/table/table.tsx @@ -1,4 +1,5 @@ import { + actionsColumnProps, benchmarkJobsColumns, jobsColumns, nodesLeaderboardColumns, @@ -6,8 +7,9 @@ import { nodesLeaderboardHomeColumns, nodesTopByJobCountColumns, nodesTopByRevenueColumns, - topNodesByJobsColumns, - topNodesByRevenueColumns, + nodeStorageFilesColumns, + nodeStorageMyBucketsColumns, + nodeStorageSharedBucketsColumns, unbanRequestsColumns, } from '@/components/table/columns'; import { TableContextType } from '@/components/table/context-type'; @@ -15,19 +17,22 @@ import CustomPagination from '@/components/table/custom-pagination'; import CustomToolbar, { CustomToolbarProps } from '@/components/table/custom-toolbar'; import { TableTypeEnum } from '@/components/table/table-type'; import styled from '@emotion/styled'; +import { LinearProgress } from '@mui/material'; import { DataGrid, GridColDef, GridFilterModel, GridInitialState, + GridRenderCellParams, GridRowIdGetter, GridRowParams, GridSortModel, + GridToolbarProps, GridValidRowModel, useGridApiRef, } from '@mui/x-data-grid'; -import { GridSlotsComponentsProps, GridToolbarProps } from '@mui/x-data-grid/internals'; -import { JSXElementConstructor, useCallback, useMemo, useRef, useState } from 'react'; +import { GridSlotsComponentsProps } from '@mui/x-data-grid/internals'; +import { JSXElementConstructor, useCallback, useEffect, useMemo, useRef, useState } from 'react'; const StyledRoot = styled('div')({ display: 'flex', @@ -37,6 +42,14 @@ const StyledRoot = styled('div')({ minWidth: 0, }); +const StyledActionsWrapper = styled('div')({ + alignItems: 'center', + display: 'flex', + flexDirection: 'row', + gap: 4, + height: '100%', +}); + const StyledDataGridWrapper = styled('div')<{ autoHeight?: boolean }>(({ autoHeight }) => ({ height: autoHeight ? 'auto' : 'calc(100dvh - 200px)', width: '100%', @@ -122,6 +135,8 @@ const StyledDataGrid = styled(DataGrid)<{ clickable?: boolean }>(({ clickable }) type TableProps = { autoHeight?: boolean; + actionsColumn?: GridColDef['renderCell']; + columns?: GridColDef[]; context?: TableContextType; data?: any[]; loading?: boolean; @@ -135,6 +150,8 @@ type TableProps = { export const Table = ({ autoHeight, + actionsColumn, + columns: columnsProp, context, data: propsData, loading: propsLoading, @@ -169,39 +186,70 @@ export const Table = ({ }; }, [paginationType, propsData, context?.crtPage, context?.data, context?.pageSize, context?.totalItems]); + useEffect(() => { + if (!actionsColumn) { + return; + } + const id = requestAnimationFrame(() => { + if (apiRef.current?.autosizeColumns) { + apiRef.current.autosizeColumns({ columns: ['_actions'], includeHeaders: true }); + } + }); + return () => cancelAnimationFrame(id); + }, [actionsColumn, data, apiRef]); + const columns = useMemo(() => { + const withActions = (cols: C[]) => + actionsColumn + ? [ + ...cols, + { + ...actionsColumnProps, + renderCell: (params: GridRenderCellParams) => ( + {actionsColumn(params)} + ), + }, + ] + : cols; + + if (columnsProp) { + return withActions(columnsProp); + } switch (tableType) { case TableTypeEnum.MY_JOBS: { - return jobsColumns; + return withActions(jobsColumns); } case TableTypeEnum.UNBAN_REQUESTS: { - return unbanRequestsColumns; + return withActions(unbanRequestsColumns); + } + case TableTypeEnum.NODE_STORAGE_FILES: { + return withActions(nodeStorageFilesColumns); + } + case TableTypeEnum.NODE_STORAGE_MY_BUCKETS: { + return withActions(nodeStorageMyBucketsColumns); + } + case TableTypeEnum.NODE_STORAGE_SHARED_BUCKETS: { + return withActions(nodeStorageSharedBucketsColumns); } case TableTypeEnum.NODES_LEADERBOARD: case TableTypeEnum.MY_NODES: { - return nodesLeaderboardColumns; + return withActions(nodesLeaderboardColumns); } case TableTypeEnum.NODES_LEADERBOARD_HOME: { - return nodesLeaderboardHomeColumns; + return withActions(nodesLeaderboardHomeColumns); } case TableTypeEnum.NODES_TOP_JOBS: { - return nodesTopByJobCountColumns; + return withActions(nodesTopByJobCountColumns); } case TableTypeEnum.NODES_TOP_REVENUE: { - return nodesTopByRevenueColumns; - } - case TableTypeEnum.NODES_TOP_JOBS: { - return topNodesByJobsColumns; - } - case TableTypeEnum.NODES_TOP_REVENUE: { - return topNodesByRevenueColumns; + return withActions(nodesTopByRevenueColumns); } case TableTypeEnum.BENCHMARK_JOBS: case TableTypeEnum.BENCHMARK_JOBS_HISTORY: { - return benchmarkJobsColumns; + return withActions(benchmarkJobsColumns); } } - }, [tableType]); + }, [tableType, actionsColumn, columnsProp]); const columnVisibilityModel = useMemo(() => { switch (tableType) { @@ -325,10 +373,10 @@ export const Table = ({ color: '#000000', }, }, - loadingOverlay: { - variant: 'skeleton', - noRowsVariant: 'skeleton', - }, + // loadingOverlay: { + // variant: 'skeleton', + // noRowsVariant: 'skeleton', + // }, toolbar: { searchTerm, onSearchChange: handleSearchChange, @@ -370,7 +418,10 @@ export const Table = ({ rowCount={totalItems} rows={data} showToolbar={showToolbar} - slots={{ toolbar: CustomToolbar as JSXElementConstructor }} + slots={{ + loadingOverlay: () => , + toolbar: CustomToolbar as JSXElementConstructor, + }} slotProps={slotProps} sortingMode={paginationType === 'none' ? 'client' : 'server'} /> diff --git a/src/constants/chains.ts b/src/constants/chains.ts index ae4d0d20..ac15d9ab 100644 --- a/src/constants/chains.ts +++ b/src/constants/chains.ts @@ -2,3 +2,8 @@ export const BASE_CHAIN_ID = 8453; export const ETH_SEPOLIA_CHAIN_ID = 11155111; export const CHAIN_ID = process.env.NEXT_PUBLIC_APP_ENV === 'production' ? BASE_CHAIN_ID : ETH_SEPOLIA_CHAIN_ID; + +export const CHAIN_LABELS: Record = { + [BASE_CHAIN_ID]: 'Base', + [ETH_SEPOLIA_CHAIN_ID]: 'Sepolia', +}; diff --git a/src/contexts/P2PContext.tsx b/src/contexts/P2PContext.tsx index f644c9d2..0a5899ad 100644 --- a/src/contexts/P2PContext.tsx +++ b/src/contexts/P2PContext.tsx @@ -1,25 +1,39 @@ import { getTokenDecimals } from '@/lib/token-symbol'; import { SignMessageFn } from '@/lib/use-ocean-account'; import { + createNodeBucket as createNodeBucketService, + deleteBucketFile as deleteBucketFileService, fetchNodeConfig, getComputeJobResult, getComputeStatus, + getNodeBuckets as getNodeBucketsService, getNodeEnvs, getNodeLogs as getNodeLogsService, getPeerMultiaddr as getPeerMultiaddrFromService, initializeCompute as initializeComputeFromService, initializeP2P, + listBucketFiles as listBucketFilesService, pushNodeConfig, streamComputeResult as streamComputeResultService, + uploadBucketFile as uploadBucketFileService, } from '@/services/nodeService'; import { OCEAN_BOOTSTRAP_NODES } from '@/shared/consts/bootstrapNodes'; -import { ComputeEnvironment, MultiaddrsOrPeerId } from '@/types/environments'; +import { ComputeEnvironment } from '@/types/environments'; import { multiaddr } from '@multiformats/multiaddr'; -import { ComputeResourceRequest, type NodeLogEntry, type NodeLogsParams, ProviderInstance } from '@oceanprotocol/lib'; +import { + ComputeResourceRequest, + type NodeLogEntry, + type NodeLogsParams, + type PersistentStorageAccessList, + type PersistentStorageBucket, + type PersistentStorageDeleteFileResponse, + type PersistentStorageFileEntry, + ProviderInstance, +} from '@oceanprotocol/lib'; import BigNumber from 'bignumber.js'; import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; -type NodeUri = string[] | string; +export type NodeUri = string[] | string; function toNodeUri(input: NodeUri) { if (Array.isArray(input)) return input.map((a) => multiaddr(a)); @@ -87,9 +101,41 @@ interface P2PContextType { nodeUri: NodeUri; signMessage: SignMessageFn; }) => Promise; + createNodeBucket: (args: { + accessLists: PersistentStorageAccessList[]; + authToken: string; + nodeUri: NodeUri; + }) => Promise<{ bucketId: string; owner: string; accessList: PersistentStorageAccessList[] }>; + getNodeBuckets: (args: { + authToken: string; + nodeUri: NodeUri; + ownerAddress: string; + }) => Promise; + listBucketFiles: (args: { + authToken: string; + bucketId: string; + nodeUri: NodeUri; + }) => Promise; + uploadBucketFile: (args: { + authToken: string; + bucketId: string; + file: File; + nodeUri: NodeUri; + }) => Promise; + deleteBucketFile: (args: { + authToken: string; + bucketId: string; + fileName: string; + nodeUri: NodeUri; + }) => Promise; getPeerMultiaddr: (peerId: string) => Promise; sendCommand: (nodeUri: NodeUri, command: any) => Promise; - streamComputeResult: (nodeUri: NodeUri, authToken: string, jobId: string, index: number) => Promise>; + streamComputeResult: ( + nodeUri: NodeUri, + authToken: string, + jobId: string, + index: number + ) => Promise>; } const P2PContext = createContext(undefined); @@ -324,6 +370,84 @@ export function P2PProvider({ children }: { children: React.ReactNode }) { [isReady] ); + const createNodeBucket = useCallback( + async ({ + accessLists, + authToken, + nodeUri, + }: { + accessLists: PersistentStorageAccessList[]; + authToken: string; + nodeUri: NodeUri; + }) => { + if (!isReady) { + throw new Error('Node not ready'); + } + return createNodeBucketService({ accessLists, authToken, nodeUri }); + }, + [isReady] + ); + + const getNodeBuckets = useCallback( + async ({ authToken, nodeUri, ownerAddress }: { authToken: string; nodeUri: NodeUri; ownerAddress: string }) => { + if (!isReady) { + throw new Error('Node not ready'); + } + return getNodeBucketsService({ authToken, nodeUri, ownerAddress }); + }, + [isReady] + ); + + const listBucketFiles = useCallback( + async ({ authToken, bucketId, nodeUri }: { authToken: string; bucketId: string; nodeUri: NodeUri }) => { + if (!isReady) { + throw new Error('Node not ready'); + } + return listBucketFilesService({ authToken, bucketId, nodeUri }); + }, + [isReady] + ); + + const uploadBucketFile = useCallback( + async ({ + authToken, + bucketId, + file, + nodeUri, + }: { + authToken: string; + bucketId: string; + file: File; + nodeUri: NodeUri; + }) => { + if (!isReady) { + throw new Error('Node not ready'); + } + return uploadBucketFileService({ authToken, bucketId, file, nodeUri }); + }, + [isReady] + ); + + const deleteBucketFile = useCallback( + async ({ + nodeUri, + authToken, + bucketId, + fileName, + }: { + nodeUri: NodeUri; + authToken: string; + bucketId: string; + fileName: string; + }) => { + if (!isReady) { + throw new Error('Node not ready'); + } + return deleteBucketFileService({ authToken, bucketId, fileName, nodeUri }); + }, + [isReady] + ); + return ( {children} diff --git a/src/contexts/node-auth-context.tsx b/src/contexts/node-auth-context.tsx new file mode 100644 index 00000000..5b8bcc61 --- /dev/null +++ b/src/contexts/node-auth-context.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useOceanAccount } from '@/lib/use-ocean-account'; +import { createAuthToken } from '@/services/nodeService'; +import { createContext, ReactNode, useCallback, useContext, useEffect, useRef } from 'react'; +import { NodeUri } from './P2PContext'; + +type NodeAuthContextType = { + getNodeToken: (nodeId: string, nodeUri: NodeUri) => Promise; + clearNodeToken: (nodeId: string) => void; + withNodeAuth: (nodeId: string, nodeUri: NodeUri, fn: (token: string) => Promise) => Promise; +}; + +const NodeAuthContext = createContext(undefined); + +export function NodeAuthProvider({ children }: { children: ReactNode }) { + const { account, signMessage } = useOceanAccount(); + + /** + * Tokens are cached by node ID. + */ + const tokensRef = useRef>({}); + + /** + * Used for preventing duplicate token requests for the same node. + */ + const inflightRef = useRef>>({}); + + /** + * Used for clearing all node tokens when the wallet address changes. + */ + const prevAddress = useRef(account.address); + + useEffect(() => { + if (prevAddress.current && !account.address) { + tokensRef.current = {}; + inflightRef.current = {}; + } + prevAddress.current = account.address; + }, [account.address]); + + /** + * Gets a node token for the given node ID and node URI. + * If the token is already cached, it will be returned. + * If the token is not cached, a token will be created and cached. + */ + const getNodeToken = useCallback( + async (nodeId: string, nodeUri: NodeUri): Promise => { + if (!account.address) { + throw new Error('Wallet not connected'); + } + if (tokensRef.current[nodeId]) { + return tokensRef.current[nodeId]; + } + if (nodeId in inflightRef.current) { + return inflightRef.current[nodeId]; + } + const promise = createAuthToken({ consumerAddress: account.address, nodeUri, signMessage }).then( + ({ token }) => { + tokensRef.current[nodeId] = token; + delete inflightRef.current[nodeId]; + return token; + }, + (err) => { + delete inflightRef.current[nodeId]; + throw err; + } + ); + inflightRef.current[nodeId] = promise; + return promise; + }, + [account.address, signMessage] + ); + + const clearNodeToken = useCallback((nodeId: string) => { + delete tokensRef.current[nodeId]; + }, []); + + /** + * Gets a node token for the given node ID and node URI and executes a function with it. + * If the token is not cached, it will be created and cached. + * If the function fails due to auth error, will create a fresh token and retry. + */ + const withNodeAuth = useCallback( + async (nodeId: string, nodeUri: NodeUri, fn: (token: string) => Promise): Promise => { + const token = await getNodeToken(nodeId, nodeUri); + try { + return await fn(token); + } catch (err: any) { + const msg = typeof err?.message === 'string' ? err.message.toLowerCase() : ''; + const status = err?.status ?? err?.httpStatus ?? err?.response?.status; + const isAuthError = status === 401 || /unauthori[sz]ed|token.*expired|invalid token/.test(msg); + if (!isAuthError) { + throw err; + } + clearNodeToken(nodeId); + const freshToken = await getNodeToken(nodeId, nodeUri); + return fn(freshToken); + } + }, + [getNodeToken, clearNodeToken] + ); + + return ( + + {children} + + ); +} + +export function useNodeAuth() { + const ctx = useContext(NodeAuthContext); + if (!ctx) { + throw new Error('useNodeAuth must be used within NodeAuthProvider'); + } + return ctx; +} diff --git a/src/contexts/node-storage-context.tsx b/src/contexts/node-storage-context.tsx new file mode 100644 index 00000000..d3b52c31 --- /dev/null +++ b/src/contexts/node-storage-context.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { CHAIN_ID } from '@/constants/chains'; +import { NodeUri, useP2P } from '@/contexts/P2PContext'; +import { useNodeAuth } from '@/contexts/node-auth-context'; +import { useAccessList } from '@/lib/use-access-list'; +import { useOceanAccount } from '@/lib/use-ocean-account'; +import { BucketAccessState } from '@/types/node-storage'; +import { rowsToAccessLists } from '@/utils/access-list'; +import { PersistentStorageAccessList, PersistentStorageBucket, PersistentStorageFileEntry } from '@oceanprotocol/lib'; +import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'; + +type NodeStorageContextType = { + /** Buckets by node ID */ + buckets: Record; + /** Bucket files by bucket ID */ + bucketFiles: Record; + /** Fetching buckets by node ID */ + fetchingBuckets: Record; + /** Fetching bucket files by bucket ID */ + fetchingFiles: Record; + /** Uploading file by bucket ID */ + uploadingFile: Record; + /** Deleting file by bucket ID */ + deletingFile: Record; + /** Fetch buckets for a node */ + fetchBuckets: (args: { nodeId: string; nodeUri: NodeUri }) => Promise; + /** Fetch bucket files for a bucket */ + fetchBucketFiles: (args: { bucketId: string; nodeId: string; nodeUri: NodeUri }) => Promise; + /** Upload file to a bucket */ + uploadFile: (args: { bucketId: string; nodeId: string; nodeUri: NodeUri; file: File }) => Promise; + /** Delete file from a bucket */ + deleteFile: (args: { bucketId: string; nodeId: string; nodeUri: NodeUri; fileName: string }) => Promise; + /** Create a bucket on a node */ + createBucket: (args: { access: BucketAccessState; nodeId: string; nodeUri: NodeUri }) => Promise; + /** Get wallet addresses in an access list contract */ + getAccessListAddresses: (contractAddress: string) => Promise; + /** Add a wallet to an access list contract */ + addToAccessList: (args: { contractAddress: string; wallet: string }) => Promise; + /** Remove a wallet from an access list contract */ + removeFromAccessList: (args: { contractAddress: string; wallet: string }) => Promise; +}; + +const NodeStorageContext = createContext(undefined); + +export function NodeStorageProvider({ children }: { children: ReactNode }) { + const { account } = useOceanAccount(); + const { withNodeAuth } = useNodeAuth(); + + const { createNodeBucket, deleteBucketFile, getNodeBuckets, listBucketFiles, uploadBucketFile } = useP2P(); + + const { deployNewAccessList, getAccessListAddresses, addWalletToAccessList, removeWalletFromAccessList } = + useAccessList(); + + const [buckets, setBuckets] = useState>({}); + const [bucketFiles, setBucketFiles] = useState>({}); + const [fetchingBuckets, setFetchingBuckets] = useState>({}); + const [fetchingFiles, setFetchingFiles] = useState>({}); + const [uploadingFile, setUploadingFile] = useState>({}); + const [deletingFile, setDeletingFile] = useState>({}); + + const prevAddress = useRef(account.address); + + useEffect(() => { + if (prevAddress.current && !account.address) { + setBuckets({}); + setBucketFiles({}); + } + prevAddress.current = account.address; + }, [account.address]); + + const fetchBuckets = useCallback( + async ({ nodeId, nodeUri }: { nodeId: string; nodeUri: NodeUri }) => { + if (!account.address) { + return; + } + setFetchingBuckets((prev) => ({ ...prev, [nodeId]: true })); + try { + const owned = await withNodeAuth(nodeId, nodeUri, async (token) => { + const all = await getNodeBuckets({ authToken: token, nodeUri, ownerAddress: account.address! }); + return all.filter((b) => b.owner.toLowerCase() === account.address!.toLowerCase()); + }); + setBuckets((prev) => ({ ...prev, [nodeId]: owned })); + } catch (e) { + setBuckets((prev) => ({ ...prev, [nodeId]: prev[nodeId] ?? [] })); + throw e; + } finally { + setFetchingBuckets((prev) => ({ ...prev, [nodeId]: false })); + } + }, + [account.address, getNodeBuckets, withNodeAuth] + ); + + const fetchBucketFiles = useCallback( + async ({ bucketId, nodeId, nodeUri }: { bucketId: string; nodeId: string; nodeUri: NodeUri }) => { + setFetchingFiles((prev) => ({ ...prev, [bucketId]: true })); + try { + const files = await withNodeAuth(nodeId, nodeUri, (token) => + listBucketFiles({ authToken: token, bucketId, nodeUri }) + ); + setBucketFiles((prev) => ({ ...prev, [bucketId]: files })); + } catch (e) { + setBucketFiles((prev) => ({ ...prev, [bucketId]: prev[bucketId] ?? [] })); + throw e; + } finally { + setFetchingFiles((prev) => ({ ...prev, [bucketId]: false })); + } + }, + [withNodeAuth, listBucketFiles] + ); + + const uploadFile = useCallback( + async ({ bucketId, nodeId, nodeUri, file }: { bucketId: string; nodeId: string; nodeUri: NodeUri; file: File }) => { + setUploadingFile((prev) => ({ ...prev, [bucketId]: true })); + try { + const entry = await withNodeAuth(nodeId, nodeUri, (token) => + uploadBucketFile({ authToken: token, bucketId, file, nodeUri }) + ); + setBucketFiles((prev) => ({ + ...prev, + [bucketId]: [...(prev[bucketId] ?? []).filter((f) => f.name !== entry.name), entry], + })); + } finally { + setUploadingFile((prev) => ({ ...prev, [bucketId]: false })); + } + }, + [withNodeAuth, uploadBucketFile] + ); + + const createBucket = useCallback( + async ({ access, nodeId, nodeUri }: { access: BucketAccessState; nodeId: string; nodeUri: NodeUri }) => { + if (!account.address) { + throw new Error('Wallet not connected'); + } + let accessLists: PersistentStorageAccessList[]; + switch (access.mode) { + case 'existing': { + accessLists = rowsToAccessLists([{ chainId: String(CHAIN_ID), address: access.address.trim() }]); + break; + } + case 'none': { + accessLists = []; + break; + } + case 'new': { + const accessListAddress = await deployNewAccessList({ wallets: access.wallets, owner: account.address }); + accessLists = rowsToAccessLists([{ chainId: String(CHAIN_ID), address: accessListAddress }]); + break; + } + } + await withNodeAuth(nodeId, nodeUri, (token) => createNodeBucket({ accessLists, authToken: token, nodeUri })); + await fetchBuckets({ nodeId, nodeUri }); + }, + [account.address, createNodeBucket, deployNewAccessList, fetchBuckets, withNodeAuth] + ); + + const deleteFile = useCallback( + async ({ + bucketId, + nodeId, + nodeUri, + fileName, + }: { + bucketId: string; + nodeId: string; + nodeUri: NodeUri; + fileName: string; + }) => { + const key = `${bucketId}:${fileName}`; + setDeletingFile((prev) => ({ ...prev, [key]: true })); + try { + await withNodeAuth(nodeId, nodeUri, (token) => + deleteBucketFile({ authToken: token, bucketId, fileName, nodeUri }) + ); + setBucketFiles((prev) => ({ + ...prev, + [bucketId]: (prev[bucketId] ?? []).filter((f) => f.name !== fileName), + })); + } finally { + setDeletingFile((prev) => ({ ...prev, [key]: false })); + } + }, + [deleteBucketFile, withNodeAuth] + ); + + return ( + addWalletToAccessList({ contractAddress, wallet }), + removeFromAccessList: ({ contractAddress, wallet }) => removeWalletFromAccessList({ contractAddress, wallet }), + }} + > + {children} + + ); +} + +export function useNodeStorage() { + const ctx = useContext(NodeStorageContext); + if (!ctx) throw new Error('useNodeStorage must be used within NodeStorageProvider'); + return ctx; +} diff --git a/src/lib/use-access-list.ts b/src/lib/use-access-list.ts new file mode 100644 index 00000000..c1497f75 --- /dev/null +++ b/src/lib/use-access-list.ts @@ -0,0 +1,167 @@ +import { CHAIN_ID } from '@/constants/chains'; +import { getRpc } from '@/lib/constants'; +import { useOceanAccount } from '@/lib/use-ocean-account'; +import Address from '@oceanprotocol/contracts/addresses/address.json'; +import AccessListABI from '@oceanprotocol/contracts/artifacts/contracts/accesslists/AccessList.sol/AccessList.json'; +import AccessListFactoryABI from '@oceanprotocol/contracts/artifacts/contracts/accesslists/AccessListFactory.sol/AccessListFactory.json'; +import { AccesslistFactory } from '@oceanprotocol/lib'; +import { ethers } from 'ethers'; +import { useCallback } from 'react'; +import { encodeFunctionData } from 'viem'; + +function getFactoryAddress(chainId: number): string { + const config = Object.values(Address).find((c) => c.chainId === chainId); + if (!config || !('AccessListFactory' in config)) { + throw new Error(`No AccessListFactory deployed on chain ${chainId}`); + } + return (config as any).AccessListFactory as string; +} + +function getReadContract(contractAddress: string): ethers.Contract { + return new ethers.Contract(contractAddress, AccessListABI.abi, new ethers.JsonRpcProvider(getRpc())); +} + +export function useAccessList() { + const { client, provider, user } = useOceanAccount(); + + const getSigner = useCallback(async () => { + if (!provider) { + throw new Error('No provider available'); + } + return provider.getSigner(); + }, [provider]); + + const sendUO = useCallback( + async (target: string, data: `0x${string}`) => { + if (!client) { + throw new Error('Wallet not connected'); + } + const { hash } = await client.sendUserOperation({ + uo: { target: target as `0x${string}`, data }, + }); + await client.waitForUserOperationTransaction({ hash }); + }, + [client] + ); + + const deployNewAccessList = useCallback( + async ({ wallets, owner }: { wallets: string[]; owner: string }): Promise => { + const factoryAddress = getFactoryAddress(CHAIN_ID); + + if (user?.type === 'eoa') { + const signer = await getSigner(); + const factory = new AccesslistFactory(factoryAddress, signer); + const address = await factory.deployAccessListContract( + 'BucketAccessList', + 'BAL', + wallets.map(() => ''), + false, + owner, + wallets + ); + if (!address) { + throw new Error('Failed to deploy access list contract'); + } + return address; + } + + if (!client) { + throw new Error('Wallet not connected'); + } + const data = encodeFunctionData({ + abi: AccessListFactoryABI.abi, + functionName: 'deployAccessListContract', + args: [ + 'BucketAccessList', + 'BAL', + false, + owner as `0x${string}`, + wallets as `0x${string}`[], + wallets.map(() => ''), + ], + }); + const { hash } = await client.sendUserOperation({ + uo: { target: factoryAddress as `0x${string}`, data }, + }); + const txHash = await client.waitForUserOperationTransaction({ hash }); + const rpcProvider = new ethers.JsonRpcProvider(getRpc()); + const receipt = await rpcProvider.getTransactionReceipt(txHash); + if (!receipt) { + throw new Error('Could not fetch transaction receipt'); + } + const newAccessListTopic = ethers.id('NewAccessList(address,address)'); + for (const log of receipt.logs) { + if (log.topics[0] === newAccessListTopic && log.address.toLowerCase() === factoryAddress.toLowerCase()) { + return ethers.getAddress('0x' + log.topics[1].slice(26)); + } + } + throw new Error('NewAccessList event not found in receipt'); + }, + [client, getSigner, user?.type] + ); + + const getAccessListAddresses = useCallback(async (contractAddress: string): Promise => { + const contract = getReadContract(contractAddress); + const totalSupply = Number(await contract.totalSupply()); + const addresses: string[] = []; + for (let i = 0; i < totalSupply; i++) { + const tokenId = await contract.tokenByIndex(i); + addresses.push(await contract.ownerOf(tokenId)); + } + return addresses; + }, []); + + const addWalletToAccessList = useCallback( + async ({ contractAddress, wallet }: { contractAddress: string; wallet: string }): Promise => { + if (user?.type === 'eoa') { + const signer = await getSigner(); + const contract = new ethers.Contract(contractAddress, AccessListABI.abi, signer); + await contract.mint(wallet, ''); + return; + } + const data = encodeFunctionData({ + abi: AccessListABI.abi, + functionName: 'mint', + args: [wallet as `0x${string}`, ''], + }); + await sendUO(contractAddress, data); + }, + [getSigner, sendUO, user?.type] + ); + + const removeWalletFromAccessList = useCallback( + async ({ contractAddress, wallet }: { contractAddress: string; wallet: string }): Promise => { + const contract = getReadContract(contractAddress); + const totalSupply = Number(await contract.totalSupply()); + let tokenId: bigint | undefined; + for (let i = 0; i < totalSupply; i++) { + const id = await contract.tokenByIndex(i); + const owner: string = await contract.ownerOf(id); + if (owner.toLowerCase() === wallet.toLowerCase()) { + tokenId = BigInt(id.toString()); + break; + } + } + if (tokenId === undefined) { + throw new Error(`Wallet ${wallet} not found in access list`); + } + const data = encodeFunctionData({ abi: AccessListABI.abi, functionName: 'burn', args: [tokenId] }); + + if (user?.type === 'eoa') { + const signer = await getSigner(); + const writableContract = new ethers.Contract(contractAddress, AccessListABI.abi, signer); + await writableContract.burn(tokenId); + return; + } + await sendUO(contractAddress, data); + }, + [getSigner, sendUO, user?.type] + ); + + return { + deployNewAccessList, + getAccessListAddresses, + addWalletToAccessList, + removeWalletFromAccessList, + }; +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index f0be7fd3..385eddb0 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,6 +9,8 @@ import { RunNodeProvider } from '@/context/run-node-context'; import { StatsProvider } from '@/context/stats-context'; import { UnbanRequestsProvider } from '@/context/unban-requests-context'; import { P2PProvider } from '@/contexts/P2PContext'; +import { NodeAuthProvider } from '@/contexts/node-auth-context'; +import { NodeStorageProvider } from '@/contexts/node-storage-context'; import { AlchemyProvider } from '@/lib/alchemy-provider'; import { OceanAccountProvider } from '@/lib/use-ocean-account'; import { PHProvider } from '@/lib/use-posthog'; @@ -19,10 +21,10 @@ import cx from 'classnames'; import App, { type AppContext, type AppProps } from 'next/app'; import dynamic from 'next/dynamic'; import { Inter, Plus_Jakarta_Sans } from 'next/font/google'; +import Script from 'next/script'; import { useEffect, useRef } from 'react'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; -import Script from 'next/script'; const GitBookProvider = dynamic(() => import('@gitbook/embed/react').then((mod) => mod.GitBookProvider), { ssr: false, @@ -65,11 +67,7 @@ export default function DashboardApp({ Component, pageProps, cookie }: AppProps return (
-