diff --git a/packages/console/src/app/space/[did]/share/page.tsx b/packages/console/src/app/space/[did]/share/page.tsx index 71031bd25..cbcb6a498 100644 --- a/packages/console/src/app/space/[did]/share/page.tsx +++ b/packages/console/src/app/space/[did]/share/page.tsx @@ -1,11 +1,11 @@ 'use client'; import { use, type JSX } from "react"; -import { ShareSpace } from '@/share' +import { SpaceShare } from '@storacha/ui-react' export default function SharePage(props): JSX.Element { const params = use(props.params); return ( - + ) } diff --git a/packages/ui/packages/react/src/FullUploader.tsx b/packages/ui/packages/react/src/FullUploader.tsx new file mode 100644 index 000000000..a2bd2ca0f --- /dev/null +++ b/packages/ui/packages/react/src/FullUploader.tsx @@ -0,0 +1,199 @@ +import type { JSX } from 'react' +import type { AnyLink, CARMetadata, ProgressStatus } from '@storacha/ui-core' +import type { OnUploadComplete } from './Uploader.js' +import { Uploader as W3Uploader, UploadStatus, WrapInDirectoryCheckbox, useUploader } from './Uploader.js' +import { useEffect, useState } from 'react' +import type { KMSConfig } from './hooks.js' + +function StatusLoader({ progressStatus }: { progressStatus: ProgressStatus }): JSX.Element { + const { total, loaded, lengthComputable } = progressStatus + if (lengthComputable) { + const percentComplete = Math.floor((loaded / total) * 100) + return ( +
+
+
+ ) + } else { + return Loading… + } +} + +function Loader({ uploadProgress }: { uploadProgress: Record }): JSX.Element { + return ( +
+ {Object.values(uploadProgress).map((status) => ( + + ))} +
+ ) +} + +function humanFileSize(bytes: number): string { + const size = (bytes / (1024 * 1024)).toFixed(2) + return `${size} MiB` +} + +function Uploading({ file, storedDAGShards, uploadProgress }: { file?: File; storedDAGShards?: CARMetadata[]; uploadProgress: Record }): JSX.Element { + return ( +
+

Uploading {file?.name}

+ + {storedDAGShards?.map(({ cid, size }) => ( +

+ shard {cid.toString()} ({humanFileSize(size)}) uploaded +

+ ))} +
+ ) +} + +function Errored({ error }: { error: any }): JSX.Element { + useEffect(() => { + if (error != null) { + // eslint-disable-next-line no-console + console.error('Uploader Error:', error) + } + }, [error]) + return ( +
+

Error

+

Failed to upload file: {error?.message}

+
+ ) +} + +function Done({ dataCID, gatewayURL }: { dataCID?: AnyLink; gatewayURL: (cid: string) => string }): JSX.Element { + const [, { setFile }] = useUploader() + const cid: string = dataCID?.toString() ?? '' + return ( +
+

Uploaded

+ {cid} +
+ +
+
+ ) +} + +enum UploadType { File = 'File', Directory = 'Directory', CAR = 'CAR' } + +function uploadPrompt(uploadType: UploadType) { + switch (uploadType) { + case UploadType.File: return 'Drag File or Click to Browse' + case UploadType.Directory: return 'Drag Directory or Click to Browse' + case UploadType.CAR: return 'Drag CAR or Click to Browse' + } +} + +function pickFileIconLabel(file: File): string | undefined { + const type = file.type.split('/') + if (type.length === 0 || type.at(0) === '') { + const ext = file.name.split('.').at(-1) + if (ext !== undefined && ext.length < 5) return ext + return 'Data' + } + if (type.at(0) === 'image') return type.at(-1) + return type.at(0) +} + +function UploaderConsole({ gatewayURL }: { gatewayURL: (cid: string) => string }): JSX.Element { + const [{ status, file, error, dataCID, storedDAGShards, uploadProgress }] = useUploader() + switch (status) { + case UploadStatus.Uploading: return + case UploadStatus.Succeeded: return + case UploadStatus.Failed: return + default: return <> + } +} + +function UploaderContents(): JSX.Element { + const [{ status, file }] = useUploader() + const hasFile = file !== undefined + if (status === UploadStatus.Idle) { + return hasFile ? ( + <> +
+
+ {pickFileIconLabel(file)} +
+
+ {file.name} + {humanFileSize(file.size)} +
+
+
+ +
+ + ) : <> + } else { + return `https://${cid}.ipfs.w3s.link`} /> + } +} + +export interface FullUploaderProps { onUploadComplete?: OnUploadComplete; space?: any; gatewayURL?: (cid: string) => string; kmsConfig?: KMSConfig } + +export function FullUploader({ onUploadComplete, space, gatewayURL, kmsConfig }: FullUploaderProps): JSX.Element { + const [{}, { setUploadAsCAR, setKmsConfig }] = useUploader() + const [allowDirectory, setAllowDirectory] = useState(false) + const [uploadType, setUploadType] = useState(UploadType.File) + const isPrivateSpace = space?.access?.type === 'private' + + useEffect(() => { if (isPrivateSpace && kmsConfig !== undefined) { setKmsConfig(kmsConfig) } }, [isPrivateSpace, setKmsConfig, kmsConfig]) + + function changeUploadType(type: UploadType) { + if (type === UploadType.File) { setUploadAsCAR(false); setAllowDirectory(false) } + else if (type === UploadType.Directory) { setUploadAsCAR(false); setAllowDirectory(true) } + else if (type === UploadType.CAR) { setUploadAsCAR(true); setAllowDirectory(false) } + setUploadType(type) + } + + const hasFile = useUploader()[0].file !== undefined + + return ( + + + {!isPrivateSpace && ( + <> +

Type

+
+ + + +
+ + )} +
+ + + + {hasFile ? '' : {uploadPrompt(uploadType)}} +
+ {!isPrivateSpace && uploadType === UploadType.File && ( + <> +

Options

+ + + )} +
+

Explain

+ {isPrivateSpace ? ( +
+

Private Data

Files uploaded to this space are encrypted locally and never published to Filecoin.

+

Hot Storage Only

Once removed from hot storage, they are gone forever and cannot be recovered.

+
+ ) : ( +
+

Public Data

All data uploaded here will be available to anyone who requests it using the correct CID.

+

Permanent Data

Removing files will remove them from the file listing for your account, but nodes may retain copies indefinitely.

+
+ )} +
+ ) +} \ No newline at end of file diff --git a/packages/ui/packages/react/src/SpaceShare.tsx b/packages/ui/packages/react/src/SpaceShare.tsx new file mode 100644 index 000000000..a7f9632ee --- /dev/null +++ b/packages/ui/packages/react/src/SpaceShare.tsx @@ -0,0 +1,235 @@ +import { useEffect, useMemo, useState } from 'react' +import type { JSX } from 'react' +import type { Delegation, Capabilities } from '@ucanto/interface' +import { DID } from '@ucanto/core' +import { useW3 } from './providers/Provider.js' +import type { SpaceDID, EmailAddress } from '@storacha/ui-core' + +function isDID(value: string): boolean { + try { + DID.parse(value.trim()) + return true + } catch { + return false + } +} + +function isEmail(value: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return !isDID(value) && emailRegex.test(value) +} + +function truncateCID(cid: string): string { + if (cid.length <= 14) return cid + return `${cid.slice(0, 7)}...${cid.slice(-7)}` +} + +async function checkDelegationRevoked(cid: string): Promise { + try { + const serviceUrl = process.env.NEXT_PUBLIC_W3UP_SERVICE_URL + if (!serviceUrl) { + return false + } + const response = await fetch(`${serviceUrl}/revocations/${cid}`) + return response.status === 200 + } catch { + return false + } +} + +export interface SpaceShareProps { + spaceDID: SpaceDID +} + +export function SpaceShare({ spaceDID }: SpaceShareProps): JSX.Element { + const [{ client }] = useW3() + const [value, setValue] = useState('') + const [shared, setShared] = useState<{ + audience: string + capabilities: string[] + delegation: Delegation + revoked?: boolean + }[]>([]) + const [revoking, setRevoking] = useState>(new Set()) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (client && spaceDID) { + setLoading(true) + const delegations = client + .delegations() + .filter((d: Delegation) => d.capabilities.some((c) => c.with === spaceDID)) + .map((d: Delegation) => ({ + audience: d.audience.did(), + capabilities: d.capabilities.map((c) => c.can), + delegation: d, + revoked: false, + })) + + if (delegations.length === 0) { + setShared([]) + setLoading(false) + return + } + + const check = async () => { + const items = await Promise.all( + delegations.map(async (entry) => ({ + ...entry, + revoked: await checkDelegationRevoked(entry.delegation.cid.toString()), + })) + ) + setShared(items) + setLoading(false) + } + void check() + } else { + setShared([]) + setLoading(false) + } + }, [client, spaceDID]) + + async function shareViaEmail(email: string): Promise { + if (!client) throw new Error('Client not found') + + const space = client.spaces().find((s: any) => s.did() === spaceDID) + if (!space) throw new Error('Could not find space to share') + + const existingRevoked = shared.find((item) => item.audience.toLowerCase().includes(email.toLowerCase()) && item.revoked) + if (existingRevoked) { + setError(`Cannot grant access to ${email}. This recipient has a previously revoked delegation.`) + return + } + + setError(null) + const mail = email.trim() as EmailAddress + const delegation: Delegation = await client.shareSpace(mail, space.did()) + setShared((prev) => [ + ...prev, + { + audience: email, + capabilities: delegation.capabilities.map((c) => c.can), + delegation, + }, + ]) + setValue('') + } + + async function makeDownloadLink(did: string): Promise { + if (!client) throw new Error('missing client') + const audience = DID.parse(did.trim()) + const delegation = await client.createDelegation(audience, [ + 'space/*', + 'store/*', + 'upload/*', + 'access/*', + 'usage/*', + 'filecoin/*', + ], { expiration: Infinity }) + const archiveRes = await delegation.archive() + if (archiveRes.error) throw new Error('failed to archive delegation', { cause: archiveRes.error }) + const blob = new Blob([archiveRes.ok]) + return URL.createObjectURL(blob) + } + + async function autoDownload(input: string): Promise { + const url = await makeDownloadLink(input) + const link = document.createElement('a') + link.href = url + const [, method = '', id = ''] = input.split(':') + link.download = method && id ? `did-${method}-${id.substring(0, 10)}.ucan` : 'delegation.ucan' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + async function revokeDelegation(key: string, delegation: Delegation): Promise { + if (!client) throw new Error('Client not found') + setRevoking((prev) => new Set([...prev, key])) + await client.revokeDelegation(delegation.cid) + setShared((prev) => prev.map((item) => (item.audience === key ? { ...item, revoked: true } : item))) + setRevoking((prev) => { + const next = new Set(prev) + next.delete(key) + return next + }) + } + + const active = useMemo(() => shared.filter((s) => !s.revoked), [shared]) + const revoked = useMemo(() => shared.filter((s) => s.revoked), [shared]) + + return ( +
+

Share your space

+
+

Enter an Email or DID to share access or download a delegation:

+
{ e.preventDefault(); if (isDID(value)) { void autoDownload(value) } else if (isEmail(value)) { void shareViaEmail(value) } }}> + { setValue(e.target.value); if (error) setError(null) }} /> + +
+ {error && ( +
+

{error}

+
+ )} +
+ + {(active.length > 0 || loading) && ( +
+

Shared With:

+ {loading ? ( +
Checking delegation status...
+ ) : ( +
    + {active.map(({ audience, capabilities, delegation }, i) => { + const key = audience + const isRevoking = revoking.has(key) + const cidString = delegation.cid.toString() + return ( +
  • +
    +
    + {audience} +
    +
    + {truncateCID(cidString)} +
    +
    + +
  • + ) + })} +
+ )} +
+ )} + + {revoked.length > 0 && ( +
+

Revoked Access:

+
    + {revoked.map(({ audience, capabilities, delegation }) => { + const cidString = delegation.cid.toString() + return ( +
  • +
    +
    + {audience} +
    +
    + {truncateCID(cidString)} +
    +
    + Revoked +
  • + ) + })} +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/packages/ui/packages/react/src/index.ts b/packages/ui/packages/react/src/index.ts index 47ec9c12f..6a0c12006 100644 --- a/packages/ui/packages/react/src/index.ts +++ b/packages/ui/packages/react/src/index.ts @@ -3,3 +3,5 @@ export * from './providers/Provider.js' export * from './Authenticator.js' export * from './Uploader.js' export * from './hooks.js' +export * from './FullUploader.js' +export * from './SpaceShare.js'