Skip to content

Commit 0882e88

Browse files
atrakhevadecker
andauthored
feat: Encrypt user form data on client side (#380)
Co-authored-by: Eva Decker <itsevadecker@gmail.com>
1 parent 173a4eb commit 0882e88

File tree

8 files changed

+596
-0
lines changed

8 files changed

+596
-0
lines changed

.changeset/nice-laws-cough.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"namesake": minor
3+
---
4+
5+
Add end-to-end encryption of user form data

convex/userFormData.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
import { v } from "convex/values";
22
import { userMutation, userQuery } from "./helpers";
33

4+
export const list = userQuery({
5+
args: {},
6+
handler: async (ctx, _args) => {
7+
const userData = await ctx.db
8+
.query("userFormData")
9+
.withIndex("userId", (q) => q.eq("userId", ctx.userId))
10+
.collect();
11+
return userData;
12+
},
13+
});
14+
415
export const get = userQuery({
516
args: {},
617
handler: async (ctx, _args) => {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { Banner, Button, Form, TextField } from "@/components/common";
2+
import { decryptData, encryptData, getEncryptionKey } from "@/utils/encryption";
3+
import { api } from "@convex/_generated/api";
4+
import { useMutation } from "convex/react";
5+
import { LoaderCircle } from "lucide-react";
6+
import posthog from "posthog-js";
7+
import { useEffect, useState } from "react";
8+
9+
interface UserDataFormProps {
10+
initialData: { field: string; value: string };
11+
}
12+
13+
// Placeholder for allowing users to view and edit their form data
14+
// TODO: Replace this with a more robust implementation that manages draft state better.
15+
export function UserDataForm({ initialData }: UserDataFormProps) {
16+
const [field, setField] = useState(initialData.field);
17+
const [decryptedValue, setDecryptedValue] = useState<string>();
18+
const [initialDecryptedValue, setInitialDecryptedValue] = useState<string>();
19+
const [encryptionKey, setEncryptionKey] = useState<CryptoKey | null>(null);
20+
const [isSaving, setIsSaving] = useState(false);
21+
const isDirty =
22+
field === initialData.field && decryptedValue === initialDecryptedValue;
23+
const [didError, setDidError] = useState(false);
24+
25+
const save = useMutation(api.userFormData.set);
26+
27+
useEffect(() => {
28+
const loadEncryptionKey = async () => {
29+
try {
30+
const key = await getEncryptionKey();
31+
setEncryptionKey(key);
32+
33+
if (!key) {
34+
return;
35+
}
36+
37+
if (initialData.value) {
38+
try {
39+
const decryptedValue = await decryptData(initialData.value, key);
40+
setDecryptedValue(decryptedValue);
41+
setInitialDecryptedValue(decryptedValue);
42+
} catch (error: any) {
43+
posthog.captureException(error);
44+
setDidError(true);
45+
}
46+
} else {
47+
setDecryptedValue("");
48+
setInitialDecryptedValue("");
49+
}
50+
} catch (error: any) {
51+
posthog.captureException(error);
52+
setDidError(true);
53+
}
54+
};
55+
56+
loadEncryptionKey();
57+
}, [initialData.value]);
58+
59+
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
60+
event.preventDefault();
61+
62+
if (!encryptionKey || !decryptedValue) {
63+
console.error("No encryption key or decrypted value available");
64+
return;
65+
}
66+
67+
try {
68+
setIsSaving(true);
69+
70+
// Encrypt the value before saving
71+
const encryptedValue = await encryptData(decryptedValue, encryptionKey);
72+
await save({ field, value: encryptedValue });
73+
} finally {
74+
setIsSaving(false);
75+
}
76+
};
77+
78+
if (didError) {
79+
return (
80+
<Banner variant="danger">Error decrypting {initialData.field}</Banner>
81+
);
82+
}
83+
84+
if (encryptionKey === null || decryptedValue === undefined) {
85+
return <LoaderCircle className="w-4 h-4 animate-spin" />;
86+
}
87+
88+
const isExistingField = initialData.field !== "";
89+
90+
return (
91+
<Form onSubmit={handleSubmit}>
92+
<div className="flex gap-2 items-end">
93+
<TextField
94+
label="Field"
95+
name="field"
96+
value={field}
97+
onChange={setField}
98+
placeholder="Enter field name"
99+
isDisabled={isExistingField}
100+
/>
101+
<TextField
102+
label="Value"
103+
name="value"
104+
value={decryptedValue}
105+
onChange={setDecryptedValue}
106+
placeholder="Enter field value"
107+
/>
108+
<Button
109+
type="submit"
110+
variant="secondary"
111+
className="w-fit"
112+
isDisabled={
113+
field === "" || decryptedValue === "" || isSaving || isDirty
114+
}
115+
>
116+
{isSaving ? (
117+
<>
118+
<LoaderCircle className="w-4 h-4 animate-spin mr-2" />
119+
Saving...
120+
</>
121+
) : (
122+
"Save"
123+
)}
124+
</Button>
125+
</div>
126+
</Form>
127+
);
128+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Button } from "@/components/common";
2+
import { initializeEncryption } from "@/utils/encryption";
3+
import { api } from "@convex/_generated/api";
4+
import { useQuery } from "convex/react";
5+
import posthog from "posthog-js";
6+
import { useEffect, useState } from "react";
7+
import { UserDataForm } from "../UserDataForm/UserDataForm";
8+
9+
// Placeholder for allowing users to view and edit their form data
10+
export function UserDataForms() {
11+
const userData = useQuery(api.userFormData.list);
12+
const [formData, setFormData] = useState<{ field: string; value: string }[]>(
13+
userData?.map((data) => ({ field: data.field, value: data.value })) ?? [],
14+
);
15+
16+
useEffect(() => {
17+
setFormData(
18+
userData?.map((data) => ({ field: data.field, value: data.value })) ?? [],
19+
);
20+
}, [userData]);
21+
22+
useEffect(() => {
23+
const setupEncryption = async () => {
24+
try {
25+
await initializeEncryption();
26+
} catch (error: any) {
27+
posthog.captureException(error);
28+
}
29+
};
30+
31+
setupEncryption();
32+
}, []);
33+
34+
// Check if we already have an empty form
35+
const hasEmptyForm = formData.some((data) => data.field === "");
36+
37+
return (
38+
<>
39+
<div className="mt-4 flex flex-col gap-4">
40+
{formData.map((data, idx) => (
41+
// biome-ignore lint/suspicious/noArrayIndexKey: This is a form where order matters and items won't be reordered
42+
<UserDataForm key={idx} initialData={data} />
43+
))}
44+
</div>
45+
{!hasEmptyForm && (
46+
<Button
47+
className="w-fit mt-4"
48+
variant="secondary"
49+
onPress={() => setFormData([...formData, { field: "", value: "" }])}
50+
>
51+
Add Value
52+
</Button>
53+
)}
54+
</>
55+
);
56+
}

src/components/settings/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export * from "./EditResidenceSetting/EditResidenceSetting";
77
export * from "./EditThemeSetting/EditThemeSetting";
88
export * from "./SettingsGroup/SettingsGroup";
99
export * from "./SettingsItem/SettingsItem";
10+
export * from "./UserDataForms/UserDataForms";

src/routes/_authenticated/settings/data.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { PageHeader } from "@/components/app";
22
import { Banner } from "@/components/common";
3+
import { UserDataForms } from "@/components/settings";
34
import { createFileRoute } from "@tanstack/react-router";
45
import { Lock } from "lucide-react";
56

@@ -14,6 +15,7 @@ function DataRoute() {
1415
<Banner variant="success" icon={Lock}>
1516
Data shown here is end-to-end encrypted. Only you can access it.
1617
</Banner>
18+
<UserDataForms />
1719
</>
1820
);
1921
}

0 commit comments

Comments
 (0)