refactor: Remove unused APIs and data structures, optimize loading state component styles

This commit is contained in:
shuaiplus
2026-04-28 23:01:23 +08:00
parent 1b0386bf78
commit 69b98f9e67
18 changed files with 221 additions and 258 deletions
+5 -38
View File
@@ -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,
+33 -31
View File
@@ -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 && (
+23
View File
@@ -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>
);
}
+4 -1
View File
@@ -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>
);
+3 -11
View File
@@ -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}>
+2 -1
View File
@@ -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>
);
+73 -11
View File
@@ -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'));
+3 -6
View File
@@ -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;
}
-8
View File
@@ -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,
-79
View File
@@ -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);
+2 -45
View File
@@ -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[]> {
+70
View File
@@ -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%);
}
}