feat: implement vaulting functionality with unlock dates and locked UI states for letters

This commit is contained in:
ramvignesh-b
2026-04-19 01:20:55 +05:30
parent e68dcb068b
commit 1c1b5ea14e
8 changed files with 340 additions and 48 deletions
+14
View File
@@ -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
+1 -1
View File
@@ -25,7 +25,7 @@ export function DrawerSection({
<div
className={`transition-normal duration-1000 ease-in-out bg-neutral/10 ${
isOpen
? "max-h-125 opacity-100 py-3 border-b border-base-content/5"
? "opacity-100 py-3 border-b border-base-content/5"
: "max-h-0 opacity-0 pointer-events-none"
}`}
>
+26 -5
View File
@@ -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({
<button
type="button"
onClick={handleNavigate}
className="p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2"
className={`${isLocked ? "pointer-events-none" : ""} p-4 border-base-content/3 flex items-start gap-4 hover:bg-base-300 transition-all duration-100 group text-left cursor-pointer w-9/12 mx-auto hover:scale-120 hover:h-24 hover:-translate-y-3 hover:pb-4 hover:border-x-5 hover:border-t-5 border-t-2 hover:-mb-2`}
>
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none">
<div className="text-[0.85rem] italic text-base-content/40 flex-1 truncate group-hover:text-base-content/60 transition-none animate-[opacity_200ms_linear_forwards]">
{preview}
</div>
<div className="font-sans text-[0.6rem] text-base-content/20 transition-none">
{timestamp}
</div>
{unlock_at ? (
<div className="flex flex-col items-end">
{isLocked ? (
<div className="font-sans text-xs badge badge-accent badge-soft rounded-2xl">
<LockIcon weight="duotone" size={16} />
Locked Until {unlock_at}
</div>
) : (
<div className="font-sans text-xs badge badge-primary badge-soft rounded-2xl">
<LockKeyOpenIcon weight="duotone" size={16} /> Unlocked
</div>
)}
</div>
) : (
<div className="font-sans text-[0.6rem] text-base-content/20 transition-none">
{timestamp}
</div>
)}
</button>
);
}
+6 -1
View File
@@ -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()}
/>
))}
</DrawerSection>
+169
View File
@@ -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 }) => (
<div data-testid="canvas" data-readonly={readOnly}>
Canvas
</div>
)),
}));
// 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(
<MemoryRouter initialEntries={["/write/test-id"]}>
<Routes>
<Route path="/write/:public_id" element={<Editor />} />
</Routes>
</MemoryRouter>,
);
// 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(
<MemoryRouter initialEntries={["/write/test-id"]}>
<Routes>
<Route path="/write/:public_id" element={<Editor />} />
</Routes>
</MemoryRouter>,
);
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();
});
});
+99 -21
View File
@@ -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<NavigateFunction>(navigate);
@@ -65,33 +71,36 @@ export default function Editor() {
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("idle");
const [showSaveOverlay, setShowSaveOverlay] = useState(false);
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
null,
);
const [recipient, setRecipient] = useState("");
const [unlockDate, setUnlockDate] = useState<Date | null>(null);
const { masterKey } = useKeyStore();
const canvasRef = useRef<CanvasTools>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [offset, setOffset] = useState(0);
const toPlaceholderList = [
"Someone dear...",
"Somewhere near...",
"Something to bear...",
];
const [toPlaceholder, setToPlaceholder] = useState(toPlaceholderList[0]);
const recipientInputRef = useRef<HTMLInputElement>(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<void> => {
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() {
<button
type="button"
className="btn btn-neutral btn-sm rounded-full px-6 group"
onClick={() => handleSave("VAULT")}
onClick={() => setConfirmModal("VAULT")}
>
<VaultIcon size={16} weight="fill" className="mr-1" />
<span className="transition-all duration-1000">Vault</span>
</button>
</div>
<button
onClick={() => window.alert("Message")}
onClick={() => setSealBtnClicked(false)}
className={`bg-transparent cursor-pointer -mt-2 absolute z-1000001 right-0 text-primary ${sealBtnClicked ? "" : "hidden"}`}
>
<QuestionIcon weight="duotone" size={20} className={""} />
@@ -422,6 +436,66 @@ export default function Editor() {
);
}
function VaultConfirm() {
return (
<div className={"modal modal-open bg-base-100/20 backdrop-blur-md"}>
<div className="modal-box p-12 flex flex-col items-center">
<VaultIcon
size={48}
className="text-primary mx-auto mb-8 animate-pulse"
/>
<h3 className="font-serif text-3xl">Vault this letter?</h3>
<p className="text-base-content/60 text-sm text-center mt-4">
Vaulting locks the letter permanently and will be{" "}
<span className={"font-bold text-primary"}>mailed</span> to you
automatically on the unlock date.
<br />
<span className={"underline"}>
You cannot edit or view the contents of the letter until then.
</span>
</p>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const unlockDateStr = formData.get("vault-date") as string;
const newUnlockDate = new Date(unlockDateStr);
setUnlockDate(newUnlockDate);
setConfirmModal(null);
handleSave("VAULT", newUnlockDate);
}}
id="vault-form"
>
<div className={"divider tracking-tightest font-display text-sm"}>
Set an unlock date
</div>
<input
required
type="date"
className="input input-bordered w-full"
name="vault-date"
/>
<button
className="btn btn-primary mt-4"
type="submit"
form="vault-form"
>
Vault
</button>
<button
type={"submit"}
className="btn btn-ghost mt-4"
onClick={() => setConfirmModal(null)}
>
Cancel
</button>
</form>
</div>
</div>
);
}
return (
<>
<Navbar
@@ -575,6 +649,8 @@ export default function Editor() {
</div>
)}
{confirmModal === "VAULT" && <VaultConfirm />}
<div className="max-w-180 mx-auto px-1 md:px-0">
<div className="flex justify-between items-end mb-16 border-b border-base-content/5 pb-8 px-0">
<div className="flex flex-col gap-2 flex-1">
@@ -587,9 +663,11 @@ export default function Editor() {
<input
id="recipient"
type="text"
placeholder={toPlaceholder}
ref={recipientInputRef}
placeholder={toPlaceholderList[0]}
data-offset={"0"}
value={recipient}
disabled={status === "SEALED"}
disabled={status !== "DRAFT"}
onChange={(e) => 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" ? <ToolBar /> : <LetterHead />}
<ComposeCanvas ref={canvasRef} readOnly={status === "SEALED"} />
<ComposeCanvas ref={canvasRef} readOnly={status !== "DRAFT"} />
</div>
</section>
</>
+7 -20
View File
@@ -152,26 +152,13 @@ export default function Reader() {
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-base-100 px-6 font-serif">
<div className="fixed inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.4)_100%)] pointer-events-none z-0" />
<div className="max-w-md w-full glass-card p-12 text-center space-y-6 z-10 animate-in fade-in zoom-in-95 duration-700">
<div className="space-y-2">
<h2 className="text-error font-display text-xl">
Something went wrong
</h2>
<p className="text-base-content/60 text-sm leading-relaxed">
{error}
</p>
</div>
<button
type="button"
className="btn btn-ghost btn-sm text-xs uppercase tracking-widest hover:text-primary transition-colors"
onClick={() => (window.location.href = "/")}
>
Return Home
</button>
</div>
</div>
<LogModal
isOpen={!!error}
onClose={() => (window.location.href = "/")}
message={error}
log={error}
status="ERROR"
/>
);
}
+18
View File
@@ -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();
}