diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 00b4b4d..04995f4 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'preact/hooks'; +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { Link, Route, Switch, useLocation } from 'wouter'; import { useQuery } from '@tanstack/react-query'; import { ArrowUpDown, Cloud, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact'; @@ -18,6 +18,8 @@ import ImportPage from '@/components/ImportPage'; import { changeMasterPassword, createFolder, + updateFolder, + deleteFolder, createCipher, createAuthedFetch, createInvite, @@ -75,6 +77,10 @@ const SEND_KEY_PURPOSE = 'send'; const IMPORT_ROUTE = '/help/import-export'; const IMPORT_ROUTE_ALIASES = new Set(['/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export']); +function looksLikeCipherString(value: string): boolean { + return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); +} + function asText(value: unknown): string { if (value === null || value === undefined) return ''; return String(value); @@ -252,6 +258,7 @@ export default function App() { const [decryptedFolders, setDecryptedFolders] = useState([]); const [decryptedCiphers, setDecryptedCiphers] = useState([]); const [decryptedSends, setDecryptedSends] = useState([]); + const migratedPlainFolderIdsRef = useRef>(new Set()); function setSession(next: SessionState | null) { setSessionState(next); @@ -714,6 +721,31 @@ export default function App() { }; }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]); + useEffect(() => { + if (!session?.symEncKey || !session?.symMacKey || !foldersQuery.data?.length) return; + let cancelled = false; + (async () => { + const pending = foldersQuery.data.filter((folder) => { + if (!folder?.id || !folder?.name) return false; + if (migratedPlainFolderIdsRef.current.has(folder.id)) return false; + return !looksLikeCipherString(String(folder.name)); + }); + if (!pending.length) return; + for (const folder of pending) { + try { + await updateFolder(authedFetch, session, folder.id, String(folder.name)); + migratedPlainFolderIdsRef.current.add(folder.id); + } catch { + // keep silent; web still supports plaintext fallback display + } + } + if (!cancelled) await foldersQuery.refetch(); + })(); + return () => { + cancelled = true; + }; + }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, authedFetch]); + async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) { if (!profile) return; if (!currentPassword || !nextPassword) { @@ -943,7 +975,8 @@ export default function App() { return; } try { - await createFolder(authedFetch, folderName); + if (!session) throw new Error('Vault key unavailable'); + await createFolder(authedFetch, session, folderName); await foldersQuery.refetch(); pushToast('success', t('txt_folder_created')); } catch (error) { @@ -952,6 +985,22 @@ export default function App() { } } + async function deleteFolderAction(folderId: string) { + const id = String(folderId || '').trim(); + if (!id) { + pushToast('error', 'Folder not found'); + return; + } + try { + await deleteFolder(authedFetch, id); + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + pushToast('success', 'Folder deleted'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Delete folder failed'); + throw error; + } + } + async function handleImportAction( payload: CiphersImportPayload, options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null } @@ -972,7 +1021,7 @@ export default function App() { if (!name) continue; let folderId = createdFolderIdByName.get(name) || null; if (!folderId) { - const created = await createFolder(authedFetch, name); + const created = await createFolder(authedFetch, session, name); folderId = created.id; createdFolderIdByName.set(name, folderId); } @@ -1261,6 +1310,7 @@ export default function App() { onVerifyMasterPassword={verifyMasterPasswordAction} onNotify={pushToast} onCreateFolder={createFolderAction} + onDeleteFolder={deleteFolderAction} /> diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index ba8f029..4b1a69d 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -44,6 +44,7 @@ interface VaultPageProps { onVerifyMasterPassword: (email: string, password: string) => Promise; onNotify: (type: 'success' | 'error', text: string) => void; onCreateFolder: (name: string) => Promise; + onDeleteFolder: (folderId: string) => Promise; } type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; @@ -339,6 +340,7 @@ export default function VaultPage(props: VaultPageProps) { const [moveFolderId, setMoveFolderId] = useState('__none__'); const [createFolderOpen, setCreateFolderOpen] = useState(false); const [newFolderName, setNewFolderName] = useState(''); + const [pendingDeleteFolder, setPendingDeleteFolder] = useState(null); const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null); const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState>({}); const [busy, setBusy] = useState(false); @@ -686,6 +688,20 @@ function folderName(id: string | null | undefined): string { } } + async function confirmDeleteFolder(): Promise { + if (!pendingDeleteFolder) return; + setBusy(true); + try { + await props.onDeleteFolder(pendingDeleteFolder.id); + if (sidebarFilter.kind === 'folder' && sidebarFilter.folderId === pendingDeleteFolder.id) { + setSidebarFilter({ kind: 'all' }); + } + setPendingDeleteFolder(null); + } finally { + setBusy(false); + } + } + return ( <>
@@ -732,17 +748,32 @@ function folderName(id: string | null | undefined): string { {t('txt_no_folder')} {props.folders.map((folder) => ( - +
+ + +
))}
@@ -1469,6 +1500,17 @@ function folderName(id: string | null | undefined): string { + void confirmDeleteFolder()} + onCancel={() => setPendingDeleteFolder(null)} + /> + Promise, + session: SessionState, name: string ): Promise<{ id: string; name?: string | null }> { + if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); + const enc = base64ToBytes(session.symEncKey); + const mac = base64ToBytes(session.symMacKey); + const encryptedName = await encryptBw(new TextEncoder().encode(name), enc, mac); const resp = await authedFetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name: encryptedName }), }); if (!resp.ok) throw new Error('Create folder failed'); const body = await parseJson<{ id?: string; name?: string | null }>(resp); @@ -319,6 +324,38 @@ export async function createFolder( return { id: body.id, name: body.name ?? null }; } +export async function deleteFolder( + authedFetch: (input: string, init?: RequestInit) => Promise, + folderId: string +): Promise { + const id = String(folderId || '').trim(); + if (!id) throw new Error('Folder id is required'); + const resp = await authedFetch(`/api/folders/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + if (!resp.ok) throw new Error('Delete folder failed'); +} + +export async function updateFolder( + authedFetch: (input: string, init?: RequestInit) => Promise, + session: SessionState, + folderId: string, + name: string +): Promise { + const id = String(folderId || '').trim(); + if (!id) throw new Error('Folder id is required'); + if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); + const enc = base64ToBytes(session.symEncKey); + const mac = base64ToBytes(session.symMacKey); + const encryptedName = await encryptBw(new TextEncoder().encode(name), enc, mac); + const resp = await authedFetch(`/api/folders/${encodeURIComponent(id)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: encryptedName }), + }); + if (!resp.ok) throw new Error('Update folder failed'); +} + export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise): Promise { const resp = await authedFetch('/api/ciphers?deleted=true'); if (!resp.ok) throw new Error('Failed to load ciphers'); diff --git a/webapp/src/styles.css b/webapp/src/styles.css index b6a2282..37fba99 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -622,6 +622,36 @@ input[type='file'].input::file-selector-button:hover { text-overflow: ellipsis; } +.folder-row { + display: flex; + align-items: center; + gap: 6px; +} + +.folder-row .tree-btn { + margin-bottom: 0; +} + +.folder-delete-btn { + border: none; + background: transparent; + color: #64748b; + width: 24px; + height: 24px; + padding: 0; + cursor: pointer; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; +} + +.folder-delete-btn:hover { + color: #b91c1c; + background: #fee2e2; +} + .list-col { display: flex; flex-direction: column;