Compare commits
4 Commits
jazz-react
...
writer-onl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5db0c2d23 | ||
|
|
4eea7a56f2 | ||
|
|
575419cf2a | ||
|
|
dbd20bcb2b |
31
examples/quest-board/.gitignore
vendored
Normal file
31
examples/quest-board/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
sync-db/
|
||||
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
35
examples/quest-board/README.md
Normal file
35
examples/quest-board/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Quest board example with Jazz and React
|
||||
|
||||
## Installing & running the example locally
|
||||
|
||||
(This requires `pnpm` to be installed, see [https://pnpm.io/installation](https://pnpm.io/installation))
|
||||
|
||||
Start by downloading the [jazz repository](https://github.com/garden-co/jazz):
|
||||
```bash
|
||||
npx degit gardencmp/jazz jazz
|
||||
```
|
||||
|
||||
Go to the chat example directory:
|
||||
```bash
|
||||
cd jazz/examples/chat
|
||||
```
|
||||
|
||||
Install and build dependencies:
|
||||
```bash
|
||||
pnpm i && npx turbo build
|
||||
```
|
||||
|
||||
Start the dev server:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Questions / problems / feedback
|
||||
|
||||
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
|
||||
|
||||
## Configuration: sync server
|
||||
|
||||
By default, the example app uses [Jazz Cloud](https://jazz.tools/cloud) (`wss://cloud.jazz.tools`) - so cross-device use, invites and collaboration should just work.
|
||||
|
||||
You can also run a local sync server by running `npx jazz-run sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?peer=ws://localhost:4200`), or by setting the `sync` parameter of the `<Jazz.Provider>` provider component in [./src/main.tsx](./src/main.tsx).
|
||||
16
examples/quest-board/components.json
Normal file
16
examples/quest-board/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
14
examples/quest-board/index.html
Normal file
14
examples/quest-board/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/jazz-logo.png" />
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jazz Quest Board Example</title>
|
||||
</head>
|
||||
<body class="h-full">
|
||||
<div id="root" class="h-full"></div>
|
||||
<script type="module" src="/src/app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
examples/quest-board/package.json
Normal file
50
examples/quest-board/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "jazz-example-quest-board",
|
||||
"private": true,
|
||||
"version": "0.0.116",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"format-and-lint": "biome check .",
|
||||
"format-and-lint:fix": "biome check . --write",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cojson": "workspace:*",
|
||||
"hash-slash": "workspace:0.2.1",
|
||||
"jazz-browser-media-images": "^0.8.38",
|
||||
"jazz-react": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.274.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router": "^6.16.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"react-use": "^17.4.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uniqolor": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.46.1",
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"@types/react": "^18.2.19",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"is-ci": "^3.0.1",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "3.3.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
}
|
||||
}
|
||||
53
examples/quest-board/playwright.config.ts
Normal file
53
examples/quest-board/playwright.config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import isCI from "is-ci";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: isCI,
|
||||
/* Retry on CI only */
|
||||
retries: isCI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: isCI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://localhost:5173/",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: [
|
||||
{
|
||||
command: "pnpm preview --port 5173",
|
||||
url: "http://localhost:5173/",
|
||||
reuseExistingServer: !isCI,
|
||||
},
|
||||
],
|
||||
});
|
||||
6
examples/quest-board/postcss.config.js
Normal file
6
examples/quest-board/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
examples/quest-board/public/jazz-logo.png
Normal file
BIN
examples/quest-board/public/jazz-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
48
examples/quest-board/src/app.tsx
Normal file
48
examples/quest-board/src/app.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useIframeHashRouter } from "hash-slash";
|
||||
import { DemoAuthBasicUI, createJazzReactApp, useDemoAuth } from "jazz-react";
|
||||
import { ID } from "jazz-tools";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import GuildMasterPage from "./app/GuildMasterPage.tsx";
|
||||
import InvitePage from "./app/InvitePage.tsx";
|
||||
import ProposalPage from "./app/ProposalPage.tsx";
|
||||
import { QuestBoard, QuestBoardAccount } from "./schema.ts";
|
||||
|
||||
const Jazz = createJazzReactApp({
|
||||
AccountSchema: QuestBoardAccount,
|
||||
});
|
||||
export const { useAccount, useCoState, useAcceptInvite } = Jazz;
|
||||
|
||||
function App() {
|
||||
const [auth, state] = useDemoAuth();
|
||||
const router = useIframeHashRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Jazz.Provider
|
||||
auth={auth}
|
||||
// peer="wss://cloud.jazz.tools/?key=chat-example-jazz@garden.co"
|
||||
peer="ws://localhost:4200"
|
||||
>
|
||||
{router.route({
|
||||
"/": () => <GuildMasterPage />,
|
||||
"/invite/:id": () => (
|
||||
<InvitePage
|
||||
onAcceptInvite={(id) => router.navigate(`/#/board/${id}`)}
|
||||
/>
|
||||
),
|
||||
"/board/:id": (id) => <ProposalPage boardID={id as ID<QuestBoard>} />,
|
||||
})}
|
||||
</Jazz.Provider>
|
||||
{state.state !== "signedIn" && (
|
||||
<DemoAuthBasicUI appName="Jazz Quest Board" state={state} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
112
examples/quest-board/src/app/GuildMasterPage.tsx
Normal file
112
examples/quest-board/src/app/GuildMasterPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useAccount } from "@/app";
|
||||
import { PublicQuest, QuestProposal, QuestRating } from "@/schema";
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import { QuestCard } from "./components/quest-card";
|
||||
import { QuestProposalCard } from "./components/quest-proposal-card";
|
||||
|
||||
export default function GuildMasterPage() {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
publicQuests: [{}],
|
||||
proposals: [{}],
|
||||
},
|
||||
});
|
||||
const board = me?.root;
|
||||
const proposals = board?.proposals;
|
||||
const publicQuests = board?.publicQuests;
|
||||
const publicGroup = board?._owner;
|
||||
|
||||
const handleApprove = (proposal: QuestProposal) => {
|
||||
if (!publicGroup || !publicQuests) return;
|
||||
|
||||
const author = proposal._owner.profile;
|
||||
|
||||
const publicQuest = PublicQuest.create(
|
||||
{
|
||||
title: proposal.title,
|
||||
description: proposal.description,
|
||||
proposal,
|
||||
author,
|
||||
},
|
||||
{ owner: publicGroup },
|
||||
);
|
||||
|
||||
proposal.status = "accepted";
|
||||
proposal.publicQuest = publicQuest;
|
||||
|
||||
publicQuests.push(publicQuest);
|
||||
};
|
||||
|
||||
const handleReject = (proposal: QuestProposal) => {
|
||||
proposal.status = "rejected";
|
||||
};
|
||||
|
||||
const handleRate = (quest: PublicQuest, rating: QuestRating) => {
|
||||
quest.rating = rating;
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!proposals) return;
|
||||
|
||||
const invite = createInviteLink(proposals, "writeOnly");
|
||||
|
||||
const url = new URL(invite);
|
||||
url.searchParams.set("board", board.id);
|
||||
|
||||
await proposals._owner.waitForSync();
|
||||
|
||||
navigator.clipboard.writeText(url.toString());
|
||||
alert(`Invite link copied to clipboard`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[url('/parchment-background.jpg')] bg-cover bg-center">
|
||||
<main className="container mx-auto p-8">
|
||||
<h1 className="text-4xl font-bold mb-8 text-center text-amber-900 font-serif">
|
||||
Guild Master's Quarters
|
||||
</h1>
|
||||
<div className="mb-8 text-center">
|
||||
<button
|
||||
className="bg-amber-700 text-amber-100 px-6 py-3 rounded-full hover:bg-amber-800 transition-colors font-semibold text-lg shadow-lg"
|
||||
onClick={handleShare}
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-4 text-amber-900 font-serif">
|
||||
Available Quests
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{publicQuests?.map((quest) => (
|
||||
<QuestCard
|
||||
key={quest.id}
|
||||
quest={quest}
|
||||
isGuildMaster={true}
|
||||
onRate={handleRate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-4 text-amber-900 font-serif">
|
||||
Quest Proposals Awaiting Review
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{proposals?.map((proposal) =>
|
||||
proposal.status === "pending" ? (
|
||||
<QuestProposalCard
|
||||
key={proposal.id}
|
||||
proposal={proposal}
|
||||
isGuildMaster={true}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
examples/quest-board/src/app/InvitePage.tsx
Normal file
19
examples/quest-board/src/app/InvitePage.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useAcceptInvite } from "@/app";
|
||||
import { QuestBoard, QuestProposals } from "@/schema";
|
||||
import { ID } from "jazz-tools";
|
||||
|
||||
export default function InvitePage({
|
||||
onAcceptInvite,
|
||||
}: { onAcceptInvite: (boardId: ID<QuestBoard>) => void }) {
|
||||
useAcceptInvite({
|
||||
invitedObjectSchema: QuestProposals,
|
||||
onAccept: () => {
|
||||
const boardId = new URL(window.location.href).searchParams.get("board");
|
||||
if (!boardId) return;
|
||||
|
||||
onAcceptInvite(boardId as ID<QuestBoard>);
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
88
examples/quest-board/src/app/ProposalPage.tsx
Normal file
88
examples/quest-board/src/app/ProposalPage.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCoState } from "@/app";
|
||||
import { useQuestProposalDraft } from "@/lib/useQuestProposalDraft";
|
||||
import { QuestBoard, QuestProposal } from "@/schema";
|
||||
import { ID } from "jazz-tools";
|
||||
import { QuestCard } from "./components/quest-card";
|
||||
import { QuestProposalCard } from "./components/quest-proposal-card";
|
||||
import { QuestProposalForm } from "./components/quest-proposal-form";
|
||||
|
||||
export default function ProposalPage({ boardID }: { boardID: ID<QuestBoard> }) {
|
||||
const board = useCoState(QuestBoard, boardID, {
|
||||
proposals: [{}],
|
||||
publicQuests: [{}],
|
||||
});
|
||||
const { draftProposal, clearDraftProposal } = useQuestProposalDraft();
|
||||
|
||||
const handleSubmitProposal = () => {
|
||||
if (!board || !draftProposal) return;
|
||||
if (!draftProposal.title || !draftProposal.description) return;
|
||||
|
||||
const ownership = { owner: board.proposals._owner };
|
||||
|
||||
const proposal = QuestProposal.create(
|
||||
{
|
||||
title: draftProposal.title,
|
||||
description: draftProposal.description,
|
||||
status: "pending",
|
||||
},
|
||||
ownership,
|
||||
);
|
||||
|
||||
clearDraftProposal();
|
||||
|
||||
board.proposals.push(proposal);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[url('/parchment-background.jpg')] bg-cover bg-center">
|
||||
<main className="container mx-auto p-8">
|
||||
<h1 className="text-4xl font-bold mb-8 text-center text-amber-900 font-serif">
|
||||
Public Fantasy Quest Board
|
||||
</h1>
|
||||
<div className="mb-8 text-center">
|
||||
<a
|
||||
href="/"
|
||||
className="bg-amber-700 text-amber-100 px-6 py-3 rounded-full hover:bg-amber-800 transition-colors font-semibold text-lg shadow-lg"
|
||||
>
|
||||
Go back to your board
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-4 text-amber-900 font-serif">
|
||||
Public board
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{board?.publicQuests.map((quest) => (
|
||||
<QuestCard key={quest.id} quest={quest} isGuildMaster={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-8 bg-amber-50 p-6 rounded-lg shadow-md">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-amber-900 font-serif">
|
||||
Submit a Quest Proposal
|
||||
</h2>
|
||||
<QuestProposalForm
|
||||
onSubmit={handleSubmitProposal}
|
||||
draft={draftProposal}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-4 text-amber-900 font-serif">
|
||||
Your Proposals
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{board?.proposals.map((proposal) =>
|
||||
proposal && proposal.status !== "accepted" ? (
|
||||
<QuestProposalCard
|
||||
key={proposal.id}
|
||||
proposal={proposal}
|
||||
isGuildMaster={false}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
examples/quest-board/src/app/components/button.tsx
Normal file
53
examples/quest-board/src/app/components/button.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-amber-700 text-amber-100 hover:bg-amber-800",
|
||||
destructive: "bg-red-700 text-red-100 hover:bg-red-800",
|
||||
outline:
|
||||
"border border-amber-700 bg-transparent hover:bg-amber-100 text-amber-900",
|
||||
secondary: "bg-amber-200 text-amber-900 hover:bg-amber-300",
|
||||
ghost: "hover:bg-amber-100 text-amber-900",
|
||||
link: "text-amber-900 underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
55
examples/quest-board/src/app/components/quest-card.tsx
Normal file
55
examples/quest-board/src/app/components/quest-card.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Button } from "@/app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/app/components/ui/card";
|
||||
import { PublicQuest, QuestRating } from "@/schema";
|
||||
|
||||
interface QuestCardProps {
|
||||
quest: PublicQuest;
|
||||
isGuildMaster: boolean;
|
||||
onRate?: (quest: PublicQuest, rating: QuestRating) => void;
|
||||
}
|
||||
|
||||
const RATINGS = ["C", "B", "A", "S", "SS"] as const;
|
||||
|
||||
export function QuestCard({ quest, isGuildMaster, onRate }: QuestCardProps) {
|
||||
return (
|
||||
<Card className="w-full max-w-md bg-amber-50 border-2 border-amber-700 shadow-lg">
|
||||
<CardHeader className="bg-amber-100 border-b-2 border-amber-700">
|
||||
<CardTitle className="text-amber-900 font-serif">
|
||||
{quest.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<p className="text-sm text-amber-800 mb-4 font-serif">
|
||||
{quest.description}
|
||||
</p>
|
||||
{quest.rating && (
|
||||
<p className="text-sm font-semibold text-amber-900 font-serif">
|
||||
Rating: {quest.rating}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
{isGuildMaster && !quest.rating && (
|
||||
<CardFooter>
|
||||
<div className="flex space-x-2">
|
||||
{RATINGS.map((rating) => (
|
||||
<Button
|
||||
key={rating}
|
||||
onClick={() => onRate?.(quest, rating)}
|
||||
variant="outline"
|
||||
className="border-amber-700 text-amber-900 hover:bg-amber-200"
|
||||
>
|
||||
{rating}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Badge } from "@/app/components/ui/badge";
|
||||
import { Button } from "@/app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/app/components/ui/card";
|
||||
import { QuestProposal } from "@/schema";
|
||||
|
||||
interface QuestProposalCardProps {
|
||||
proposal: QuestProposal;
|
||||
isGuildMaster: boolean;
|
||||
onApprove?: (proposal: QuestProposal) => void;
|
||||
onReject?: (proposal: QuestProposal) => void;
|
||||
}
|
||||
|
||||
export function QuestProposalCard({
|
||||
proposal,
|
||||
isGuildMaster,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: QuestProposalCardProps) {
|
||||
return (
|
||||
<Card className="w-full max-w-md bg-amber-50 border-2 border-amber-700 shadow-lg">
|
||||
<CardHeader className="bg-amber-100 border-b-2 border-amber-700">
|
||||
<CardTitle className="text-amber-900 font-serif">
|
||||
{proposal.title}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant={
|
||||
proposal.status === "accepted"
|
||||
? "default"
|
||||
: proposal.status === "rejected"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className="font-serif"
|
||||
>
|
||||
{proposal.status.charAt(0).toUpperCase() + proposal.status.slice(1)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<p className="text-sm text-amber-800 mb-4 font-serif">
|
||||
{proposal.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
{isGuildMaster && proposal.status === "pending" && (
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
onClick={() => onApprove?.(proposal)}
|
||||
variant="outline"
|
||||
className="border-amber-700 text-amber-900 hover:bg-amber-200"
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onReject?.(proposal)}
|
||||
variant="outline"
|
||||
className="border-amber-700 text-amber-900 hover:bg-amber-200"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Button } from "@/app/components/ui/button";
|
||||
import { Input } from "@/app/components/ui/input";
|
||||
import { Textarea } from "@/app/components/ui/textarea";
|
||||
import { QuestProposalDraft } from "@/schema";
|
||||
|
||||
export function QuestProposalForm({
|
||||
onSubmit,
|
||||
draft,
|
||||
}: { onSubmit: () => void; draft: QuestProposalDraft | undefined }) {
|
||||
if (!draft) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
draft.title = e.target.value;
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (
|
||||
e: React.ChangeEvent<HTMLTextAreaElement>,
|
||||
) => {
|
||||
draft.description = e.target.value;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Quest Title"
|
||||
aria-label="Quest Title"
|
||||
value={draft.title ?? ""}
|
||||
onChange={handleTitleChange}
|
||||
required
|
||||
className="bg-amber-100 border-amber-300 text-amber-900 placeholder-amber-500"
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="Quest Description"
|
||||
aria-label="Quest Description"
|
||||
value={draft.description ?? ""}
|
||||
onChange={handleDescriptionChange}
|
||||
required
|
||||
className="bg-amber-100 border-amber-300 text-amber-900 placeholder-amber-500"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-amber-700 hover:bg-amber-800 text-amber-100"
|
||||
>
|
||||
Submit Quest Proposal
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
36
examples/quest-board/src/app/components/ui/badge.tsx
Normal file
36
examples/quest-board/src/app/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
56
examples/quest-board/src/app/components/ui/button.tsx
Normal file
56
examples/quest-board/src/app/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
86
examples/quest-board/src/app/components/ui/card.tsx
Normal file
86
examples/quest-board/src/app/components/ui/card.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
22
examples/quest-board/src/app/components/ui/input.tsx
Normal file
22
examples/quest-board/src/app/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
22
examples/quest-board/src/app/components/ui/textarea.tsx
Normal file
22
examples/quest-board/src/app/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
78
examples/quest-board/src/index.css
Normal file
78
examples/quest-board/src/index.css
Normal file
@@ -0,0 +1,78 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
|
||||
--primary: 24 9.8% 10%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--primary: 60 9.1% 97.8%;
|
||||
--primary-foreground: 24 9.8% 10%;
|
||||
|
||||
--secondary: 12 6.5% 15.1%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--ring: 24 5.7% 82.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
31
examples/quest-board/src/lib/useQuestProposalDraft.ts
Normal file
31
examples/quest-board/src/lib/useQuestProposalDraft.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useAccount } from "@/app";
|
||||
import { QuestProposalDraft } from "@/schema";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useQuestProposalDraft() {
|
||||
const { me } = useAccount({
|
||||
root: {
|
||||
draftProposal: {},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!me?.root) return;
|
||||
if (me.root.draftProposal) return;
|
||||
|
||||
me.root.draftProposal = QuestProposalDraft.create({}, { owner: me });
|
||||
}, [me?.root]);
|
||||
|
||||
function clearDraftProposal() {
|
||||
if (!me?.root) return;
|
||||
if (!me.root.draftProposal) return;
|
||||
|
||||
// @ts-expect-error We should accept null values and reject undefined values
|
||||
me.root.draftProposal = null;
|
||||
}
|
||||
|
||||
return {
|
||||
draftProposal: me?.root.draftProposal,
|
||||
clearDraftProposal,
|
||||
};
|
||||
}
|
||||
6
examples/quest-board/src/lib/utils.ts
Normal file
6
examples/quest-board/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
56
examples/quest-board/src/schema.ts
Normal file
56
examples/quest-board/src/schema.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Account, CoList, CoMap, Group, Profile, co } from "jazz-tools";
|
||||
|
||||
export class QuestProposalDraft extends CoMap {
|
||||
title = co.optional.string;
|
||||
description = co.optional.string;
|
||||
}
|
||||
|
||||
export class QuestProposal extends CoMap {
|
||||
title = co.string;
|
||||
description = co.string;
|
||||
status = co.json<"rejected" | "accepted" | "pending">();
|
||||
publicQuest = co.optional.ref(PublicQuest);
|
||||
}
|
||||
|
||||
export type QuestRating = "C" | "B" | "A" | "S" | "SS";
|
||||
|
||||
export class PublicQuest extends CoMap {
|
||||
title = co.string;
|
||||
description = co.string;
|
||||
proposal = co.ref(QuestProposal);
|
||||
rating = co.optional.json<QuestRating>();
|
||||
author = co.optional.ref(Profile);
|
||||
}
|
||||
|
||||
export class QuestProposals extends CoList.Of(co.ref(QuestProposal)) {}
|
||||
export class PublicQuests extends CoList.Of(co.ref(PublicQuest)) {}
|
||||
|
||||
export class QuestBoard extends CoMap {
|
||||
proposals = co.ref(QuestProposals);
|
||||
publicQuests = co.ref(PublicQuests);
|
||||
draftProposal = co.optional.ref(QuestProposalDraft);
|
||||
}
|
||||
|
||||
export class QuestBoardAccount extends Account {
|
||||
profile = co.ref(Profile);
|
||||
root = co.ref(QuestBoard);
|
||||
|
||||
async migrate(creationProps?: { name: string }) {
|
||||
super.migrate(creationProps);
|
||||
|
||||
if (!this._refs.root) {
|
||||
const publicOwnership = { owner: Group.create({ owner: this }) };
|
||||
const proposalsOwnership = { owner: Group.create({ owner: this }) };
|
||||
|
||||
publicOwnership.owner.addMember("everyone", "reader");
|
||||
|
||||
this.root = QuestBoard.create(
|
||||
{
|
||||
proposals: QuestProposals.create([], proposalsOwnership),
|
||||
publicQuests: PublicQuests.create([], publicOwnership),
|
||||
},
|
||||
publicOwnership,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
examples/quest-board/src/vite-env.d.ts
vendored
Normal file
1
examples/quest-board/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
82
examples/quest-board/tailwind.config.js
Normal file
82
examples/quest-board/tailwind.config.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./pages/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
blue: {
|
||||
700: "#3313F7",
|
||||
DEFAULT: "#3313F7",
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
lineClamp: {
|
||||
10: "10",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
64
examples/quest-board/tests/main.spec.ts
Normal file
64
examples/quest-board/tests/main.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Browser, expect, test } from "@playwright/test";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
|
||||
async function createIsolatedPage(browser: Browser) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
test("interaction between three users", async ({ browser, page }) => {
|
||||
const users = {
|
||||
guildMaster: page,
|
||||
player1: await createIsolatedPage(browser),
|
||||
player2: await createIsolatedPage(browser),
|
||||
};
|
||||
|
||||
for (const [name, page] of Object.entries(users)) {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.goto();
|
||||
await loginPage.fillUsername(name);
|
||||
await loginPage.signup();
|
||||
}
|
||||
|
||||
await users.guildMaster.getByRole("button", { name: "Share" }).click();
|
||||
|
||||
// Wait for the share to be completed
|
||||
await users.guildMaster.waitForFunction(() => {
|
||||
return Boolean(navigator.clipboard.readText());
|
||||
});
|
||||
|
||||
const inviteLink = await users.guildMaster.evaluate(() => {
|
||||
return navigator.clipboard.readText();
|
||||
});
|
||||
|
||||
// Invite users to the board
|
||||
await users.player1.goto(inviteLink);
|
||||
await users.player2.goto(inviteLink);
|
||||
|
||||
// Player 1 submits a quest
|
||||
await users.player1.getByLabel("Quest Title").fill("Quest From Player 1");
|
||||
await users.player1
|
||||
.getByLabel("Quest Description")
|
||||
.fill("Quest Description From Player 1");
|
||||
await users.player1
|
||||
.getByRole("button", { name: "Submit Quest Proposal" })
|
||||
.click();
|
||||
|
||||
// The guild master can see the quest proposal
|
||||
expect(users.guildMaster.getByText("Quest From Player 1")).toBeVisible();
|
||||
|
||||
// The player their own quest proposal
|
||||
expect(users.player1.getByText("Quest From Player 1")).toBeVisible();
|
||||
|
||||
// The player cannot see the other player's quest proposal
|
||||
expect(users.player2.getByText("Quest From Player 1")).not.toBeVisible();
|
||||
|
||||
await users.guildMaster.getByRole("button", { name: "Approve" }).click();
|
||||
|
||||
// Everyone can see the approved quests
|
||||
expect(users.guildMaster.getByText("Quest From Player 1")).toBeVisible();
|
||||
expect(users.player2.getByText("Quest From Player 1")).toBeVisible();
|
||||
});
|
||||
40
examples/quest-board/tests/pages/LoginPage.ts
Normal file
40
examples/quest-board/tests/pages/LoginPage.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Locator, Page, expect } from "@playwright/test";
|
||||
|
||||
export class LoginPage {
|
||||
readonly page: Page;
|
||||
readonly usernameInput: Locator;
|
||||
readonly signupButton: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.usernameInput = page.getByRole("textbox");
|
||||
this.signupButton = page.getByRole("button", {
|
||||
name: "Sign up",
|
||||
});
|
||||
}
|
||||
|
||||
async goto() {
|
||||
this.page.goto("/");
|
||||
}
|
||||
|
||||
async fillUsername(value: string) {
|
||||
await this.usernameInput.clear();
|
||||
await this.usernameInput.fill(value);
|
||||
}
|
||||
|
||||
async loginAs(value: string) {
|
||||
await this.page
|
||||
.getByRole("button", {
|
||||
name: `Log in as "${value}"`,
|
||||
})
|
||||
.click();
|
||||
}
|
||||
|
||||
async signup() {
|
||||
await this.signupButton.click();
|
||||
}
|
||||
|
||||
async expectLoaded() {
|
||||
await expect(this.signupButton).toBeVisible();
|
||||
}
|
||||
}
|
||||
29
examples/quest-board/tsconfig.json
Normal file
29
examples/quest-board/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
examples/quest-board/tsconfig.node.json
Normal file
10
examples/quest-board/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
examples/quest-board/vite.config.ts
Normal file
16
examples/quest-board/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import path from "path";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: false,
|
||||
},
|
||||
});
|
||||
@@ -684,7 +684,7 @@ export class CoValueCore {
|
||||
if (this.header.ruleset.type === "group") {
|
||||
const content = expectGroup(this.getCurrentContent());
|
||||
|
||||
const currentKeyId = content.get("readKey");
|
||||
const currentKeyId = content.getCurrentKeyReadId();
|
||||
|
||||
if (!currentKeyId) {
|
||||
throw new Error("No readKey set");
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
isParentGroupReference,
|
||||
} from "../ids.js";
|
||||
import { JsonObject } from "../jsonValue.js";
|
||||
import { Role } from "../permissions.js";
|
||||
import { AccountRole, Role } from "../permissions.js";
|
||||
import { expectGroup } from "../typeUtils/expectGroup.js";
|
||||
import {
|
||||
ControlledAccountOrAgent,
|
||||
@@ -33,6 +33,7 @@ export type GroupShape = {
|
||||
[key: RawAccountID | AgentID]: Role;
|
||||
[EVERYONE]?: Role;
|
||||
readKey?: KeyID;
|
||||
[writeKeyFor: `writeKeyFor_${RawAccountID | AgentID}`]: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${RawAccountID | AgentID}`]: Sealed<KeySecret>;
|
||||
[revelationFor: `${KeyID}_for_${Everyone}`]: KeySecret;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
@@ -213,18 +214,19 @@ export class RawGroup<
|
||||
account: RawAccount | ControlledAccountOrAgent | AgentID | Everyone,
|
||||
role: Role,
|
||||
) {
|
||||
const currentReadKey = this.core.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
}
|
||||
|
||||
if (account === EVERYONE) {
|
||||
if (!(role === "reader" || role === "writer")) {
|
||||
throw new Error(
|
||||
"Can't make everyone something other than reader or writer",
|
||||
);
|
||||
}
|
||||
|
||||
const currentReadKey = this.core.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
}
|
||||
|
||||
this.set(account, role, "trusting");
|
||||
|
||||
if (this.get(account) !== role) {
|
||||
@@ -236,44 +238,168 @@ export class RawGroup<
|
||||
currentReadKey.secret,
|
||||
"trusting",
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const memberKey = typeof account === "string" ? account : account.id;
|
||||
const agent =
|
||||
typeof account === "string"
|
||||
? account
|
||||
: account.currentAgentID()._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
/**
|
||||
* WriteOnly members can only see their own changes.
|
||||
*
|
||||
* We don't want to reveal the readKey to them so we create a new one and reveal it
|
||||
* to everyone else.
|
||||
*
|
||||
* To never reveal the readKey to writeOnly members we also create a dedicated writeKey for the
|
||||
* invite.
|
||||
*/
|
||||
if (role === "writeOnly" || role === "writeOnlyInvite") {
|
||||
const writeKeyForNewMember = this.core.crypto.newRandomKeySecret();
|
||||
|
||||
this.set(memberKey, role, "trusting");
|
||||
this.set(`writeKeyFor_${memberKey}`, writeKeyForNewMember.id, "trusting");
|
||||
|
||||
this.storeKeyRevelationForMember(
|
||||
memberKey,
|
||||
agent,
|
||||
writeKeyForNewMember.id,
|
||||
writeKeyForNewMember.secret,
|
||||
);
|
||||
|
||||
for (const otherMemberKey of this.getMemberKeys()) {
|
||||
const memberRole = this.get(otherMemberKey);
|
||||
|
||||
if (
|
||||
memberRole === "reader" ||
|
||||
memberRole === "writer" ||
|
||||
memberRole === "admin" ||
|
||||
memberRole === "readerInvite" ||
|
||||
memberRole === "writerInvite" ||
|
||||
memberRole === "adminInvite"
|
||||
) {
|
||||
const otherMemberAgent = this.core.node
|
||||
.resolveAccountAgent(
|
||||
otherMemberKey,
|
||||
"Expected member agent to be loaded",
|
||||
)
|
||||
._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
this.storeKeyRevelationForMember(
|
||||
otherMemberKey,
|
||||
otherMemberAgent,
|
||||
writeKeyForNewMember.id,
|
||||
writeKeyForNewMember.secret,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const memberKey = typeof account === "string" ? account : account.id;
|
||||
const agent =
|
||||
typeof account === "string"
|
||||
? account
|
||||
: account.currentAgentID()._unsafeUnwrap({ withStackTrace: true });
|
||||
const currentReadKey = this.core.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
}
|
||||
|
||||
this.set(memberKey, role, "trusting");
|
||||
|
||||
if (this.get(memberKey) !== role) {
|
||||
throw new Error("Failed to set role");
|
||||
}
|
||||
|
||||
this.set(
|
||||
`${currentReadKey.id}_for_${memberKey}`,
|
||||
this.core.crypto.seal({
|
||||
message: currentReadKey.secret,
|
||||
from: this.core.node.account.currentSealerSecret(),
|
||||
to: this.core.crypto.getAgentSealerID(agent),
|
||||
nOnceMaterial: {
|
||||
in: this.id,
|
||||
tx: this.core.nextTransactionID(),
|
||||
},
|
||||
}),
|
||||
"trusting",
|
||||
this.storeKeyRevelationForMember(
|
||||
memberKey,
|
||||
agent,
|
||||
currentReadKey.id,
|
||||
currentReadKey.secret,
|
||||
);
|
||||
|
||||
for (const keyID of this.getWriteOnlyKeys()) {
|
||||
const secret = this.core.getReadKey(keyID);
|
||||
|
||||
if (!secret) {
|
||||
console.error("Can't find key", keyID);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.storeKeyRevelationForMember(memberKey, agent, keyID, secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private storeKeyRevelationForMember(
|
||||
memberKey: RawAccountID | AgentID,
|
||||
agent: AgentID,
|
||||
keyID: KeyID,
|
||||
secret: KeySecret,
|
||||
) {
|
||||
this.set(
|
||||
`${keyID}_for_${memberKey}`,
|
||||
this.core.crypto.seal({
|
||||
message: secret,
|
||||
from: this.core.node.account.currentSealerSecret(),
|
||||
to: this.core.crypto.getAgentSealerID(agent),
|
||||
nOnceMaterial: {
|
||||
in: this.id,
|
||||
tx: this.core.nextTransactionID(),
|
||||
},
|
||||
}),
|
||||
"trusting",
|
||||
);
|
||||
}
|
||||
|
||||
private getWriteOnlyKeys() {
|
||||
const keys: KeyID[] = [];
|
||||
|
||||
for (const key of this.keys()) {
|
||||
if (key.startsWith("writeKeyFor_")) {
|
||||
keys.push(
|
||||
this.get(key as `writeKeyFor_${RawAccountID | AgentID}`) as KeyID,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
getCurrentKeyReadId() {
|
||||
if (this.myRole() === "writeOnly") {
|
||||
const accountId = this.core.node.account.id;
|
||||
|
||||
return this.get(`writeKeyFor_${accountId}`) as KeyID;
|
||||
}
|
||||
|
||||
return this.get("readKey");
|
||||
}
|
||||
|
||||
getMemberKeys(): (RawAccountID | AgentID)[] {
|
||||
return this.keys().filter((key): key is RawAccountID | AgentID => {
|
||||
return key.startsWith("co_") || isAgentID(key);
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
rotateReadKey() {
|
||||
const currentlyPermittedReaders = this.keys().filter((key) => {
|
||||
if (key.startsWith("co_") || isAgentID(key)) {
|
||||
const role = this.get(key);
|
||||
return role === "admin" || role === "writer" || role === "reader";
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as (RawAccountID | AgentID)[];
|
||||
const memberKeys = this.getMemberKeys();
|
||||
|
||||
const currentlyPermittedReaders = memberKeys.filter((key) => {
|
||||
const role = this.get(key);
|
||||
return (
|
||||
role === "admin" ||
|
||||
role === "writer" ||
|
||||
role === "reader" ||
|
||||
role === "adminInvite" ||
|
||||
role === "writerInvite" ||
|
||||
role === "readerInvite"
|
||||
);
|
||||
});
|
||||
|
||||
const writeOnlyMembers = memberKeys.filter((key) => {
|
||||
const role = this.get(key);
|
||||
return role === "writeOnly" || role === "writeOnlyInvite";
|
||||
});
|
||||
|
||||
// Get these early, so we fail fast if they are unavailable
|
||||
const parentGroups = this.getParentGroups();
|
||||
@@ -293,28 +419,54 @@ export class RawGroup<
|
||||
const newReadKey = this.core.crypto.newRandomKeySecret();
|
||||
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const reader = this.core.node
|
||||
const agent = this.core.node
|
||||
.resolveAccountAgent(
|
||||
readerID,
|
||||
"Expected to know currently permitted reader",
|
||||
)
|
||||
._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
this.set(
|
||||
`${newReadKey.id}_for_${readerID}`,
|
||||
this.core.crypto.seal({
|
||||
message: newReadKey.secret,
|
||||
from: this.core.node.account.currentSealerSecret(),
|
||||
to: this.core.crypto.getAgentSealerID(reader),
|
||||
nOnceMaterial: {
|
||||
in: this.id,
|
||||
tx: this.core.nextTransactionID(),
|
||||
},
|
||||
}),
|
||||
"trusting",
|
||||
this.storeKeyRevelationForMember(
|
||||
readerID,
|
||||
agent,
|
||||
newReadKey.id,
|
||||
newReadKey.secret,
|
||||
);
|
||||
}
|
||||
|
||||
for (const writeOnlyMemberID of writeOnlyMembers) {
|
||||
const agent = this.core.node
|
||||
.resolveAccountAgent(
|
||||
writeOnlyMemberID,
|
||||
"Expected to know writeOnly member",
|
||||
)
|
||||
._unsafeUnwrap({ withStackTrace: true });
|
||||
const writeOnlyKey = this.core.crypto.newRandomKeySecret();
|
||||
this.storeKeyRevelationForMember(
|
||||
writeOnlyMemberID,
|
||||
agent,
|
||||
writeOnlyKey.id,
|
||||
writeOnlyKey.secret,
|
||||
);
|
||||
this.set(`writeKeyFor_${writeOnlyMemberID}`, writeOnlyKey.id, "trusting");
|
||||
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const agent = this.core.node
|
||||
.resolveAccountAgent(
|
||||
readerID,
|
||||
"Expected to know currently permitted reader",
|
||||
)
|
||||
._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
this.storeKeyRevelationForMember(
|
||||
readerID,
|
||||
agent,
|
||||
writeOnlyKey.id,
|
||||
writeOnlyKey.secret,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.set(
|
||||
`${currentReadKey.id}_for_${newReadKey.id}`,
|
||||
this.core.crypto.encryptKeySecret({
|
||||
@@ -426,7 +578,7 @@ export class RawGroup<
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
|
||||
createInvite(role: AccountRole): InviteSecret {
|
||||
const secretSeed = this.core.crypto.newRandomSecretSeed();
|
||||
|
||||
const inviteSecret = this.core.crypto.agentSecretFromSecretSeed(secretSeed);
|
||||
@@ -562,7 +714,7 @@ function isMorePermissiveAndShouldInherit(
|
||||
}
|
||||
|
||||
if (roleInParent === "reader") {
|
||||
return !roleInChild;
|
||||
return !roleInChild || roleInChild === "writeOnly";
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -387,7 +387,8 @@ export class LocalNode {
|
||||
existingRole === "admin" ||
|
||||
(existingRole === "writer" && inviteRole === "writerInvite") ||
|
||||
(existingRole === "writer" && inviteRole === "reader") ||
|
||||
(existingRole === "reader" && inviteRole === "readerInvite")
|
||||
(existingRole === "reader" && inviteRole === "readerInvite") ||
|
||||
(existingRole && inviteRole === "writeOnlyInvite")
|
||||
) {
|
||||
console.debug(
|
||||
"Not accepting invite that would replace or downgrade role",
|
||||
@@ -410,7 +411,9 @@ export class LocalNode {
|
||||
? "admin"
|
||||
: inviteRole === "writerInvite"
|
||||
? "writer"
|
||||
: "reader",
|
||||
: inviteRole === "writeOnlyInvite"
|
||||
? "writeOnly"
|
||||
: "reader",
|
||||
);
|
||||
|
||||
group.core._sessionLogs = groupAsInvite.core.sessionLogs;
|
||||
|
||||
@@ -22,14 +22,15 @@ export type PermissionsDef =
|
||||
| { type: "ownedByGroup"; group: RawCoID }
|
||||
| { type: "unsafeAllowAll" };
|
||||
|
||||
export type AccountRole = "reader" | "writer" | "admin" | "writeOnly";
|
||||
|
||||
export type Role =
|
||||
| "reader"
|
||||
| "writer"
|
||||
| "admin"
|
||||
| AccountRole
|
||||
| "revoked"
|
||||
| "adminInvite"
|
||||
| "writerInvite"
|
||||
| "readerInvite";
|
||||
| "readerInvite"
|
||||
| "writeOnlyInvite";
|
||||
|
||||
type ValidTransactionsResult = { txID: TransactionID; tx: Transaction };
|
||||
type MemberState = { [agent: RawAccountID | AgentID]: Role; [EVERYONE]?: Role };
|
||||
@@ -81,7 +82,8 @@ export function determineValidTransactions(
|
||||
|
||||
if (
|
||||
transactorRoleAtTxTime !== "admin" &&
|
||||
transactorRoleAtTxTime !== "writer"
|
||||
transactorRoleAtTxTime !== "writer" &&
|
||||
transactorRoleAtTxTime !== "writeOnly"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -177,6 +179,9 @@ function determineValidTransactionsForGroup(
|
||||
const memberState: MemberState = {};
|
||||
const validTransactions: ValidTransactionsResult[] = [];
|
||||
|
||||
const keyRevelations = new Set<string>();
|
||||
const writeKeys = new Set<string>();
|
||||
|
||||
for (const { sessionID, txIndex, tx } of allTransactionsSorted) {
|
||||
// console.log("before", { memberState, validTransactions });
|
||||
const transactor = accountOrAgentIDfromSessionID(sessionID);
|
||||
@@ -254,14 +259,26 @@ function determineValidTransactionsForGroup(
|
||||
memberState[transactor] !== "admin" &&
|
||||
memberState[transactor] !== "adminInvite" &&
|
||||
memberState[transactor] !== "writerInvite" &&
|
||||
memberState[transactor] !== "readerInvite"
|
||||
memberState[transactor] !== "readerInvite" &&
|
||||
memberState[transactor] !== "writeOnlyInvite"
|
||||
) {
|
||||
console.warn("Only admins can reveal keys");
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: check validity of agents who the key is revealed to?
|
||||
if (
|
||||
keyRevelations.has(change.key) &&
|
||||
memberState[transactor] !== "admin"
|
||||
) {
|
||||
console.warn(
|
||||
"Key revelation already exists and can't be overridden by invite",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
keyRevelations.add(change.key);
|
||||
|
||||
// TODO: check validity of agents who the key is revealed to?
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (isParentExtension(change.key)) {
|
||||
@@ -277,6 +294,26 @@ function determineValidTransactionsForGroup(
|
||||
console.warn("Only admins can set child extensions");
|
||||
continue;
|
||||
}
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (isWriteKeyForMember(change.key)) {
|
||||
if (
|
||||
memberState[transactor] !== "admin" &&
|
||||
memberState[transactor] !== "writeOnlyInvite"
|
||||
) {
|
||||
console.warn("Only admins can set writeKeys");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (writeKeys.has(change.key) && memberState[transactor] !== "admin") {
|
||||
console.warn(
|
||||
"Write key already exists and can't be overridden by invite",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
writeKeys.add(change.key);
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
}
|
||||
@@ -288,10 +325,12 @@ function determineValidTransactionsForGroup(
|
||||
change.value !== "admin" &&
|
||||
change.value !== "writer" &&
|
||||
change.value !== "reader" &&
|
||||
change.value !== "writeOnly" &&
|
||||
change.value !== "revoked" &&
|
||||
change.value !== "adminInvite" &&
|
||||
change.value !== "writerInvite" &&
|
||||
change.value !== "readerInvite"
|
||||
change.value !== "readerInvite" &&
|
||||
change.value !== "writeOnlyInvite"
|
||||
) {
|
||||
console.warn("Group transaction must set a valid role");
|
||||
continue;
|
||||
@@ -341,6 +380,11 @@ function determineValidTransactionsForGroup(
|
||||
console.warn("ReaderInvites can only create reader.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "writeOnlyInvite") {
|
||||
if (change.value !== "writeOnly") {
|
||||
console.warn("WriteOnlyInvites can only create writeOnly.");
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Group transaction must be made by current admin or invite",
|
||||
@@ -377,6 +421,12 @@ function agentInAccountOrMemberInGroup(
|
||||
return transactor;
|
||||
}
|
||||
|
||||
export function isWriteKeyForMember(
|
||||
co: string,
|
||||
): co is `writeKeyFor_${RawAccountID | AgentID}` {
|
||||
return co.startsWith("writeKeyFor_");
|
||||
}
|
||||
|
||||
export function isKeyForKeyField(co: string): co is `${KeyID}_for_${KeyID}` {
|
||||
return co.startsWith("key_") && co.includes("_for_key");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { RawCoList } from "../coValues/coList.js";
|
||||
import { RawCoMap } from "../coValues/coMap.js";
|
||||
import { RawCoStream } from "../coValues/coStream.js";
|
||||
@@ -7,6 +7,7 @@ import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
||||
import { LocalNode } from "../localNode.js";
|
||||
import {
|
||||
createThreeConnectedNodes,
|
||||
createTwoConnectedNodes,
|
||||
loadCoValueOrFail,
|
||||
randomAnonymousAccountAndSessionID,
|
||||
} from "./testUtils.js";
|
||||
@@ -59,33 +60,44 @@ test("Can create a FileStream in a group", () => {
|
||||
});
|
||||
|
||||
test("Remove a member from a group where the admin role is inherited", async () => {
|
||||
const { node1, node2, node3, node1ToNode2Peer, node2ToNode3Peer } =
|
||||
createThreeConnectedNodes("server", "server", "server");
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const group = node1.createGroup();
|
||||
const group = node1.node.createGroup();
|
||||
|
||||
group.addMember(node2.account, "admin");
|
||||
group.addMember(node3.account, "reader");
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node2.accountID),
|
||||
"admin",
|
||||
);
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node3.accountID),
|
||||
"reader",
|
||||
);
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
|
||||
await group.core.waitForSync();
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
|
||||
|
||||
// The account of node2 create a child group and extend the initial group
|
||||
// This way the node1 account should become "admin" of the child group
|
||||
// by inheriting the admin role from the initial group
|
||||
const childGroup = node2.createGroup();
|
||||
const childGroup = node2.node.createGroup();
|
||||
childGroup.extend(groupOnNode2);
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Available to everyone");
|
||||
|
||||
const mapOnNode3 = await loadCoValueOrFail(node3, map.id);
|
||||
const mapOnNode3 = await loadCoValueOrFail(node3.node, map.id);
|
||||
|
||||
// Check that the sync between node2 and node3 worked
|
||||
expect(mapOnNode3.get("test")).toEqual("Available to everyone");
|
||||
|
||||
// The node1 account removes the reader from the group
|
||||
// The reader should be automatically kicked out of the child group
|
||||
await group.removeMember(node3.account);
|
||||
await group.removeMember(node3.node.account);
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
@@ -97,26 +109,37 @@ test("Remove a member from a group where the admin role is inherited", async ()
|
||||
// Check that the value has not been updated on node3
|
||||
expect(mapOnNode3.get("test")).toEqual("Available to everyone");
|
||||
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
||||
|
||||
expect(mapOnNode1.get("test")).toEqual("Hidden to node3");
|
||||
});
|
||||
|
||||
test("An admin should be able to rotate the readKey on child groups and keep access to new coValues", async () => {
|
||||
const { node1, node2, node3, node1ToNode2Peer, node2ToNode1Peer } =
|
||||
createThreeConnectedNodes("server", "server", "server");
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const group = node1.createGroup();
|
||||
const group = node1.node.createGroup();
|
||||
|
||||
group.addMember(node2.account, "admin");
|
||||
group.addMember(node3.account, "reader");
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node2.accountID),
|
||||
"admin",
|
||||
);
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node3.accountID),
|
||||
"reader",
|
||||
);
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
|
||||
await group.core.waitForSync();
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
|
||||
|
||||
// The account of node2 create a child group and extend the initial group
|
||||
// This way the node1 account should become "admin" of the child group
|
||||
// by inheriting the admin role from the initial group
|
||||
const childGroup = node2.createGroup();
|
||||
const childGroup = node2.node.createGroup();
|
||||
childGroup.extend(groupOnNode2);
|
||||
|
||||
await childGroup.core.waitForSync();
|
||||
@@ -124,78 +147,270 @@ test("An admin should be able to rotate the readKey on child groups and keep acc
|
||||
// The node1 account removes the reader from the group
|
||||
// In this case we want to ensure that node1 is still able to read new coValues
|
||||
// Even if some childs are not available when the readKey is rotated
|
||||
await group.removeMember(node3.account);
|
||||
await group.removeMember(node3.node.account);
|
||||
await group.core.waitForSync();
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Available to node1");
|
||||
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
||||
expect(mapOnNode1.get("test")).toEqual("Available to node1");
|
||||
});
|
||||
|
||||
test("An admin should be able to rotate the readKey on child groups even if it was unavailable when kicking out a member from a parent group", async () => {
|
||||
const { node1, node2, node3, node1ToNode2Peer, node2ToNode1Peer } =
|
||||
createThreeConnectedNodes("server", "server", "server");
|
||||
|
||||
const group = node1.createGroup();
|
||||
|
||||
group.addMember(node2.account, "admin");
|
||||
group.addMember(node3.account, "reader");
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
|
||||
|
||||
// The account of node2 create a child group and extend the initial group
|
||||
// This way the node1 account should become "admin" of the child group
|
||||
// by inheriting the admin role from the initial group
|
||||
const childGroup = node2.createGroup();
|
||||
childGroup.extend(groupOnNode2);
|
||||
|
||||
// The node1 account removes the reader from the group
|
||||
// In this case we want to ensure that node1 is still able to read new coValues
|
||||
// Even if some childs are not available when the readKey is rotated
|
||||
await group.removeMember(node3.account);
|
||||
await group.core.waitForSync();
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Available to node1");
|
||||
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
|
||||
expect(mapOnNode1.get("test")).toEqual("Available to node1");
|
||||
});
|
||||
|
||||
test("An admin should be able to rotate the readKey on child groups even if it was unavailable when kicking out a member from a parent group (grandChild)", async () => {
|
||||
const { node1, node2, node3, node1ToNode2Peer } = createThreeConnectedNodes(
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const group = node1.createGroup();
|
||||
const group = node1.node.createGroup();
|
||||
|
||||
group.addMember(node2.account, "admin");
|
||||
group.addMember(node3.account, "reader");
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node2.accountID),
|
||||
"admin",
|
||||
);
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node3.accountID),
|
||||
"reader",
|
||||
);
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
|
||||
await group.core.waitForSync();
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
|
||||
|
||||
// The account of node2 create a child group and extend the initial group
|
||||
// This way the node1 account should become "admin" of the child group
|
||||
// by inheriting the admin role from the initial group
|
||||
const childGroup = node2.createGroup();
|
||||
const childGroup = node2.node.createGroup();
|
||||
childGroup.extend(groupOnNode2);
|
||||
const grandChildGroup = node2.createGroup();
|
||||
grandChildGroup.extend(childGroup);
|
||||
|
||||
// The node1 account removes the reader from the group
|
||||
// In this case we want to ensure that node1 is still able to read new coValues
|
||||
// Even if some childs are not available when the readKey is rotated
|
||||
await group.removeMember(node3.account);
|
||||
await group.removeMember(node3.node.account);
|
||||
await group.core.waitForSync();
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Available to node1");
|
||||
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
||||
expect(mapOnNode1.get("test")).toEqual("Available to node1");
|
||||
});
|
||||
|
||||
test("An admin should be able to rotate the readKey on child groups even if it was unavailable when kicking out a member from a parent group (grandChild)", async () => {
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const group = node1.node.createGroup();
|
||||
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node2.accountID),
|
||||
"admin",
|
||||
);
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node3.accountID),
|
||||
"reader",
|
||||
);
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
|
||||
|
||||
// The account of node2 create a child group and extend the initial group
|
||||
// This way the node1 account should become "admin" of the child group
|
||||
// by inheriting the admin role from the initial group
|
||||
const childGroup = node2.node.createGroup();
|
||||
childGroup.extend(groupOnNode2);
|
||||
const grandChildGroup = node2.node.createGroup();
|
||||
grandChildGroup.extend(childGroup);
|
||||
|
||||
// The node1 account removes the reader from the group
|
||||
// In this case we want to ensure that node1 is still able to read new coValues
|
||||
// Even if some childs are not available when the readKey is rotated
|
||||
await group.removeMember(node3.node.account);
|
||||
await group.core.waitForSync();
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Available to node1");
|
||||
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
||||
|
||||
expect(mapOnNode1.get("test")).toEqual("Available to node1");
|
||||
});
|
||||
|
||||
test("A user add after a key rotation should have access to the old transactions", async () => {
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const group = node1.node.createGroup();
|
||||
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node2.accountID),
|
||||
"writer",
|
||||
);
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
|
||||
|
||||
const map = groupOnNode2.createMap();
|
||||
map.set("test", "Written from node2");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
await group.removeMember(node3.node.account);
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node3.accountID),
|
||||
"reader",
|
||||
);
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
const mapOnNode3 = await loadCoValueOrFail(node3.node, map.id);
|
||||
expect(mapOnNode3.get("test")).toEqual("Written from node2");
|
||||
});
|
||||
|
||||
test("Invites should have access to the new keys", async () => {
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const group = node1.node.createGroup();
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node3.accountID),
|
||||
"reader",
|
||||
);
|
||||
|
||||
const invite = group.createInvite("admin");
|
||||
|
||||
await group.removeMember(node3.node.account);
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("test", "Written from node1");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
await node2.node.acceptInvite(group.id, invite);
|
||||
|
||||
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
|
||||
expect(mapOnNode2.get("test")).toEqual("Written from node1");
|
||||
});
|
||||
|
||||
describe("writeOnly", () => {
|
||||
test("Admins can invite writeOnly members", async () => {
|
||||
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
||||
|
||||
const group = node1.node.createGroup();
|
||||
|
||||
const invite = group.createInvite("writeOnly");
|
||||
|
||||
await node2.node.acceptInvite(group.id, invite);
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
|
||||
expect(groupOnNode2.myRole()).toEqual("writeOnly");
|
||||
});
|
||||
|
||||
test("Edits by writeOnly members are visible to other members", async () => {
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const group = node1.node.createGroup();
|
||||
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node2.accountID),
|
||||
"writeOnly",
|
||||
);
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node3.accountID),
|
||||
"reader",
|
||||
);
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
|
||||
const map = groupOnNode2.createMap();
|
||||
|
||||
map.set("test", "Written from a writeOnly member");
|
||||
expect(map.get("test")).toEqual("Written from a writeOnly member");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
||||
expect(mapOnNode1.get("test")).toEqual("Written from a writeOnly member");
|
||||
|
||||
const mapOnNode3 = await loadCoValueOrFail(node3.node, map.id);
|
||||
expect(mapOnNode3.get("test")).toEqual("Written from a writeOnly member");
|
||||
});
|
||||
|
||||
test("Edits by other members are not visible to writeOnly members", async () => {
|
||||
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
||||
|
||||
const group = node1.node.createGroup();
|
||||
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node2.accountID),
|
||||
"writeOnly",
|
||||
);
|
||||
const map = group.createMap();
|
||||
map.set("test", "Written from the admin");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
|
||||
expect(mapOnNode2.get("test")).toEqual(undefined);
|
||||
});
|
||||
|
||||
test("Write only member keys are rotated when a member is kicked out", async () => {
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const group = node1.node.createGroup();
|
||||
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node2.accountID),
|
||||
"writeOnly",
|
||||
);
|
||||
group.addMember(
|
||||
await loadCoValueOrFail(node1.node, node3.accountID),
|
||||
"reader",
|
||||
);
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
|
||||
const map = groupOnNode2.createMap();
|
||||
|
||||
map.set("test", "Written from a writeOnly member");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
await group.removeMember(node3.node.account);
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
map.set("test", "Updated after key rotation");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
|
||||
expect(mapOnNode1.get("test")).toEqual("Updated after key rotation");
|
||||
|
||||
const mapOnNode3 = await loadCoValueOrFail(node3.node, map.id);
|
||||
expect(mapOnNode3.get("test")).toEqual("Written from a writeOnly member");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1574,6 +1574,330 @@ test("ReaderInvites can not invite writers", () => {
|
||||
expect(groupAsInvite.get(invitedWriterID)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("WriteOnlyInvites can not invite writers", () => {
|
||||
const { groupCore, admin } = newGroup();
|
||||
|
||||
const inviteSecret = Crypto.newRandomAgentSecret();
|
||||
const inviteID = Crypto.getAgentID(inviteSecret);
|
||||
|
||||
const group = expectGroup(groupCore.getCurrentContent());
|
||||
|
||||
const { secret: readKey, id: readKeyID } = Crypto.newRandomKeySecret();
|
||||
const revelation = Crypto.seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID()._unsafeUnwrap(),
|
||||
nOnceMaterial: {
|
||||
in: groupCore.id,
|
||||
tx: groupCore.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
group.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
group.set("readKey", readKeyID, "trusting");
|
||||
|
||||
group.set(inviteID, "writeOnlyInvite", "trusting");
|
||||
|
||||
expect(group.get(inviteID)).toEqual("writeOnlyInvite");
|
||||
|
||||
const revelationForInvite = Crypto.seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: Crypto.getAgentSealerID(inviteID),
|
||||
nOnceMaterial: {
|
||||
in: groupCore.id,
|
||||
tx: groupCore.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
group.set(`${readKeyID}_for_${inviteID}`, revelationForInvite, "trusting");
|
||||
|
||||
const groupAsInvite = expectGroup(
|
||||
groupCore
|
||||
.testWithDifferentAccount(
|
||||
new ControlledAgent(inviteSecret, Crypto),
|
||||
Crypto.newRandomSessionID(inviteID),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
const invitedWriterSecret = Crypto.newRandomAgentSecret();
|
||||
const invitedWriterID = Crypto.getAgentID(invitedWriterSecret);
|
||||
|
||||
groupAsInvite.set(invitedWriterID, "writer", "trusting");
|
||||
expect(groupAsInvite.get(invitedWriterID)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("WriteOnlyInvites can not invite admins", () => {
|
||||
const { groupCore, admin } = newGroup();
|
||||
|
||||
const inviteSecret = Crypto.newRandomAgentSecret();
|
||||
const inviteID = Crypto.getAgentID(inviteSecret);
|
||||
|
||||
const group = expectGroup(groupCore.getCurrentContent());
|
||||
|
||||
const { secret: readKey, id: readKeyID } = Crypto.newRandomKeySecret();
|
||||
const revelation = Crypto.seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID()._unsafeUnwrap(),
|
||||
nOnceMaterial: {
|
||||
in: groupCore.id,
|
||||
tx: groupCore.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
group.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
group.set("readKey", readKeyID, "trusting");
|
||||
|
||||
group.set(inviteID, "writeOnlyInvite", "trusting");
|
||||
|
||||
expect(group.get(inviteID)).toEqual("writeOnlyInvite");
|
||||
|
||||
const revelationForInvite = Crypto.seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: Crypto.getAgentSealerID(inviteID),
|
||||
nOnceMaterial: {
|
||||
in: groupCore.id,
|
||||
tx: groupCore.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
group.set(`${readKeyID}_for_${inviteID}`, revelationForInvite, "trusting");
|
||||
|
||||
const groupAsInvite = expectGroup(
|
||||
groupCore
|
||||
.testWithDifferentAccount(
|
||||
new ControlledAgent(inviteSecret, Crypto),
|
||||
Crypto.newRandomSessionID(inviteID),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
const invitedWriterSecret = Crypto.newRandomAgentSecret();
|
||||
const invitedWriterID = Crypto.getAgentID(invitedWriterSecret);
|
||||
|
||||
groupAsInvite.set(invitedWriterID, "admin", "trusting");
|
||||
expect(groupAsInvite.get(invitedWriterID)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("WriteOnlyInvites can invite writeOnly", () => {
|
||||
const { groupCore, admin } = newGroup();
|
||||
|
||||
const inviteSecret = Crypto.newRandomAgentSecret();
|
||||
const inviteID = Crypto.getAgentID(inviteSecret);
|
||||
|
||||
const group = expectGroup(groupCore.getCurrentContent());
|
||||
|
||||
const { secret: readKey, id: readKeyID } = Crypto.newRandomKeySecret();
|
||||
const revelation = Crypto.seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID()._unsafeUnwrap(),
|
||||
nOnceMaterial: {
|
||||
in: groupCore.id,
|
||||
tx: groupCore.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
group.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
group.set("readKey", readKeyID, "trusting");
|
||||
|
||||
group.set(inviteID, "writeOnlyInvite", "trusting");
|
||||
|
||||
expect(group.get(inviteID)).toEqual("writeOnlyInvite");
|
||||
|
||||
const revelationForInvite = Crypto.seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: Crypto.getAgentSealerID(inviteID),
|
||||
nOnceMaterial: {
|
||||
in: groupCore.id,
|
||||
tx: groupCore.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
group.set(`${readKeyID}_for_${inviteID}`, revelationForInvite, "trusting");
|
||||
|
||||
const groupAsInvite = expectGroup(
|
||||
groupCore
|
||||
.testWithDifferentAccount(
|
||||
new ControlledAgent(inviteSecret, Crypto),
|
||||
Crypto.newRandomSessionID(inviteID),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
const invitedWriterSecret = Crypto.newRandomAgentSecret();
|
||||
const invitedWriterID = Crypto.getAgentID(invitedWriterSecret);
|
||||
|
||||
groupAsInvite.set(invitedWriterID, "writeOnly", "trusting");
|
||||
expect(groupAsInvite.get(invitedWriterID)).toEqual("writeOnly");
|
||||
});
|
||||
|
||||
test("WriteOnlyInvites can set writeKeys", () => {
|
||||
const { groupCore, admin } = newGroup();
|
||||
|
||||
const inviteSecret = Crypto.newRandomAgentSecret();
|
||||
const inviteID = Crypto.getAgentID(inviteSecret);
|
||||
|
||||
const group = expectGroup(groupCore.getCurrentContent());
|
||||
|
||||
const { secret: readKey, id: readKeyID } = Crypto.newRandomKeySecret();
|
||||
const revelation = Crypto.seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID()._unsafeUnwrap(),
|
||||
nOnceMaterial: {
|
||||
in: groupCore.id,
|
||||
tx: groupCore.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
group.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
group.set("readKey", readKeyID, "trusting");
|
||||
|
||||
group.set(inviteID, "writeOnlyInvite", "trusting");
|
||||
|
||||
expect(group.get(inviteID)).toEqual("writeOnlyInvite");
|
||||
|
||||
const revelationForInvite = Crypto.seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: Crypto.getAgentSealerID(inviteID),
|
||||
nOnceMaterial: {
|
||||
in: groupCore.id,
|
||||
tx: groupCore.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
group.set(`${readKeyID}_for_${inviteID}`, revelationForInvite, "trusting");
|
||||
|
||||
const groupAsInvite = expectGroup(
|
||||
groupCore
|
||||
.testWithDifferentAccount(
|
||||
new ControlledAgent(inviteSecret, Crypto),
|
||||
Crypto.newRandomSessionID(inviteID),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsInvite.set(`writeKeyFor_${admin.id}`, readKeyID, "trusting");
|
||||
expect(groupAsInvite.get(`writeKeyFor_${admin.id}`)).toEqual(readKeyID);
|
||||
});
|
||||
|
||||
test("Invites can't override key revelations", () => {
|
||||
const { groupCore, admin } = newGroup();
|
||||
|
||||
const inviteSecret = Crypto.newRandomAgentSecret();
|
||||
const inviteID = Crypto.getAgentID(inviteSecret);
|
||||
|
||||
const group = expectGroup(groupCore.getCurrentContent());
|
||||
|
||||
const { secret: readKey, id: readKeyID } = Crypto.newRandomKeySecret();
|
||||
const revelation = Crypto.seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID()._unsafeUnwrap(),
|
||||
nOnceMaterial: {
|
||||
in: groupCore.id,
|
||||
tx: groupCore.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
group.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
group.set("readKey", readKeyID, "trusting");
|
||||
|
||||
group.set(inviteID, "readerInvite", "trusting");
|
||||
|
||||
expect(group.get(inviteID)).toEqual("readerInvite");
|
||||
|
||||
const revelationForInvite = Crypto.seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: Crypto.getAgentSealerID(inviteID),
|
||||
nOnceMaterial: {
|
||||
in: groupCore.id,
|
||||
tx: groupCore.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
group.set(`${readKeyID}_for_${inviteID}`, revelationForInvite, "trusting");
|
||||
|
||||
const groupAsInvite = expectGroup(
|
||||
groupCore
|
||||
.testWithDifferentAccount(
|
||||
new ControlledAgent(inviteSecret, Crypto),
|
||||
Crypto.newRandomSessionID(inviteID),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsInvite.set(
|
||||
`${readKeyID}_for_${admin.id}`,
|
||||
"Evil change" as any,
|
||||
"trusting",
|
||||
);
|
||||
expect(groupAsInvite.get(`${readKeyID}_for_${admin.id}`)).toBe(revelation);
|
||||
});
|
||||
|
||||
test("WriteOnlyInvites can't override writeKeys", () => {
|
||||
const { groupCore, admin } = newGroup();
|
||||
|
||||
const inviteSecret = Crypto.newRandomAgentSecret();
|
||||
const inviteID = Crypto.getAgentID(inviteSecret);
|
||||
|
||||
const group = expectGroup(groupCore.getCurrentContent());
|
||||
|
||||
const { secret: readKey, id: readKeyID } = Crypto.newRandomKeySecret();
|
||||
const revelation = Crypto.seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: admin.currentSealerID()._unsafeUnwrap(),
|
||||
nOnceMaterial: {
|
||||
in: groupCore.id,
|
||||
tx: groupCore.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
group.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
group.set("readKey", readKeyID, "trusting");
|
||||
|
||||
group.set(inviteID, "writeOnlyInvite", "trusting");
|
||||
|
||||
expect(group.get(inviteID)).toEqual("writeOnlyInvite");
|
||||
|
||||
const revelationForInvite = Crypto.seal({
|
||||
message: readKey,
|
||||
from: admin.currentSealerSecret(),
|
||||
to: Crypto.getAgentSealerID(inviteID),
|
||||
nOnceMaterial: {
|
||||
in: groupCore.id,
|
||||
tx: groupCore.nextTransactionID(),
|
||||
},
|
||||
});
|
||||
|
||||
group.set(`${readKeyID}_for_${inviteID}`, revelationForInvite, "trusting");
|
||||
|
||||
const groupAsInvite = expectGroup(
|
||||
groupCore
|
||||
.testWithDifferentAccount(
|
||||
new ControlledAgent(inviteSecret, Crypto),
|
||||
Crypto.newRandomSessionID(inviteID),
|
||||
)
|
||||
.getCurrentContent(),
|
||||
);
|
||||
|
||||
groupAsInvite.set(`writeKeyFor_${admin.id}`, readKeyID, "trusting");
|
||||
groupAsInvite.set(
|
||||
`writeKeyFor_${admin.id}`,
|
||||
"Evil change" as any,
|
||||
"trusting",
|
||||
);
|
||||
expect(groupAsInvite.get(`writeKeyFor_${admin.id}`)).toEqual(readKeyID);
|
||||
});
|
||||
|
||||
test("Can give read permission to 'everyone'", () => {
|
||||
const { node, groupCore } = newGroup();
|
||||
|
||||
|
||||
@@ -60,17 +60,11 @@ export async function createTwoConnectedNodes(
|
||||
};
|
||||
}
|
||||
|
||||
export function createThreeConnectedNodes(
|
||||
export async function createThreeConnectedNodes(
|
||||
node1Role: Peer["role"],
|
||||
node2Role: Peer["role"],
|
||||
node3Role: Peer["role"],
|
||||
) {
|
||||
// Setup nodes
|
||||
const node1 = createTestNode();
|
||||
const node2 = createTestNode();
|
||||
const node3 = createTestNode();
|
||||
|
||||
// Connect nodes initially
|
||||
const [node1ToNode2Peer, node2ToNode1Peer] = connectedPeers(
|
||||
"node1ToNode2",
|
||||
"node2ToNode1",
|
||||
@@ -98,12 +92,23 @@ export function createThreeConnectedNodes(
|
||||
},
|
||||
);
|
||||
|
||||
node1.syncManager.addPeer(node1ToNode2Peer);
|
||||
node1.syncManager.addPeer(node1ToNode3Peer);
|
||||
node2.syncManager.addPeer(node2ToNode1Peer);
|
||||
node2.syncManager.addPeer(node2ToNode3Peer);
|
||||
node3.syncManager.addPeer(node3ToNode1Peer);
|
||||
node3.syncManager.addPeer(node3ToNode2Peer);
|
||||
const node1 = await LocalNode.withNewlyCreatedAccount({
|
||||
peersToLoadFrom: [node1ToNode2Peer, node1ToNode3Peer],
|
||||
crypto: Crypto,
|
||||
creationProps: { name: "Node 1" },
|
||||
});
|
||||
|
||||
const node2 = await LocalNode.withNewlyCreatedAccount({
|
||||
peersToLoadFrom: [node2ToNode1Peer, node2ToNode3Peer],
|
||||
crypto: Crypto,
|
||||
creationProps: { name: "Node 2" },
|
||||
});
|
||||
|
||||
const node3 = await LocalNode.withNewlyCreatedAccount({
|
||||
peersToLoadFrom: [node3ToNode1Peer, node3ToNode2Peer],
|
||||
crypto: Crypto,
|
||||
creationProps: { name: "Node 3" },
|
||||
});
|
||||
|
||||
return {
|
||||
node1,
|
||||
|
||||
@@ -204,7 +204,7 @@ export function provideBrowserLockSession(
|
||||
/** @category Invite Links */
|
||||
export function createInviteLink<C extends CoValue>(
|
||||
value: C,
|
||||
role: "reader" | "writer" | "admin",
|
||||
role: "reader" | "writer" | "admin" | "writeOnly",
|
||||
// default to same address as window.location, but without hash
|
||||
{
|
||||
baseURL = window.location.href.replace(/#.*$/, ""),
|
||||
|
||||
572
pnpm-lock.yaml
generated
572
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:local": "VITE_WS_PEER=ws://localhost:4200/ vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"format-and-lint": "biome check .",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ResumeSyncState } from "./pages/ResumeSyncState";
|
||||
import { RetryUnavailable } from "./pages/RetryUnavailable";
|
||||
import { Sharing } from "./pages/Sharing";
|
||||
import { TestInput } from "./pages/TestInput";
|
||||
import { WriteOnlyRole } from "./pages/WriteOnly";
|
||||
|
||||
function Index() {
|
||||
return (
|
||||
@@ -26,6 +27,9 @@ function Index() {
|
||||
<li>
|
||||
<Link to="/sharing">Sharing</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/write-only">Write Only</Link>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +55,10 @@ const router = createBrowserRouter([
|
||||
path: "/sharing",
|
||||
element: <Sharing />,
|
||||
},
|
||||
{
|
||||
path: "/write-only",
|
||||
element: <WriteOnlyRole />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <Index />,
|
||||
|
||||
108
tests/e2e/src/pages/WriteOnly.tsx
Normal file
108
tests/e2e/src/pages/WriteOnly.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import { CoList, CoMap, Group, ID, co } from "jazz-tools";
|
||||
import { useState } from "react";
|
||||
import { useAcceptInvite, useAccount, useCoState } from "../jazz";
|
||||
|
||||
class SharedCoMap extends CoMap {
|
||||
value = co.string;
|
||||
}
|
||||
|
||||
class SharedCoList extends CoList.Of(co.ref(SharedCoMap)) {}
|
||||
|
||||
export function WriteOnlyRole() {
|
||||
const { me } = useAccount();
|
||||
const [id, setId] = useState<ID<SharedCoList> | undefined>(undefined);
|
||||
const [revealLevels, setRevealLevels] = useState(1);
|
||||
const [inviteLinks, setInviteLinks] = useState<Record<string, string>>({});
|
||||
const coList = useCoState(SharedCoList, id, []);
|
||||
|
||||
const createCoList = async () => {
|
||||
if (!me || id) return;
|
||||
|
||||
const group = Group.create({ owner: me });
|
||||
|
||||
const coList = SharedCoList.create([], { owner: group });
|
||||
|
||||
setInviteLinks({
|
||||
writer: createInviteLink(coList, "writer"),
|
||||
reader: createInviteLink(coList, "reader"),
|
||||
admin: createInviteLink(coList, "admin"),
|
||||
writeOnly: createInviteLink(coList, "writeOnly"),
|
||||
});
|
||||
|
||||
await group.waitForSync();
|
||||
|
||||
setId(coList.id);
|
||||
};
|
||||
|
||||
const addNewItem = async () => {
|
||||
if (!me || !coList) return;
|
||||
|
||||
const group = coList._owner as Group;
|
||||
const coMap = SharedCoMap.create({ value: "" }, { owner: group });
|
||||
|
||||
coList.push(coMap);
|
||||
};
|
||||
|
||||
const revokeAccess = () => {
|
||||
if (!coList) return;
|
||||
|
||||
const coListGroup = coList._owner as Group;
|
||||
|
||||
for (const member of coListGroup.members) {
|
||||
if (
|
||||
member.account &&
|
||||
member.role !== "admin" &&
|
||||
member.account.id !== me.id
|
||||
) {
|
||||
coListGroup.removeMember(member.account);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useAcceptInvite({
|
||||
invitedObjectSchema: SharedCoList,
|
||||
onAccept: (id) => {
|
||||
setId(id);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Sharing</h1>
|
||||
<p data-testid="id">{coList?.id}</p>
|
||||
{Object.entries(inviteLinks).map(([role, inviteLink]) => (
|
||||
<div key={role} style={{ display: "flex", gap: 5 }}>
|
||||
<p style={{ fontWeight: "bold" }}>{role} invitation:</p>
|
||||
<p data-testid={`invite-link-${role}`}>{inviteLink}</p>
|
||||
</div>
|
||||
))}
|
||||
<pre data-testid="values">
|
||||
{coList?.map(
|
||||
(map) => map && <EditSharedCoMap key={map.id} id={map.id} />,
|
||||
)}
|
||||
</pre>
|
||||
{!id && <button onClick={createCoList}>Create the list</button>}
|
||||
{id && <button onClick={addNewItem}>Add a new item</button>}
|
||||
{coList && <button onClick={revokeAccess}>Revoke access</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditSharedCoMap(props: {
|
||||
id: ID<SharedCoMap>;
|
||||
}) {
|
||||
const coMap = useCoState(SharedCoMap, props.id, {});
|
||||
|
||||
if (!coMap) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>{coMap.value}</div>
|
||||
<input
|
||||
value={coMap.value}
|
||||
onChange={(e) => (coMap.value = e.target.value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
101
tests/e2e/tests/WriteOnly.test.ts
Normal file
101
tests/e2e/tests/WriteOnly.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Browser, Page, expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("WriteOnly role", () => {
|
||||
test("should share simple coValues", async ({ page, browser }) => {
|
||||
await page.goto("/write-only");
|
||||
|
||||
await page.getByRole("button", { name: "Create the list" }).click();
|
||||
await waitForReady(page);
|
||||
|
||||
const id = await page.getByTestId("id").textContent();
|
||||
const inviteLink = await page
|
||||
.getByTestId("invite-link-writeOnly1")
|
||||
.textContent();
|
||||
|
||||
// Create a new incognito instance and accept the invite
|
||||
const newUserPage = await (await browser.newContext()).newPage();
|
||||
await newUserPage.goto(inviteLink!);
|
||||
|
||||
await waitForReady(newUserPage);
|
||||
|
||||
await expect(newUserPage.getByTestId("id")).toHaveText(id ?? "");
|
||||
});
|
||||
|
||||
test("writeOnly1 roles should be able to see only their own changes", async ({
|
||||
page,
|
||||
browser,
|
||||
context,
|
||||
}) => {
|
||||
const pages = {
|
||||
initialOwner: page,
|
||||
writeOnly1: await createIsolatedPage(browser),
|
||||
writeOnly2: await createIsolatedPage(browser),
|
||||
reader: await createIsolatedPage(browser),
|
||||
};
|
||||
|
||||
await pages.initialOwner.goto("/write-only?userName=InitialOwner");
|
||||
await pages.writeOnly1.goto("/write-only?userName=WriteOnly");
|
||||
await pages.writeOnly2.goto("/write-only?userName=WriteOnly2");
|
||||
|
||||
await pages.initialOwner
|
||||
.getByRole("button", { name: "Create the list" })
|
||||
.click();
|
||||
await waitForReady(pages.initialOwner);
|
||||
|
||||
const writeOnlyInviteLink = await pages.initialOwner
|
||||
.getByTestId("invite-link-writeOnly")
|
||||
.textContent();
|
||||
|
||||
await pages.writeOnly1.goto(writeOnlyInviteLink!);
|
||||
await pages.writeOnly2.goto(writeOnlyInviteLink!);
|
||||
|
||||
await waitForReady(pages.writeOnly1);
|
||||
await waitForReady(pages.writeOnly2);
|
||||
|
||||
await pages.writeOnly1
|
||||
.getByRole("button", { name: "Add a new item" })
|
||||
.click();
|
||||
await pages.writeOnly2
|
||||
.getByRole("button", { name: "Add a new item" })
|
||||
.click();
|
||||
|
||||
await pages.writeOnly1.getByRole("textbox").fill("From WriteOnly1");
|
||||
await pages.writeOnly2.getByRole("textbox").fill("From WriteOnly2");
|
||||
|
||||
await expect(pages.initialOwner.getByText("From WriteOnly1")).toBeVisible();
|
||||
await expect(pages.initialOwner.getByText("From WriteOnly2")).toBeVisible();
|
||||
|
||||
await expect(pages.writeOnly1.getByText("From WriteOnly1")).toBeVisible();
|
||||
await expect(
|
||||
pages.writeOnly1.getByText("From WriteOnly2"),
|
||||
).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
pages.writeOnly2.getByText("From WriteOnly1"),
|
||||
).not.toBeVisible();
|
||||
await expect(pages.writeOnly2.getByText("From WriteOnly2")).toBeVisible();
|
||||
|
||||
const readerInviteLink = await pages.initialOwner
|
||||
.getByTestId("invite-link-reader")
|
||||
.textContent();
|
||||
|
||||
await pages.reader.goto(readerInviteLink!);
|
||||
await waitForReady(pages.reader);
|
||||
|
||||
await expect(pages.reader.getByText("From WriteOnly1")).toBeVisible();
|
||||
await expect(pages.reader.getByText("From WriteOnly2")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForReady(page: Page) {
|
||||
await expect(page.getByTestId("id")).not.toHaveText("", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function createIsolatedPage(browser: Browser) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
return page;
|
||||
}
|
||||
Reference in New Issue
Block a user