mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Compare commits
10 Commits
v1.6.1
...
d5c2ab2b0f
| Author | SHA1 | Date | |
|---|---|---|---|
| d5c2ab2b0f | |||
| 9e0908f43c | |||
| 7b3be2c819 | |||
| a8183166ac | |||
| f6169b7610 | |||
| 493f901ec1 | |||
| b4dfb0409b | |||
| a06cb0ed71 | |||
| b0242265f4 | |||
| b444c0f4b8 |
@@ -14,10 +14,12 @@ export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||
|
||||
export type BackupDestinationType = 's3' | 'webdav';
|
||||
export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style';
|
||||
|
||||
export interface S3BackupDestination {
|
||||
endpoint: string;
|
||||
bucket: string;
|
||||
addressingStyle: S3BackupAddressingStyle;
|
||||
region: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
@@ -103,6 +105,7 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
||||
return {
|
||||
endpoint: '',
|
||||
bucket: '',
|
||||
addressingStyle: 'path-style',
|
||||
region: BACKUP_DEFAULT_S3_REGION,
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type BackupRuntimeState,
|
||||
type BackupScheduleConfig,
|
||||
type BackupSettings,
|
||||
type S3BackupAddressingStyle,
|
||||
type S3BackupDestination,
|
||||
type WebDavBackupDestination,
|
||||
createBackupRandomId,
|
||||
@@ -35,6 +36,7 @@ export type {
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
BackupSettings,
|
||||
S3BackupAddressingStyle,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from '../../shared/backup-schema';
|
||||
@@ -109,6 +111,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
const endpoint = asTrimmedString(source.endpoint);
|
||||
const bucket = asTrimmedString(source.bucket);
|
||||
const addressingStyleRaw = asTrimmedString(source.addressingStyle);
|
||||
const addressingStyle: S3BackupAddressingStyle =
|
||||
addressingStyleRaw === 'virtual-hosted-style' ? 'virtual-hosted-style' : 'path-style';
|
||||
const accessKeyId = asTrimmedString(source.accessKeyId);
|
||||
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
||||
const region = asTrimmedString(source.region) || 'auto';
|
||||
@@ -131,6 +136,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
||||
return {
|
||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
||||
bucket,
|
||||
addressingStyle,
|
||||
region,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -448,8 +448,27 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
|
||||
return true;
|
||||
}
|
||||
|
||||
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
|
||||
const hostname = endpoint.hostname.toLowerCase();
|
||||
const bucketName = bucket.trim().toLowerCase();
|
||||
return !!bucketName && (hostname === bucketName || hostname.startsWith(`${bucketName}.`));
|
||||
}
|
||||
|
||||
function s3BucketBaseUrl(config: S3BackupDestination): URL {
|
||||
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
||||
const endpoint = new URL(config.endpoint.replace(/\/+$/, ''));
|
||||
const bucket = config.bucket.trim();
|
||||
|
||||
if (config.addressingStyle === 'virtual-hosted-style') {
|
||||
if (isBucketHostedS3Endpoint(endpoint, bucket)) return endpoint;
|
||||
endpoint.hostname = `${bucket}.${endpoint.hostname}`;
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
return new URL(`${endpoint.toString().replace(/\/+$/, '')}/${encodeURIComponent(bucket)}`);
|
||||
}
|
||||
|
||||
function s3ObjectUrl(config: S3BackupDestination, objectKey: string): URL {
|
||||
return new URL(`${s3BucketBaseUrl(config).toString().replace(/\/+$/, '')}/${encodePathSegments(objectKey)}`);
|
||||
}
|
||||
|
||||
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
|
||||
@@ -501,7 +520,7 @@ async function putToS3(
|
||||
options: RemoteBackupFilePutOptions = {}
|
||||
): Promise<void> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const url = s3ObjectUrl(config, objectKey);
|
||||
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -594,7 +613,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
|
||||
throw new Error('Please select a backup file');
|
||||
}
|
||||
const objectKey = normalizeS3ObjectKey(config, normalized);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const url = s3ObjectUrl(config, objectKey);
|
||||
const response = await signedS3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 download failed: ${response.status}`);
|
||||
@@ -610,7 +629,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
|
||||
|
||||
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const url = s3ObjectUrl(config, objectKey);
|
||||
const response = await signedS3Request(config, 'DELETE', url);
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`S3 delete failed: ${response.status}`);
|
||||
@@ -619,7 +638,7 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
|
||||
|
||||
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const url = s3ObjectUrl(config, objectKey);
|
||||
const response = await signedS3Request(config, 'HEAD', url);
|
||||
if (response.status === 404) return false;
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { t } from '@/lib/i18n';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import WebsiteIcon from '@/components/vault/WebsiteIcon';
|
||||
import { isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
|
||||
import { formatTotp, isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface TotpCodesPageProps {
|
||||
ciphers: Cipher[];
|
||||
@@ -26,13 +26,6 @@ function getTotpTimeState(): { windowId: number; remain: number } {
|
||||
};
|
||||
}
|
||||
|
||||
function formatTotp(code: string): string {
|
||||
if (!code) return code;
|
||||
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
||||
if (code.length < 6) return code;
|
||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
||||
}
|
||||
|
||||
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||
}
|
||||
|
||||
@@ -17,13 +17,14 @@ import {
|
||||
createEmptyDraft,
|
||||
creationTimeValue,
|
||||
draftFromCipher,
|
||||
buildCipherDuplicateSignature,
|
||||
buildCipherDuplicateSignatures,
|
||||
firstCipherUri,
|
||||
firstPasskeyCreationTime,
|
||||
isCipherVisibleInArchive,
|
||||
isCipherVisibleInNormalVault,
|
||||
isCipherVisibleInTrash,
|
||||
sortTimeValue,
|
||||
type DuplicateDetectionMode,
|
||||
type SidebarFilter,
|
||||
type VaultSortMode,
|
||||
} from '@/components/vault/vault-page-helpers';
|
||||
@@ -79,6 +80,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
||||
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
|
||||
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
|
||||
const [duplicateMode, setDuplicateMode] = useState<DuplicateDetectionMode>('exact');
|
||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||
@@ -338,16 +340,41 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
|
||||
const duplicateSignatureInfo = useMemo(() => {
|
||||
if (sidebarFilter.kind !== 'duplicates') return null;
|
||||
const byId = new Map<string, string>();
|
||||
const byId = new Map<string, string[]>();
|
||||
const counts = new Map<string, number>();
|
||||
for (const cipher of props.ciphers) {
|
||||
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
||||
const signature = buildCipherDuplicateSignature(cipher);
|
||||
byId.set(cipher.id, signature);
|
||||
counts.set(signature, (counts.get(signature) || 0) + 1);
|
||||
const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode)));
|
||||
byId.set(cipher.id, signatures);
|
||||
for (const signature of signatures) {
|
||||
counts.set(signature, (counts.get(signature) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return { byId, counts };
|
||||
}, [props.ciphers, sidebarFilter.kind]);
|
||||
}, [props.ciphers, sidebarFilter.kind, duplicateMode]);
|
||||
|
||||
const duplicateGroupIndexById = useMemo(() => {
|
||||
if (!duplicateSignatureInfo) return new Map<string, number>();
|
||||
const groupKeyById = new Map<string, string>();
|
||||
const groupKeys = new Set<string>();
|
||||
for (const cipher of props.ciphers) {
|
||||
const groupKey = (duplicateSignatureInfo.byId.get(cipher.id) || [])
|
||||
.filter((signature) => (duplicateSignatureInfo.counts.get(signature) || 0) >= 2)
|
||||
.sort()[0];
|
||||
if (!groupKey) continue;
|
||||
groupKeyById.set(cipher.id, groupKey);
|
||||
groupKeys.add(groupKey);
|
||||
}
|
||||
const groupIndexByKey = new Map<string, number>();
|
||||
Array.from(groupKeys).sort().forEach((groupKey, index) => {
|
||||
groupIndexByKey.set(groupKey, index % 64);
|
||||
});
|
||||
const byId = new Map<string, number>();
|
||||
for (const [cipherId, groupKey] of groupKeyById.entries()) {
|
||||
byId.set(cipherId, groupIndexByKey.get(groupKey) || 0);
|
||||
}
|
||||
return byId;
|
||||
}, [props.ciphers, duplicateSignatureInfo]);
|
||||
|
||||
const filteredCiphers = useMemo(() => {
|
||||
const next = props.ciphers.filter((cipher) => {
|
||||
@@ -358,8 +385,11 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
if (!isCipherVisibleInArchive(cipher)) return false;
|
||||
} else {
|
||||
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
||||
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) {
|
||||
return false;
|
||||
if (sidebarFilter.kind === 'duplicates') {
|
||||
const signatures = duplicateSignatureInfo?.byId.get(cipher.id) || [];
|
||||
if (!signatures.some((signature) => (duplicateSignatureInfo?.counts.get(signature) || 0) >= 2)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
||||
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
|
||||
@@ -404,8 +434,9 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const sidebarFilterKey = useMemo(() => {
|
||||
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
||||
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
|
||||
if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`;
|
||||
return sidebarFilter.kind;
|
||||
}, [sidebarFilter]);
|
||||
}, [sidebarFilter, duplicateMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setListScrollTop(0);
|
||||
@@ -419,6 +450,10 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
}
|
||||
}, [sidebarFilter.kind, sortMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
|
||||
}, [sidebarFilter.kind, duplicateMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreating) return;
|
||||
if (!filteredCiphers.length) {
|
||||
@@ -984,10 +1019,11 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
|
||||
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
||||
const handleSelectDuplicates = useCallback(() => {
|
||||
if (duplicateMode !== 'exact') return;
|
||||
const map: Record<string, boolean> = {};
|
||||
const seen = new Set<string>();
|
||||
for (const cipher of filteredCiphers) {
|
||||
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
|
||||
const signature = duplicateSignatureInfo?.byId.get(cipher.id)?.[0] || buildCipherDuplicateSignatures(cipher, 'exact')[0];
|
||||
if (seen.has(signature)) {
|
||||
map[cipher.id] = true;
|
||||
continue;
|
||||
@@ -995,7 +1031,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
seen.add(signature);
|
||||
}
|
||||
setSelectedMap(map);
|
||||
}, [filteredCiphers, duplicateSignatureInfo]);
|
||||
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||
@@ -1082,10 +1118,12 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
searchInput={searchInput}
|
||||
sortMode={sortMode}
|
||||
sortMenuOpen={sortMenuOpen}
|
||||
duplicateMode={duplicateMode}
|
||||
selectedCount={selectedCount}
|
||||
totalCipherCount={totalCipherCount}
|
||||
filteredCiphers={filteredCiphers}
|
||||
visibleCiphers={visibleCiphers}
|
||||
duplicateGroupIndexById={duplicateGroupIndexById}
|
||||
virtualRange={virtualRange}
|
||||
selectedCipherId={selectedCipherId}
|
||||
selectedMap={selectedMap}
|
||||
@@ -1102,6 +1140,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
onSearchCompositionEnd={handleSearchCompositionEnd}
|
||||
onToggleSortMenu={handleToggleSortMenu}
|
||||
onSelectSortMode={handleSelectSortMode}
|
||||
onDuplicateModeChange={setDuplicateMode}
|
||||
onSyncVault={handleSyncVault}
|
||||
onOpenBulkDelete={handleOpenBulkDelete}
|
||||
onSelectDuplicates={handleSelectDuplicates}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CloudUpload, Save, Trash2 } from 'lucide-preact';
|
||||
import type {
|
||||
BackupDestinationRecord,
|
||||
RemoteBackupBrowserResponse,
|
||||
S3BackupAddressingStyle,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from '@/lib/api/backup';
|
||||
@@ -401,7 +402,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
|
||||
{props.selectedDestination.type === 's3' ? (
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_s3_endpoint')}</span>
|
||||
<input
|
||||
className="input"
|
||||
@@ -417,6 +418,24 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_s3_addressing_style')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as S3BackupDestination).addressingStyle || 'path-style'}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onChange={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
...(destination.destination as S3BackupDestination),
|
||||
addressingStyle: (event.currentTarget as HTMLSelectElement).value as S3BackupAddressingStyle,
|
||||
},
|
||||
}))}
|
||||
>
|
||||
<option value="path-style">{t('txt_backup_s3_addressing_path_style')}</option>
|
||||
<option value="virtual-hosted-style">{t('txt_backup_s3_addressing_virtual_hosted_style')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_s3_bucket')}</span>
|
||||
<input
|
||||
|
||||
@@ -8,8 +8,10 @@ import { t } from '@/lib/i18n';
|
||||
import {
|
||||
CreateTypeIcon,
|
||||
getCreateTypeOptions,
|
||||
getDuplicateDetectionOptions,
|
||||
getVaultSortOptions,
|
||||
VaultListIcon,
|
||||
type DuplicateDetectionMode,
|
||||
type SidebarFilter,
|
||||
type VaultSortMode,
|
||||
} from '@/components/vault/vault-page-helpers';
|
||||
@@ -28,10 +30,12 @@ interface VaultListPanelProps {
|
||||
searchInput: string;
|
||||
sortMode: VaultSortMode;
|
||||
sortMenuOpen: boolean;
|
||||
duplicateMode: DuplicateDetectionMode;
|
||||
selectedCount: number;
|
||||
totalCipherCount: number;
|
||||
filteredCiphers: Cipher[];
|
||||
visibleCiphers: Cipher[];
|
||||
duplicateGroupIndexById: Map<string, number>;
|
||||
virtualRange: VirtualRange;
|
||||
selectedCipherId: string;
|
||||
selectedMap: Record<string, boolean>;
|
||||
@@ -48,6 +52,7 @@ interface VaultListPanelProps {
|
||||
onSearchCompositionEnd: (value: string) => void;
|
||||
onToggleSortMenu: () => void;
|
||||
onSelectSortMode: (value: VaultSortMode) => void;
|
||||
onDuplicateModeChange: (value: DuplicateDetectionMode) => void;
|
||||
onSyncVault: () => void;
|
||||
onOpenBulkDelete: () => void;
|
||||
onSelectDuplicates: () => void;
|
||||
@@ -69,15 +74,18 @@ interface CipherListItemProps {
|
||||
cipher: Cipher;
|
||||
selected: boolean;
|
||||
checked: boolean;
|
||||
duplicateGroupIndex: number | null;
|
||||
subtitle: string;
|
||||
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||
onSelectCipher: (cipherId: string) => void;
|
||||
}
|
||||
|
||||
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
||||
const duplicateGroupHue = props.duplicateGroupIndex === null ? null : (props.duplicateGroupIndex * 137.508) % 360;
|
||||
return (
|
||||
<div
|
||||
className={`list-item ${props.selected ? 'active' : ''}`}
|
||||
className={`list-item ${props.selected ? 'active' : ''} ${duplicateGroupHue === null ? '' : 'duplicate-group-item'}`}
|
||||
style={duplicateGroupHue === null ? undefined : { '--duplicate-group-hue': `${duplicateGroupHue}deg` }}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.row-check')) return;
|
||||
@@ -108,6 +116,7 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
|
||||
|
||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
const createTypeOptions = getCreateTypeOptions();
|
||||
const duplicateDetectionOptions = getDuplicateDetectionOptions();
|
||||
const vaultSortOptions = getVaultSortOptions();
|
||||
const createMenu = (
|
||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
||||
@@ -137,29 +146,44 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
<section className="list-col">
|
||||
<div className="list-head">
|
||||
<div className="search-input-wrap">
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder={t('txt_search_your_secure_vault')}
|
||||
value={props.searchInput}
|
||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||
onCompositionStart={props.onSearchCompositionStart}
|
||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||
e.preventDefault();
|
||||
props.onClearSearch();
|
||||
}}
|
||||
/>
|
||||
{!!props.searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
className="search-clear-btn"
|
||||
aria-label={t('txt_clear_search')}
|
||||
title={t('txt_clear_search_esc')}
|
||||
onClick={props.onClearSearch}
|
||||
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||
<select
|
||||
className="input duplicate-mode-select duplicate-mode-head-select"
|
||||
value={props.duplicateMode}
|
||||
aria-label={t('txt_duplicate_detection_mode')}
|
||||
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{duplicateDetectionOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder={t('txt_search_your_secure_vault')}
|
||||
value={props.searchInput}
|
||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||
onCompositionStart={props.onSearchCompositionStart}
|
||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||
e.preventDefault();
|
||||
props.onClearSearch();
|
||||
}}
|
||||
/>
|
||||
{!!props.searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
className="search-clear-btn"
|
||||
aria-label={t('txt_clear_search')}
|
||||
title={t('txt_clear_search_esc')}
|
||||
onClick={props.onClearSearch}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||
@@ -195,8 +219,20 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar actions">
|
||||
{props.sidebarFilter.kind === 'duplicates' && (
|
||||
<div className={`toolbar actions ${props.sidebarFilter.kind === 'duplicates' ? 'duplicates-toolbar' : ''}`}>
|
||||
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
||||
<select
|
||||
className="input duplicate-mode-select duplicate-mode-toolbar-select"
|
||||
value={props.duplicateMode}
|
||||
aria-label={t('txt_duplicate_detection_mode')}
|
||||
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
|
||||
>
|
||||
{duplicateDetectionOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{props.sidebarFilter.kind === 'duplicates' && props.duplicateMode === 'exact' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
|
||||
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||
</button>
|
||||
@@ -229,12 +265,16 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
||||
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||
</button>
|
||||
{props.isMobileLayout && typeof document !== 'undefined'
|
||||
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
|
||||
: createMenu}
|
||||
{props.sidebarFilter.kind !== 'duplicates' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||
</button>
|
||||
)}
|
||||
{props.sidebarFilter.kind !== 'duplicates' && (
|
||||
props.isMobileLayout && typeof document !== 'undefined'
|
||||
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
|
||||
: createMenu
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||
@@ -255,6 +295,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
cipher={cipher}
|
||||
selected={props.selectedCipherId === cipher.id}
|
||||
checked={!!props.selectedMap[cipher.id]}
|
||||
duplicateGroupIndex={props.sidebarFilter.kind === 'duplicates' ? props.duplicateGroupIndexById.get(cipher.id) ?? null : null}
|
||||
subtitle={props.listSubtitle(cipher)}
|
||||
onToggleSelected={props.onToggleSelected}
|
||||
onSelectCipher={props.onSelectCipher}
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
|
||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
|
||||
import WebsiteIcon from './WebsiteIcon';
|
||||
|
||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
|
||||
export type SidebarFilter =
|
||||
| { kind: 'all' }
|
||||
| { kind: 'favorite' }
|
||||
@@ -126,6 +129,16 @@ export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1';
|
||||
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
|
||||
export const VAULT_LIST_ROW_HEIGHT = 74;
|
||||
export const VAULT_LIST_OVERSCAN = 10;
|
||||
|
||||
export function getDuplicateDetectionOptions(): Array<{ value: DuplicateDetectionMode; label: string }> {
|
||||
return [
|
||||
{ value: 'exact', label: t('txt_duplicate_mode_exact') },
|
||||
{ value: 'login-site', label: t('txt_duplicate_mode_login_site') },
|
||||
{ value: 'login-credentials', label: t('txt_duplicate_mode_login_credentials') },
|
||||
{ value: 'password', label: t('txt_duplicate_mode_password') },
|
||||
];
|
||||
}
|
||||
|
||||
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
|
||||
return [
|
||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||
@@ -242,7 +255,7 @@ export function toBooleanFieldValue(raw: string): boolean {
|
||||
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
||||
}
|
||||
|
||||
export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||
export { firstCipherUri, hostFromUri, websiteIconUrl };
|
||||
|
||||
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||
return { uri: '', match: null, originalUri: '', extra: {} };
|
||||
@@ -257,6 +270,30 @@ function valueOrFallback(value: string | null | undefined): string {
|
||||
return String(value || '');
|
||||
}
|
||||
|
||||
function duplicateLoginUsername(cipher: Cipher): string {
|
||||
return valueOrFallback(cipher.login?.decUsername ?? cipher.login?.username).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function duplicateLoginPassword(cipher: Cipher): string {
|
||||
return valueOrFallback(cipher.login?.decPassword ?? cipher.login?.password);
|
||||
}
|
||||
|
||||
function duplicateLoginSites(cipher: Cipher): string[] {
|
||||
const sites = new Set<string>();
|
||||
for (const uri of cipher.login?.uris || []) {
|
||||
const raw = valueOrFallback(uri.decUri ?? uri.uri).trim();
|
||||
if (!raw) continue;
|
||||
const host = hostFromUri(raw).trim().toLowerCase().replace(/^www\./, '');
|
||||
const site = normalizeEquivalentDomain(raw) || host;
|
||||
if (site) sites.add(site);
|
||||
}
|
||||
return Array.from(sites).sort();
|
||||
}
|
||||
|
||||
function duplicateSignature(parts: string[]): string {
|
||||
return JSON.stringify(parts);
|
||||
}
|
||||
|
||||
export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
||||
const normalized = {
|
||||
type: Number(cipher.type || 1),
|
||||
@@ -326,13 +363,30 @@ 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),
|
||||
})),
|
||||
};
|
||||
return JSON.stringify(normalized);
|
||||
}
|
||||
|
||||
export function buildCipherDuplicateSignatures(cipher: Cipher, mode: DuplicateDetectionMode): string[] {
|
||||
if (mode === 'exact') return [buildCipherDuplicateSignature(cipher)];
|
||||
if (Number(cipher.type || 1) !== 1 || !cipher.login) return [];
|
||||
|
||||
const username = duplicateLoginUsername(cipher);
|
||||
const password = duplicateLoginPassword(cipher);
|
||||
if (mode === 'password') {
|
||||
return password ? [duplicateSignature(['password', password])] : [];
|
||||
}
|
||||
if (!username || !password) return [];
|
||||
if (mode === 'login-credentials') {
|
||||
return [duplicateSignature(['login-credentials', username, password])];
|
||||
}
|
||||
|
||||
return duplicateLoginSites(cipher).map((site) => duplicateSignature(['login-site', site, username, password]));
|
||||
}
|
||||
|
||||
export function createEmptyDraft(type: number): VaultDraft {
|
||||
return {
|
||||
type,
|
||||
@@ -453,8 +507,9 @@ export function maskSecret(value: string): string {
|
||||
export function formatTotp(code: string): string {
|
||||
if (!code) return code;
|
||||
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
||||
if (code.length < 6) return code;
|
||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
||||
if (code.length <= 4) return code;
|
||||
if (code.length === 8) return `${code.slice(0, 4)} ${code.slice(4)}`;
|
||||
return code.replace(/(.{3})(?=.)/g, '$1 ');
|
||||
}
|
||||
|
||||
export function formatHistoryTime(value: string | null | undefined): string {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
BackupSettings as AdminBackupSettings,
|
||||
S3BackupAddressingStyle,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from '@shared/backup-schema';
|
||||
@@ -26,6 +27,7 @@ export type {
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
AdminBackupSettings,
|
||||
S3BackupAddressingStyle,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
};
|
||||
@@ -96,6 +98,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 };
|
||||
|
||||
@@ -220,6 +220,25 @@ function normalizeTotpSecret(secret: string): string {
|
||||
return secret.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function readOtpAuthParam(raw: string, name: string): string {
|
||||
const queryStart = raw.indexOf('?');
|
||||
if (queryStart < 0) return '';
|
||||
const fragmentStart = raw.indexOf('#', queryStart + 1);
|
||||
const query = raw.slice(queryStart + 1, fragmentStart > queryStart ? fragmentStart : undefined);
|
||||
for (const part of query.split('&')) {
|
||||
const eq = part.indexOf('=');
|
||||
const key = eq >= 0 ? part.slice(0, eq) : part;
|
||||
if (key.trim().toLowerCase() !== name.toLowerCase()) continue;
|
||||
const value = eq >= 0 ? part.slice(eq + 1) : '';
|
||||
try {
|
||||
return decodeURIComponent(value.replace(/\+/g, ' '));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseSteamSecret(raw: string): string {
|
||||
const match = raw.trim().match(/^steam:\/\/([^/?#]+)(?:[/?#].*)?$/i);
|
||||
if (!match?.[1]) return '';
|
||||
@@ -276,7 +295,8 @@ function parseTotpConfig(raw: string): TotpConfig {
|
||||
if (/^otpauth:\/\//i.test(s)) {
|
||||
try {
|
||||
const u = new URL(s);
|
||||
if (u.hostname.toLowerCase() !== 'totp') {
|
||||
const otpType = u.hostname.toLowerCase();
|
||||
if (otpType !== 'totp') {
|
||||
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||
}
|
||||
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
|
||||
@@ -291,7 +311,16 @@ function parseTotpConfig(raw: string): TotpConfig {
|
||||
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
||||
};
|
||||
} catch {
|
||||
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||
const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
|
||||
const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
|
||||
const steam = issuer === 'steam' || algorithm === 'steam';
|
||||
return {
|
||||
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
|
||||
steam,
|
||||
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm),
|
||||
digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
|
||||
period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { secret: normalizeTotpSecret(s), steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -267,6 +267,9 @@ const en: Record<string, string> = {
|
||||
"txt_backup_webdav_password": "WebDAV Password",
|
||||
"txt_backup_webdav_path": "Remote Folder",
|
||||
"txt_backup_s3_endpoint": "S3 Endpoint",
|
||||
"txt_backup_s3_addressing_style": "S3 Addressing Style",
|
||||
"txt_backup_s3_addressing_path_style": "path-style (default)",
|
||||
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||
"txt_backup_s3_bucket": "Bucket",
|
||||
"txt_backup_s3_region": "Region",
|
||||
"txt_backup_s3_access_key": "Access Key",
|
||||
@@ -447,6 +450,11 @@ const en: Record<string, string> = {
|
||||
"txt_favorite": "Favorite",
|
||||
"txt_favorites": "Favorites",
|
||||
"txt_duplicates": "Duplicates",
|
||||
"txt_duplicate_detection_mode": "Match by",
|
||||
"txt_duplicate_mode_exact": "Exact item",
|
||||
"txt_duplicate_mode_login_site": "Site + username + password",
|
||||
"txt_duplicate_mode_login_credentials": "Username + password",
|
||||
"txt_duplicate_mode_password": "Reused password",
|
||||
"txt_field": "Field",
|
||||
"txt_field_label": "Field Label",
|
||||
"txt_field_label_is_required": "Field label is required.",
|
||||
|
||||
@@ -267,6 +267,9 @@ const es: Record<string, string> = {
|
||||
"txt_backup_webdav_password": "Contraseña WebDAV",
|
||||
"txt_backup_webdav_path": "Carpeta remota",
|
||||
"txt_backup_s3_endpoint": "Endpoint S3",
|
||||
"txt_backup_s3_addressing_style": "Estilo de direccionamiento S3",
|
||||
"txt_backup_s3_addressing_path_style": "path-style (predeterminado)",
|
||||
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||
"txt_backup_s3_bucket": "Bucket S3",
|
||||
"txt_backup_s3_region": "Región",
|
||||
"txt_backup_s3_access_key": "Clave de acceso",
|
||||
@@ -447,6 +450,11 @@ const es: Record<string, string> = {
|
||||
"txt_favorite": "Favorito",
|
||||
"txt_favorites": "Favoritos",
|
||||
"txt_duplicates": "Duplicados",
|
||||
"txt_duplicate_detection_mode": "Coincidir por",
|
||||
"txt_duplicate_mode_exact": "Elemento exacto",
|
||||
"txt_duplicate_mode_login_site": "Sitio + usuario + contraseña",
|
||||
"txt_duplicate_mode_login_credentials": "Usuario + contraseña",
|
||||
"txt_duplicate_mode_password": "Contraseña reutilizada",
|
||||
"txt_field": "Campo",
|
||||
"txt_field_label": "Etiqueta del campo",
|
||||
"txt_field_label_is_required": "La etiqueta del campo es obligatoria.",
|
||||
|
||||
@@ -267,6 +267,9 @@ const ru: Record<string, string> = {
|
||||
"txt_backup_webdav_password": "Пароль WebDAV",
|
||||
"txt_backup_webdav_path": "Удаленная папка",
|
||||
"txt_backup_s3_endpoint": "S3 endpoint",
|
||||
"txt_backup_s3_addressing_style": "Стиль адресации S3",
|
||||
"txt_backup_s3_addressing_path_style": "path-style (по умолчанию)",
|
||||
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||
"txt_backup_s3_bucket": "Бакет",
|
||||
"txt_backup_s3_region": "Регион",
|
||||
"txt_backup_s3_access_key": "Ключ доступа",
|
||||
@@ -447,6 +450,11 @@ const ru: Record<string, string> = {
|
||||
"txt_favorite": "Любимый",
|
||||
"txt_favorites": "Избранное",
|
||||
"txt_duplicates": "Дубликаты",
|
||||
"txt_duplicate_detection_mode": "Сравнивать по",
|
||||
"txt_duplicate_mode_exact": "Полное совпадение",
|
||||
"txt_duplicate_mode_login_site": "Сайт + логин + пароль",
|
||||
"txt_duplicate_mode_login_credentials": "Логин + пароль",
|
||||
"txt_duplicate_mode_password": "Повтор пароля",
|
||||
"txt_field": "Поле",
|
||||
"txt_field_label": "Метка поля",
|
||||
"txt_field_label_is_required": "Метка поля обязательна.",
|
||||
|
||||
@@ -267,6 +267,9 @@ const zhCN: Record<string, string> = {
|
||||
"txt_backup_webdav_password": "WebDAV 密码",
|
||||
"txt_backup_webdav_path": "远程目录",
|
||||
"txt_backup_s3_endpoint": "S3 端点",
|
||||
"txt_backup_s3_addressing_style": "S3 寻址方式",
|
||||
"txt_backup_s3_addressing_path_style": "path-style(默认)",
|
||||
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||
"txt_backup_s3_bucket": "存储桶",
|
||||
"txt_backup_s3_region": "区域",
|
||||
"txt_backup_s3_access_key": "访问密钥",
|
||||
@@ -447,6 +450,11 @@ const zhCN: Record<string, string> = {
|
||||
"txt_favorite": "收藏",
|
||||
"txt_favorites": "收藏",
|
||||
"txt_duplicates": "重复项",
|
||||
"txt_duplicate_detection_mode": "匹配方式",
|
||||
"txt_duplicate_mode_exact": "完全相同",
|
||||
"txt_duplicate_mode_login_site": "网站+账号+密码",
|
||||
"txt_duplicate_mode_login_credentials": "账号+密码",
|
||||
"txt_duplicate_mode_password": "密码复用",
|
||||
"txt_field": "字段",
|
||||
"txt_field_label": "字段标签",
|
||||
"txt_field_label_is_required": "字段标签不能为空",
|
||||
|
||||
@@ -267,6 +267,9 @@ const zhTW: Record<string, string> = {
|
||||
"txt_backup_webdav_password": "WebDAV 密碼",
|
||||
"txt_backup_webdav_path": "遠程目錄",
|
||||
"txt_backup_s3_endpoint": "S3 端點",
|
||||
"txt_backup_s3_addressing_style": "S3 定址方式",
|
||||
"txt_backup_s3_addressing_path_style": "path-style(預設)",
|
||||
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||
"txt_backup_s3_bucket": "儲存桶",
|
||||
"txt_backup_s3_region": "區域",
|
||||
"txt_backup_s3_access_key": "存取金鑰",
|
||||
@@ -447,6 +450,11 @@ const zhTW: Record<string, string> = {
|
||||
"txt_favorite": "收藏",
|
||||
"txt_favorites": "收藏",
|
||||
"txt_duplicates": "重複項",
|
||||
"txt_duplicate_detection_mode": "匹配方式",
|
||||
"txt_duplicate_mode_exact": "完全相同",
|
||||
"txt_duplicate_mode_login_site": "網站+帳號+密碼",
|
||||
"txt_duplicate_mode_login_credentials": "帳號+密碼",
|
||||
"txt_duplicate_mode_password": "密碼重複使用",
|
||||
"txt_field": "字段",
|
||||
"txt_field_label": "字段標籤",
|
||||
"txt_field_label_is_required": "字段標籤不能為空",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -200,6 +200,21 @@
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .list-item.duplicate-group-item {
|
||||
background: hsl(var(--duplicate-group-hue) 34% 18%);
|
||||
border-color: hsl(var(--duplicate-group-hue) 28% 30%);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .list-item.duplicate-group-item:hover {
|
||||
background: hsl(var(--duplicate-group-hue) 36% 21%);
|
||||
border-color: hsl(var(--duplicate-group-hue) 34% 38%);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .list-item.duplicate-group-item.active {
|
||||
background: hsl(var(--duplicate-group-hue) 38% 24%);
|
||||
border-color: hsl(var(--duplicate-group-hue) 42% 48%);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .card-brand-icon {
|
||||
color: #bfdbfe;
|
||||
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
||||
|
||||
@@ -319,6 +319,10 @@
|
||||
@apply h-[42px] w-full min-w-0 rounded-[14px];
|
||||
}
|
||||
|
||||
.list-head .duplicate-mode-head-select {
|
||||
@apply h-[34px] min-w-0 w-auto max-w-full rounded-full;
|
||||
}
|
||||
|
||||
.list-icon-btn {
|
||||
@apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px];
|
||||
}
|
||||
@@ -329,6 +333,11 @@
|
||||
gap: var(--actions-gap);
|
||||
}
|
||||
|
||||
.toolbar.actions.duplicates-toolbar {
|
||||
@apply justify-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: var(--actions-gap);
|
||||
}
|
||||
@@ -883,6 +892,10 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.totp-code-main strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.settings-module .field,
|
||||
.auth-card .field {
|
||||
margin-bottom: 8px;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -184,8 +184,45 @@
|
||||
gap: var(--actions-gap);
|
||||
}
|
||||
|
||||
.toolbar.actions.duplicates-toolbar {
|
||||
@apply items-center;
|
||||
}
|
||||
|
||||
.toolbar .btn.small {
|
||||
@apply h-[30px] rounded-full text-xs;
|
||||
@apply h-[32px] rounded-full text-xs;
|
||||
}
|
||||
|
||||
.duplicate-mode-select {
|
||||
@apply h-8 min-w-[150px] rounded-full py-0 pl-3 pr-6 text-xs;
|
||||
border-color: rgba(74, 103, 150, 0.26);
|
||||
box-shadow: none;
|
||||
line-height: 32px;
|
||||
background-position:
|
||||
calc(100% - 10px) calc(50% - 2px),
|
||||
calc(100% - 6px) calc(50% - 2px);
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
select.input.duplicate-mode-toolbar-select {
|
||||
height: 32px;
|
||||
padding-top: 0;
|
||||
padding-right: 24px;
|
||||
padding-bottom: 0;
|
||||
line-height: 32px;
|
||||
background-position:
|
||||
calc(100% - 10px) calc(50% - 2px),
|
||||
calc(100% - 6px) calc(50% - 2px);
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.duplicate-mode-head-select {
|
||||
@apply h-[34px] w-auto min-w-[156px] max-w-full;
|
||||
}
|
||||
|
||||
.duplicate-mode-toolbar-select {
|
||||
@apply w-auto max-w-[170px] shrink-0;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
@@ -281,6 +318,11 @@
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.list-item.duplicate-group-item {
|
||||
background: hsl(var(--duplicate-group-hue) 84% 94%);
|
||||
border-color: hsl(var(--duplicate-group-hue) 42% 78%);
|
||||
}
|
||||
|
||||
.list-item::before {
|
||||
content: '';
|
||||
@apply absolute inset-0 opacity-0;
|
||||
@@ -361,6 +403,11 @@
|
||||
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(37, 99, 235, 0.04);
|
||||
}
|
||||
|
||||
.list-item.duplicate-group-item:hover {
|
||||
background: hsl(var(--duplicate-group-hue) 88% 92%);
|
||||
border-color: hsl(var(--duplicate-group-hue) 52% 68%);
|
||||
}
|
||||
|
||||
.list-item:hover::before {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
@@ -372,6 +419,11 @@
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.list-item.duplicate-group-item.active {
|
||||
background: hsl(var(--duplicate-group-hue) 88% 89%);
|
||||
border-color: hsl(var(--duplicate-group-hue) 58% 58%);
|
||||
}
|
||||
|
||||
.list-item.active::before {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
@@ -900,7 +952,7 @@
|
||||
|
||||
.totp-codes-list {
|
||||
@apply grid w-full items-start gap-2.5;
|
||||
grid-template-columns: repeat(var(--totp-columns, 1), minmax(300px, 1fr));
|
||||
grid-template-columns: repeat(var(--totp-columns, 1), minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.totp-code-row {
|
||||
@@ -925,7 +977,7 @@
|
||||
}
|
||||
|
||||
.totp-code-main strong {
|
||||
@apply whitespace-nowrap text-[22px] leading-none;
|
||||
@apply min-w-0 whitespace-nowrap text-[22px] leading-none;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user