Compare commits

5 Commits

Author SHA1 Message Date
shuaiplus f6169b7610 fix: add support for trusted two-factor device tokens in backup import and export 2026-06-13 17:45:01 +08:00
shuaiplus 493f901ec1 fix: refine typography styles for improved readability and consistency 2026-06-13 17:20:25 +08:00
shuaiplus b4dfb0409b fix: improve network status handling and probe logic 2026-06-13 17:05:30 +08:00
shuaiplus a06cb0ed71 fix: serialize Bitwarden CSV login URIs 2026-06-13 16:38:25 +08:00
DiaMeoww b0242265f4 fix(webapp): add CSV export and stabilize dialog dismissal
fix(webapp): 添加 CSV 导出并稳定弹窗关闭行为
2026-06-13 16:38:25 +08:00
14 changed files with 336 additions and 47 deletions
+21 -1
View File
@@ -68,6 +68,7 @@ export interface BackupPayload {
ciphers: SqlRow[]; ciphers: SqlRow[];
attachments: SqlRow[]; attachments: SqlRow[];
webauthn_credentials?: SqlRow[]; webauthn_credentials?: SqlRow[];
trusted_two_factor_device_tokens?: SqlRow[];
}; };
} }
@@ -302,6 +303,7 @@ export function validateBackupPayloadContents(
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers'); const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments'); const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials'); const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens');
const externalAttachmentKeys = new Set<string>( const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs options.allowExternalAttachmentBlobs
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`) ? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
@@ -390,6 +392,21 @@ export function validateBackupPayloadContents(
accountPasskeyIds.add(id); accountPasskeyIds.add(id);
accountPasskeyCredentialIds.add(credentialId); accountPasskeyCredentialIds.add(credentialId);
} }
const trustedTwoFactorTokens = new Set<string>();
for (const row of trustedTwoFactorTokenRows) {
const token = String(row.token || '').trim();
const userId = String(row.user_id || '').trim();
const deviceIdentifier = String(row.device_identifier || '').trim();
const expiresAt = Number(row.expires_at || 0);
if (!token || !userIds.has(userId) || !deviceIdentifier || !Number.isFinite(expiresAt) || expiresAt <= 0) {
throw new Error('Backup archive contains an invalid trusted two-factor device token row');
}
if (trustedTwoFactorTokens.has(token)) {
throw new Error(`Backup archive contains duplicate trusted two-factor device token: ${token}`);
}
trustedTwoFactorTokens.add(token);
}
} }
export async function buildBackupArchive( export async function buildBackupArchive(
@@ -408,7 +425,7 @@ export async function buildBackupArchive(
includeAttachments, includeAttachments,
}); });
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([ const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
@@ -417,6 +434,7 @@ export async function buildBackupArchive(
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'), queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
]); ]);
const exportedConfigRows = sanitizeConfigRowsForExport(configRows); const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
const exportedAttachmentRows = includeAttachments ? attachmentRows : []; const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
@@ -445,6 +463,7 @@ export async function buildBackupArchive(
ciphers: cipherRows.length, ciphers: cipherRows.length,
attachments: exportedAttachmentRows.length, attachments: exportedAttachmentRows.length,
webauthn_credentials: accountPasskeyRows.length, webauthn_credentials: accountPasskeyRows.length,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
}, },
includes: { includes: {
attachments: includeAttachments, attachments: includeAttachments,
@@ -468,6 +487,7 @@ export async function buildBackupArchive(
ciphers: cipherRows, ciphers: cipherRows,
attachments: exportedAttachmentRows, attachments: exportedAttachmentRows,
webauthn_credentials: accountPasskeyRows, webauthn_credentials: accountPasskeyRows,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
}, null, BACKUP_JSON_INDENT)), }, null, BACKUP_JSON_INDENT)),
}; };
+21
View File
@@ -24,6 +24,7 @@ type BackupTableName =
| 'users' | 'users'
| 'domain_settings' | 'domain_settings'
| 'user_revisions' | 'user_revisions'
| 'trusted_two_factor_device_tokens'
| 'webauthn_credentials' | 'webauthn_credentials'
| 'folders' | 'folders'
| 'ciphers' | 'ciphers'
@@ -34,6 +35,7 @@ const BACKUP_TABLES: BackupTableName[] = [
'users', 'users',
'domain_settings', 'domain_settings',
'user_revisions', 'user_revisions',
'trusted_two_factor_device_tokens',
'webauthn_credentials', 'webauthn_credentials',
'folders', 'folders',
'ciphers', 'ciphers',
@@ -51,6 +53,7 @@ export interface BackupImportResultBody {
users: number; users: number;
domainSettings: number; domainSettings: number;
userRevisions: number; userRevisions: number;
trustedTwoFactorDeviceTokens: number;
webauthnCredentials: number; webauthnCredentials: number;
folders: number; folders: number;
ciphers: number; ciphers: number;
@@ -172,6 +175,7 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
'DELETE FROM ciphers', 'DELETE FROM ciphers',
'DELETE FROM folders', 'DELETE FROM folders',
'DELETE FROM webauthn_credentials', 'DELETE FROM webauthn_credentials',
'DELETE FROM trusted_two_factor_device_tokens',
'DELETE FROM domain_settings', 'DELETE FROM domain_settings',
'DELETE FROM user_revisions', 'DELETE FROM user_revisions',
'DELETE FROM users', 'DELETE FROM users',
@@ -296,6 +300,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []), user_revisions: cloneRows(payload.user_revisions || []),
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []), webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
folders: cloneRows(payload.folders || []), folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({ ciphers: cloneRows(payload.ciphers || []).map((row) => ({
@@ -634,6 +639,16 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
true true
) )
); );
await runInsertBatch(
db,
tableName('trusted_two_factor_device_tokens'),
buildInsertStatements(
db,
tableName('trusted_two_factor_device_tokens'),
['token', 'user_id', 'device_identifier', 'expires_at'],
payload.trusted_two_factor_device_tokens || []
)
);
await runInsertBatch( await runInsertBatch(
db, db,
tableName('webauthn_credentials'), tableName('webauthn_credentials'),
@@ -712,6 +727,7 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -735,6 +751,7 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -776,6 +793,7 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length, domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length, userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length, webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -853,6 +871,7 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -876,6 +895,7 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -923,6 +943,7 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length, domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length, userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length, webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
+5 -1
View File
@@ -83,6 +83,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
const [present, setPresent] = useState(props.open); const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const cardRef = useRef<HTMLFormElement | null>(null); const cardRef = useRef<HTMLFormElement | null>(null);
const maskPointerStartedRef = useRef(false);
const restoreFocusRef = useRef<HTMLElement | null>(null); const restoreFocusRef = useRef<HTMLElement | null>(null);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []); const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`; const titleId = `${dialogId}-title`;
@@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
return createPortal(( return createPortal((
<div <div
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`} className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
onPointerDown={(event) => {
maskPointerStartedRef.current = event.target === event.currentTarget;
}}
onClick={(event) => { onClick={(event) => {
if (event.target !== event.currentTarget || !canDismiss) return; if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
props.onCancel(); props.onCancel();
}} }}
> >
+1 -6
View File
@@ -23,7 +23,6 @@ export default function NetworkStatusBadge() {
const Icon = status === 'online' ? Wifi : WifiOff; const Icon = status === 'online' ? Wifi : WifiOff;
useEffect(() => { useEffect(() => {
let cancelled = false;
let timer = 0; let timer = 0;
const checkService = async () => { const checkService = async () => {
@@ -31,10 +30,7 @@ export default function NetworkStatusBadge() {
setCurrentNetworkStatus('offline'); setCurrentNetworkStatus('offline');
return; return;
} }
const reachable = await probeNodeWardenService(); await probeNodeWardenService();
if (!cancelled) {
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
}
}; };
const scheduleNextCheck = () => { const scheduleNextCheck = () => {
@@ -62,7 +58,6 @@ export default function NetworkStatusBadge() {
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
return () => { return () => {
cancelled = true;
unsubscribe(); unsubscribe();
window.clearTimeout(timer); window.clearTimeout(timer);
window.removeEventListener('online', handleOnline); window.removeEventListener('online', handleOnline);
+7
View File
@@ -4,6 +4,7 @@ import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
import { import {
attachNodeWardenEncryptedAttachmentPayload, attachNodeWardenEncryptedAttachmentPayload,
buildAccountEncryptedBitwardenJsonString, buildAccountEncryptedBitwardenJsonString,
buildBitwardenCsvString,
buildBitwardenZipBytes, buildBitwardenZipBytes,
buildExportFileName, buildExportFileName,
buildNodeWardenAttachmentRecords, buildNodeWardenAttachmentRecords,
@@ -1190,6 +1191,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
mimeType: 'application/json', mimeType: 'application/json',
bytes: new TextEncoder().encode(await getPlainJson()), bytes: new TextEncoder().encode(await getPlainJson()),
}; };
} else if (format === 'bitwarden_csv') {
result = {
fileName: buildExportFileName(format),
mimeType: 'text/csv;charset=utf-8',
bytes: new TextEncoder().encode(buildBitwardenCsvString(await getPlainJsonDoc())),
};
} else if (format === 'bitwarden_encrypted_json') { } else if (format === 'bitwarden_encrypted_json') {
if (request.encryptedJsonMode === 'password') { if (request.encryptedJsonMode === 'password') {
const plainJson = await getPlainJson(); const plainJson = await getPlainJson();
+3
View File
@@ -9,6 +9,7 @@ import type {
TokenSuccess, TokenSuccess,
} from '../types'; } from '../types';
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys'; import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
import { parseJson, type AuthedFetch, type SessionSetter } from './shared'; import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
const SESSION_KEY = 'nodewarden.web.session.v4'; const SESSION_KEY = 'nodewarden.web.session.v4';
@@ -474,6 +475,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
for (let attempt = 0; attempt < maxAttempts; attempt += 1) { for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try { try {
const response = await fetch(input, { ...init, headers }); const response = await fetch(input, { ...init, headers });
recordNodeWardenReachable();
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) { if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
return response; return response;
} }
@@ -484,6 +486,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
} catch (error) { } catch (error) {
lastError = error; lastError = error;
if (attempt === maxAttempts - 1) { if (attempt === maxAttempts - 1) {
recordNodeWardenUnreachable();
throw error; throw error;
} }
} }
+1
View File
@@ -96,6 +96,7 @@ export interface AdminBackupImportCounts {
users: number; users: number;
domainSettings?: number; domainSettings?: number;
userRevisions: number; userRevisions: number;
trustedTwoFactorDeviceTokens?: number;
webauthnCredentials?: number; webauthnCredentials?: number;
folders: number; folders: number;
ciphers: number; ciphers: number;
+8 -18
View File
@@ -279,20 +279,16 @@ export async function hydrateLockedSession(
fallbackProfile: Profile | null = null fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> { ): Promise<{ session: SessionState | null; profile: Profile | null }> {
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email); const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
let serviceReachable = true; if (hasOfflineUnlock && browserReportsOffline()) {
if (hasOfflineUnlock) { return {
serviceReachable = await probeNodeWardenService(); session,
if (!serviceReachable) { profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
return { };
session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
};
}
} }
const refreshedSession = await maybeRefreshSession(session); const refreshedSession = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) { if (!refreshedSession?.accessToken) {
if (hasOfflineUnlock && !serviceReachable) { if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
return { return {
session, session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email), profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
@@ -571,14 +567,8 @@ export async function performUnlock(
} }
}; };
if (hasOfflineUnlock) { if (hasOfflineUnlock && browserReportsOffline()) {
if (browserReportsOffline()) { return unlockOffline();
return unlockOffline();
}
const serviceReachable = await probeNodeWardenService();
if (!serviceReachable) {
return unlockOffline();
}
} }
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string }; let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
+128
View File
@@ -9,6 +9,7 @@ configureZipJs({ useWebWorkers: false });
export const EXPORT_FORMATS = [ export const EXPORT_FORMATS = [
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' }, { id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
{ id: 'bitwarden_csv', label: 'Bitwarden (vault as csv)' },
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' }, { id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' }, { id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' }, { id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
@@ -70,6 +71,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'; return !!value && typeof value === 'object';
} }
function csvText(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function escapeCsvCell(value: unknown): string {
const text = csvText(value);
if (!/[",\r\n]/.test(text)) return text;
return `"${text.replace(/"/g, '""')}"`;
}
function buildCsvString(rows: string[][]): string {
return `${rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n')}\r\n`;
}
function buildSingleRowCsvString(values: string[]): string {
return values.map(escapeCsvCell).join(',');
}
function isCipherString(value: string): boolean { function isCipherString(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());
} }
@@ -383,6 +409,106 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
return JSON.stringify(doc, null, 2); return JSON.stringify(doc, null, 2);
} }
const BITWARDEN_CSV_HEADERS = [
'folder',
'favorite',
'type',
'name',
'notes',
'fields',
'reprompt',
'login_uri',
'login_username',
'login_password',
'login_totp',
] as const;
function bitwardenCsvType(type: number): 'login' | 'note' {
return type === 1 ? 'login' : 'note';
}
function sourceTypeLabel(type: number): string {
if (type === 3) return 'card';
if (type === 4) return 'identity';
if (type === 5) return 'sshKey';
if (type === 2) return 'note';
return `type ${type}`;
}
function appendFieldLine(lines: string[], name: unknown, value: unknown): void {
const key = csvText(name).trim();
const text = csvText(value);
if (!key || !text) return;
lines.push(`${key}: ${text}`);
}
function appendRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
if (!isRecord(value)) return;
for (const [key, fieldValue] of Object.entries(value)) {
appendFieldLine(lines, `${prefix}.${key}`, fieldValue);
}
}
function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): string {
const lines: string[] = [];
const fields = Array.isArray(item.fields) ? item.fields : [];
for (const field of fields) {
if (!isRecord(field)) continue;
appendFieldLine(lines, field.name, field.value);
}
if (type !== 1 && type !== 2) {
appendFieldLine(lines, 'nodewardenType', sourceTypeLabel(type));
appendRecordFieldLines(lines, sourceTypeLabel(type), item[sourceTypeLabel(type)]);
}
return lines.join('\n');
}
function buildFolderNameById(foldersRaw: unknown): Map<string, string> {
const out = new Map<string, string>();
const folders = Array.isArray(foldersRaw) ? foldersRaw : [];
for (const folder of folders) {
if (!isRecord(folder)) continue;
const id = csvText(folder.id).trim();
if (!id) continue;
out.set(id, csvText(folder.name));
}
return out;
}
function buildBitwardenCsvLoginUri(login: Record<string, unknown> | null): string {
const uris = Array.isArray(login?.uris) ? login.uris : [];
return buildSingleRowCsvString(uris
.map((uri) => (isRecord(uri) ? csvText(uri.uri).trim() : ''))
.filter(Boolean));
}
export function buildBitwardenCsvString(bitwardenJsonDoc: Record<string, unknown>): string {
const folderNameById = buildFolderNameById(bitwardenJsonDoc.folders);
const rows: string[][] = [[...BITWARDEN_CSV_HEADERS]];
const items = Array.isArray(bitwardenJsonDoc.items) ? bitwardenJsonDoc.items : [];
for (const itemRaw of items) {
if (!isRecord(itemRaw)) continue;
const type = normalizeNumber(itemRaw.type, 1);
const isLogin = type === 1;
const login = isRecord(itemRaw.login) ? itemRaw.login : null;
const folderId = csvText(itemRaw.folderId).trim();
rows.push([
folderNameById.get(folderId) || '',
itemRaw.favorite ? '1' : '0',
bitwardenCsvType(type),
csvText(itemRaw.name) || '--',
csvText(itemRaw.notes),
buildBitwardenCsvFields(itemRaw, type),
String(normalizeNumber(itemRaw.reprompt, 0)),
isLogin ? buildBitwardenCsvLoginUri(login) : '',
isLogin ? csvText(login?.username) : '',
isLogin ? csvText(login?.password) : '',
isLogin ? csvText(login?.totp) : '',
]);
}
return `\uFEFF${buildCsvString(rows)}`;
}
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);
@@ -566,11 +692,13 @@ function nowStamp(now = new Date()): string {
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string { export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
const stamp = nowStamp(); const stamp = nowStamp();
if ( if (
format === 'bitwarden_csv' ||
format === 'bitwarden_json' || format === 'bitwarden_json' ||
format === 'bitwarden_encrypted_json' || format === 'bitwarden_encrypted_json' ||
format === 'nodewarden_json' || format === 'nodewarden_json' ||
format === 'nodewarden_encrypted_json' format === 'nodewarden_encrypted_json'
) { ) {
if (format === 'bitwarden_csv') return `bitwarden_export_${stamp}.csv`;
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`; if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
return `bitwarden_export_${stamp}.json`; return `bitwarden_export_${stamp}.json`;
} }
+23 -3
View File
@@ -1,12 +1,14 @@
export type NetworkStatus = 'online' | 'offline'; export type NetworkStatus = 'online' | 'offline';
const STATUS_PROBE_TIMEOUT_MS = 3500; const STATUS_PROBE_TIMEOUT_MS = 8000;
const STATUS_PROBE_CACHE_MS = 5000; const STATUS_PROBE_CACHE_MS = 5000;
const PROBE_FAILURES_BEFORE_OFFLINE = 2;
const listeners = new Set<(status: NetworkStatus) => void>(); const listeners = new Set<(status: NetworkStatus) => void>();
let currentStatus: NetworkStatus = getInitialNetworkStatus(); let currentStatus: NetworkStatus = getInitialNetworkStatus();
let pendingProbe: Promise<boolean> | null = null; let pendingProbe: Promise<boolean> | null = null;
let lastProbeAt = 0; let lastProbeAt = 0;
let lastProbeResult = currentStatus === 'online'; let lastProbeResult = currentStatus === 'online';
let consecutiveProbeFailures = 0;
export function browserReportsOffline(): boolean { export function browserReportsOffline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine === false; return typeof navigator !== 'undefined' && navigator.onLine === false;
@@ -35,8 +37,23 @@ export function subscribeNetworkStatus(listener: (status: NetworkStatus) => void
}; };
} }
export function recordNodeWardenReachable(): void {
consecutiveProbeFailures = 0;
lastProbeResult = true;
setCurrentNetworkStatus('online');
}
export function recordNodeWardenUnreachable(): void {
lastProbeResult = false;
consecutiveProbeFailures += 1;
if (browserReportsOffline() || consecutiveProbeFailures >= PROBE_FAILURES_BEFORE_OFFLINE) {
setCurrentNetworkStatus('offline');
}
}
export async function probeNodeWardenService(): Promise<boolean> { export async function probeNodeWardenService(): Promise<boolean> {
if (browserReportsOffline()) { if (browserReportsOffline()) {
consecutiveProbeFailures = PROBE_FAILURES_BEFORE_OFFLINE;
setCurrentNetworkStatus('offline'); setCurrentNetworkStatus('offline');
return false; return false;
} }
@@ -68,8 +85,11 @@ export async function probeNodeWardenService(): Promise<boolean> {
.catch(() => false) .catch(() => false)
.then((result) => { .then((result) => {
lastProbeAt = Date.now(); lastProbeAt = Date.now();
lastProbeResult = result; if (result) {
setCurrentNetworkStatus(result ? 'online' : 'offline'); recordNodeWardenReachable();
} else {
recordNodeWardenUnreachable();
}
return result; return result;
}) })
.finally(() => { .finally(() => {
-2
View File
@@ -1,5 +1,4 @@
import type { Send } from './types'; import type { Send } from './types';
import { getCurrentNetworkStatus } from './network-status';
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt'; import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
type WorkerSuccess<T> = { id: number; ok: true; result: T }; type WorkerSuccess<T> = { id: number; ok: true; result: T };
@@ -13,7 +12,6 @@ const pending = new Map<number, { resolve: (value: any) => void; reject: (error:
function getWorker(): Worker | null { function getWorker(): Worker | null {
if (typeof Worker === 'undefined') return null; if (typeof Worker === 'undefined') return null;
if (worker) return worker; if (worker) return worker;
if (getCurrentNetworkStatus() === 'offline') return null;
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' }); worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => { worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
const message = event.data; const message = event.data;
+95
View File
@@ -810,6 +810,101 @@ h4 {
background: transparent; background: transparent;
} }
/* Typography refinement: stronger scan targets for dense vault/admin surfaces. */
body {
font-family: var(--font-sans);
font-size: var(--font-base);
font-weight: 400;
}
button,
input,
select,
textarea {
font: inherit;
}
.side-link,
.side-group-trigger,
.side-sub-link,
.tree-btn,
.mobile-settings-link,
.backup-destination-item,
.backup-browser-entry,
.sort-menu-item,
.create-menu-item,
.nav-layout-option {
font-size: var(--font-sm);
font-weight: 600;
letter-spacing: 0;
}
.side-link.active,
.side-group-trigger.active,
.side-sub-link.active,
.tree-btn.active,
.mobile-tab.active,
.mobile-settings-link.active,
.nav-layout-option.active {
font-weight: 700;
}
.sidebar-title,
.list-count,
.field > span,
.table th,
.dialog-warning-kicker,
.backup-recommendation-group-title {
font-weight: 700;
letter-spacing: 0;
}
.list-title {
color: var(--text);
font-size: var(--font-base);
font-weight: 600;
letter-spacing: 0;
}
.list-sub,
.detail-sub,
.backup-destination-meta,
.totp-code-username,
.field-help,
.settings-field-note {
font-size: var(--font-sm);
line-height: var(--leading-snug);
}
.btn,
.input,
.search-input,
.user-chip,
.network-status-badge {
font-weight: 600;
letter-spacing: 0;
}
.btn-primary,
.btn-danger,
.btn.full,
.topbar-actions .btn,
.network-status-badge {
font-weight: 700;
}
.card h4,
.settings-module h3,
.section-head h3,
.section-head h4,
.detail-title,
.totp-code-name,
.backup-destination-name,
.mobile-sidebar-title {
font-weight: 700;
letter-spacing: 0;
}
.toast-item, .toast-item,
.dialog-card { .dialog-card {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
+5 -4
View File
@@ -8,11 +8,12 @@ body,
@apply m-0 h-full w-full p-0; @apply m-0 h-full w-full p-0;
color: var(--text); color: var(--text);
background: var(--bg-accent); background: var(--bg-accent);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif; font-family: var(--font-sans);
font-size: var(--font-base); font-size: var(--font-base);
line-height: var(--leading-normal); line-height: var(--leading-normal);
letter-spacing: var(--tracking-normal); letter-spacing: var(--tracking-normal);
font-feature-settings: 'liga' 1, 'kern' 1; font-feature-settings: 'liga' 1, 'kern' 1, 'calt' 1;
font-variant-numeric: tabular-nums;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@@ -33,8 +34,8 @@ body.dialog-open {
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
font-weight: 600; font-weight: 700;
letter-spacing: var(--tracking-tight); letter-spacing: 0;
line-height: var(--leading-tight); line-height: var(--leading-tight);
margin: 0; margin: 0;
} }
+18 -12
View File
@@ -46,16 +46,22 @@
--dur-slow: 350ms; --dur-slow: 350ms;
--actions-gap: clamp(5px, calc((100vw - 520px) * 1), 10px); --actions-gap: clamp(5px, calc((100vw - 520px) * 1), 10px);
/* Typography Families */
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei',
'Noto Sans CJK SC', 'Noto Sans SC', Arial, sans-serif;
--font-mono: 'SFMono-Regular', 'Cascadia Code', 'Cascadia Mono', Consolas, 'Liberation Mono', monospace;
/* Typography Scale */ /* Typography Scale */
--font-xs: 11px; --font-xs: 11px;
--font-sm: 13px; --font-sm: 14px;
--font-base: 14px; --font-base: 15px;
--font-md: 15px; --font-md: 16px;
--font-lg: 16px; --font-lg: 17px;
--font-xl: 18px; --font-xl: 19px;
--font-2xl: 20px; --font-2xl: 21px;
--font-3xl: 24px; --font-3xl: 25px;
--font-4xl: 28px; --font-4xl: 29px;
/* Line Heights */ /* Line Heights */
--leading-tight: 1.25; --leading-tight: 1.25;
@@ -65,11 +71,11 @@
--leading-loose: 1.75; --leading-loose: 1.75;
/* Letter Spacing */ /* Letter Spacing */
--tracking-tighter: -0.02em; --tracking-tighter: 0;
--tracking-tight: -0.01em; --tracking-tight: 0;
--tracking-normal: 0; --tracking-normal: 0;
--tracking-wide: 0.01em; --tracking-wide: 0;
--tracking-wider: 0.02em; --tracking-wider: 0;
/* Spacing Scale */ /* Spacing Scale */
--space-1: 4px; --space-1: 4px;