diff --git a/package.json b/package.json index e47d7a2..7078f21 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,10 @@ "@ucanto/interface": "^9.0.0", "@ucanto/transport": "^9.0.0", "@w3ui/react": "^1.3.0", + "@web3-storage/access": "^18.1.0", "@web3-storage/content-claims": "^3.2.1", "@web3-storage/data-segment": "^5.0.0", + "@web3-storage/w3up-client": "^11.2.1", "archy": "^1.0.0", "ariakit-utils": "0.17.0-next.27", "blueimp-md5": "^2.19.0", @@ -31,6 +33,7 @@ "next": "^13.5.4", "react": "latest", "react-dom": "latest", + "react-use-wizard": "^2.2.4", "swr": "^2.2.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1f8534..796820c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,12 +32,18 @@ dependencies: '@w3ui/react': specifier: ^1.3.0 version: 1.3.0(@types/react@18.2.42)(react-dom@18.2.0)(react@18.2.0) + '@web3-storage/access': + specifier: ^18.1.0 + version: 18.1.0 '@web3-storage/content-claims': specifier: ^3.2.1 version: 3.2.1 '@web3-storage/data-segment': specifier: ^5.0.0 version: 5.0.0 + '@web3-storage/w3up-client': + specifier: ^11.2.1 + version: 11.2.1 archy: specifier: ^1.0.0 version: 1.0.0 @@ -59,6 +65,9 @@ dependencies: react-dom: specifier: latest version: 18.2.0(react@18.2.0) + react-use-wizard: + specifier: ^2.2.4 + version: 2.2.4(react-dom@18.2.0)(react@18.2.0) swr: specifier: ^2.2.4 version: 2.2.4(react@18.2.0) @@ -1117,7 +1126,7 @@ packages: resolution: {integrity: sha512-SsYvKCO3FD27roTVcg8ASxnixjn+j96sPlijpVq1uBUxq7SmuNxNPYFZqpxXKj2R4gty/Oc8XTse12ebB9Kofg==} dependencies: '@ipld/car': 5.2.4 - '@ipld/dag-cbor': 9.0.5 + '@ipld/dag-cbor': 9.0.6 '@ipld/dag-ucan': 3.4.0 '@ucanto/interface': 9.0.0 multiformats: 11.0.2 @@ -1162,7 +1171,7 @@ packages: resolution: {integrity: sha512-H9GMOXHNW3vCv36eQZN1/h8zOXHEljRV5yNZ/huyOaJLVAKxt7Va1Ww8VBf2Ho/ac6P7jwvQRT7WgxaXx1/3Hg==} dependencies: '@ipld/car': 5.2.4 - '@ipld/dag-cbor': 9.0.5 + '@ipld/dag-cbor': 9.0.6 '@ucanto/core': 9.0.1 '@ucanto/interface': 9.0.0 multiformats: 11.0.2 @@ -1359,7 +1368,7 @@ packages: '@ucanto/interface': 9.0.0 '@ucanto/principal': 9.0.0 '@ucanto/transport': 9.0.0 - '@web3-storage/access': 18.0.6 + '@web3-storage/access': 18.1.0 '@web3-storage/did-mailto': 2.1.0 '@web3-storage/w3up-client': 11.2.1 transitivePeerDependencies: @@ -1388,8 +1397,8 @@ packages: web-streams-polyfill: 3.2.1 dev: false - /@web3-storage/access@18.0.6: - resolution: {integrity: sha512-IuzqkqRp53ILoSeALsyXmZO7zPn6syJGM6X1a+hsmbCGoVG1821i2WWMRz5aCsdgfJxl6Ge0rec+gCMfPx5h8Q==} + /@web3-storage/access@18.1.0: + resolution: {integrity: sha512-M6hMIm7GRJxN+l41u2+aK97whe15YF7vYVqvpm4dsbk8HQBzmjrRXw9jKhh71jPNgVRCpwvhLYpUM+lEgImvhw==} dependencies: '@ipld/car': 5.2.4 '@ipld/dag-ucan': 3.4.0 @@ -1505,7 +1514,7 @@ packages: '@ucanto/interface': 9.0.0 '@ucanto/principal': 9.0.0 '@ucanto/transport': 9.0.0 - '@web3-storage/access': 18.0.6 + '@web3-storage/access': 18.1.0 '@web3-storage/capabilities': 12.1.0 '@web3-storage/did-mailto': 2.1.0 '@web3-storage/filecoin-client': 3.2.0 @@ -4780,6 +4789,17 @@ packages: p-defer: 3.0.0 dev: false + /react-use-wizard@2.2.4(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-2GkKtV3VD3n1PWkfYy82W4pnUA9G3PRgwIg0vCJlmYAUxLA7k+o1kVognFMlPM3is5t9N5L5/+gSIw/4fhNh6A==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} diff --git a/src/app/space/create/page.tsx b/src/app/space/create/page.tsx index 51216bd..13d8b96 100644 --- a/src/app/space/create/page.tsx +++ b/src/app/space/create/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { SpaceCreatorForm } from '@/components/SpaceCreator' +import { SpaceCreatorWizard } from '@/components/SpaceCreator' import { SpacesNav } from '../layout' import { H2 } from '@/components/Text' @@ -10,7 +10,7 @@ export default function CreateSpacePage (): JSX.Element {

