mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Compare commits
6 Commits
v1.6.1
...
f6169b7610
| Author | SHA1 | Date | |
|---|---|---|---|
| f6169b7610 | |||
| 493f901ec1 | |||
| b4dfb0409b | |||
| a06cb0ed71 | |||
| b0242265f4 | |||
| b444c0f4b8 |
@@ -68,6 +68,7 @@ export interface BackupPayload {
|
||||
ciphers: SqlRow[];
|
||||
attachments: 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 attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||
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>(
|
||||
options.allowExternalAttachmentBlobs
|
||||
? (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);
|
||||
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(
|
||||
@@ -408,7 +425,7 @@ export async function buildBackupArchive(
|
||||
includeAttachments,
|
||||
});
|
||||
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 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'),
|
||||
@@ -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, 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 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 exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||
@@ -445,6 +463,7 @@ export async function buildBackupArchive(
|
||||
ciphers: cipherRows.length,
|
||||
attachments: exportedAttachmentRows.length,
|
||||
webauthn_credentials: accountPasskeyRows.length,
|
||||
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
|
||||
},
|
||||
includes: {
|
||||
attachments: includeAttachments,
|
||||
@@ -468,6 +487,7 @@ export async function buildBackupArchive(
|
||||
ciphers: cipherRows,
|
||||
attachments: exportedAttachmentRows,
|
||||
webauthn_credentials: accountPasskeyRows,
|
||||
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
|
||||
}, null, BACKUP_JSON_INDENT)),
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ type BackupTableName =
|
||||
| 'users'
|
||||
| 'domain_settings'
|
||||
| 'user_revisions'
|
||||
| 'trusted_two_factor_device_tokens'
|
||||
| 'webauthn_credentials'
|
||||
| 'folders'
|
||||
| 'ciphers'
|
||||
@@ -34,6 +35,7 @@ const BACKUP_TABLES: BackupTableName[] = [
|
||||
'users',
|
||||
'domain_settings',
|
||||
'user_revisions',
|
||||
'trusted_two_factor_device_tokens',
|
||||
'webauthn_credentials',
|
||||
'folders',
|
||||
'ciphers',
|
||||
@@ -51,6 +53,7 @@ export interface BackupImportResultBody {
|
||||
users: number;
|
||||
domainSettings: number;
|
||||
userRevisions: number;
|
||||
trustedTwoFactorDeviceTokens: number;
|
||||
webauthnCredentials: number;
|
||||
folders: number;
|
||||
ciphers: number;
|
||||
@@ -172,6 +175,7 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
||||
'DELETE FROM ciphers',
|
||||
'DELETE FROM folders',
|
||||
'DELETE FROM webauthn_credentials',
|
||||
'DELETE FROM trusted_two_factor_device_tokens',
|
||||
'DELETE FROM domain_settings',
|
||||
'DELETE FROM user_revisions',
|
||||
'DELETE FROM users',
|
||||
@@ -296,6 +300,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
||||
})),
|
||||
domain_settings: cloneRows(payload.domain_settings || []),
|
||||
user_revisions: cloneRows(payload.user_revisions || []),
|
||||
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
||||
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
|
||||
folders: cloneRows(payload.folders || []),
|
||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||
@@ -634,6 +639,16 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
||||
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(
|
||||
db,
|
||||
tableName('webauthn_credentials'),
|
||||
@@ -712,6 +727,7 @@ export async function importBackupArchiveBytes(
|
||||
users: (db.users || []).length,
|
||||
domain_settings: (db.domain_settings || []).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,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
@@ -735,6 +751,7 @@ export async function importBackupArchiveBytes(
|
||||
users: (db.users || []).length,
|
||||
domain_settings: (db.domain_settings || []).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,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
@@ -776,6 +793,7 @@ export async function importBackupArchiveBytes(
|
||||
users: (db.users || []).length,
|
||||
domainSettings: (db.domain_settings || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
@@ -853,6 +871,7 @@ export async function importRemoteBackupArchiveBytes(
|
||||
users: (db.users || []).length,
|
||||
domain_settings: (db.domain_settings || []).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,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
@@ -876,6 +895,7 @@ export async function importRemoteBackupArchiveBytes(
|
||||
users: (db.users || []).length,
|
||||
domain_settings: (db.domain_settings || []).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,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
@@ -923,6 +943,7 @@ export async function importRemoteBackupArchiveBytes(
|
||||
users: (db.users || []).length,
|
||||
domainSettings: (db.domain_settings || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
|
||||
@@ -83,6 +83,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const [present, setPresent] = useState(props.open);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||
const maskPointerStartedRef = useRef(false);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||
const titleId = `${dialogId}-title`;
|
||||
@@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
return createPortal((
|
||||
<div
|
||||
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||
onPointerDown={(event) => {
|
||||
maskPointerStartedRef.current = event.target === event.currentTarget;
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target !== event.currentTarget || !canDismiss) return;
|
||||
if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
|
||||
props.onCancel();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -23,7 +23,6 @@ export default function NetworkStatusBadge() {
|
||||
const Icon = status === 'online' ? Wifi : WifiOff;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
|
||||
const checkService = async () => {
|
||||
@@ -31,10 +30,7 @@ export default function NetworkStatusBadge() {
|
||||
setCurrentNetworkStatus('offline');
|
||||
return;
|
||||
}
|
||||
const reachable = await probeNodeWardenService();
|
||||
if (!cancelled) {
|
||||
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
|
||||
}
|
||||
await probeNodeWardenService();
|
||||
};
|
||||
|
||||
const scheduleNextCheck = () => {
|
||||
@@ -62,7 +58,6 @@ export default function NetworkStatusBadge() {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
|
||||
@@ -326,7 +326,7 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
||||
linkedId: field.linkedId ?? null,
|
||||
})),
|
||||
passwordHistory: (cipher.passwordHistory || []).map((entry) => ({
|
||||
password: valueOrFallback(entry.password),
|
||||
password: valueOrFallback(entry.decPassword ?? entry.password),
|
||||
lastUsedDate: valueOrFallback(entry.lastUsedDate),
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
|
||||
import {
|
||||
attachNodeWardenEncryptedAttachmentPayload,
|
||||
buildAccountEncryptedBitwardenJsonString,
|
||||
buildBitwardenCsvString,
|
||||
buildBitwardenZipBytes,
|
||||
buildExportFileName,
|
||||
buildNodeWardenAttachmentRecords,
|
||||
@@ -1190,6 +1191,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
mimeType: 'application/json',
|
||||
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') {
|
||||
if (request.encryptedJsonMode === 'password') {
|
||||
const plainJson = await getPlainJson();
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
TokenSuccess,
|
||||
} from '../types';
|
||||
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
||||
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
|
||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||
|
||||
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) {
|
||||
try {
|
||||
const response = await fetch(input, { ...init, headers });
|
||||
recordNodeWardenReachable();
|
||||
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
|
||||
return response;
|
||||
}
|
||||
@@ -484,6 +486,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === maxAttempts - 1) {
|
||||
recordNodeWardenUnreachable();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface AdminBackupImportCounts {
|
||||
users: number;
|
||||
domainSettings?: number;
|
||||
userRevisions: number;
|
||||
trustedTwoFactorDeviceTokens?: number;
|
||||
webauthnCredentials?: number;
|
||||
folders: number;
|
||||
ciphers: number;
|
||||
|
||||
@@ -279,20 +279,16 @@ export async function hydrateLockedSession(
|
||||
fallbackProfile: Profile | null = null
|
||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
|
||||
let serviceReachable = true;
|
||||
if (hasOfflineUnlock) {
|
||||
serviceReachable = await probeNodeWardenService();
|
||||
if (!serviceReachable) {
|
||||
return {
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
};
|
||||
}
|
||||
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||
return {
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
};
|
||||
}
|
||||
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession?.accessToken) {
|
||||
if (hasOfflineUnlock && !serviceReachable) {
|
||||
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
|
||||
return {
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
@@ -571,14 +567,8 @@ export async function performUnlock(
|
||||
}
|
||||
};
|
||||
|
||||
if (hasOfflineUnlock) {
|
||||
if (browserReportsOffline()) {
|
||||
return unlockOffline();
|
||||
}
|
||||
const serviceReachable = await probeNodeWardenService();
|
||||
if (!serviceReachable) {
|
||||
return unlockOffline();
|
||||
}
|
||||
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||
return unlockOffline();
|
||||
}
|
||||
|
||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
|
||||
@@ -9,6 +9,7 @@ configureZipJs({ useWebWorkers: false });
|
||||
|
||||
export const EXPORT_FORMATS = [
|
||||
{ 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_json_zip', label: 'Bitwarden (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';
|
||||
}
|
||||
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
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> {
|
||||
const userEnc = base64ToBytes(args.userEncB64);
|
||||
const userMac = base64ToBytes(args.userMacB64);
|
||||
@@ -566,11 +692,13 @@ function nowStamp(now = new Date()): string {
|
||||
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
|
||||
const stamp = nowStamp();
|
||||
if (
|
||||
format === 'bitwarden_csv' ||
|
||||
format === 'bitwarden_json' ||
|
||||
format === 'bitwarden_encrypted_json' ||
|
||||
format === 'nodewarden_json' ||
|
||||
format === 'nodewarden_encrypted_json'
|
||||
) {
|
||||
if (format === 'bitwarden_csv') return `bitwarden_export_${stamp}.csv`;
|
||||
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
|
||||
return `bitwarden_export_${stamp}.json`;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export type NetworkStatus = 'online' | 'offline';
|
||||
|
||||
const STATUS_PROBE_TIMEOUT_MS = 3500;
|
||||
const STATUS_PROBE_TIMEOUT_MS = 8000;
|
||||
const STATUS_PROBE_CACHE_MS = 5000;
|
||||
const PROBE_FAILURES_BEFORE_OFFLINE = 2;
|
||||
const listeners = new Set<(status: NetworkStatus) => void>();
|
||||
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
||||
let pendingProbe: Promise<boolean> | null = null;
|
||||
let lastProbeAt = 0;
|
||||
let lastProbeResult = currentStatus === 'online';
|
||||
let consecutiveProbeFailures = 0;
|
||||
|
||||
export function browserReportsOffline(): boolean {
|
||||
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> {
|
||||
if (browserReportsOffline()) {
|
||||
consecutiveProbeFailures = PROBE_FAILURES_BEFORE_OFFLINE;
|
||||
setCurrentNetworkStatus('offline');
|
||||
return false;
|
||||
}
|
||||
@@ -68,8 +85,11 @@ export async function probeNodeWardenService(): Promise<boolean> {
|
||||
.catch(() => false)
|
||||
.then((result) => {
|
||||
lastProbeAt = Date.now();
|
||||
lastProbeResult = result;
|
||||
setCurrentNetworkStatus(result ? 'online' : 'offline');
|
||||
if (result) {
|
||||
recordNodeWardenReachable();
|
||||
} else {
|
||||
recordNodeWardenUnreachable();
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Send } from './types';
|
||||
import { getCurrentNetworkStatus } from './network-status';
|
||||
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
||||
|
||||
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 {
|
||||
if (typeof Worker === 'undefined') return null;
|
||||
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.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
||||
const message = event.data;
|
||||
|
||||
@@ -810,6 +810,101 @@ h4 {
|
||||
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,
|
||||
.dialog-card {
|
||||
border-radius: var(--radius-lg);
|
||||
|
||||
@@ -8,11 +8,12 @@ body,
|
||||
@apply m-0 h-full w-full p-0;
|
||||
color: var(--text);
|
||||
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);
|
||||
line-height: var(--leading-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;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -33,8 +34,8 @@ body.dialog-open {
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 600;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
line-height: var(--leading-tight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -46,16 +46,22 @@
|
||||
--dur-slow: 350ms;
|
||||
--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 */
|
||||
--font-xs: 11px;
|
||||
--font-sm: 13px;
|
||||
--font-base: 14px;
|
||||
--font-md: 15px;
|
||||
--font-lg: 16px;
|
||||
--font-xl: 18px;
|
||||
--font-2xl: 20px;
|
||||
--font-3xl: 24px;
|
||||
--font-4xl: 28px;
|
||||
--font-sm: 14px;
|
||||
--font-base: 15px;
|
||||
--font-md: 16px;
|
||||
--font-lg: 17px;
|
||||
--font-xl: 19px;
|
||||
--font-2xl: 21px;
|
||||
--font-3xl: 25px;
|
||||
--font-4xl: 29px;
|
||||
|
||||
/* Line Heights */
|
||||
--leading-tight: 1.25;
|
||||
@@ -65,11 +71,11 @@
|
||||
--leading-loose: 1.75;
|
||||
|
||||
/* Letter Spacing */
|
||||
--tracking-tighter: -0.02em;
|
||||
--tracking-tight: -0.01em;
|
||||
--tracking-tighter: 0;
|
||||
--tracking-tight: 0;
|
||||
--tracking-normal: 0;
|
||||
--tracking-wide: 0.01em;
|
||||
--tracking-wider: 0.02em;
|
||||
--tracking-wide: 0;
|
||||
--tracking-wider: 0;
|
||||
|
||||
/* Spacing Scale */
|
||||
--space-1: 4px;
|
||||
|
||||
Reference in New Issue
Block a user