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
57 changes: 57 additions & 0 deletions src/__tests__/documentsVaultPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, jest } from "@jest/globals";

import type { VaultTrustView } from "@/lib/vault-trust-types";

type Props = { props: { view: VaultTrustView | null } };

const MODULE = "@/lib/documents/vault-preview";

/**
* The preview embeds the demo vault inside a wallet page, which puts a
* filesystem read and a hash graph on the request path of a treasury surface.
* Two things must hold: the props Next hands the client are actually
* serialisable, and a vault that fails to build hides the panel instead of
* taking the page down.
*/
describe("documents vault preview page", () => {
afterEach(() => {
jest.resetModules();
jest.restoreAllMocks();
});

it("supplies a view that survives Next's JSON serialisation", async () => {
const { loadVaultPreviewProps } = await import(MODULE);
const result = { props: loadVaultPreviewProps() } as Props;

expect(result.props.view).not.toBeNull();
expect(result.props.view!.hubs.length).toBeGreaterThan(0);
expect(result.props.view!.notes.length).toBeGreaterThan(0);
expect(result.props.view!.rootHash).toMatch(/^[0-9a-f]{64}$/);

// getServerSideProps props are JSON-serialised. A Map, Set, Date or
// undefined in there is a runtime "Error serializing" on the deployed page
// that no type check catches, so assert the round trip is lossless.
const roundTripped: unknown = JSON.parse(JSON.stringify(result.props.view));
expect(roundTripped).toEqual(result.props.view);
});

it("hides the panel instead of 500-ing when the vault will not build", async () => {
jest.resetModules();
jest.doMock("@/lib/vault-trust", () => ({
loadVaultTrustView: () => {
throw new Error("vault trust graph: Trust cycle: A -> B -> A");
},
}));
const errors = jest
.spyOn(console, "error")
.mockImplementation(() => undefined);

const { loadVaultPreviewProps } = await import(MODULE);
const result = { props: loadVaultPreviewProps() } as Props;

// Degraded, not thrown: a Markdown edit in an unrelated directory must not
// be able to take down a wallet's Documents section.
expect(result.props.view).toBeNull();
expect(errors).toHaveBeenCalled();
});
});
23 changes: 17 additions & 6 deletions src/components/pages/wallet/documents/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { FileSignature, Plus } from "lucide-react";
import { FileSignature, Plus, Shield } from "lucide-react";

