Skip to content

Commit 3c7234e

Browse files
authored
Merge pull request #25 from constructive-io/feat/edit-vault-items
feat(desktop): edit a vault item — change a value, remove a field, rename it
2 parents 978142c + f1141b0 commit 3c7234e

1 file changed

Lines changed: 149 additions & 27 deletions

File tree

apps/desktop/src/renderer/src/components/ItemDetail.tsx

Lines changed: 149 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { Button } from '@constructive-io/ui/button';
1313
import { Input } from '@constructive-io/ui/input';
1414
import { Progress } from '@constructive-io/ui/progress';
1515
import { Separator } from '@constructive-io/ui/separator';
16-
import { Copy, Eye, EyeOff, Plus, Star, Trash2, X } from 'lucide-react';
16+
import { Check, Copy, Eye, EyeOff, Pencil, Plus, Star, Trash2, X } from 'lucide-react';
1717
import { useCallback, useEffect, useState } from 'react';
1818
import { toast } from 'sonner';
1919

@@ -53,10 +53,16 @@ export const ItemDetail = ({
5353
const [newFieldValue, setNewFieldValue] = useState('');
5454
const [newTag, setNewTag] = useState('');
5555
const [totp, setTotp] = useState<TotpEntry | null>(null);
56+
/** The field being edited, and the value typed so far. */
57+
const [editing, setEditing] = useState<{ name: string; value: string } | null>(null);
58+
/** Non-null while the title is being renamed. */
59+
const [title, setTitle] = useState<string | null>(null);
60+
const [removing, setRemoving] = useState<VaultFieldMeta | null>(null);
5661
const icons = useBrandIcons([item.title]);
5762

5863
const refresh = useCallback(async () => {
5964
setRevealed({});
65+
setEditing(null);
6066
setLoaded({ itemId: item.id, fields: await dcrypt.fields.list(item.id) });
6167
setUrls(await dcrypt.organize.urls(item.id));
6268
setTags(await dcrypt.organize.tags(item.id));
@@ -113,6 +119,35 @@ export const ItemDetail = ({
113119
toast.success(`Copied ${name} — clipboard clears in 30s`);
114120
};
115121

122+
/** Opens the editor on a field, revealing what is there to be edited. */
123+
const startEdit = async (field: VaultFieldMeta) => {
124+
const value = revealed[field.name] ?? (await dcrypt.fields.reveal(item.id, field.name));
125+
setEditing({ name: field.name, value });
126+
};
127+
128+
const saveEdit = async (field: VaultFieldMeta) => {
129+
if (!editing) return;
130+
// same name and purpose, so the vault replaces the value in place and the
131+
// database keeps the previous one in the item's password history
132+
await dcrypt.fields.set(item.id, field.name, field.purpose, editing.value, field.concealed);
133+
toast.success(`Updated ${field.name}`);
134+
await refresh();
135+
};
136+
137+
const removeField = async (field: VaultFieldMeta) => {
138+
await dcrypt.fields.remove(item.id, field.name);
139+
setRemoving(null);
140+
toast.success(`Removed ${field.name}`);
141+
await refresh();
142+
};
143+
144+
const saveTitle = async () => {
145+
const next = title?.trim();
146+
if (next && next !== item.title) await dcrypt.items.rename(item.id, next);
147+
setTitle(null);
148+
onChanged();
149+
};
150+
116151
const addField = async () => {
117152
if (!newFieldName.trim() || !newFieldValue) return;
118153
await dcrypt.fields.set(item.id, newFieldName.trim(), 'text', newFieldValue);
@@ -146,7 +181,28 @@ export const ItemDetail = ({
146181
<div>
147182
<h2 className="flex items-center gap-2 text-xl font-semibold">
148183
<BrandGlyph name={item.title} icon={icons[item.title]} className="size-6" />
149-
{item.title}
184+
{title === null ? (
185+
<button
186+
className="rounded px-1 text-left hover:bg-muted"
187+
onClick={() => setTitle(item.title)}
188+
aria-label="Rename item"
189+
>
190+
{item.title}
191+
</button>
192+
) : (
193+
<Input
194+
autoFocus
195+
value={title}
196+
onChange={(e) => setTitle(e.target.value)}
197+
onBlur={() => void saveTitle()}
198+
onKeyDown={(e) => {
199+
if (e.key === 'Enter') void saveTitle();
200+
if (e.key === 'Escape') setTitle(null);
201+
}}
202+
className="h-9 text-xl font-semibold"
203+
aria-label="Item title"
204+
/>
205+
)}
150206
</h2>
151207
<div className="mt-1 flex items-center gap-2">
152208
<Badge variant="secondary">{KIND_LABEL[item.kind] ?? item.kind}</Badge>
@@ -208,24 +264,38 @@ export const ItemDetail = ({
208264
{fields.map((field) => (
209265
<div key={field.id} className="flex items-center gap-2">
210266
<span className="w-32 shrink-0 text-sm text-muted-foreground">{field.name}</span>
211-
<Input
212-
readOnly
213-
type={field.concealed && revealed[field.name] === undefined ? 'password' : 'text'}
214-
value={
215-
revealed[field.name] !== undefined
216-
? revealed[field.name]
217-
: field.concealed
218-
? '••••••••••••'
219-
: (revealed[field.name] ?? '')
220-
}
221-
onFocus={() => {
222-
if (!field.concealed && revealed[field.name] === undefined) {
223-
void toggleReveal(field.name);
267+
{editing?.name === field.name ? (
268+
<Input
269+
autoFocus
270+
value={editing.value}
271+
onChange={(e) => setEditing({ name: field.name, value: e.target.value })}
272+
onKeyDown={(e) => {
273+
if (e.key === 'Enter') void saveEdit(field);
274+
if (e.key === 'Escape') setEditing(null);
275+
}}
276+
className="font-mono"
277+
aria-label={`${field.name} value`}
278+
/>
279+
) : (
280+
<Input
281+
readOnly
282+
type={field.concealed && revealed[field.name] === undefined ? 'password' : 'text'}
283+
value={
284+
revealed[field.name] !== undefined
285+
? revealed[field.name]
286+
: field.concealed
287+
? '••••••••••••'
288+
: (revealed[field.name] ?? '')
224289
}
225-
}}
226-
className="font-mono"
227-
/>
228-
{field.concealed && (
290+
onFocus={() => {
291+
if (!field.concealed && revealed[field.name] === undefined) {
292+
void toggleReveal(field.name);
293+
}
294+
}}
295+
className="font-mono"
296+
/>
297+
)}
298+
{field.concealed && editing?.name !== field.name && (
229299
<Button
230300
variant="ghost"
231301
size="icon"
@@ -239,14 +309,48 @@ export const ItemDetail = ({
239309
)}
240310
</Button>
241311
)}
242-
<Button
243-
variant="ghost"
244-
size="icon"
245-
onClick={() => void copyField(field.name)}
246-
aria-label="Copy"
247-
>
248-
<Copy className="size-4" />
249-
</Button>
312+
{editing?.name === field.name ? (
313+
<>
314+
<Button
315+
variant="ghost"
316+
size="icon"
317+
onClick={() => void saveEdit(field)}
318+
aria-label={`Save ${field.name}`}
319+
>
320+
<Check className="size-4" />
321+
</Button>
322+
<Button variant="ghost" size="icon" onClick={() => setEditing(null)} aria-label="Cancel">
323+
<X className="size-4" />
324+
</Button>
325+
</>
326+
) : (
327+
<>
328+
<Button
329+
variant="ghost"
330+
size="icon"
331+
onClick={() => void copyField(field.name)}
332+
aria-label="Copy"
333+
>
334+
<Copy className="size-4" />
335+
</Button>
336+
<Button
337+
variant="ghost"
338+
size="icon"
339+
onClick={() => void startEdit(field)}
340+
aria-label={`Edit ${field.name}`}
341+
>
342+
<Pencil className="size-4" />
343+
</Button>
344+
<Button
345+
variant="ghost"
346+
size="icon"
347+
onClick={() => setRemoving(field)}
348+
aria-label={`Remove ${field.name}`}
349+
>
350+
<Trash2 className="size-4 text-destructive" />
351+
</Button>
352+
</>
353+
)}
250354
</div>
251355
))}
252356
{!fields.length && (
@@ -299,6 +403,24 @@ export const ItemDetail = ({
299403
</Button>
300404
</div>
301405

406+
<AlertDialog open={removing !== null} onOpenChange={(open) => !open && setRemoving(null)}>
407+
<AlertDialogContent>
408+
<AlertDialogHeader>
409+
<AlertDialogTitle>Remove {removing?.name}?</AlertDialogTitle>
410+
<AlertDialogDescription>
411+
The value is deleted from the vault. A field has no trash to go to,
412+
so this cannot be undone.
413+
</AlertDialogDescription>
414+
</AlertDialogHeader>
415+
<AlertDialogFooter>
416+
<AlertDialogCancel>Cancel</AlertDialogCancel>
417+
<AlertDialogAction onClick={() => removing && void removeField(removing)}>
418+
Remove field
419+
</AlertDialogAction>
420+
</AlertDialogFooter>
421+
</AlertDialogContent>
422+
</AlertDialog>
423+
302424
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
303425
<AlertDialogContent>
304426
<AlertDialogHeader>

0 commit comments

Comments
 (0)