feat: implement folder management features including create, update, and delete actions

This commit is contained in:
shuaiplus
2026-03-03 21:03:16 +08:00
parent 7193df7f11
commit 6ca1fa739f
4 changed files with 174 additions and 15 deletions
+53 -3
View File
@@ -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 { Link, Route, Switch, useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ArrowUpDown, Cloud, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact'; 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 { import {
changeMasterPassword, changeMasterPassword,
createFolder, createFolder,
updateFolder,
deleteFolder,
createCipher, createCipher,
createAuthedFetch, createAuthedFetch,
createInvite, createInvite,
@@ -75,6 +77,10 @@ const SEND_KEY_PURPOSE = 'send';
const IMPORT_ROUTE = '/help/import-export'; const IMPORT_ROUTE = '/help/import-export';
const IMPORT_ROUTE_ALIASES = new Set(['/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/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 { function asText(value: unknown): string {
if (value === null || value === undefined) return ''; if (value === null || value === undefined) return '';
return String(value); return String(value);
@@ -252,6 +258,7 @@ export default function App() {
const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]); const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]); const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]); const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
function setSession(next: SessionState | null) { function setSession(next: SessionState | null) {
setSessionState(next); setSessionState(next);
@@ -714,6 +721,31 @@ export default function App() {
}; };
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]); }, [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) { async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) {
if (!profile) return; if (!profile) return;
if (!currentPassword || !nextPassword) { if (!currentPassword || !nextPassword) {
@@ -943,7 +975,8 @@ export default function App() {
return; return;
} }
try { try {
await createFolder(authedFetch, folderName); if (!session) throw new Error('Vault key unavailable');
await createFolder(authedFetch, session, folderName);
await foldersQuery.refetch(); await foldersQuery.refetch();
pushToast('success', t('txt_folder_created')); pushToast('success', t('txt_folder_created'));
} catch (error) { } 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( async function handleImportAction(
payload: CiphersImportPayload, payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null } options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
@@ -972,7 +1021,7 @@ export default function App() {
if (!name) continue; if (!name) continue;
let folderId = createdFolderIdByName.get(name) || null; let folderId = createdFolderIdByName.get(name) || null;
if (!folderId) { if (!folderId) {
const created = await createFolder(authedFetch, name); const created = await createFolder(authedFetch, session, name);
folderId = created.id; folderId = created.id;
createdFolderIdByName.set(name, folderId); createdFolderIdByName.set(name, folderId);
} }
@@ -1261,6 +1310,7 @@ export default function App() {
onVerifyMasterPassword={verifyMasterPasswordAction} onVerifyMasterPassword={verifyMasterPasswordAction}
onNotify={pushToast} onNotify={pushToast}
onCreateFolder={createFolderAction} onCreateFolder={createFolderAction}
onDeleteFolder={deleteFolderAction}
/> />
</Route> </Route>
<Route path="/settings"> <Route path="/settings">
+53 -11
View File
@@ -44,6 +44,7 @@ interface VaultPageProps {
onVerifyMasterPassword: (email: string, password: string) => Promise<void>; onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onNotify: (type: 'success' | 'error', text: string) => void; onNotify: (type: 'success' | 'error', text: string) => void;
onCreateFolder: (name: string) => Promise<void>; onCreateFolder: (name: string) => Promise<void>;
onDeleteFolder: (folderId: string) => Promise<void>;
} }
type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
@@ -339,6 +340,7 @@ export default function VaultPage(props: VaultPageProps) {
const [moveFolderId, setMoveFolderId] = useState('__none__'); const [moveFolderId, setMoveFolderId] = useState('__none__');
const [createFolderOpen, setCreateFolderOpen] = useState(false); const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState(''); const [newFolderName, setNewFolderName] = useState('');
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null); const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({}); const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -686,6 +688,20 @@ function folderName(id: string | null | undefined): string {
} }
} }
async function confirmDeleteFolder(): Promise<void> {
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 ( return (
<> <>
<div className="vault-grid"> <div className="vault-grid">
@@ -732,17 +748,32 @@ function folderName(id: string | null | undefined): string {
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span> <FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
</button> </button>
{props.folders.map((folder) => ( {props.folders.map((folder) => (
<button <div key={folder.id} className="folder-row">
key={folder.id} <button
type="button" type="button"
className={`tree-btn ${sidebarFilter.kind === 'folder' && sidebarFilter.folderId === folder.id ? 'active' : ''}`} className={`tree-btn ${sidebarFilter.kind === 'folder' && sidebarFilter.folderId === folder.id ? 'active' : ''}`}
onClick={() => setSidebarFilter({ kind: 'folder', folderId: folder.id })} onClick={() => setSidebarFilter({ kind: 'folder', folderId: folder.id })}
> >
<FolderIcon size={14} className="tree-icon" /> <FolderIcon size={14} className="tree-icon" />
<span className="tree-label" title={folder.decName || folder.name || folder.id}> <span className="tree-label" title={folder.decName || folder.name || folder.id}>
{folder.decName || folder.name || folder.id} {folder.decName || folder.name || folder.id}
</span> </span>
</button> </button>
<button
type="button"
className="folder-delete-btn"
title={t('txt_delete')}
aria-label={t('txt_delete')}
disabled={busy}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setPendingDeleteFolder(folder);
}}
>
<X size={12} />
</button>
</div>
))} ))}
</div> </div>
</aside> </aside>
@@ -1469,6 +1500,17 @@ function folderName(id: string | null | undefined): string {
</label> </label>
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog
open={!!pendingDeleteFolder}
title={`${t('txt_delete')} ${t('txt_folder')}`}
message={`Delete folder "${pendingDeleteFolder?.decName || pendingDeleteFolder?.name || pendingDeleteFolder?.id || ''}"? Items inside will move to ${t('txt_no_folder')}.`}
confirmText={t('txt_delete')}
cancelText={t('txt_cancel')}
danger
onConfirm={() => void confirmDeleteFolder()}
onCancel={() => setPendingDeleteFolder(null)}
/>
<ConfirmDialog <ConfirmDialog
open={repromptOpen} open={repromptOpen}
title={t('txt_unlock_item')} title={t('txt_unlock_item')}
+38 -1
View File
@@ -306,12 +306,17 @@ export async function getFolders(authedFetch: (input: string, init?: RequestInit
export async function createFolder( export async function createFolder(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>, authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
name: string name: string
): Promise<{ id: string; name?: string | null }> { ): 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', { const resp = await authedFetch('/api/folders', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }), body: JSON.stringify({ name: encryptedName }),
}); });
if (!resp.ok) throw new Error('Create folder failed'); if (!resp.ok) throw new Error('Create folder failed');
const body = await parseJson<{ id?: string; name?: string | null }>(resp); 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 }; return { id: body.id, name: body.name ?? null };
} }
export async function deleteFolder(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
folderId: string
): Promise<void> {
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<Response>,
session: SessionState,
folderId: string,
name: string
): Promise<void> {
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<Response>): Promise<Cipher[]> { export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<Cipher[]> {
const resp = await authedFetch('/api/ciphers?deleted=true'); const resp = await authedFetch('/api/ciphers?deleted=true');
if (!resp.ok) throw new Error('Failed to load ciphers'); if (!resp.ok) throw new Error('Failed to load ciphers');
+30
View File
@@ -622,6 +622,36 @@ input[type='file'].input::file-selector-button:hover {
text-overflow: ellipsis; 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 { .list-col {
display: flex; display: flex;
flex-direction: column; flex-direction: column;