diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index f904b6a..e6bf188 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -182,14 +182,6 @@ CREATE TABLE IF NOT EXISTS login_attempts_ip ( updated_at INTEGER NOT NULL ); -CREATE TABLE IF NOT EXISTS api_rate_limits ( - identifier TEXT NOT NULL, - window_start INTEGER NOT NULL, - count INTEGER NOT NULL, - PRIMARY KEY (identifier, window_start) -); -CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); - CREATE TABLE IF NOT EXISTS used_attachment_download_tokens ( jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index b7a2b2c..37bdee7 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -426,9 +426,6 @@ export async function handleDeleteAttachment( // Delete attachment metadata await storage.deleteAttachment(attachmentId); - // Remove attachment from cipher - await storage.removeAttachmentFromCipher(cipherId, attachmentId); - // Update cipher revision date const revisionInfo = await storage.updateCipherRevisionDate(cipherId); if (revisionInfo) { diff --git a/src/services/storage-attachment-repo.ts b/src/services/storage-attachment-repo.ts index a7c6744..e42ac2e 100644 --- a/src/services/storage-attachment-repo.ts +++ b/src/services/storage-attachment-repo.ts @@ -119,11 +119,6 @@ export async function addAttachmentToCipher(db: D1Database, cipherId: string, at await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run(); } -export async function removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise { - void cipherId; - void attachmentId; -} - export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise { await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run(); } diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index 1916160..c2eecd8 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -94,11 +94,6 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)', - 'CREATE TABLE IF NOT EXISTS api_rate_limits (' + - 'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' + - 'PRIMARY KEY (identifier, window_start))', - 'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)', - 'CREATE TABLE IF NOT EXISTS login_attempts_ip (' + 'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)', diff --git a/src/services/storage.ts b/src/services/storage.ts index 601ab49..981e3c4 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -57,7 +57,6 @@ import { getAttachmentsByCipher as listStoredAttachmentsByCipher, getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds, getAttachmentsByUserId as listStoredAttachmentsByUserId, - removeAttachmentFromCipher as detachStoredAttachmentFromCipher, saveAttachment as saveStoredAttachment, updateCipherRevisionDate as updateStoredCipherRevisionDate, } from './storage-attachment-repo'; @@ -389,10 +388,6 @@ export class StorageService { await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId); } - async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise { - await detachStoredAttachmentFromCipher(cipherId, attachmentId); - } - async deleteAllAttachmentsByCipher(cipherId: string): Promise { await deleteStoredAttachmentsByCipher(this.db, cipherId); } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index e94a0be..5d8ae87 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -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(null); const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false); const sessionRef = useRef(initialBootstrap.session); - const migratedPlainFolderIdsRef = useRef>(new Set()); const silentRefreshVaultRef = useRef<() => Promise>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); const repairAttemptRef = useRef(''); @@ -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, diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index eaa7eac..20586f1 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -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) { - + }> + + {props.profile && ( diff --git a/webapp/src/components/LoadingState.tsx b/webapp/src/components/LoadingState.tsx new file mode 100644 index 0000000..2024c0b --- /dev/null +++ b/webapp/src/components/LoadingState.tsx @@ -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 ( + )} + {!isEditing && !selectedSend && props.loading && } ); diff --git a/webapp/src/components/TotpCodesPage.tsx b/webapp/src/components/TotpCodesPage.tsx index 09c5347..adca39b 100644 --- a/webapp/src/components/TotpCodesPage.tsx +++ b/webapp/src/components/TotpCodesPage.tsx @@ -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(null); @@ -447,6 +438,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) { className="totp-codes-list" style={{ '--totp-columns': String(columnCount) } as Record} > + {!totpItems.length && props.loading && } {!totpItems.length && !props.loading &&
{t('txt_no_verification_codes')}
} diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index d4768e3..c7b7894 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -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 => { )} - {!isEditing && !selectedCipher &&
{t('txt_select_an_item')}
} + {!isEditing && !selectedCipher && (props.loading ? :
{t('txt_select_an_item')}
)} diff --git a/webapp/src/components/vault/VaultListPanel.tsx b/webapp/src/components/vault/VaultListPanel.tsx index f7eda56..86a160b 100644 --- a/webapp/src/components/vault/VaultListPanel.tsx +++ b/webapp/src/components/vault/VaultListPanel.tsx @@ -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) {
props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> + {props.loading && !props.filteredCiphers.length && } {!!props.filteredCiphers.length && (
{props.visibleCiphers.map((cipher) => ( @@ -249,7 +251,7 @@ export default function VaultListPanel(props: VaultListPanelProps) { ))}
)} - {!props.filteredCiphers.length &&
{t('txt_no_items')}
} + {!props.loading && !props.filteredCiphers.length &&
{t('txt_no_items')}
}
); diff --git a/webapp/src/hooks/useVaultSendActions.ts b/webapp/src/hooks/useVaultSendActions.ts index b149737..cebc47b 100644 --- a/webapp/src/hooks/useVaultSendActions.ts +++ b/webapp/src/hooks/useVaultSendActions.ts @@ -68,6 +68,7 @@ interface UseVaultSendActionsOptions { refetchSends: () => Promise; 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(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; bySourceId: Map } @@ -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')); diff --git a/webapp/src/lib/app-support.ts b/webapp/src/lib/app-support.ts index b5bc87a..ddb3168 100644 --- a/webapp/src/lib/app-support.ts +++ b/webapp/src/lib/app-support.ts @@ -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; -} diff --git a/webapp/src/lib/decrypt-cipher.ts b/webapp/src/lib/decrypt-cipher.ts index c9de4f0..0ffc1a1 100644 --- a/webapp/src/lib/decrypt-cipher.ts +++ b/webapp/src/lib/decrypt-cipher.ts @@ -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, diff --git a/webapp/src/lib/export-formats.ts b/webapp/src/lib/export-formats.ts index 685d1ea..2b1456e 100644 --- a/webapp/src/lib/export-formats.ts +++ b/webapp/src/lib/export-formats.ts @@ -380,85 +380,6 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P return JSON.stringify(doc, null, 2); } -export async function buildBitwardenCsvString(args: BuildPlainJsonArgs): Promise { - const doc = await buildPlainBitwardenJsonDocument(args); - const folders = Array.isArray(doc.folders) ? (doc.folders as Array>) : []; - const items = Array.isArray(doc.items) ? (doc.items as Array>) : []; - - const folderNameById = new Map(); - 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>) - .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) : null; - const loginUris = login && Array.isArray(login.uris) - ? (login.uris as Array>) - .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 { const userEnc = base64ToBytes(args.userEncB64); const userMac = base64ToBytes(args.userMacB64); diff --git a/webapp/src/lib/vault-decrypt.ts b/webapp/src/lib/vault-decrypt.ts index 35a1578..4fd09a4 100644 --- a/webapp/src/lib/vault-decrypt.ts +++ b/webapp/src/lib/vault-decrypt.ts @@ -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 { 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 { - const attachmentId = String(attachment?.id || '').trim(); const fileNameResult = await decryptFieldWithSource( attachment.fileName || '', itemEnc, @@ -204,36 +191,6 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise= 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 { diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 2eab3c7..01e0b87 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -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%); + } +}