Skip to content

Commit 6a6ae51

Browse files
committed
UI
1 parent 23c87db commit 6a6ae51

8 files changed

Lines changed: 277 additions & 40 deletions

File tree

apps/desktop-ui/src/app/app/notes/context/NotesContext.tsx

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { auth } from "@/database/firebase";
77
import { useTranslations } from "next-intl";
88
import { fetchAllPages } from "@/lib/fetch-all-pages";
99
import { proxyJsonAuthed } from "@/lib/backend-auth";
10+
import { useCipherKey, cipherKeyErrorMessage } from "@/lib/use-cipher-key";
11+
import { encryptField, decryptField, isEnvelope, type ContentEnvelope } from "@/lib/content-envelope";
1012

1113
const BACKEND_BASE_URL: string =
1214
process.env.NEXT_PUBLIC_FASTAPI_BASE_URL ||
@@ -52,14 +54,49 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
5254
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
5355
const [focusMode, setFocusMode] = useState(false);
5456
const contentLoadedIds = useRef<Set<string>>(new Set());
57+
// Notes whose content is an envelope we couldn't decrypt (vault locked). Their
58+
// in-state `content` is a null placeholder — writing it back would clobber the
59+
// real body, so content writes are blocked until unlock.
60+
const lockedIds = useRef<Set<string>>(new Set());
61+
62+
// Zero-knowledge: note bodies are AES-GCM envelopes on the wire; `notes` state
63+
// always holds decrypted content. Key comes from the master-password gate.
64+
const cipherKey = useCipherKey();
5565

5666
// Refs to read latest state inside stable action callbacks
5767
const notesRef = useRef<Note[]>(notes);
5868
const activeNoteIdRef = useRef<string | null>(activeNoteId);
5969
const userRef = useRef(user);
70+
const cipherKeyRef = useRef<CryptoKey | null>(cipherKey);
6071
useEffect(() => { notesRef.current = notes; }, [notes]);
6172
useEffect(() => { activeNoteIdRef.current = activeNoteId; }, [activeNoteId]);
6273
useEffect(() => { userRef.current = user; }, [user]);
74+
useEffect(() => { cipherKeyRef.current = cipherKey; }, [cipherKey]);
75+
76+
// Decrypt a note's content envelope into plaintext for in-state use. Legacy
77+
// plaintext notes (pre-encryption) pass through untouched. When locked, the
78+
// body becomes a null placeholder and the note is flagged in `lockedIds`.
79+
const decryptNote = useCallback(async (n: Note): Promise<Note> => {
80+
if (!isEnvelope(n.content)) { lockedIds.current.delete(n.id); return n; }
81+
const key = cipherKeyRef.current;
82+
if (!key) { lockedIds.current.add(n.id); return { ...n, content: null }; }
83+
try {
84+
const content = await decryptField(key, n.content as ContentEnvelope);
85+
lockedIds.current.delete(n.id);
86+
return { ...n, content };
87+
} catch {
88+
lockedIds.current.add(n.id);
89+
return { ...n, content: null };
90+
}
91+
}, []);
92+
93+
// Encrypt a plaintext body into an envelope for the wire. Throws when locked
94+
// so callers surface the reason instead of silently persisting an empty body.
95+
const encryptContent = useCallback(async (content: unknown): Promise<ContentEnvelope> => {
96+
const key = cipherKeyRef.current;
97+
if (!key) throw new Error(cipherKeyErrorMessage());
98+
return encryptField(key, content);
99+
}, []);
63100

64101
const apiRequest = useCallback(
65102
async <T,>(method: string, path: string, body?: unknown): Promise<T> => {
@@ -86,24 +123,27 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
86123
),
87124
});
88125
contentLoadedIds.current.clear();
89-
setNotes([...allNotes].sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
90-
}, [apiRequest]);
126+
const decrypted = await Promise.all(allNotes.map(decryptNote));
127+
setNotes([...decrypted].sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
128+
}, [apiRequest, decryptNote]);
91129

92130
useEffect(() => {
93131
if (!activeNoteId || contentLoadedIds.current.has(activeNoteId)) return;
94132
let cancelled = false;
95133
setIsContentLoading(true);
96134
apiRequest<Note>("GET", `/api/v1/notes/${activeNoteId}`)
97-
.then((full) => {
98-
if (cancelled) return;
135+
.then(async (full) => {
99136
if (!full?.id) return;
100-
contentLoadedIds.current.add(activeNoteId);
101-
setNotes((prev) => prev.map((n) => (n.id === full.id ? full : n)));
137+
const dec = await decryptNote(full);
138+
if (cancelled) return;
139+
// Only mark loaded once the body is actually available (unlocked).
140+
if (!lockedIds.current.has(dec.id)) contentLoadedIds.current.add(activeNoteId);
141+
setNotes((prev) => prev.map((n) => (n.id === dec.id ? dec : n)));
102142
})
103143
.catch(() => { /* note may have been deleted */ })
104144
.finally(() => { if (!cancelled) setIsContentLoading(false); });
105145
return () => { cancelled = true; };
106-
}, [activeNoteId, apiRequest]);
146+
}, [activeNoteId, apiRequest, decryptNote]);
107147

