Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions forge-web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,20 @@ export const metadata: Metadata = {
// inline bootstrap scripts.
// - frame-ancestors is ignored in a <meta> CSP; the host must send it as a header (GitHub
// Pages cannot; see docs/guides/identity-and-keys.md).
// - connect-src https:/wss:: DAPI endpoints + IPFS/S3/HTTPS pack backends.
// - connect-src https:/wss:: DAPI endpoints + IPFS/S3/HTTPS pack backends. Plain http to this
// machine is allowed so the storage settings can reach the user's OWN local node (a kubo
// RPC API, a MinIO endpoint), which is how kubo ships. It cannot be narrowed to one page: a
// static export has one <meta> CSP, and several combine as an intersection. It is not a read
// path: readers fetch only public https URLs (`lib/net.ts` `externalFetchUrls`), and nothing
// loopback or private is ever recorded on chain (`fitManifestUris`, `publishProblem`).
// - worker-src blob:: materialization / search / pack workers run off-main-thread.
const CSP = [
"default-src 'self'",
"script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https: blob:",
"font-src 'self'",
"connect-src 'self' https: wss:",
"connect-src 'self' https: wss: http://127.0.0.1:* http://localhost:*",
"worker-src 'self' blob:",
"child-src 'self' blob:",
"frame-ancestors 'none'",
Expand Down
10 changes: 10 additions & 0 deletions forge-web/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ export default function SettingsPage(): JSX.Element {
<SpendPanel />
</section>

<section className="rounded-lg border border-anvil-200 p-4 dark:border-anvil-800">
<h2 className="mb-2 text-dense font-medium text-anvil-500 dark:text-anvil-400">Storage</h2>
<p className="text-dense text-anvil-600 dark:text-anvil-300">
Your buckets and IPFS nodes for browser pushes, tested from this page, with their keys encrypted in this browser.
</p>
<Link href="/settings/storage" className="mt-2 inline-block text-dense text-forge-700 underline dark:text-forge-400">
Storage settings →
</Link>
</section>

<section className="rounded-lg border border-anvil-200 p-4 dark:border-anvil-800">
<h2 className="mb-3 text-dense font-medium text-anvil-500 dark:text-anvil-400">This browser&apos;s key</h2>
<KeysPanel />
Expand Down
46 changes: 46 additions & 0 deletions forge-web/app/settings/storage/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client'

/** `/settings/storage` — bring your own storage (`ux-dx-spec.md` §3.1). */

import Link from 'next/link'
import { HardDrive, MonitorSmartphone } from 'lucide-react'
import { AppShell } from '@/components/app-shell'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/states'
import { StorageWizard } from '@/components/storage/storage-wizard'
import { useAuth } from '@/contexts/auth-context'
import { useUiStore } from '@/hooks/use-ui-store'

export default function StorageSettingsPage(): JSX.Element {
const { identity } = useAuth()
const openLogin = useUiStore((s) => s.openLogin)

return (
<AppShell>
<div className="mx-auto max-w-4xl space-y-5">
<div>
<p className="text-[12px] text-anvil-500 dark:text-anvil-400">
<Link href="/settings" className="hover:underline">Settings</Link> / Storage
</p>
<h1 className="mt-1 text-xl">Storage</h1>
<p className="mt-1 max-w-2xl text-dense text-anvil-600 dark:text-anvil-300">
We host nothing. Packs go to a bucket or IPFS node you own; Platform keeps only the signed manifest and refs (about 0.0003 DASH a push). Readers check every byte against its on-chain hash, so your storage provides availability, never trust.
</p>
</div>
<p className="flex items-center gap-2 rounded-md border border-anvil-200 bg-anvil-50 px-3 py-2 text-[12px] text-anvil-600 dark:border-anvil-800 dark:bg-anvil-900 dark:text-anvil-300 md:hidden">
<MonitorSmartphone className="h-4 w-4 shrink-0" aria-hidden /> Use a desktop browser for this step: it needs a few long fields and a copy-paste from your provider’s console.
</p>
{identity ? (
<StorageWizard />
) : (
<EmptyState
icon={HardDrive}
title="Sign in to set up storage"
body="Storage credentials are kept encrypted with this browser’s key, so they need a signed-in session."
action={<Button variant="primary" onClick={() => openLogin()}>Sign in</Button>}
/>
)}
</div>
</AppShell>
)
}
7 changes: 6 additions & 1 deletion forge-web/components/repo/settings-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*/

import { useState } from 'react'
import { Fingerprint, ShieldPlus, Snowflake, UserCog } from 'lucide-react'
import { Fingerprint, HardDrive, ShieldPlus, Snowflake, UserCog } from 'lucide-react'
import type { RepoHome } from '@/lib/view'
import type { Collaborator, V1RepoRef, V2RepoRef } from '@/lib/repo'
import {
Expand Down Expand Up @@ -41,6 +41,7 @@ import { Button } from '@/components/ui/button'
import { Field, Input } from '@/components/ui/input'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ErrorState, LoadingBlock } from '@/components/ui/states'
import { RepoStoragePolicy } from '@/components/storage/repo-storage-policy'

type Role = 'write' | 'maintain'
type Action = { kind: 'grant' | 'suspend' | 'revoke'; member: string; role: Role }
Expand Down Expand Up @@ -266,6 +267,10 @@ function V2Settings({ home, repo }: { home: RepoHome; repo: V2RepoRef }): JSX.El
<StorageBackend backend={home.backend} emptyText="Readers follow each pack manifest's own storage." />
</Section>

<Section title="Your browser pushes" icon={<HardDrive className="h-4 w-4 text-anvil-400" aria-hidden />}>
<RepoStoragePolicy repoId={repo.repoId} />
</Section>

<Section title="Members" icon={<ShieldPlus className="h-4 w-4 text-anvil-400" aria-hidden />}>
{members.loading ? (
<LoadingBlock label="Reading members" />
Expand Down
33 changes: 33 additions & 0 deletions forge-web/components/storage/copy-block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client'

import { useState } from 'react'
import { Check, Copy } from 'lucide-react'

/** A multi-line copyable block (a CORS policy, a few shell lines). */
export function CopyBlock({ text, label }: { text: string; label: string }): JSX.Element {
const [copied, setCopied] = useState(false)
const copy = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1200)
} catch {
/* insecure context: the text is still selectable */
}
}
return (
<div className="relative">
<pre className="max-h-72 overflow-auto rounded-md border border-anvil-200 bg-white p-3 pr-10 font-mono text-[12px] leading-relaxed text-anvil-800 dark:border-anvil-800 dark:bg-anvil-950 dark:text-anvil-200">
{text}
</pre>
<button
type="button"
onClick={copy}
aria-label={label}
className="absolute right-2 top-2 rounded p-1 text-anvil-500 hover:bg-anvil-100 hover:text-anvil-800 dark:text-anvil-400 dark:hover:bg-anvil-800 dark:hover:text-anvil-100"
>
{copied ? <Check className="h-4 w-4 text-verify-700 dark:text-verify" aria-hidden /> : <Copy className="h-4 w-4" aria-hidden />}
</button>
</div>
)
}
56 changes: 56 additions & 0 deletions forge-web/components/storage/cost-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* The cost comparison card of the storage wizard (`ux-dx-spec.md` §3.1 step 5), always
* visible: what a repo costs with its packs in your bucket versus on Platform. Platform fees
* are the measured per-push manifest + ref costs; bucket prices are the providers' public
* list prices, rounded, and say so.
*/

import { CREDITS_PER_DASH, estimateChunkCredits } from '@/lib/sdk/cost'
import { dashToUsd, formatDash } from '@/lib/view/format'

const MIB = 1024 * 1024
/** Manifest + ref update per push, measured on moutai (≈ 0.0003 DASH). */
const PER_PUSH_DASH = 0.0003

export function CostCard(): JSX.Element {
const platform50 = estimateChunkCredits(50 * MIB) / CREDITS_PER_DASH
const perMib = estimateChunkCredits(MIB) / CREDITS_PER_DASH
const pushes = PER_PUSH_DASH * 10
const rows: [string, string, string, string][] = [
['Your R2 bucket', `manifest + refs ≈ ${formatDash(PER_PUSH_DASH)} DASH / push`, '≈ $0.001', `≈ ${formatDash(pushes)} DASH ≈ ${dashToUsd(pushes)} + $0.00`],
['Your B2 / S3 bucket', 'same', '≈ $0.0003 / $0.001', `≈ ${formatDash(pushes)} DASH + < $0.01`],
['Dash Platform', `${formatDash(perMib)} DASH / MiB, permanent`, '0', `≈ ${formatDash(platform50)} DASH ≈ ${dashToUsd(platform50)} first upload, then ${formatDash(perMib)} DASH per pushed MiB`],
]
return (
<section aria-labelledby="cost-card-title" className="rounded-lg border border-anvil-200 bg-white p-4 dark:border-anvil-750 dark:bg-anvil-900">
<h2 id="cost-card-title" className="mb-2 text-dense font-medium text-anvil-500 dark:text-anvil-400">
What it costs
</h2>
<div className="overflow-x-auto">
<table className="w-full min-w-[34rem] text-left text-[12px]">
<thead className="text-anvil-500 dark:text-anvil-400">
<tr>
<th scope="col" className="py-1 pr-3 font-medium">Where</th>
<th scope="col" className="py-1 pr-3 font-medium">One-time (Platform fees)</th>
<th scope="col" className="py-1 pr-3 font-medium">Monthly</th>
<th scope="col" className="py-1 font-medium">50 MiB repo, 10 pushes/month</th>
</tr>
</thead>
<tbody className="font-mono text-anvil-700 dark:text-anvil-200">
{rows.map(([where, once, monthly, example]) => (
<tr key={where} className="border-t border-anvil-100 dark:border-anvil-850">
<th scope="row" className="py-1.5 pr-3 font-sans font-medium">{where}</th>
<td className="py-1.5 pr-3">{once}</td>
<td className="py-1.5 pr-3">{monthly}</td>
<td className="py-1.5">{example}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-2 text-[11px] text-anvil-500 dark:text-anvil-400">
Platform figures are estimates from measured fees; bucket prices are the providers’ list prices, rounded. USD is indicative.
</p>
</section>
)
}
Loading
Loading