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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions app/components/console/publish-steps.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,46 @@
"use client";
import { useState } from "react";
import downloadIcon from "@jetbrains/icons/download";
import folderIcon from "@jetbrains/icons/folder";
import Banner from "@jetbrains/ring-ui-built/components/banner/banner";
import Button from "@jetbrains/ring-ui-built/components/button/button";
import Checkbox from "@jetbrains/ring-ui-built/components/checkbox/checkbox";
import { H1, H2 } from "@jetbrains/ring-ui-built/components/heading/heading";
import Input, { Size } from "@jetbrains/ring-ui-built/components/input/input";
import { ErrorMessage, withIcon } from "@malverdesoftware/ui/controls";
import { publishSession, renderSessionFile, type SessionFile } from "@malverdesoftware/session-client";
import { ErrorMessage, FileButton, withIcon } from "@malverdesoftware/ui/controls";
import { publishSession, renderSessionFile, verifySessionFile, type SessionFile } from "@malverdesoftware/session-client";
import { Frame } from "../frame";
import { download, readKey, removeKey, writeKey } from "@/lib/client";
import { CREATION_KEY_STORAGE, isProductionRelay, keyFileName } from "@/lib/console";
import { describe } from "./errors";
import { useUnsavedGuard } from "./use-unsaved-guard";

// Save the key file (twice), then publish. Publishing is safe to retry: the request is rebuilt from the file.
// Save the key file (twice), open the saved copy to prove it works, then publish. Publishing is safe to retry: the request is rebuilt from the file.
export default function PublishSteps({ file, onPublished }: { file: SessionFile; onPublished: () => void }) {
const [saved, setSaved] = useState(false);
const [verified, setVerified] = useState(false);
const [checkError, setCheckError] = useState("");
const [secondCopy, setSecondCopy] = useState(false);
const [creationKey, setCreationKey] = useState(() => readKey(CREATION_KEY_STORAGE) ?? "");
const [remember, setRemember] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useUnsavedGuard(!saved);

// Opens the file the researcher actually saved, not the copy in memory, so a damaged or wrong file is caught
// while the session can still be prepared again.
async function check(picked: File | undefined) {
if (!picked) return;
setCheckError("");
setVerified(false);
try {
await verifySessionFile(await picked.text(), file);
setVerified(true);
} catch (e) {
setCheckError((e as Error).message);
}
}

async function publish(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
Expand Down Expand Up @@ -70,6 +87,20 @@ export default function PublishSteps({ file, onPublished }: { file: SessionFile;
{withIcon(downloadIcon, saved ? "Download again" : "Download the key file")}
</Button>
</div>
{saved && (
<>
<p>Then open the file you saved, to check it can open the responses. Nothing leaves this computer.</p>
<div>
<FileButton accept=".md,text/markdown" icon={folderIcon} onPick={(f) => void check(f)}>
Check the saved key file
</FileButton>
</div>
</>
)}
{verified && (
<Banner mode="success" withIcon>The saved key file opens this session&apos;s responses.</Banner>
)}
<ErrorMessage error={checkError} />
<Checkbox
checked={secondCopy}
onChange={(e) => setSecondCopy(e.target.checked)}
Expand All @@ -94,11 +125,13 @@ export default function PublishSteps({ file, onPublished }: { file: SessionFile;
/>
<ErrorMessage error={error} />
<div>
<Button primary type="submit" disabled={busy || !saved || !secondCopy || !creationKey.trim()}>
<Button primary type="submit" disabled={busy || !verified || !secondCopy || !creationKey.trim()}>
{busy ? "Publishing…" : "Publish the session"}
</Button>
</div>
{(!saved || !secondCopy) && <p className="secondary-text">Save the key file and its second copy first.</p>}
{(!verified || !secondCopy) && (
<p className="secondary-text">Save the key file, check it, and save its second copy first.</p>
)}
</form>
</main>
</Frame>
Expand Down
13 changes: 8 additions & 5 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"@jetbrains/ring-ui-built": "7.0.133",
"@malverdesoftware/relay": "0.1.1",
"@malverdesoftware/sealed-envelope": "0.1.2",
"@malverdesoftware/session-client": "0.1.4",
"@malverdesoftware/session-client": "0.2.0",
"@malverdesoftware/ui": "0.1.1",
"next": "16.4.6",
"react": "19.3.1",
Expand Down
4 changes: 3 additions & 1 deletion app/tests/console-smoke.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Drives the researcher console in headless Chrome against a local console server (synthetic data only):
// create, save the key file, publish, reopen the saved file, add and remove a participant, collect, export,
// create, save and check the key file, publish, reopen the saved file, add and remove a participant, collect, export,
// close, delete, then reopen the deleted session's file. Participants act through session-client in between.
// npm run console (in another terminal) then npm run test:console
import assert from "node:assert/strict";
Expand Down Expand Up @@ -51,6 +51,8 @@ try {
const savedKeyFile = await download(keyFile);
const file = parseSessionFile(readFileSync(savedKeyFile, "utf8"));
assert.deepEqual(file.participants.map((p) => p.label), ["Y01", "Y02"]);
await pickFile('input[type="file"]', savedKeyFile);
await waitFor(hasText("opens this session"), "saved key file checked");
await tap("label", "saved a second copy");
await type('input[type="password"]', creationKey);
await screenshot("console-prepared");
Expand Down
Loading