108148
useEffect(() => {
109149
if (!user) {
@@ -121,28 +161,38 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
121161
setIsLoading(false);
122162
}
123163
})();
124-
}, [refreshNotes, user]);
164+
// cipherKey in deps: re-fetch + decrypt bodies once the vault unlocks.
165+
}, [refreshNotes, user, cipherKey]);
125166

126167
const createNote = useCallback(async (parentId: string | null = null) => {
127168
if (!userRef.current) throw new Error(t("authRequiredError"));
128169
const created = await apiRequest<Note>("POST", "/api/v1/notes", {
129170
title: t("defaultTitle"),
130-
content: {},
171+
content: await encryptContent({}),
131172
parentId,
132173
icon: undefined,
133174
});
134175
if (!created?.id) throw new Error("Note create failed");
135176
contentLoadedIds.current.add(created.id);
136177
setActiveNoteId(created.id);
137-
setNotes((prev) => [...prev, created].sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
178+
// Server echoes the envelope back; keep decrypted (empty) body in state.
179+
const local: Note = { ...created, content: {} };
180+
setNotes((prev) => [...prev, local].sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
138181
return created.id;
139-
}, [apiRequest, t]);
182+
}, [apiRequest, t, encryptContent]);
140183

141184
const updateNote = useCallback(async (id: string, updates: Partial<Note>) => {
142185
if (!userRef.current) return;
143-
const payload: Partial<Pick<Note, "title" | "content" | "parentId" | "icon" | "pinned" | "tags">> = {};
186+
// Block body writes while locked — persisting would clobber the real
187+
// (undecryptable) content with an empty one.
188+
if (updates.content !== undefined && (lockedIds.current.has(id) || !cipherKeyRef.current)) {
189+
throw new Error(cipherKeyErrorMessage());
190+
}
191+
192+
// Plaintext metadata for the wire; encrypted body swapped in below.
193+
const payload: Record<string, unknown> = {};
144194
if (updates.title !== undefined) payload.title = updates.title;
145-
if (updates.content !== undefined) payload.content = updates.content;
195+
if (updates.content !== undefined) payload.content = await encryptContent(updates.content);
146196
if (updates.parentId !== undefined) payload.parentId = updates.parentId;
147197
if (updates.icon !== undefined) payload.icon = updates.icon;
148198
if (updates.pinned !== undefined) payload.pinned = updates.pinned;
@@ -151,8 +201,11 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
151201
const updated = await apiRequest<Note>("PATCH", `/api/v1/notes/${id}`, payload);
152202
if (!updated?.id) throw new Error("Update failed: empty response");
153203
if (updates.content !== undefined) contentLoadedIds.current.add(id);
154-
setNotes((prev) => prev.map((n) => (n.id === updated.id ? updated : n)));
155-
}, [apiRequest]);
204+
// Merge server echo but keep the plaintext body we already hold in memory.
205+
setNotes((prev) => prev.map((n) => (n.id === updated.id
206+
? { ...updated, content: updates.content !== undefined ? updates.content : n.content }
207+
: n)));
208+
}, [apiRequest, encryptContent]);
156209

157210
const pinNote = useCallback(async (id: string, pinned: boolean) => {
158211
await updateNote(id, { pinned });
@@ -165,23 +218,26 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
165218
if (!contentLoadedIds.current.has(id)) {
166219
const full = await apiRequest<Note>("GET", `/api/v1/notes/${id}`);
167220
if (!full?.id) throw new Error("Note fetch failed");
168-
contentLoadedIds.current.add(id);
169-
setNotes((prev) => prev.map((n) => (n.id === full.id ? full : n)));
170-
content = full.content;
221+
const dec = await decryptNote(full);
222+
if (!lockedIds.current.has(dec.id)) contentLoadedIds.current.add(id);
223+
setNotes((prev) => prev.map((n) => (n.id === dec.id ? dec : n)));
224+
content = dec.content;
171225
}
226+
if (lockedIds.current.has(id) || !cipherKeyRef.current) throw new Error(cipherKeyErrorMessage());
172227
const created = await apiRequest<Note>("POST", "/api/v1/notes", {
173228
title: `${src.title || t("defaultTitle")} (copy)`,
174-
content,
229+
content: await encryptContent(content),
175230
parentId: src.parentId ?? null,
176231
icon: src.icon,
177232
tags: src.tags,
178233
});
179234
if (!created?.id) throw new Error("Note duplicate failed");
180235
contentLoadedIds.current.add(created.id);
181236
setActiveNoteId(created.id);
182-
setNotes((prev) => [...prev, created].sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
237+
const local: Note = { ...created, content };
238+
setNotes((prev) => [...prev, local].sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
183239
return created.id;
184-
}, [apiRequest, t]);
240+
}, [apiRequest, t, decryptNote, encryptContent]);
185241