Create a new Space

- +

Explain

diff --git a/src/components/SpaceCreator.tsx b/src/components/SpaceCreator.tsx index 24bc9a4..21c9901 100644 --- a/src/components/SpaceCreator.tsx +++ b/src/components/SpaceCreator.tsx @@ -1,37 +1,45 @@ -import type { ChangeEvent } from 'react' +import type { ChangeEvent, ReactNode } from 'react' +import type { OwnedSpace } from '@web3-storage/access/space' +import * as W3Space from '@web3-storage/w3up-client/space' import React, { useState } from 'react' -import { Space, useW3 } from '@w3ui/react' +import { Wizard, useWizard } from 'react-use-wizard'; +import { SpaceDID, useW3 } from '@w3ui/react' import Loader from '../components/Loader' -import { DID, DIDKey } from '@ucanto/interface' +import { Failure } from '@ucanto/interface' import { DidIcon } from './DidIcon' import Link from 'next/link' +import { usePlan } from '@/hooks'; -export function SpaceCreatorCreating (): JSX.Element { +interface SpaceCreatorWizardLoaderProps { + message: string + className?: string +} + +export function SpaceCreatorWizardLoader ({ message, className = 'flex flex-col items-center space-y-4' }: SpaceCreatorWizardLoaderProps): ReactNode { return ( -
-
Creating Space...
+
+
{message}
) } +const SpaceCreatorCreating = () => + interface SpaceCreatorFormProps { className?: string + setOwnedSpace: React.Dispatch> } export function SpaceCreatorForm ({ - className = '' -}: SpaceCreatorFormProps): JSX.Element { + className = '', + setOwnedSpace +}: SpaceCreatorFormProps): ReactNode { const [{ client, accounts }] = useW3() const [submitted, setSubmitted] = useState(false) - const [created, setCreated] = useState(false) const [name, setName] = useState('') - const [space, setSpace] = useState() - - function resetForm (): void { - setName('') - } + const { nextStep } = useWizard() async function onSubmit (e: React.FormEvent): Promise { e.preventDefault() @@ -42,48 +50,21 @@ export function SpaceCreatorForm ({ throw new Error('cannot create space, no account found, have you authorized your email?') } - const { ok: plan } = await account.plan.get() - if (!plan) { - throw new Error('a payment plan is required on account to provision a new space.') - } - setSubmitted(true) try { const space = await client.createSpace(name) - - const provider = (process.env.NEXT_PUBLIC_W3UP_PROVIDER || 'did:web:web3.storage') as DID<'web'> - await account.provision(space.did(), { provider }) - - // MUST do this before creating recovery, as it creates necessary authorizations - await space.save() - - // TODO this should have its own UX, like the CLI does, which would allow us to handle errors - const recovery = await space.createRecovery(account.did()) - - // TODO we are currently ignoring the result of this because we have no good way to handle errors - revamp this ASAP! - await client.capability.access.delegate({ - space: space.did(), - delegations: [recovery], - }) - - setSpace(client.spaces().find(s => s.did() === space.did())) - setCreated(true) - resetForm() + // @ts-ignore TODO this should be unecessary after we thread the access client dep through the libraries + setOwnedSpace(space) + nextStep() } catch (error) { /* eslint-disable-next-line no-console */ console.error(error) throw new Error('failed to create space', { cause: error }) + } finally { + setSubmitted(false) } } - if (created && space) { - return ( -
- -
- ) - } - if (submitted) { return (
@@ -110,6 +91,305 @@ export function SpaceCreatorForm ({ ) } +interface SpaceMnemonicFormProps { + className?: string + ownedSpace?: OwnedSpace, + setPreferDelegateRecovery: React.Dispatch> +} + +export function SpaceMnemonicForm ({ + className = '', + ownedSpace, + setPreferDelegateRecovery +}: SpaceMnemonicFormProps): ReactNode { + const [showMnemonic, setShowMnemonic] = useState(false) + const { nextStep } = useWizard() + function saved () { + nextStep() + } + function backup () { + setPreferDelegateRecovery(true) + nextStep() + } + const mnemonic = ownedSpace && W3Space.toMnemonic(ownedSpace) + return ( +
+
+

+ Your space is controlled by a private key. We'll give you an opportunity to delegate + the ability to recover control over your space to our service, but we won't store + your private key. +

+

+ In order to ensure you don't lose access to your space, please write down the + following phrase and keep it in a secure place like 1Password or a piece of paper + in a safe. It is a representation of your private key, and anybody with access + to this phrase will be able to control and access your space. Keep it secret, keep + it safe! +

+
+ + + {showMnemonic ? mnemonic : ''} + +
+ + +
+
+ ) +} + +interface SpaceBillingFormProps { + className?: string + spaceDID?: SpaceDID, +} + +export function SpaceBillingForm ({ + className = '', + spaceDID +}: SpaceBillingFormProps): ReactNode { + const [{ accounts }] = useW3() + const account = accounts[0] + const { data: plan, isLoading: planLoading, mutate } = usePlan(account) + const [error, setError] = useState() + const [submitted, setSubmitted] = useState(false) + + const { nextStep } = useWizard() + async function setUpBilling () { + if (spaceDID) { + setSubmitted(true) + try { + const provisionResult = await account.provision(spaceDID) + if (provisionResult.ok) { + nextStep() + } else { + setError(provisionResult.error) + } + } finally { + setSubmitted(false) + } + } + } + function refreshPlan () { + // calling mutate will refresh plan information + mutate() + } + const buttonDisabled = !spaceDID + + if (submitted) { + return ( +
+ +
+ ) + } + if (planLoading) { + return ( +
+ +
+ ) + } + return ( +
+ {plan ? ( + <> +
+

+ Before you can store data in your space on web3.storage, you need to + set this space up for billing. +

+
+ {error && ( + <> +

Error setting up billing:

+

{error.message}

+ + )} + + + ) : ( + <> +
+

+ Before you can set up billing for this space, you need to pick a plan. You can + do that by opening the following link in a new tab: +

+ Plan Picker Page +

+ Once you're done, hit the button below to refresh your plan information: +

+
+ + + )} +
+ ) +} + +interface SpaceRecoveryFormProps { + className?: string + ownedSpace?: OwnedSpace, +} + +export function SpaceRecoveryForm ({ + className = '', + ownedSpace +}: SpaceRecoveryFormProps): ReactNode { + const [{ accounts, client }] = useW3() + const account = accounts[0] + const [saved, setSaved] = useState(false) + const [error, setError] = useState() + const [submitted, setSubmitted] = useState(false) + + const { nextStep } = useWizard() + async function setUpRecovery () { + if (client && ownedSpace) { + setSubmitted(true) + try { + let saveSuccess; + if (!saved) { + const saveResult = await ownedSpace.save() + if (saveResult.ok) { + setSaved(true) + saveSuccess = true + } else { + setError(saveResult.error) + } + } + if (saved || saveSuccess) { + const recovery = await ownedSpace.createRecovery(account.did()) + const delegateResult = await client.capability.access.delegate({ + space: ownedSpace.did(), + delegations: [recovery] + }) + if (delegateResult.ok) { + nextStep() + } else { + setError(delegateResult.error) + } + } + } catch (err: any) { + setError(err.message) + } finally { + setSubmitted(false) + } + } + } + const buttonDisabled = !ownedSpace + if (submitted) { + return ( +
+ +
+ ) + } + return ( +
+
+

+ Finally, we recommend you connect this space to {account.toEmail()}'s + web3.storage account. +

+

+ If you choose not to do this we will not be able to + automatically give you access to this space on other devices and we + will not be able to help you regain access if you lose your recovery + phrase. You can still import your space into other devices + using your recovery phrase. +

+
+ {error && ( + <> +

Error delegating recovery to the service:

+

{error.message}

+ + )} + +
+ ) +} + +interface SpacePreviewProps { + did?: SpaceDID + name?: string + className?: string +} + +export function SpacePreview ({ did, name, className }: SpacePreviewProps) { + if (!did) { + return ( +
+ +
+ ) + } + return ( +
+
+ + + +
+ + + {name ?? 'Untitled'} + + + {did} + + +
+
+ + View + +
+
+
+ ) +} + +interface SpaceCreatorWizardProps { + className?: string +} + +export function SpaceCreatorWizard ({ + className = '' +}: SpaceCreatorWizardProps): ReactNode { + const [ownedSpace, setOwnedSpace] = useState() + const [preferDelegateRecovery, setPreferDelegateRecovery] = useState(false) + + return ( + + + + + + + + ) +} + interface SpaceCreatorProps { className?: string } @@ -123,7 +403,7 @@ export function SpaceCreator ({
{creating ? ( - + ) : (
);