diff --git a/.gitignore b/.gitignore index 97471a6..10c0818 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,17 @@ dist/ # Certificates certs/*.pem tmp/ +.idea/.gitignore +.idea/misc.xml +.idea/modules.xml +.idea/pi ku.iml +.idea/vcs.xml +.idea/inspectionProfiles/profiles_settings.xml +.idea/runConfigurations/pi_ku.xml +backend/.idea/.gitignore +backend/.idea/backend.iml +backend/.idea/misc.xml +backend/.idea/modules.xml +backend/.idea/vcs.xml +backend/.idea/inspectionProfiles/profiles_settings.xml +backend/.idea/runConfigurations/backend.xml diff --git a/frontend/src/components/ui/DrawerSection.tsx b/frontend/src/components/ui/DrawerSection.tsx index 71ef9fa..ba3b8bf 100644 --- a/frontend/src/components/ui/DrawerSection.tsx +++ b/frontend/src/components/ui/DrawerSection.tsx @@ -25,7 +25,7 @@ export function DrawerSection({
diff --git a/frontend/src/components/ui/LetterItem.tsx b/frontend/src/components/ui/LetterItem.tsx index ad00060..b2df74e 100644 --- a/frontend/src/components/ui/LetterItem.tsx +++ b/frontend/src/components/ui/LetterItem.tsx @@ -1,3 +1,4 @@ +import { LockIcon, LockKeyOpenIcon, LockOpenIcon } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; import { PATHS } from "../../config/routes"; @@ -6,14 +7,19 @@ export function LetterItem({ timestamp, id, status, + unlock_at, + isLocked = false, }: { preview: string; timestamp: string; id: string; status: "DRAFT" | "SEALED" | "BURNED"; + unlock_at?: string; + isLocked?: boolean; }) { const navigate = useNavigate(); function handleNavigate(): void { + if (isLocked) return; if (status === "SEALED") { navigate(PATHS.read(id)); } else { @@ -25,14 +31,29 @@ export function LetterItem({ ); } 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() {
+ + + + + + ); + } + return ( <> )} + {confirmModal === "VAULT" && } +
@@ -587,9 +663,11 @@ export default function Editor() { setRecipient(e.target.value)} className="bg-transparent border-none outline-none text-2xl md:text-3xl lg:text-4xl font-serif text-base-content placeholder:text-base-content/10 w-full disabled:opacity-50" /> @@ -599,7 +677,7 @@ export default function Editor() { {status === "DRAFT" ? : } - +
diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index 1e28e74..4cadb0a 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -152,26 +152,13 @@ export default function Reader() { if (error) { return ( -
-
-
-
-

- Something went wrong -

-

- {error} -

-
- -
-
+ (window.location.href = "/")} + message={error} + log={error} + status="ERROR" + /> ); } diff --git a/frontend/src/utils/dateFormat.ts b/frontend/src/utils/dateFormat.ts index 92acd02..65b1779 100644 --- a/frontend/src/utils/dateFormat.ts +++ b/frontend/src/utils/dateFormat.ts @@ -16,6 +16,7 @@ function startOfDay(d: Date) { } export function formatRelativeDate(input: Date | string | number) { + if (!input) return ""; const date = new Date(input); const now = new Date(); @@ -32,3 +33,20 @@ export function formatRelativeDate(input: Date | string | number) { return dateTimeFormatter.format(date); } + +export function formateRelativeDateWithoutTime(input: Date | string | number) { + if (!input) return ""; + const date = new Date(input); + const now = new Date(); + + const dayMs = 24 * 60 * 60 * 1000; + const diffDays = Math.round( + (startOfDay(date).getTime() - startOfDay(now).getTime()) / dayMs, + ); + + if (diffDays === 0) return `Today`; + if (diffDays === -1) return `Yesterday`; + if (diffDays > -7) return `${rtf.format(diffDays, "day")}`; + + return date.toDateString(); +}