+
{preview}
-
- {timestamp}
-
+ {unlock_at ? (
+
+ {isLocked ? (
+
+
+ Locked Until {unlock_at}
+
+ ) : (
+
+ Unlocked
+
+ )}
+
+ ) : (
+
+ {timestamp}
+
+ )}
);
}
diff --git a/frontend/src/pages/Drawer.tsx b/frontend/src/pages/Drawer.tsx
index bda849d..521a1a4 100644
--- a/frontend/src/pages/Drawer.tsx
+++ b/frontend/src/pages/Drawer.tsx
@@ -7,7 +7,10 @@ import { LetterItem } from "../components/ui/LetterItem";
import { PATHS } from "../config/routes";
import { useAuth } from "../hooks/useAuth";
import { useLetters } from "../hooks/useLetters";
-import { formatRelativeDate } from "../utils/dateFormat.ts";
+import {
+ formateRelativeDateWithoutTime,
+ formatRelativeDate,
+} from "../utils/dateFormat.ts";
export default function Drawer() {
const { user, logout, unlock } = useAuth();
@@ -172,6 +175,8 @@ export default function Drawer() {
id={letter.public_id}
preview={letter.metadata?.recipient || "Future Self"}
timestamp={formatRelativeDate(letter.updated_at)}
+ unlock_at={formateRelativeDateWithoutTime(letter.unlock_at)}
+ isLocked={letter.unlock_at > new Date().toISOString()}
/>
))}
diff --git a/frontend/src/pages/Editor.test.tsx b/frontend/src/pages/Editor.test.tsx
new file mode 100644
index 0000000..63768d0
--- /dev/null
+++ b/frontend/src/pages/Editor.test.tsx
@@ -0,0 +1,169 @@
+import { fireEvent, render, screen, waitFor } 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 { mockMasterKey } from "../../test/fixtures/auth.fixture";
+import { mockUser } from "../../test/fixtures/user.fixture";
+import { server } from "../../test/mocks/server";
+import { endpoints } from "../config/endpoints";
+import { useAuthStore } from "../store/useAuthStore";
+import { useKeyStore } from "../store/useKeyStore";
+import Editor from "./Editor";
+
+const API_URL = import.meta.env.VITE_API_URL;
+
+// Mock ComposeCanvas to avoid Fabric.js issues and check readOnly prop
+vi.mock("../components/ui/ComposeCanvas", () => ({
+ ComposeCanvas: vi.fn(({ readOnly }) => (
+
+ Canvas
+
+ )),
+}));
+
+// Mock CryptoUtils to avoid real crypto calls in UI tests
+vi.mock("../utils/crypto", () => {
+ return {
+ CryptoUtils: vi.fn().mockImplementation(function () {
+ return {
+ initialize: vi.fn().mockResolvedValue(undefined),
+ encryptLetter: vi.fn().mockResolvedValue({
+ encrypted_content: "enc-content",
+ encrypted_dek: "enc-dek",
+ sharingKey: "share-key",
+ }),
+ encryptMetadata: vi.fn().mockResolvedValue({
+ encrypted_content: "enc-meta",
+ encrypted_dek: "enc-dek",
+ }),
+ decryptMetadata: vi.fn().mockResolvedValue({ recipient: "Test User" }),
+ decryptLetter: vi.fn().mockResolvedValue("{}"),
+ };
+ }),
+ };
+});
+
+describe("Editor Page", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useAuthStore.setState({
+ user: mockUser,
+ accessToken: "fake-token",
+ isInitializing: false,
+ });
+ useKeyStore.setState({ masterKey: mockMasterKey });
+ });
+
+ it("should set canvas to readOnly when status is VAULT", async () => {
+ server.use(
+ http.get(`${API_URL}${endpoints.LETTERS}:id/`, () => {
+ return HttpResponse.json({
+ public_id: "test-id",
+ status: "DRAFT",
+ updated_at: new Date().toISOString(),
+ encrypted_content: "{}",
+ encrypted_metadata: "{}",
+ encrypted_dek: "wrapped-dek",
+ });
+ }),
+ http.put(`${API_URL}${endpoints.LETTERS}:id/`, () => {
+ return HttpResponse.json({ status: "success" });
+ }),
+ );
+
+ const { container } = render(
+
+
+ } />
+
+ ,
+ );
+
+ // Wait for initial load to complete
+ await waitFor(() => {
+ expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
+ });
+
+ // Initial state: DRAFT (not read-only)
+ const canvas = screen.getByTestId("canvas");
+ expect(canvas.getAttribute("data-readonly")).toBe("false");
+
+ // Click Seal in the main toolbar (it's in the div with id="writer-toolbar")
+ const toolbar = container.querySelector("#writer-toolbar");
+ const sealBtn = toolbar?.querySelector(".btn-primary");
+ if (!sealBtn) throw new Error("Seal button not found");
+ fireEvent.click(sealBtn);
+
+ // Click Vault to show confirm modal
+ const vaultBtn = screen.getByRole("button", { name: /vault/i });
+ fireEvent.click(vaultBtn);
+
+ // Set date and submit vault form
+ const dateInput = container.querySelector('input[name="vault-date"]');
+ if (!dateInput) throw new Error("Date input not found");
+ fireEvent.change(dateInput, { target: { value: "2026-12-31" } });
+
+ const confirmVaultBtn = container.querySelector(
+ 'button[form="vault-form"]',
+ );
+ if (!confirmVaultBtn) throw new Error("Confirm vault button not found");
+ fireEvent.click(confirmVaultBtn);
+
+ // Wait for save to complete and check readOnly
+ await waitFor(() => {
+ expect(screen.getByText(/Your letter is saved/i)).toBeInTheDocument();
+ });
+
+ expect(canvas.getAttribute("data-readonly")).toBe("true");
+ expect(screen.getByLabelText(/recipient/i)).toBeDisabled();
+ });
+
+ it("should set canvas to readOnly when status is SEALED", async () => {
+ server.use(
+ http.get(`${API_URL}${endpoints.LETTERS}:id/`, () => {
+ return HttpResponse.json({
+ public_id: "test-id",
+ status: "DRAFT",
+ updated_at: new Date().toISOString(),
+ encrypted_content: "{}",
+ encrypted_metadata: "{}",
+ encrypted_dek: "wrapped-dek",
+ });
+ }),
+ http.put(`${API_URL}${endpoints.LETTERS}:id/`, () => {
+ return HttpResponse.json({ status: "success" });
+ }),
+ );
+
+ const { container } = render(
+
+
+ } />
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByText(/Opening your draft/i)).not.toBeInTheDocument();
+ });
+
+ const canvas = screen.getByTestId("canvas");
+
+ const toolbar = container.querySelector("#writer-toolbar");
+ const sealBtn = toolbar?.querySelector(".btn-primary");
+ if (!sealBtn) throw new Error("Seal button not found");
+ fireEvent.click(sealBtn);
+
+ // The secondary seal button appears (it has btn-accent class)
+ const secondarySealBtn = container.querySelector(".btn-accent");
+ if (!secondarySealBtn) throw new Error("Secondary seal button not found");
+ fireEvent.click(secondarySealBtn);
+
+ await waitFor(() => {
+ expect(screen.getByText(/Sealed & Ready/i)).toBeInTheDocument();
+ });
+
+ expect(canvas.getAttribute("data-readonly")).toBe("true");
+ expect(screen.getByLabelText(/recipient/i)).toBeDisabled();
+ });
+});
diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx
index 07163dd..82a552f 100644
--- a/frontend/src/pages/Editor.tsx
+++ b/frontend/src/pages/Editor.tsx
@@ -38,6 +38,12 @@ const OVERLAY_FADE_MS = 250;
const SAVED_VISIBLE_MS = 1400;
const ERROR_VISIBLE_MS = 2400;
+const toPlaceholderList = [
+ "Someone dear...",
+ "Somewhere near...",
+ "Something to bear...",
+];
+
export default function Editor() {
const navigate = useNavigate();
const navigateRef = useRef
(navigate);
@@ -65,33 +71,36 @@ export default function Editor() {
const [saveOverlay, setSaveOverlay] = useState("idle");
const [showSaveOverlay, setShowSaveOverlay] = useState(false);
+ const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
+ null,
+ );
const [recipient, setRecipient] = useState("");
+ const [unlockDate, setUnlockDate] = useState(null);
+
const { masterKey } = useKeyStore();
const canvasRef = useRef(null);
const fileInputRef = useRef(null);
-
- const [offset, setOffset] = useState(0);
- const toPlaceholderList = [
- "Someone dear...",
- "Somewhere near...",
- "Something to bear...",
- ];
- const [toPlaceholder, setToPlaceholder] = useState(toPlaceholderList[0]);
+ const recipientInputRef = useRef(null);
useEffect(() => {
const interval = setInterval(() => {
- setOffset((offset) => {
- const nextOffset = offset + 1;
- setToPlaceholder(toPlaceholderList[offset % toPlaceholderList.length]);
- console.log("Setting to ", toPlaceholder);
- return nextOffset;
- });
+ if (recipientInputRef.current) {
+ let currentOffset = parseInt(
+ recipientInputRef.current.dataset.offset || "0",
+ );
+ recipientInputRef.current.dataset.offset = (
+ (currentOffset + 1) %
+ toPlaceholderList.length
+ ).toString();
+ recipientInputRef.current.placeholder =
+ toPlaceholderList[parseInt(recipientInputRef.current.dataset.offset)];
+ }
}, 4000);
return () => clearInterval(interval);
- }, [offset, toPlaceholder]);
+ }, []);
useEffect(() => {
if (!(public_id && masterKey)) return;
@@ -221,6 +230,7 @@ export default function Editor() {
const handleSave = async (
status: "SEALED" | "DRAFT" | "VAULT",
+ vaultDate?: Date,
): Promise => {
setSealBtnClicked(false);
@@ -260,8 +270,12 @@ export default function Editor() {
const formData = new FormData();
if (status === "VAULT") {
+ const finalDate = vaultDate || unlockDate;
+ console.log(finalDate?.toISOString());
formData.append("type", "VAULT");
- formData.append("unlock_at", "");
+ if (finalDate) {
+ formData.append("unlock_at", finalDate.toISOString());
+ }
formData.append("status", "SEALED");
} else {
formData.append("type", "KEPT");
@@ -393,14 +407,14 @@ export default function Editor() {