import { api } from "@/utils/api";
import useAppWallet from "@/hooks/useAppWallet";
Expand Down Expand Up @@ -30,6 +30,12 @@ export default function PageDocuments() {
return (
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-4 p-3 sm:p-4 md:gap-6 lg:gap-8 lg:p-8">
<PageHeader pageTitle="Documents" backUrl={`/wallets/${walletId}`}>
<Button asChild size="sm" variant="outline">
<Link href={`/wallets/${walletId}/documents/preview`}>
<Shield className="mr-2 h-4 w-4" />
Shielded sign-off
</Link>
</Button>
<Button asChild size="sm">
<Link href={`/wallets/${walletId}/documents/new`}>
<Plus className="mr-2 h-4 w-4" />
Expand All @@ -39,9 +45,9 @@ export default function PageDocuments() {
</PageHeader>

<p className="max-w-3xl text-sm text-muted-foreground">
Approvals are bound to an exact version hash and inherit this wallet&apos;s
signers and threshold. Uploading a new version starts a fresh round at zero
approvals.
Approvals are bound to an exact version hash and inherit this
wallet&apos;s signers and threshold. Uploading a new version starts a
fresh round at zero approvals.
</p>

{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
Expand All @@ -53,7 +59,9 @@ export default function PageDocuments() {
description="Create a document to collect threshold sign-off from this wallet's signers."
action={
<Button asChild size="sm">
<Link href={`/wallets/${walletId}/documents/new`}>New document</Link>
<Link href={`/wallets/${walletId}/documents/new`}>
New document
</Link>
</Button>
}
/>
Expand Down Expand Up @@ -90,7 +98,10 @@ export default function PageDocuments() {
<>
v{latest.versionNumber}
{required !== undefined && (
<> · {approvals}/{required} approvals</>
<>
{" "}
· {approvals}/{required} approvals
</>
)}
</>
) : (
Expand Down
92 changes: 92 additions & 0 deletions src/components/pages/wallet/documents/vault-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { FileSignature, Shield } from "lucide-react";

import VaultBrowser from "@/components/pages/vault/browser";
import { EmptyState } from "@/components/common/empty-state";
import PageHeader from "@/components/ui/page-header";
import { Button } from "@/components/ui/button";
import type { VaultTrustView } from "@/lib/vault-trust-types";

/**
* Shielded sign-off, shown inside Documents as a working preview.
*
* The content is Mesh Multisig's OWN feature vault, not this wallet's documents,
* and the copy says so plainly — a demo that lets someone believe they are
* looking at their own records would be worse than no demo. What it does show
* honestly is the mechanism they would get: a vault of linked Markdown where
* every trust edge commits to a hash, so a single document can be proved to
* belong without revealing the ones beside it.
*
* This reuses `VaultBrowser` exactly as the public /vault page does — it takes
* one `view` prop and owns all its state — so there is one implementation of
* the browser, not a second one that drifts.
*/
export default function PageDocumentsVaultPreview({
view,
}: {
view: VaultTrustView | null;
}) {
const router = useRouter();
const walletId = router.query.wallet as string;

return (
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-4 p-3 sm:p-4 md:gap-6 lg:gap-8 lg:p-8">
<PageHeader
pageTitle="Shielded sign-off preview"
backUrl={`/wallets/${walletId}/documents`}
>
<Button asChild size="sm" variant="outline">
<Link href={`/wallets/${walletId}/documents`}>
<FileSignature className="mr-2 h-4 w-4" />
Your documents
</Link>
</Button>
</PageHeader>

<div className="max-w-3xl space-y-3 text-sm text-muted-foreground">
<p className="flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3">
<Shield className="mt-0.5 h-4 w-4 shrink-0" />
<span>
<strong className="font-medium text-foreground">
This is Mesh Multisig&apos;s own feature vault, not your
documents.
</strong>{" "}
It is real content with real hashes, shown so you can see how
shielded sign-off behaves before it holds anything of yours.
</span>
</p>
<p>
Today a document is signed on its own: one file, one hash, one round
of approvals. A vault adds the relationships between them. Every trust
edge commits to the hash of what it points at, so signing the root
binds the whole structure underneath it.
</p>
<p>
That is what makes selective disclosure possible. Pick any note and
the panel on the right shows the path a proof of it would reveal — and
the documents it would keep sealed, present only as hashes. A
counterparty can verify that one policy belongs to your governance
spine without learning what else is in it.
</p>
</div>

{view ? (
<VaultBrowser view={view} />
) : (
<EmptyState
icon={Shield}
title="Preview unavailable"
description="The demo vault could not be loaded. Your documents are unaffected — this panel is illustrative content only."
action={
<Button asChild size="sm" variant="outline">
<Link href={`/wallets/${walletId}/documents`}>
Back to documents
</Link>
</Button>
}
/>
)}
</main>
);
}
30 changes: 30 additions & 0 deletions src/lib/documents/vault-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { loadVaultTrustView } from "@/lib/vault-trust";
import type { VaultTrustView } from "@/lib/vault-trust-types";

/**
* Props for the shielded sign-off preview inside a wallet's Documents section.
*
* Server-only: `loadVaultTrustView` reads the filesystem, so this must never be
* imported from a client component.
*
* It lives here rather than inline in the page for the same reason every other
* page under `documents/` is a thin shell — and because the behaviour below is
* worth testing on its own, which importing the page cannot do: the page pulls
* the whole component tree, including ESM-only `react-markdown`.
*/
export function loadVaultPreviewProps(): { view: VaultTrustView | null } {
try {
return { view: loadVaultTrustView() };
} catch (error) {
// `loadVaultTrustView` throws when the vault's trust edges are not a DAG.
// That is right for a build-time check and wrong here: this panel is
// illustrative content on a treasury page, and an unrelated Markdown edit
// must not be able to turn Documents into a 500. A CI test asserts the
// vault IS a DAG, so this branch should stay unreachable — and if it ever
// is reached, hiding one panel beats taking the page down.
console.error("vault preview unavailable", {
message: (error as Error)?.message,
});
return { view: null };
}
}
19 changes: 19 additions & 0 deletions src/pages/wallets/[wallet]/documents/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { InferGetServerSidePropsType } from "next";

import PageDocumentsVaultPreview from "@/components/pages/wallet/documents/vault-preview";
import { loadVaultPreviewProps } from "@/lib/documents/vault-preview";

/**
* Shielded sign-off, previewed inside the wallet's Documents section.
*
* `getServerSideProps` rather than static, for the reason /vault and
* /roadmap/graph both use it: prerendering this SPA dies with "NextRouter was
* not mounted", which passes locally and breaks the deployed build.
*/
export const getServerSideProps = () => ({ props: loadVaultPreviewProps() });

export default function PageWalletDocumentsPreview({
view,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return <PageDocumentsVaultPreview view={view} />;
}
Loading