mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
refactor: Remove unused APIs and data structures, optimize loading state component styles
This commit is contained in:
+5
-38
@@ -23,13 +23,8 @@ import {
|
||||
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
|
||||
import { getSends } from '@/lib/api/send';
|
||||
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
||||
import {
|
||||
repairCipherAttachmentMetadata,
|
||||
updateFolder,
|
||||
} from '@/lib/api/vault';
|
||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||
import {
|
||||
looksLikeCipherString,
|
||||
parseSignalRTextFrames,
|
||||
readInviteCodeFromUrl,
|
||||
} from '@/lib/app-support';
|
||||
@@ -170,7 +165,6 @@ export default function App() {
|
||||
const [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null);
|
||||
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
|
||||
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
||||
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
|
||||
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||
const repairAttemptRef = useRef<string>('');
|
||||
@@ -850,9 +844,6 @@ export default function App() {
|
||||
setDecryptedFolders(result.folders);
|
||||
setDecryptedCiphers(result.ciphers);
|
||||
setVaultInitialDecryptDone(true);
|
||||
for (const repair of result.attachmentRepairs) {
|
||||
void repairCipherAttachmentMetadata(authedFetch, repair.cipherId, repair.attachmentId, repair.metadata);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!active) return;
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
|
||||
@@ -862,7 +853,7 @@ export default function App() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers, authedFetch]);
|
||||
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.symEncKey || !session?.symMacKey) {
|
||||
@@ -904,31 +895,6 @@ export default function App() {
|
||||
};
|
||||
}, [session?.symEncKey, session?.symMacKey, sendsQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.symEncKey || !session?.symMacKey || !encryptedFolders?.length) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const pending = encryptedFolders.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 refetchVaultCoreData();
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, authedFetch]);
|
||||
|
||||
async function refreshVaultSilently() {
|
||||
if (pendingVaultCoreRefreshRef.current) {
|
||||
await pendingVaultCoreRefreshRef.current;
|
||||
@@ -1107,6 +1073,7 @@ export default function App() {
|
||||
refetchSends: sendsQuery.refetch,
|
||||
onNotify: pushToast,
|
||||
patchDecryptedCiphers: setDecryptedCiphers,
|
||||
patchDecryptedFolders: setDecryptedFolders,
|
||||
});
|
||||
const accountSecurityActions = useAccountSecurityActions({
|
||||
authedFetch,
|
||||
@@ -1203,9 +1170,9 @@ export default function App() {
|
||||
decryptedCiphers,
|
||||
decryptedFolders,
|
||||
decryptedSends,
|
||||
ciphersLoading: vaultCoreQuery.isFetching,
|
||||
foldersLoading: vaultCoreQuery.isFetching,
|
||||
sendsLoading: sendsQuery.isFetching,
|
||||
ciphersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore,
|
||||
foldersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore,
|
||||
sendsLoading: sendsQuery.isFetching && !sendsQuery.data,
|
||||
users: usersQuery.data || [],
|
||||
invites: invitesQuery.data || [],
|
||||
totpEnabled: !!totpStatusQuery.data?.enabled,
|
||||
|
||||
@@ -3,13 +3,13 @@ import { useEffect } from 'preact/hooks';
|
||||
import { Link, Route, Switch } from 'wouter';
|
||||
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import VaultPage from '@/components/VaultPage';
|
||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||
@@ -181,36 +181,38 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="/vault">
|
||||
<VaultPage
|
||||
ciphers={props.decryptedCiphers}
|
||||
folders={props.decryptedFolders}
|
||||
loading={props.ciphersLoading || props.foldersLoading}
|
||||
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateVaultItem}
|
||||
onUpdate={props.onUpdateVaultItem}
|
||||
onDelete={props.onDeleteVaultItem}
|
||||
onArchive={props.onArchiveVaultItem}
|
||||
onUnarchive={props.onUnarchiveVaultItem}
|
||||
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||
onBulkArchive={props.onBulkArchiveVaultItems}
|
||||
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
|
||||
onBulkMove={props.onBulkMoveVaultItems}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onNotify={props.onNotify}
|
||||
onCreateFolder={props.onCreateFolder}
|
||||
onRenameFolder={props.onRenameFolder}
|
||||
onDeleteFolder={props.onDeleteFolder}
|
||||
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||
uploadingAttachmentName={props.uploadingAttachmentName}
|
||||
attachmentUploadPercent={props.attachmentUploadPercent}
|
||||
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
|
||||
/>
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<VaultPage
|
||||
ciphers={props.decryptedCiphers}
|
||||
folders={props.decryptedFolders}
|
||||
loading={props.ciphersLoading || props.foldersLoading}
|
||||
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateVaultItem}
|
||||
onUpdate={props.onUpdateVaultItem}
|
||||
onDelete={props.onDeleteVaultItem}
|
||||
onArchive={props.onArchiveVaultItem}
|
||||
onUnarchive={props.onUnarchiveVaultItem}
|
||||
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||
onBulkArchive={props.onBulkArchiveVaultItems}
|
||||
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
|
||||
onBulkMove={props.onBulkMoveVaultItems}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onNotify={props.onNotify}
|
||||
onCreateFolder={props.onCreateFolder}
|
||||
onRenameFolder={props.onRenameFolder}
|
||||
onDeleteFolder={props.onDeleteFolder}
|
||||
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||
uploadingAttachmentName={props.uploadingAttachmentName}
|
||||
attachmentUploadPercent={props.attachmentUploadPercent}
|
||||
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
|
||||
/>
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path={props.settingsAccountRoute}>
|
||||
{props.profile && (
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
interface LoadingStateProps {
|
||||
lines?: number;
|
||||
compact?: boolean;
|
||||
card?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LoadingState(props: LoadingStateProps) {
|
||||
const lines = Math.max(1, props.lines || 4);
|
||||
return (
|
||||
<div className={`${props.card ? 'loading-state-card card' : 'loading-state'}${props.compact ? ' compact' : ''}${props.className ? ` ${props.className}` : ''}`} aria-hidden="true">
|
||||
{Array.from({ length: lines }, (_, index) => (
|
||||
<div key={index} className="loading-state-row">
|
||||
<div className="loading-state-icon shimmer" />
|
||||
<div className="loading-state-text">
|
||||
<div className="loading-state-line shimmer" />
|
||||
<div className="loading-state-line short shimmer" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { Send, SendDraft } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
@@ -322,6 +323,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-panel">
|
||||
{props.loading && !filteredSends.length && <LoadingState lines={6} compact />}
|
||||
{filteredSends.map((send, index) => (
|
||||
<div
|
||||
key={send.id}
|
||||
@@ -375,7 +377,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
|
||||
{!props.loading && !filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -553,6 +555,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isEditing && !selectedSend && props.loading && <LoadingState card lines={4} />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,8 @@ import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
||||
import { calcTotpNow } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import { hostFromUri, isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface TotpCodesPageProps {
|
||||
ciphers: Cipher[];
|
||||
@@ -62,16 +63,6 @@ function firstCipherUri(cipher: Cipher): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function hostFromUri(uri: string): string {
|
||||
if (!uri.trim()) return '';
|
||||
try {
|
||||
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
|
||||
return new URL(normalized).hostname || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
||||
const iconStackRef = useRef<HTMLSpanElement | null>(null);
|
||||
@@ -447,6 +438,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
className="totp-codes-list"
|
||||
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
|
||||
>
|
||||
{!totpItems.length && props.loading && <LoadingState lines={6} />}
|
||||
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sortableTotpItems} strategy={rectSortingStrategy}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import VaultDialogs from '@/components/vault/VaultDialogs';
|
||||
import VaultDetailView from '@/components/vault/VaultDetailView';
|
||||
import VaultEditor from '@/components/vault/VaultEditor';
|
||||
@@ -1139,7 +1140,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>}
|
||||
{!isEditing && !selectedCipher && (props.loading ? <LoadingState card lines={5} /> : <div className="empty card">{t('txt_select_an_item')}</div>)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RefObject } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
@@ -234,6 +235,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
</div>
|
||||
|
||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
|
||||
{!!props.filteredCiphers.length && (
|
||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||
{props.visibleCiphers.map((cipher) => (
|
||||
@@ -249,7 +251,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
||||
{!props.loading && !props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -68,6 +68,7 @@ interface UseVaultSendActionsOptions {
|
||||
refetchSends: () => Promise<unknown>;
|
||||
onNotify: Notify;
|
||||
patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
|
||||
patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void;
|
||||
}
|
||||
|
||||
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
|
||||
@@ -98,6 +99,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
refetchSends,
|
||||
onNotify,
|
||||
patchDecryptedCiphers,
|
||||
patchDecryptedFolders,
|
||||
} = options;
|
||||
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
|
||||
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
|
||||
@@ -142,6 +144,44 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id));
|
||||
}
|
||||
|
||||
function patchCipherBatch(ids: string[], updater: (cipher: Cipher) => Cipher | null) {
|
||||
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||
if (!idSet.size) return;
|
||||
patchDecryptedCiphers((prev) => {
|
||||
let changed = false;
|
||||
const next: Cipher[] = [];
|
||||
for (const cipher of prev) {
|
||||
if (!idSet.has(cipher.id)) {
|
||||
next.push(cipher);
|
||||
continue;
|
||||
}
|
||||
const updated = updater(cipher);
|
||||
changed = true;
|
||||
if (updated) next.push(updated);
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
function patchFolderBatch(ids: string[], updater: (folder: VaultFolder) => VaultFolder | null) {
|
||||
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||
if (!idSet.size) return;
|
||||
patchDecryptedFolders((prev) => {
|
||||
let changed = false;
|
||||
const next: VaultFolder[] = [];
|
||||
for (const folder of prev) {
|
||||
if (!idSet.has(folder.id)) {
|
||||
next.push(folder);
|
||||
continue;
|
||||
}
|
||||
const updated = updater(folder);
|
||||
changed = true;
|
||||
if (updated) next.push(updated);
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
const uploadImportedAttachments = async (
|
||||
attachments: ImportAttachmentFile[],
|
||||
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
||||
@@ -311,7 +351,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkDeleteCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
const deletedDate = new Date().toISOString();
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate, archivedDate: null }));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_deleted_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
|
||||
@@ -322,7 +364,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkArchiveVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkArchiveCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
const archivedDate = new Date().toISOString();
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate, deletedDate: null }));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_archived_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed'));
|
||||
@@ -333,7 +377,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkUnarchiveVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkUnarchiveCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_unarchived_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed'));
|
||||
@@ -344,7 +389,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkMoveVaultItems(ids: string[], folderId: string | null) {
|
||||
try {
|
||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_moved_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
|
||||
@@ -360,8 +406,16 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
}
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
await createFolder(authedFetch, session, folderName);
|
||||
await refetchFolders();
|
||||
const created = await createFolder(authedFetch, session, folderName);
|
||||
patchDecryptedFolders((prev) => [
|
||||
{
|
||||
id: created.id,
|
||||
name: created.name || folderName,
|
||||
decName: folderName,
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_folder_created'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
|
||||
@@ -377,7 +431,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
}
|
||||
try {
|
||||
await deleteFolder(authedFetch, id);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
patchFolderBatch([id], () => null);
|
||||
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher)));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_folder_deleted'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
|
||||
@@ -399,7 +455,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
await updateFolder(authedFetch, session, id, nextName);
|
||||
await refetchFolders();
|
||||
patchFolderBatch([id], (folder) => ({ ...folder, decName: nextName }));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_folder_updated'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
|
||||
@@ -410,7 +467,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkRestoreVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkRestoreCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_restored_selected_items'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
|
||||
@@ -421,7 +479,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkPermanentDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
patchCipherBatch(ids, () => null);
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_deleted_selected_items_permanently'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
|
||||
@@ -434,7 +493,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
await bulkDeleteFolders(authedFetch, ids);
|
||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
||||
const removedIds = new Set(ids);
|
||||
patchDecryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id)));
|
||||
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher)));
|
||||
syncVaultCoreInBackground({ includeFolders: true });
|
||||
onNotify('success', t('txt_folders_deleted'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { hkdf } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Cipher, VaultDraft } from '@/lib/types';
|
||||
import type { VaultDraft } from '@/lib/types';
|
||||
import type { ImportResultSummary } from '@/components/ImportPage';
|
||||
|
||||
const SEND_KEY_SALT = 'bitwarden-send';
|
||||
@@ -26,7 +26,7 @@ export function looksLikeCipherString(value: string): boolean {
|
||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
export function asText(value: unknown): string {
|
||||
function asText(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value);
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export function summarizeImportResult(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEmptyImportDraft(type: number): VaultDraft {
|
||||
function buildEmptyImportDraft(type: number): VaultDraft {
|
||||
return {
|
||||
type,
|
||||
favorite: false,
|
||||
@@ -279,6 +279,3 @@ export async function deriveSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{
|
||||
return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) };
|
||||
}
|
||||
|
||||
export function findCipherById(ciphers: Cipher[], id: string): Cipher | null {
|
||||
return ciphers.find((cipher) => cipher.id === id) || null;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { decryptStr, decryptBw } from './crypto';
|
||||
import type { Cipher } from './types';
|
||||
|
||||
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.byteLength !== b.byteLength) return false;
|
||||
for (let i = 0; i < a.byteLength; i += 1) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function decryptField(
|
||||
value: string | null | undefined,
|
||||
enc: Uint8Array,
|
||||
|
||||
@@ -380,85 +380,6 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
|
||||
return JSON.stringify(doc, null, 2);
|
||||
}
|
||||
|
||||
export async function buildBitwardenCsvString(args: BuildPlainJsonArgs): Promise<string> {
|
||||
const doc = await buildPlainBitwardenJsonDocument(args);
|
||||
const folders = Array.isArray(doc.folders) ? (doc.folders as Array<Record<string, unknown>>) : [];
|
||||
const items = Array.isArray(doc.items) ? (doc.items as Array<Record<string, unknown>>) : [];
|
||||
|
||||
const folderNameById = new Map<string, string>();
|
||||
for (const folder of folders) {
|
||||
const id = normalizeString(folder.id);
|
||||
if (!id) continue;
|
||||
folderNameById.set(id, normalizeString(folder.name) || '');
|
||||
}
|
||||
|
||||
const header = [
|
||||
'folder',
|
||||
'favorite',
|
||||
'type',
|
||||
'name',
|
||||
'notes',
|
||||
'fields',
|
||||
'reprompt',
|
||||
'archivedDate',
|
||||
'login_uri',
|
||||
'login_username',
|
||||
'login_password',
|
||||
'login_totp',
|
||||
];
|
||||
|
||||
const rows: string[][] = [header];
|
||||
for (const item of items) {
|
||||
const type = normalizeNumber(item.type, 1);
|
||||
if (type !== 1 && type !== 2) continue;
|
||||
const folderId = normalizeString(item.folderId);
|
||||
const folderName = folderId ? folderNameById.get(folderId) || '' : '';
|
||||
const fields = Array.isArray(item.fields)
|
||||
? (item.fields as Array<Record<string, unknown>>)
|
||||
.map((field) => {
|
||||
const name = normalizeString(field.name) || '';
|
||||
const value = normalizeString(field.value) || '';
|
||||
if (!name && !value) return '';
|
||||
return `${name}: ${value}`;
|
||||
})
|
||||
.filter((line) => !!line)
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
const login = isRecord(item.login) ? (item.login as Record<string, unknown>) : null;
|
||||
const loginUris = login && Array.isArray(login.uris)
|
||||
? (login.uris as Array<Record<string, unknown>>)
|
||||
.map((uri) => normalizeString(uri.uri) || '')
|
||||
.filter((uri) => !!uri)
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
rows.push([
|
||||
folderName,
|
||||
item.favorite ? '1' : '',
|
||||
type === 1 ? 'login' : 'note',
|
||||
normalizeString(item.name) || '',
|
||||
normalizeString(item.notes) || '',
|
||||
fields,
|
||||
String(normalizeNumber(item.reprompt, 0)),
|
||||
normalizeString(item.archivedDate) || '',
|
||||
loginUris,
|
||||
normalizeString(login?.username) || '',
|
||||
normalizeString(login?.password) || '',
|
||||
normalizeString(login?.totp) || '',
|
||||
]);
|
||||
}
|
||||
|
||||
const escapeCsv = (value: string): string => {
|
||||
if (/[",\n\r]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return rows.map((row) => row.map((cell) => escapeCsv(String(cell || ''))).join(',')).join('\n');
|
||||
}
|
||||
|
||||
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
||||
const userEnc = base64ToBytes(args.userEncB64);
|
||||
const userMac = base64ToBytes(args.userMacB64);
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { base64ToBytes, decryptBw, decryptStr, encryptBw } from './crypto';
|
||||
import { base64ToBytes, decryptBw, decryptStr } from './crypto';
|
||||
import { deriveSendKeyParts } from './app-support';
|
||||
import type { Cipher, Folder, Send } from './types';
|
||||
|
||||
export interface AttachmentRepairTask {
|
||||
cipherId: string;
|
||||
attachmentId: string;
|
||||
metadata: { fileName?: string; key?: string | null };
|
||||
}
|
||||
|
||||
export interface DecryptVaultCoreArgs {
|
||||
folders: Folder[];
|
||||
ciphers: Cipher[];
|
||||
@@ -18,7 +12,6 @@ export interface DecryptVaultCoreArgs {
|
||||
export interface DecryptVaultCoreResult {
|
||||
folders: Folder[];
|
||||
ciphers: Cipher[];
|
||||
attachmentRepairs: AttachmentRepairTask[];
|
||||
}
|
||||
|
||||
export interface DecryptSendsArgs {
|
||||
@@ -28,10 +21,6 @@ export interface DecryptSendsArgs {
|
||||
origin: string;
|
||||
}
|
||||
|
||||
function looksLikeCipherString(value: string): boolean {
|
||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.byteLength !== b.byteLength) return false;
|
||||
for (let i = 0; i < a.byteLength; i += 1) {
|
||||
@@ -81,7 +70,6 @@ async function decryptFieldWithSource(
|
||||
export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
|
||||
const userEnc = base64ToBytes(args.symEncKeyB64);
|
||||
const userMac = base64ToBytes(args.symMacKeyB64);
|
||||
const attachmentRepairs: AttachmentRepairTask[] = [];
|
||||
|
||||
const folders = await Promise.all(
|
||||
args.folders.map(async (folder) => ({
|
||||
@@ -195,7 +183,6 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
|
||||
if (Array.isArray(cipher.attachments)) {
|
||||
nextCipher.attachments = await Promise.all(
|
||||
cipher.attachments.map(async (attachment) => {
|
||||
const attachmentId = String(attachment?.id || '').trim();
|
||||
const fileNameResult = await decryptFieldWithSource(
|
||||
attachment.fileName || '',
|
||||
itemEnc,
|
||||
@@ -204,36 +191,6 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
|
||||
userMac,
|
||||
!itemUsesUserKey
|
||||
);
|
||||
const metadata: { fileName?: string; key?: string | null } = {};
|
||||
|
||||
if (attachmentId && fileNameResult.source === 'user') {
|
||||
metadata.fileName = await encryptBw(new TextEncoder().encode(fileNameResult.text), itemEnc, itemMac);
|
||||
}
|
||||
|
||||
const attachmentKey = String(attachment?.key || '').trim();
|
||||
if (attachmentId && attachmentKey && looksLikeCipherString(attachmentKey) && !itemUsesUserKey) {
|
||||
try {
|
||||
await decryptBw(attachmentKey, itemEnc, itemMac);
|
||||
} catch {
|
||||
try {
|
||||
const rawAttachmentKey = await decryptBw(attachmentKey, userEnc, userMac);
|
||||
if (rawAttachmentKey.length >= 64) {
|
||||
metadata.key = await encryptBw(rawAttachmentKey, itemEnc, itemMac);
|
||||
}
|
||||
} catch {
|
||||
// Download path still supports legacy format.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentId && Object.keys(metadata).length > 0) {
|
||||
attachmentRepairs.push({
|
||||
cipherId: cipher.id,
|
||||
attachmentId,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...attachment,
|
||||
decFileName: fileNameResult.text,
|
||||
@@ -246,7 +203,7 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
|
||||
})
|
||||
);
|
||||
|
||||
return { folders, ciphers, attachmentRepairs };
|
||||
return { folders, ciphers };
|
||||
}
|
||||
|
||||
export async function decryptSends(args: DecryptSendsArgs): Promise<Send[]> {
|
||||
|
||||
@@ -170,3 +170,73 @@
|
||||
:root[data-theme='dark'] .tree-btn:hover {
|
||||
background: var(--panel-subtle);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.loading-state-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading-state.compact,
|
||||
.loading-state-card.compact {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.loading-state-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.loading-state-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading-state-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 36px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--panel-muted) 78%, transparent);
|
||||
}
|
||||
|
||||
.loading-state-text {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.loading-state-line {
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--panel-muted) 80%, transparent);
|
||||
}
|
||||
|
||||
.loading-state-line.short {
|
||||
width: 42%;
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shimmer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, white 38%, transparent) 48%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: loading-shimmer 1.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user