mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
refactor: Remove unused APIs and data structures, optimize loading state component styles
This commit is contained in:
@@ -182,14 +182,6 @@ CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
|||||||
updated_at INTEGER NOT NULL
|
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 (
|
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
|
||||||
jti TEXT PRIMARY KEY,
|
jti TEXT PRIMARY KEY,
|
||||||
expires_at INTEGER NOT NULL
|
expires_at INTEGER NOT NULL
|
||||||
|
|||||||
@@ -426,9 +426,6 @@ export async function handleDeleteAttachment(
|
|||||||
// Delete attachment metadata
|
// Delete attachment metadata
|
||||||
await storage.deleteAttachment(attachmentId);
|
await storage.deleteAttachment(attachmentId);
|
||||||
|
|
||||||
// Remove attachment from cipher
|
|
||||||
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
|
|
||||||
|
|
||||||
// Update cipher revision date
|
// Update cipher revision date
|
||||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
if (revisionInfo) {
|
if (revisionInfo) {
|
||||||
|
|||||||
@@ -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();
|
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
|
||||||
void cipherId;
|
|
||||||
void attachmentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
|
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
|
||||||
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,11 +94,6 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'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 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 (' +
|
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ import {
|
|||||||
getAttachmentsByCipher as listStoredAttachmentsByCipher,
|
getAttachmentsByCipher as listStoredAttachmentsByCipher,
|
||||||
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
|
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
|
||||||
getAttachmentsByUserId as listStoredAttachmentsByUserId,
|
getAttachmentsByUserId as listStoredAttachmentsByUserId,
|
||||||
removeAttachmentFromCipher as detachStoredAttachmentFromCipher,
|
|
||||||
saveAttachment as saveStoredAttachment,
|
saveAttachment as saveStoredAttachment,
|
||||||
updateCipherRevisionDate as updateStoredCipherRevisionDate,
|
updateCipherRevisionDate as updateStoredCipherRevisionDate,
|
||||||
} from './storage-attachment-repo';
|
} from './storage-attachment-repo';
|
||||||
@@ -389,10 +388,6 @@ export class StorageService {
|
|||||||
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
|
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
|
||||||
await detachStoredAttachmentFromCipher(cipherId, attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
||||||
await deleteStoredAttachmentsByCipher(this.db, cipherId);
|
await deleteStoredAttachmentsByCipher(this.db, cipherId);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-38
@@ -23,13 +23,8 @@ import {
|
|||||||
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
|
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
|
||||||
import { getSends } from '@/lib/api/send';
|
import { getSends } from '@/lib/api/send';
|
||||||
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
||||||
import {
|
|
||||||
repairCipherAttachmentMetadata,
|
|
||||||
updateFolder,
|
|
||||||
} from '@/lib/api/vault';
|
|
||||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||||
import {
|
import {
|
||||||
looksLikeCipherString,
|
|
||||||
parseSignalRTextFrames,
|
parseSignalRTextFrames,
|
||||||
readInviteCodeFromUrl,
|
readInviteCodeFromUrl,
|
||||||
} from '@/lib/app-support';
|
} from '@/lib/app-support';
|
||||||
@@ -170,7 +165,6 @@ export default function App() {
|
|||||||
const [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null);
|
const [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null);
|
||||||
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
|
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
|
||||||
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
||||||
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
|
|
||||||
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const repairAttemptRef = useRef<string>('');
|
const repairAttemptRef = useRef<string>('');
|
||||||
@@ -850,9 +844,6 @@ export default function App() {
|
|||||||
setDecryptedFolders(result.folders);
|
setDecryptedFolders(result.folders);
|
||||||
setDecryptedCiphers(result.ciphers);
|
setDecryptedCiphers(result.ciphers);
|
||||||
setVaultInitialDecryptDone(true);
|
setVaultInitialDecryptDone(true);
|
||||||
for (const repair of result.attachmentRepairs) {
|
|
||||||
void repairCipherAttachmentMetadata(authedFetch, repair.cipherId, repair.attachmentId, repair.metadata);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
|
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
|
||||||
@@ -862,7 +853,7 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers, authedFetch]);
|
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.symEncKey || !session?.symMacKey) {
|
if (!session?.symEncKey || !session?.symMacKey) {
|
||||||
@@ -904,31 +895,6 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [session?.symEncKey, session?.symMacKey, sendsQuery.data]);
|
}, [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() {
|
async function refreshVaultSilently() {
|
||||||
if (pendingVaultCoreRefreshRef.current) {
|
if (pendingVaultCoreRefreshRef.current) {
|
||||||
await pendingVaultCoreRefreshRef.current;
|
await pendingVaultCoreRefreshRef.current;
|
||||||
@@ -1107,6 +1073,7 @@ export default function App() {
|
|||||||
refetchSends: sendsQuery.refetch,
|
refetchSends: sendsQuery.refetch,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
patchDecryptedCiphers: setDecryptedCiphers,
|
patchDecryptedCiphers: setDecryptedCiphers,
|
||||||
|
patchDecryptedFolders: setDecryptedFolders,
|
||||||
});
|
});
|
||||||
const accountSecurityActions = useAccountSecurityActions({
|
const accountSecurityActions = useAccountSecurityActions({
|
||||||
authedFetch,
|
authedFetch,
|
||||||
@@ -1203,9 +1170,9 @@ export default function App() {
|
|||||||
decryptedCiphers,
|
decryptedCiphers,
|
||||||
decryptedFolders,
|
decryptedFolders,
|
||||||
decryptedSends,
|
decryptedSends,
|
||||||
ciphersLoading: vaultCoreQuery.isFetching,
|
ciphersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore,
|
||||||
foldersLoading: vaultCoreQuery.isFetching,
|
foldersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore,
|
||||||
sendsLoading: sendsQuery.isFetching,
|
sendsLoading: sendsQuery.isFetching && !sendsQuery.data,
|
||||||
users: usersQuery.data || [],
|
users: usersQuery.data || [],
|
||||||
invites: invitesQuery.data || [],
|
invites: invitesQuery.data || [],
|
||||||
totpEnabled: !!totpStatusQuery.data?.enabled,
|
totpEnabled: !!totpStatusQuery.data?.enabled,
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { useEffect } from 'preact/hooks';
|
|||||||
import { Link, Route, Switch } from 'wouter';
|
import { Link, Route, Switch } from 'wouter';
|
||||||
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
import VaultPage from '@/components/VaultPage';
|
|
||||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||||
import type { ExportRequest } from '@/lib/export-formats';
|
import type { ExportRequest } from '@/lib/export-formats';
|
||||||
|
|
||||||
|
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||||
@@ -181,6 +181,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/vault">
|
<Route path="/vault">
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
<VaultPage
|
<VaultPage
|
||||||
ciphers={props.decryptedCiphers}
|
ciphers={props.decryptedCiphers}
|
||||||
folders={props.decryptedFolders}
|
folders={props.decryptedFolders}
|
||||||
@@ -211,6 +212,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
attachmentUploadPercent={props.attachmentUploadPercent}
|
attachmentUploadPercent={props.attachmentUploadPercent}
|
||||||
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
|
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={props.settingsAccountRoute}>
|
<Route path={props.settingsAccountRoute}>
|
||||||
{props.profile && (
|
{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 { 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 { 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 { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { Send, SendDraft } from '@/lib/types';
|
import type { Send, SendDraft } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -322,6 +323,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="list-panel">
|
<div className="list-panel">
|
||||||
|
{props.loading && !filteredSends.length && <LoadingState lines={6} compact />}
|
||||||
{filteredSends.map((send, index) => (
|
{filteredSends.map((send, index) => (
|
||||||
<div
|
<div
|
||||||
key={send.id}
|
key={send.id}
|
||||||
@@ -375,7 +377,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
|
{!props.loading && !filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -553,6 +555,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isEditing && !selectedSend && props.loading && <LoadingState card lines={4} />}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
|||||||
import { calcTotpNow } from '@/lib/crypto';
|
import { calcTotpNow } from '@/lib/crypto';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher } from '@/lib/types';
|
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 {
|
interface TotpCodesPageProps {
|
||||||
ciphers: Cipher[];
|
ciphers: Cipher[];
|
||||||
@@ -62,16 +63,6 @@ function firstCipherUri(cipher: Cipher): string {
|
|||||||
return '';
|
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 }) {
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
const host = useMemo(() => hostFromUri(firstCipherUri(cipher)), [cipher]);
|
||||||
const iconStackRef = useRef<HTMLSpanElement | null>(null);
|
const iconStackRef = useRef<HTMLSpanElement | null>(null);
|
||||||
@@ -447,6 +438,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
className="totp-codes-list"
|
className="totp-codes-list"
|
||||||
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
|
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>}
|
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<SortableContext items={sortableTotpItems} strategy={rectSortingStrategy}>
|
<SortableContext items={sortableTotpItems} strategy={rectSortingStrategy}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import VaultDialogs from '@/components/vault/VaultDialogs';
|
import VaultDialogs from '@/components/vault/VaultDialogs';
|
||||||
import VaultDetailView from '@/components/vault/VaultDetailView';
|
import VaultDetailView from '@/components/vault/VaultDetailView';
|
||||||
import VaultEditor from '@/components/vault/VaultEditor';
|
import VaultEditor from '@/components/vault/VaultEditor';
|
||||||
@@ -1139,7 +1140,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
</div>
|
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { RefObject } from 'preact';
|
|||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
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 type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
@@ -234,6 +235,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
<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 && (
|
{!!props.filteredCiphers.length && (
|
||||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||||
{props.visibleCiphers.map((cipher) => (
|
{props.visibleCiphers.map((cipher) => (
|
||||||
@@ -249,7 +251,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ interface UseVaultSendActionsOptions {
|
|||||||
refetchSends: () => Promise<unknown>;
|
refetchSends: () => Promise<unknown>;
|
||||||
onNotify: Notify;
|
onNotify: Notify;
|
||||||
patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
|
patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
|
||||||
|
patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
|
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
|
||||||
@@ -98,6 +99,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
refetchSends,
|
refetchSends,
|
||||||
onNotify,
|
onNotify,
|
||||||
patchDecryptedCiphers,
|
patchDecryptedCiphers,
|
||||||
|
patchDecryptedFolders,
|
||||||
} = options;
|
} = options;
|
||||||
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
|
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
|
||||||
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
|
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));
|
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 (
|
const uploadImportedAttachments = async (
|
||||||
attachments: ImportAttachmentFile[],
|
attachments: ImportAttachmentFile[],
|
||||||
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
||||||
@@ -311,7 +351,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
async bulkDeleteVaultItems(ids: string[]) {
|
async bulkDeleteVaultItems(ids: string[]) {
|
||||||
try {
|
try {
|
||||||
await bulkDeleteCiphers(authedFetch, ids);
|
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'));
|
onNotify('success', t('txt_deleted_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
|
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[]) {
|
async bulkArchiveVaultItems(ids: string[]) {
|
||||||
try {
|
try {
|
||||||
await bulkArchiveCiphers(authedFetch, ids);
|
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'));
|
onNotify('success', t('txt_archived_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed'));
|
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[]) {
|
async bulkUnarchiveVaultItems(ids: string[]) {
|
||||||
try {
|
try {
|
||||||
await bulkUnarchiveCiphers(authedFetch, ids);
|
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'));
|
onNotify('success', t('txt_unarchived_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed'));
|
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) {
|
async bulkMoveVaultItems(ids: string[], folderId: string | null) {
|
||||||
try {
|
try {
|
||||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
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'));
|
onNotify('success', t('txt_moved_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
|
||||||
@@ -360,8 +406,16 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
await createFolder(authedFetch, session, folderName);
|
const created = await createFolder(authedFetch, session, folderName);
|
||||||
await refetchFolders();
|
patchDecryptedFolders((prev) => [
|
||||||
|
{
|
||||||
|
id: created.id,
|
||||||
|
name: created.name || folderName,
|
||||||
|
decName: folderName,
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
]);
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_folder_created'));
|
onNotify('success', t('txt_folder_created'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
|
||||||
@@ -377,7 +431,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await deleteFolder(authedFetch, id);
|
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'));
|
onNotify('success', t('txt_folder_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
|
||||||
@@ -399,7 +455,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
await updateFolder(authedFetch, session, id, nextName);
|
await updateFolder(authedFetch, session, id, nextName);
|
||||||
await refetchFolders();
|
patchFolderBatch([id], (folder) => ({ ...folder, decName: nextName }));
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_folder_updated'));
|
onNotify('success', t('txt_folder_updated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
|
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[]) {
|
async bulkRestoreVaultItems(ids: string[]) {
|
||||||
try {
|
try {
|
||||||
await bulkRestoreCiphers(authedFetch, ids);
|
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'));
|
onNotify('success', t('txt_restored_selected_items'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
|
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[]) {
|
async bulkPermanentDeleteVaultItems(ids: string[]) {
|
||||||
try {
|
try {
|
||||||
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
||||||
await Promise.all([refetchCiphers(), refetchFolders()]);
|
patchCipherBatch(ids, () => null);
|
||||||
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_deleted_selected_items_permanently'));
|
onNotify('success', t('txt_deleted_selected_items_permanently'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
|
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;
|
if (!ids.length) return;
|
||||||
try {
|
try {
|
||||||
await bulkDeleteFolders(authedFetch, ids);
|
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'));
|
onNotify('success', t('txt_folders_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { hkdf } from '@/lib/crypto';
|
import { hkdf } from '@/lib/crypto';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher, VaultDraft } from '@/lib/types';
|
import type { VaultDraft } from '@/lib/types';
|
||||||
import type { ImportResultSummary } from '@/components/ImportPage';
|
import type { ImportResultSummary } from '@/components/ImportPage';
|
||||||
|
|
||||||
const SEND_KEY_SALT = 'bitwarden-send';
|
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());
|
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 '';
|
if (value === null || value === undefined) return '';
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ export function summarizeImportResult(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildEmptyImportDraft(type: number): VaultDraft {
|
function buildEmptyImportDraft(type: number): VaultDraft {
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
@@ -279,6 +279,3 @@ export async function deriveSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{
|
|||||||
return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) };
|
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 { decryptStr, decryptBw } from './crypto';
|
||||||
import type { Cipher } from './types';
|
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(
|
async function decryptField(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
enc: Uint8Array,
|
enc: Uint8Array,
|
||||||
|
|||||||
@@ -380,85 +380,6 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
|
|||||||
return JSON.stringify(doc, null, 2);
|
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> {
|
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
||||||
const userEnc = base64ToBytes(args.userEncB64);
|
const userEnc = base64ToBytes(args.userEncB64);
|
||||||
const userMac = base64ToBytes(args.userMacB64);
|
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 { deriveSendKeyParts } from './app-support';
|
||||||
import type { Cipher, Folder, Send } from './types';
|
import type { Cipher, Folder, Send } from './types';
|
||||||
|
|
||||||
export interface AttachmentRepairTask {
|
|
||||||
cipherId: string;
|
|
||||||
attachmentId: string;
|
|
||||||
metadata: { fileName?: string; key?: string | null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DecryptVaultCoreArgs {
|
export interface DecryptVaultCoreArgs {
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
ciphers: Cipher[];
|
ciphers: Cipher[];
|
||||||
@@ -18,7 +12,6 @@ export interface DecryptVaultCoreArgs {
|
|||||||
export interface DecryptVaultCoreResult {
|
export interface DecryptVaultCoreResult {
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
ciphers: Cipher[];
|
ciphers: Cipher[];
|
||||||
attachmentRepairs: AttachmentRepairTask[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DecryptSendsArgs {
|
export interface DecryptSendsArgs {
|
||||||
@@ -28,10 +21,6 @@ export interface DecryptSendsArgs {
|
|||||||
origin: string;
|
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 {
|
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
if (a.byteLength !== b.byteLength) return false;
|
if (a.byteLength !== b.byteLength) return false;
|
||||||
for (let i = 0; i < a.byteLength; i += 1) {
|
for (let i = 0; i < a.byteLength; i += 1) {
|
||||||
@@ -81,7 +70,6 @@ async function decryptFieldWithSource(
|
|||||||
export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
|
export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
|
||||||
const userEnc = base64ToBytes(args.symEncKeyB64);
|
const userEnc = base64ToBytes(args.symEncKeyB64);
|
||||||
const userMac = base64ToBytes(args.symMacKeyB64);
|
const userMac = base64ToBytes(args.symMacKeyB64);
|
||||||
const attachmentRepairs: AttachmentRepairTask[] = [];
|
|
||||||
|
|
||||||
const folders = await Promise.all(
|
const folders = await Promise.all(
|
||||||
args.folders.map(async (folder) => ({
|
args.folders.map(async (folder) => ({
|
||||||
@@ -195,7 +183,6 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
|
|||||||
if (Array.isArray(cipher.attachments)) {
|
if (Array.isArray(cipher.attachments)) {
|
||||||
nextCipher.attachments = await Promise.all(
|
nextCipher.attachments = await Promise.all(
|
||||||
cipher.attachments.map(async (attachment) => {
|
cipher.attachments.map(async (attachment) => {
|
||||||
const attachmentId = String(attachment?.id || '').trim();
|
|
||||||
const fileNameResult = await decryptFieldWithSource(
|
const fileNameResult = await decryptFieldWithSource(
|
||||||
attachment.fileName || '',
|
attachment.fileName || '',
|
||||||
itemEnc,
|
itemEnc,
|
||||||
@@ -204,36 +191,6 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
|
|||||||
userMac,
|
userMac,
|
||||||
!itemUsesUserKey
|
!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 {
|
return {
|
||||||
...attachment,
|
...attachment,
|
||||||
decFileName: fileNameResult.text,
|
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[]> {
|
export async function decryptSends(args: DecryptSendsArgs): Promise<Send[]> {
|
||||||
|
|||||||
@@ -170,3 +170,73 @@
|
|||||||
:root[data-theme='dark'] .tree-btn:hover {
|
:root[data-theme='dark'] .tree-btn:hover {
|
||||||
background: var(--panel-subtle);
|
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