mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 19:10:52 +00:00
feat: implement Reader page for viewing encrypted letters and add read-only mode to ComposeCanvas
This commit is contained in:
@@ -7,7 +7,7 @@ import Drawer from "./Drawer";
|
||||
|
||||
describe("Drawer Page", () => {
|
||||
beforeEach(() => {
|
||||
// Setup authenticated state
|
||||
// Setup authenticated state for the test
|
||||
useAuthStore.setState({
|
||||
user: mockUser,
|
||||
accessToken: "fake-token",
|
||||
|
||||
@@ -102,6 +102,11 @@ export default function Drawer() {
|
||||
timestamp={letter.updated_at}
|
||||
/>
|
||||
))}
|
||||
{sent.length === 0 && (
|
||||
<p className="text-center text-base-content/20 mt-4">
|
||||
This drawer remains silent
|
||||
</p>
|
||||
)}
|
||||
</DrawerSection>
|
||||
<DrawerSection
|
||||
id="vault"
|
||||
|
||||
+119
-119
@@ -17,17 +17,7 @@ import { endpoints } from "../config/endpoints";
|
||||
import { PATHS } from "../config/routes";
|
||||
import { useKeyStore } from "../store/useKeyStore";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
|
||||
// convert blob url to file
|
||||
async function blobUrlToFile(
|
||||
blobUrl: string,
|
||||
fileName: string,
|
||||
mimeType?: string,
|
||||
): Promise<File> {
|
||||
const response = await fetch(blobUrl);
|
||||
const blob = await response.blob();
|
||||
return new File([blob], fileName, { type: mimeType ?? blob.type });
|
||||
}
|
||||
import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic";
|
||||
|
||||
export default function Editor() {
|
||||
const navigate = useNavigate();
|
||||
@@ -37,6 +27,7 @@ export default function Editor() {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
||||
const [isSealing, setIsSealing] = useState(false);
|
||||
const [isSaveSuccess, setIsSaveSuccess] = useState(false);
|
||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||
|
||||
const [recipient, setRecipient] = useState("");
|
||||
const { masterKey } = useKeyStore();
|
||||
@@ -50,13 +41,13 @@ export default function Editor() {
|
||||
|
||||
const loadExistingLetter = async () => {
|
||||
setIsInitialLoading(true);
|
||||
const cryptoUtils = new CryptoUtils();
|
||||
const crypto = new CryptoUtils();
|
||||
try {
|
||||
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
||||
const letterData = res.data;
|
||||
|
||||
// metadata for recipient
|
||||
const metadata = await cryptoUtils.decryptMetadata(
|
||||
// Decrypt the metadata (for the recipient field)
|
||||
const metadata = await crypto.decryptMetadata(
|
||||
{
|
||||
encrypted_content: letterData.encrypted_metadata,
|
||||
encrypted_dek: letterData.encrypted_dek,
|
||||
@@ -65,8 +56,8 @@ export default function Editor() {
|
||||
);
|
||||
setRecipient(metadata.recipient || "");
|
||||
|
||||
// decrypt canvas data
|
||||
const decryptedJsonStr = await cryptoUtils.decryptLetter(
|
||||
// Decrypt the main canvas JSON
|
||||
const decryptedJsonStr = await crypto.decryptLetter(
|
||||
{
|
||||
encrypted_content: letterData.encrypted_content,
|
||||
encrypted_dek: letterData.encrypted_dek,
|
||||
@@ -75,45 +66,16 @@ export default function Editor() {
|
||||
);
|
||||
const canvasData = JSON.parse(decryptedJsonStr);
|
||||
|
||||
// traverse through canvas images and replace encrypted image with decrypted image
|
||||
if (canvasData.objects) {
|
||||
for (const obj of canvasData.objects) {
|
||||
if (obj.type === "Image" && typeof obj.src === "string") {
|
||||
const filename = obj.src;
|
||||
const remoteImage = letterData.images.find(
|
||||
(img: any) => img.file_name === filename,
|
||||
);
|
||||
// Batch decrypt images within the canvas
|
||||
await decryptCanvasImages(
|
||||
canvasData,
|
||||
letterData.images,
|
||||
letterData.encrypted_dek,
|
||||
masterKey,
|
||||
true, // restore raw files for the editor
|
||||
);
|
||||
|
||||
if (remoteImage) {
|
||||
try {
|
||||
// fetch encrypted image blob using authenticated API
|
||||
const imageRes = await api.get(remoteImage.file, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const encryptedBlob = imageRes.data;
|
||||
|
||||
// decrypt image blob
|
||||
const blobUrl = await cryptoUtils.decryptImage(
|
||||
encryptedBlob,
|
||||
letterData.encrypted_dek,
|
||||
masterKey,
|
||||
);
|
||||
obj.src = blobUrl;
|
||||
obj._customRawFile = await blobUrlToFile(blobUrl, filename);
|
||||
console.log("Decrypted image object:", obj);
|
||||
} catch (imgErr) {
|
||||
console.error(
|
||||
"Failed to decrypt image object:",
|
||||
filename,
|
||||
imgErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// load updated data into canvas
|
||||
// Load data into the Fabric canvas
|
||||
requestAnimationFrame(() => {
|
||||
canvasRef.current?.loadData(canvasData);
|
||||
});
|
||||
@@ -129,7 +91,7 @@ export default function Editor() {
|
||||
|
||||
// --------------------------------------------------------------------------------------
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]; // pick one file at a time
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
canvasRef.current?.addImage(url, file);
|
||||
@@ -138,88 +100,80 @@ export default function Editor() {
|
||||
|
||||
const handleSave = async (status: "SEALED" | "DRAFT"): Promise<void> => {
|
||||
if (!public_id && !letterIdRef.current) {
|
||||
// if no uuid slug, then generate a new one and update params
|
||||
letterIdRef.current = crypto.randomUUID();
|
||||
navigate(PATHS.write(letterIdRef.current), { replace: true });
|
||||
} else if (public_id) {
|
||||
letterIdRef.current = public_id;
|
||||
}
|
||||
|
||||
if (isSealing) return;
|
||||
if (isSealing || !masterKey) return;
|
||||
setIsSealing(true);
|
||||
|
||||
const cryptoUtils = new CryptoUtils();
|
||||
await cryptoUtils.initialize();
|
||||
|
||||
const images = canvasRef.current?.getImages() || [];
|
||||
const imageEncMap = new Map<string, string>();
|
||||
const encImageFilesMap = new Map<string, Blob>();
|
||||
|
||||
if (!masterKey) {
|
||||
throw new Error("Master key is not initialized");
|
||||
}
|
||||
|
||||
for (const image of images) {
|
||||
if (image.src.endsWith(".bin")) continue;
|
||||
try {
|
||||
const encrypted_image = await cryptoUtils.encryptImage(
|
||||
image.file,
|
||||
masterKey,
|
||||
);
|
||||
imageEncMap.set(image.src, encrypted_image.filename);
|
||||
encImageFilesMap.set(
|
||||
encrypted_image.filename,
|
||||
encrypted_image.encryptedBlob,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to re-encrypt image:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// replace image src with encrypted image filename
|
||||
const canvasData = canvasRef.current?.getData();
|
||||
if (canvasData?.objects) {
|
||||
canvasData.objects = canvasData.objects.map((obj: any) => {
|
||||
if (obj.type === "Image" && imageEncMap.has(obj.src)) {
|
||||
return { ...obj, src: imageEncMap.get(obj.src) };
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
|
||||
const encrypted_letter = await cryptoUtils.encryptLetter(
|
||||
JSON.stringify(canvasData),
|
||||
masterKey,
|
||||
);
|
||||
|
||||
const encrypted_metadata = await cryptoUtils.encryptMetadata(
|
||||
{ recipient, tags: [] },
|
||||
masterKey,
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("public_id", letterIdRef.current);
|
||||
formData.append("type", "KEPT");
|
||||
formData.append("status", status);
|
||||
formData.append("encrypted_content", encrypted_letter.encrypted_content);
|
||||
formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
|
||||
formData.append("encrypted_metadata", encrypted_metadata.encrypted_content);
|
||||
encImageFilesMap.forEach((image, filename) => {
|
||||
formData.append("image_files", image, filename);
|
||||
});
|
||||
|
||||
try {
|
||||
const canvasData = canvasRef.current?.getData();
|
||||
const canvasImages = canvasRef.current?.getImages() || [];
|
||||
|
||||
// Secure any new images first
|
||||
const encImageFilesMap = await encryptCanvasImages(
|
||||
canvasData,
|
||||
canvasImages,
|
||||
masterKey,
|
||||
);
|
||||
|
||||
// Encrypt the updated canvas JSON
|
||||
const encrypted_letter = await cryptoUtils.encryptLetter(
|
||||
JSON.stringify(canvasData),
|
||||
masterKey,
|
||||
);
|
||||
|
||||
const encrypted_metadata = await cryptoUtils.encryptMetadata(
|
||||
{ recipient, tags: [] },
|
||||
masterKey,
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("public_id", letterIdRef.current);
|
||||
formData.append("type", "KEPT");
|
||||
formData.append("status", status);
|
||||
formData.append("encrypted_content", encrypted_letter.encrypted_content);
|
||||
formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
|
||||
formData.append(
|
||||
"encrypted_metadata",
|
||||
encrypted_metadata.encrypted_content,
|
||||
);
|
||||
|
||||
encImageFilesMap.forEach((blob, filename) => {
|
||||
formData.append("image_files", blob, filename);
|
||||
});
|
||||
|
||||
await api.put(`${endpoints.LETTERS}${letterIdRef.current}/`, formData);
|
||||
setIsSaveSuccess(true);
|
||||
setTimeout(() => {
|
||||
setIsSaveSuccess(false);
|
||||
}, 5000);
|
||||
|
||||
if (status === "SEALED" && encrypted_letter.sharingKey) {
|
||||
const link = `${window.location.origin}${PATHS.read(letterIdRef.current)}#${encrypted_letter.sharingKey}`;
|
||||
setShareLink(link);
|
||||
}
|
||||
|
||||
setTimeout(() => setIsSaveSuccess(false), 5000);
|
||||
} catch (error) {
|
||||
console.error("Error sealing letter:", error);
|
||||
console.error("Save failed:", error);
|
||||
} finally {
|
||||
setIsSealing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (!shareLink) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 py-12 bg-base-300 relative">
|
||||
{isInitialLoading && (
|
||||
@@ -236,7 +190,53 @@ export default function Editor() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isSaveSuccess && (
|
||||
{/* Sharing Modal */}
|
||||
{shareLink && (
|
||||
<div className="modal modal-open modal-bottom sm:modal-middle bg-base-100/20 backdrop-blur-md z-[100]">
|
||||
<div className="modal-box bg-base-100 border border-base-content/5 shadow-2xl relative">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
onClick={() => setShareLink(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div className="flex flex-col items-center text-center gap-6 py-4">
|
||||
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<LockIcon size={32} weight="fill" className="text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-serif text-3xl">Sealed & Ready</h3>
|
||||
<p className="text-base-content/60 text-sm max-w-xs">
|
||||
This letter is now encrypted. Share this secret link with your
|
||||
recipient.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex items-center gap-2 bg-base-300 p-2 rounded-xl group relative">
|
||||
<input
|
||||
readOnly
|
||||
value={shareLink}
|
||||
className="flex-1 bg-transparent text-xs font-mono px-2 overflow-hidden text-ellipsis whitespace-nowrap outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyToClipboard}
|
||||
className="btn btn-primary btn-sm rounded-lg"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] uppercase tracking-widest text-base-content/30">
|
||||
Zero-Knowledge: The key is in the link, not our servers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSaveSuccess && !shareLink && (
|
||||
<div
|
||||
className="modal modal-open bg-base-100 backdrop-blur-md transition-all duration-2000 ease-in-out
|
||||
animate-fade-in opacity-80"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { server } from "../../test/mocks/server";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import Login from "./Login";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
describe("Login Page", () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it("should render the sign-in form correctly", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Login />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Sign in to")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display a technical issues message when the server is down", async () => {
|
||||
server.use(
|
||||
http.post(`${API_URL}${endpoints.LOGIN}`, () =>
|
||||
HttpResponse.json({ detail: "Internal Server Error" }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Login />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/email/i), "test@example.com");
|
||||
await userEvent.type(screen.getByLabelText(/password/i), "password123");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
|
||||
expect(await screen.findByText(/technical issues/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should redirect to the drawer when login is successful", async () => {
|
||||
const mockUser = {
|
||||
public_id: "user-123",
|
||||
email: "test@example.com",
|
||||
full_name: "Test User",
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.post(`${API_URL}${endpoints.LOGIN}`, () =>
|
||||
HttpResponse.json({ access: "fake-token" }),
|
||||
),
|
||||
);
|
||||
server.use(
|
||||
http.get(`${API_URL}${endpoints.ME}`, () => HttpResponse.json(mockUser)),
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/drawer" element={<div>Drawer</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/email/i), "test@example.com");
|
||||
await userEvent.type(screen.getByLabelText(/password/i), "password123");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
|
||||
expect(await screen.findByText(/Drawer/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { server } from "../../test/mocks/server";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
import Reader from "./Reader";
|
||||
|
||||
// We use the same API_URL logic as our other tests
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Spy on crypto methods so we don't have to do actual decryption in the UI test
|
||||
const spyDecryptLetter = vi.spyOn(
|
||||
CryptoUtils.prototype,
|
||||
"decryptLetterWithSharingKey",
|
||||
);
|
||||
const spyDecryptMetadata = vi.spyOn(
|
||||
CryptoUtils.prototype,
|
||||
"decryptMetadataWithSharingKey",
|
||||
);
|
||||
const spyDecryptImage = vi.spyOn(
|
||||
CryptoUtils.prototype,
|
||||
"decryptImageWithSharingKey",
|
||||
);
|
||||
|
||||
// Fabric.js needs to know when fonts are loaded
|
||||
Object.defineProperty(document, "fonts", {
|
||||
value: { ready: Promise.resolve() },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
describe("Reader Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default mock behavior for successful decryption
|
||||
spyDecryptLetter.mockResolvedValue('{"objects": []}');
|
||||
spyDecryptMetadata.mockResolvedValue({ recipient: "Guest" });
|
||||
spyDecryptImage.mockResolvedValue("blob:url");
|
||||
|
||||
// Clear the URL hash
|
||||
vi.stubGlobal("location", {
|
||||
hash: "",
|
||||
href: "http://localhost/",
|
||||
});
|
||||
});
|
||||
|
||||
it("should notify the user if the sharing key is missing from the URL", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/read/123"]}>
|
||||
<Routes>
|
||||
<Route path="/read/:public_id" element={<Reader />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/No sharing key provided/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should load and decrypt the letter when a valid key is provided", async () => {
|
||||
const mockPublicId = "test-uuid";
|
||||
const mockKey = "fake-key";
|
||||
|
||||
// Mock the server response using MSW
|
||||
server.use(
|
||||
http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => {
|
||||
return HttpResponse.json({
|
||||
encrypted_content: "packed-content",
|
||||
encrypted_metadata: "packed-metadata",
|
||||
images: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/read/${mockPublicId}#${mockKey}`]}>
|
||||
<Routes>
|
||||
<Route path="/read/:public_id" element={<Reader />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Should show loading state first
|
||||
expect(screen.getByText(/Decrypting.../i)).toBeInTheDocument();
|
||||
|
||||
// Eventually should show the decrypted recipient header
|
||||
expect(
|
||||
await screen.findByText(/A sealed message for Guest/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display an error message if the server request fails", async () => {
|
||||
const mockPublicId = "fail-uuid";
|
||||
const mockKey = "some-key";
|
||||
|
||||
server.use(
|
||||
http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/read/${mockPublicId}#${mockKey}`]}>
|
||||
<Routes>
|
||||
<Route path="/read/:public_id" element={<Reader />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Failed to load letter/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { CrossIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation, useParams } from "react-router-dom";
|
||||
import { api } from "../api/apiClient";
|
||||
import { ComposeCanvas } from "../components/ui/ComposeCanvas";
|
||||
import { endpoints } from "../config/endpoints";
|
||||
import { CryptoUtils } from "../utils/crypto";
|
||||
import { decryptCanvasImagesWithSharingKey } from "../utils/letterLogic";
|
||||
|
||||
export default function Reader() {
|
||||
const { public_id } = useParams();
|
||||
const location = useLocation();
|
||||
const sharingKey = location.hash.replace("#", "");
|
||||
const [isDecrypting, setIsDecrypting] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [canvasData, setCanvasData] = useState<any>(null);
|
||||
const [metadata, setMetadata] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sharingKey) {
|
||||
setError("No sharing key provided. Please check the link.");
|
||||
setIsDecrypting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadAndDecrypt = async () => {
|
||||
try {
|
||||
const response = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
||||
const { encrypted_content, encrypted_metadata, images } = response.data;
|
||||
|
||||
const crypto = new CryptoUtils();
|
||||
|
||||
// 1. Decrypt metadata using the sharing key from the URL
|
||||
const decryptedMetadata = await crypto.decryptMetadataWithSharingKey(
|
||||
encrypted_metadata,
|
||||
sharingKey,
|
||||
);
|
||||
setMetadata(decryptedMetadata);
|
||||
|
||||
// 2. Decrypt the main letter content
|
||||
const decryptedContent = await crypto.decryptLetterWithSharingKey(
|
||||
encrypted_content,
|
||||
sharingKey,
|
||||
);
|
||||
const json = JSON.parse(decryptedContent);
|
||||
|
||||
// 3. Batch decrypt any images on the canvas
|
||||
if (images && images.length > 0) {
|
||||
await decryptCanvasImagesWithSharingKey(json, images, sharingKey);
|
||||
}
|
||||
|
||||
setCanvasData(json);
|
||||
setIsDecrypting(false);
|
||||
} catch (err: any) {
|
||||
console.error("Reader Error:", err);
|
||||
setError(`Failed to load letter: ${err.message || "Unknown error"}`);
|
||||
setIsDecrypting(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAndDecrypt();
|
||||
}, [public_id, sharingKey]);
|
||||
|
||||
if (isDecrypting) {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 flex flex-col items-center justify-center p-8">
|
||||
<span className="loading loading-ring loading-lg text-primary"></span>
|
||||
<p className="mt-4 text-sm opacity-50 font-medium">Decrypting...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="alert alert-error max-w-md shadow-lg">
|
||||
<CrossIcon size={24} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost mt-6"
|
||||
onClick={() => (window.location.href = "/")}
|
||||
>
|
||||
Back to Home
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-base-200 flex flex-col items-center justify-center p-8 gap-4 overflow-hidden">
|
||||
{metadata?.recipient && (
|
||||
<div className="mb-6 animate-in fade-in slide-in-from-top duration-1000">
|
||||
<h2 className="text-xl font-serif text-base-content/60 italic">
|
||||
A sealed message for {metadata.recipient}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
{canvasData && <ComposeCanvas initialData={canvasData} readOnly={true} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user