186242
const moveNote = useCallback(async (id: string, newParentId: string | null) => {
187243
await updateNote(id, { parentId: newParentId });

apps/desktop-ui/src/app/app/notes/page.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import dynamic from "next/dynamic";
44
import useAuth from "@/utils/useAuth";
55
import { useTranslations } from "next-intl";
66
import { Skeleton } from "@/components/ui/skeleton";
7+
import { useVaultGuard } from "@/hooks/use-vault-guard";
8+
import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder";
9+
import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton";
710

811
const NotionEditor = dynamic(() => import("@/components/notes/NotionEditor"), {
912
ssr: false,
@@ -23,6 +26,7 @@ const NotionEditor = dynamic(() => import("@/components/notes/NotionEditor"), {
2326

2427
export default function NotesPage() {
2528
const { user, loading } = useAuth(true);
29+
const { isUnlocked, isRestoring } = useVaultGuard();
2630
const t = useTranslations("Notes.page");
2731

2832
if (loading) {
@@ -46,5 +50,10 @@ export default function NotesPage() {
4650
return null;
4751
}
4852

53+
// Zero-knowledge: note bodies are encrypted with the master password. Gate the
54+
// tool until the vault is unlocked (mirrors password-manager / env-manager).
55+
if (isRestoring) return <VaultRestoringSkeleton />;
56+
if (!isUnlocked) return <VaultLockedPlaceholder appName="Notes" />;
57+
4958
return <NotionEditor />;
5059
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1+
"use client";
12
import { SnippetManagerTool } from "@/components/snippet-manager/snippet-manager-tool";
3+
import { useVaultGuard } from "@/hooks/use-vault-guard";
4+
import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder";
5+
import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton";
6+
27
export default function SnippetManagerPage() {
8+
// Zero-knowledge: snippet code is encrypted with the master password. Gate the
9+
// tool until the vault is unlocked (mirrors password-manager / env-manager).
10+
const { isUnlocked, isRestoring } = useVaultGuard();
11+
if (isRestoring) return <VaultRestoringSkeleton />;
12+
if (!isUnlocked) return <VaultLockedPlaceholder appName="Snippet Manager" />;
313
return <SnippetManagerTool />;
414
}

apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
listCodeSnippetsApi,
1515
patchCodeSnippetApi,
1616
} from "@/lib/code-snippets-api";
17+
import { useCipherKey } from "@/lib/use-cipher-key";
1718
import {
1819
IconPlus,
1920
IconTrash,
@@ -99,6 +100,12 @@ export function SnippetManagerTool() {
99100
const userRef = useRef(user);
100101
userRef.current = user;
101102

103+
// Zero-knowledge: snippet code is encrypted with the workspace cipher key
104+
// before it leaves the app. Ref so the stable async callbacks read the latest.
105+
const cipherKey = useCipherKey();
106+
const cipherKeyRef = useRef(cipherKey);
107+
cipherKeyRef.current = cipherKey;
108+
102109
const snippets = useSnippetManagerStore((s) => s.snippets);
103110
const addSnippet = useSnippetManagerStore((s) => s.addSnippet);
104111
const updateSnippet = useSnippetManagerStore((s) => s.updateSnippet);
@@ -129,7 +136,7 @@ export function SnippetManagerTool() {
129136
useSnippetManagerStore.getState().updateSnippet(id, { code });
130137
const u = userRef.current;
131138
if (u) {
132-
void patchCodeSnippetApi(id, { code })
139+
void patchCodeSnippetApi(id, { code }, cipherKeyRef.current)
133140
.then((s) =>
134141
useSnippetManagerStore.getState().mergeSnippetFromRemote(s)
135142
)
@@ -147,7 +154,7 @@ export function SnippetManagerTool() {
147154
useSnippetManagerStore.getState().updateSnippet(id, { title, language });
148155
const u = userRef.current;
149156
if (u) {
150-
void patchCodeSnippetApi(id, { title, language })
157+
void patchCodeSnippetApi(id, { title, language }, cipherKeyRef.current)
151158
.then((s) =>
152159
useSnippetManagerStore.getState().mergeSnippetFromRemote(s)
153160
)
@@ -178,6 +185,8 @@ export function SnippetManagerTool() {
178185

179186
useEffect(() => {
180187
if (!storeHydrated || authLoading) return;
188+
// Server-backed snippets need the cipher key to en/decrypt code — wait for unlock.
189+
if (user && !cipherKey) return;
181190

182191
if (!user) {
183192
if (guestBootstrapped.current) return;
@@ -207,7 +216,8 @@ export function SnippetManagerTool() {
207216
let cancelled = false;
208217
(async () => {
209218
try {
210-
const remote = await listCodeSnippetsApi();
219+
const key = cipherKeyRef.current;
220+
const remote = await listCodeSnippetsApi(key);
211221
if (cancelled) return;
212222
if (remote.length > 0) {
213223
importSnippets(remote);
@@ -223,12 +233,12 @@ export function SnippetManagerTool() {
223233
code: sn.code,
224234
createdAt: sn.createdAt,
225235
updatedAt: sn.updatedAt,
226-
});
236+
}, key);
227237
} catch (e) {
228238
if (!isSnippetDuplicateError(e)) throw e;
229239
}
230240
}
231-
const again = await listCodeSnippetsApi();
241+
const again = await listCodeSnippetsApi(key);
232242
if (cancelled) return;
233243
importSnippets(again.length > 0 ? again : local);
234244
setRemoteListEpoch((e) => e + 1);
@@ -240,7 +250,7 @@ export function SnippetManagerTool() {
240250
title: t("defaultSnippetTitle"),
241251
language: SNIPPET_LANGUAGE_AUTO,
242252
code: t("defaultSnippetCode"),
243-
});
253+
}, key);
244254
if (cancelled) return;
245255
importSnippets([created]);
246256
setRemoteListEpoch((e) => e + 1);
@@ -263,7 +273,7 @@ export function SnippetManagerTool() {
263273
return () => {
264274
cancelled = true;
265275
};
266-
}, [storeHydrated, authLoading, user, t, importSnippets]);
276+
}, [storeHydrated, authLoading, user, t, importSnippets, cipherKey]);
267277

268278
useEffect(() => {
269279
debouncedSaveCode.cancel();
@@ -332,7 +342,7 @@ export function SnippetManagerTool() {
332342
updateSnippet(id, { code });
333343
const u = userRef.current;
334344
if (u) {
335-
void patchCodeSnippetApi(id, { code })
345+
void patchCodeSnippetApi(id, { code }, cipherKeyRef.current)
336346
.then((s) => useSnippetManagerStore.getState().mergeSnippetFromRemote(s))
337347
.catch(() => toast.error(t("toastSyncFailed")));
338348
}
@@ -348,7 +358,7 @@ export function SnippetManagerTool() {
348358
updateSnippet(id, { pinned: newPinned });
349359
const u = userRef.current;
350360
if (u) {
351-
void patchCodeSnippetApi(id, { pinned: newPinned })
361+
void patchCodeSnippetApi(id, { pinned: newPinned }, cipherKeyRef.current)
352362
.then((s) => mergeSnippetFromRemote(s))
353363
.catch(() => toast.error(t("toastSyncFailed")));
354364
}
@@ -365,7 +375,7 @@ export function SnippetManagerTool() {
365375
updateSnippet(selectedId, { tags: next });
366376
const u = userRef.current;
367377
if (u) {
368-
void patchCodeSnippetApi(selectedId, { tags: next })
378+
void patchCodeSnippetApi(selectedId, { tags: next }, cipherKeyRef.current)
369379
.then((s) => mergeSnippetFromRemote(s))
370380
.catch(() => toast.error(t("toastSyncFailed")));
371381
}
@@ -381,7 +391,7 @@ export function SnippetManagerTool() {
381391
updateSnippet(selectedId, { tags: next });
382392
const u = userRef.current;
383393
if (u) {
384-
void patchCodeSnippetApi(selectedId, { tags: next })
394+
void patchCodeSnippetApi(selectedId, { tags: next }, cipherKeyRef.current)
385395
.then((s) => mergeSnippetFromRemote(s))
386396
.catch(() => toast.error(t("toastSyncFailed")));
387397
}
@@ -414,7 +424,7 @@ export function SnippetManagerTool() {
414424
};
415425
if (u) {
416426
try {
417-
const created = await createCodeSnippetApi(payload);
427+
const created = await createCodeSnippetApi(payload, cipherKeyRef.current);
418428
useSnippetManagerStore.getState().mergeSnippetFromRemote(created);
419429
setSelectedId(created.id);
420430
} catch {
@@ -442,7 +452,7 @@ export function SnippetManagerTool() {
442452
const u = userRef.current;
443453
if (u) {
444454
try {
445-
const created = await createCodeSnippetApi(payload);
455+
const created = await createCodeSnippetApi(payload, cipherKeyRef.current);
446456
useSnippetManagerStore.getState().mergeSnippetFromRemote(created);
447457
setSelectedId(created.id);
448458
} catch {

0 commit comments

Comments
 (0)