mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: implement folder management features including create, update, and delete actions
This commit is contained in:
+53
-3
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user