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 const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||||
|
|
||||||
export type BackupDestinationType = 's3' | 'webdav';
|
export type BackupDestinationType = 's3' | 'webdav';
|
||||||
|
export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style';
|
||||||
|
|
||||||
export interface S3BackupDestination {
|
export interface S3BackupDestination {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
|
addressingStyle: S3BackupAddressingStyle;
|
||||||
region: string;
|
region: string;
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
secretAccessKey: string;
|
secretAccessKey: string;
|
||||||
@@ -103,6 +105,7 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
|||||||
return {
|
return {
|
||||||
endpoint: '',
|
endpoint: '',
|
||||||
bucket: '',
|
bucket: '',
|
||||||
|
addressingStyle: 'path-style',
|
||||||
region: BACKUP_DEFAULT_S3_REGION,
|
region: BACKUP_DEFAULT_S3_REGION,
|
||||||
accessKeyId: '',
|
accessKeyId: '',
|
||||||
secretAccessKey: '',
|
secretAccessKey: '',
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface BackupPayload {
|
|||||||
ciphers: SqlRow[];
|
ciphers: SqlRow[];
|
||||||
attachments: SqlRow[];
|
attachments: SqlRow[];
|
||||||
webauthn_credentials?: SqlRow[];
|
webauthn_credentials?: SqlRow[];
|
||||||
|
trusted_two_factor_device_tokens?: SqlRow[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +303,7 @@ export function validateBackupPayloadContents(
|
|||||||
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||||
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||||
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
|
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
|
||||||
|
const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens');
|
||||||
const externalAttachmentKeys = new Set<string>(
|
const externalAttachmentKeys = new Set<string>(
|
||||||
options.allowExternalAttachmentBlobs
|
options.allowExternalAttachmentBlobs
|
||||||
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
||||||
@@ -390,6 +392,21 @@ export function validateBackupPayloadContents(
|
|||||||
accountPasskeyIds.add(id);
|
accountPasskeyIds.add(id);
|
||||||
accountPasskeyCredentialIds.add(credentialId);
|
accountPasskeyCredentialIds.add(credentialId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trustedTwoFactorTokens = new Set<string>();
|
||||||
|
for (const row of trustedTwoFactorTokenRows) {
|
||||||
|
const token = String(row.token || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
const deviceIdentifier = String(row.device_identifier || '').trim();
|
||||||
|
const expiresAt = Number(row.expires_at || 0);
|
||||||
|
if (!token || !userIds.has(userId) || !deviceIdentifier || !Number.isFinite(expiresAt) || expiresAt <= 0) {
|
||||||
|
throw new Error('Backup archive contains an invalid trusted two-factor device token row');
|
||||||
|
}
|
||||||
|
if (trustedTwoFactorTokens.has(token)) {
|
||||||
|
throw new Error(`Backup archive contains duplicate trusted two-factor device token: ${token}`);
|
||||||
|
}
|
||||||
|
trustedTwoFactorTokens.add(token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildBackupArchive(
|
export async function buildBackupArchive(
|
||||||
@@ -408,7 +425,7 @@ export async function buildBackupArchive(
|
|||||||
includeAttachments,
|
includeAttachments,
|
||||||
});
|
});
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([
|
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
|
||||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
||||||
@@ -417,6 +434,7 @@ export async function buildBackupArchive(
|
|||||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
|
||||||
]);
|
]);
|
||||||
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||||
@@ -445,6 +463,7 @@ export async function buildBackupArchive(
|
|||||||
ciphers: cipherRows.length,
|
ciphers: cipherRows.length,
|
||||||
attachments: exportedAttachmentRows.length,
|
attachments: exportedAttachmentRows.length,
|
||||||
webauthn_credentials: accountPasskeyRows.length,
|
webauthn_credentials: accountPasskeyRows.length,
|
||||||
|
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
|
||||||
},
|
},
|
||||||
includes: {
|
includes: {
|
||||||
attachments: includeAttachments,
|
attachments: includeAttachments,
|
||||||
@@ -468,6 +487,7 @@ export async function buildBackupArchive(
|
|||||||
ciphers: cipherRows,
|
ciphers: cipherRows,
|
||||||
attachments: exportedAttachmentRows,
|
attachments: exportedAttachmentRows,
|
||||||
webauthn_credentials: accountPasskeyRows,
|
webauthn_credentials: accountPasskeyRows,
|
||||||
|
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
|
||||||
}, null, BACKUP_JSON_INDENT)),
|
}, null, BACKUP_JSON_INDENT)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type BackupRuntimeState,
|
type BackupRuntimeState,
|
||||||
type BackupScheduleConfig,
|
type BackupScheduleConfig,
|
||||||
type BackupSettings,
|
type BackupSettings,
|
||||||
|
type S3BackupAddressingStyle,
|
||||||
type S3BackupDestination,
|
type S3BackupDestination,
|
||||||
type WebDavBackupDestination,
|
type WebDavBackupDestination,
|
||||||
createBackupRandomId,
|
createBackupRandomId,
|
||||||
@@ -35,6 +36,7 @@ export type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
BackupSettings,
|
BackupSettings,
|
||||||
|
S3BackupAddressingStyle,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '../../shared/backup-schema';
|
} from '../../shared/backup-schema';
|
||||||
@@ -109,6 +111,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
const source = isPlainObject(value) ? value : {};
|
const source = isPlainObject(value) ? value : {};
|
||||||
const endpoint = asTrimmedString(source.endpoint);
|
const endpoint = asTrimmedString(source.endpoint);
|
||||||
const bucket = asTrimmedString(source.bucket);
|
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 accessKeyId = asTrimmedString(source.accessKeyId);
|
||||||
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
||||||
const region = asTrimmedString(source.region) || 'auto';
|
const region = asTrimmedString(source.region) || 'auto';
|
||||||
@@ -131,6 +136,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
return {
|
return {
|
||||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
||||||
bucket,
|
bucket,
|
||||||
|
addressingStyle,
|
||||||
region,
|
region,
|
||||||
accessKeyId,
|
accessKeyId,
|
||||||
secretAccessKey,
|
secretAccessKey,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type BackupTableName =
|
|||||||
| 'users'
|
| 'users'
|
||||||
| 'domain_settings'
|
| 'domain_settings'
|
||||||
| 'user_revisions'
|
| 'user_revisions'
|
||||||
|
| 'trusted_two_factor_device_tokens'
|
||||||
| 'webauthn_credentials'
|
| 'webauthn_credentials'
|
||||||
| 'folders'
|
| 'folders'
|
||||||
| 'ciphers'
|
| 'ciphers'
|
||||||
@@ -34,6 +35,7 @@ const BACKUP_TABLES: BackupTableName[] = [
|
|||||||
'users',
|
'users',
|
||||||
'domain_settings',
|
'domain_settings',
|
||||||
'user_revisions',
|
'user_revisions',
|
||||||
|
'trusted_two_factor_device_tokens',
|
||||||
'webauthn_credentials',
|
'webauthn_credentials',
|
||||||
'folders',
|
'folders',
|
||||||
'ciphers',
|
'ciphers',
|
||||||
@@ -51,6 +53,7 @@ export interface BackupImportResultBody {
|
|||||||
users: number;
|
users: number;
|
||||||
domainSettings: number;
|
domainSettings: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
|
trustedTwoFactorDeviceTokens: number;
|
||||||
webauthnCredentials: number;
|
webauthnCredentials: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
@@ -172,6 +175,7 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
|||||||
'DELETE FROM ciphers',
|
'DELETE FROM ciphers',
|
||||||
'DELETE FROM folders',
|
'DELETE FROM folders',
|
||||||
'DELETE FROM webauthn_credentials',
|
'DELETE FROM webauthn_credentials',
|
||||||
|
'DELETE FROM trusted_two_factor_device_tokens',
|
||||||
'DELETE FROM domain_settings',
|
'DELETE FROM domain_settings',
|
||||||
'DELETE FROM user_revisions',
|
'DELETE FROM user_revisions',
|
||||||
'DELETE FROM users',
|
'DELETE FROM users',
|
||||||
@@ -296,6 +300,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
|||||||
})),
|
})),
|
||||||
domain_settings: cloneRows(payload.domain_settings || []),
|
domain_settings: cloneRows(payload.domain_settings || []),
|
||||||
user_revisions: cloneRows(payload.user_revisions || []),
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
|
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
||||||
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
|
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
|
||||||
folders: cloneRows(payload.folders || []),
|
folders: cloneRows(payload.folders || []),
|
||||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||||
@@ -634,6 +639,16 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('trusted_two_factor_device_tokens'),
|
||||||
|
buildInsertStatements(
|
||||||
|
db,
|
||||||
|
tableName('trusted_two_factor_device_tokens'),
|
||||||
|
['token', 'user_id', 'device_identifier', 'expires_at'],
|
||||||
|
payload.trusted_two_factor_device_tokens || []
|
||||||
|
)
|
||||||
|
);
|
||||||
await runInsertBatch(
|
await runInsertBatch(
|
||||||
db,
|
db,
|
||||||
tableName('webauthn_credentials'),
|
tableName('webauthn_credentials'),
|
||||||
@@ -712,6 +727,7 @@ export async function importBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -735,6 +751,7 @@ export async function importBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -776,6 +793,7 @@ export async function importBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domainSettings: (db.domain_settings || []).length,
|
domainSettings: (db.domain_settings || []).length,
|
||||||
userRevisions: (db.user_revisions || []).length,
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthnCredentials: (db.webauthn_credentials || []).length,
|
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -853,6 +871,7 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -876,6 +895,7 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthn_credentials: (db.webauthn_credentials || []).length,
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
@@ -923,6 +943,7 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domainSettings: (db.domain_settings || []).length,
|
domainSettings: (db.domain_settings || []).length,
|
||||||
userRevisions: (db.user_revisions || []).length,
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
webauthnCredentials: (db.webauthn_credentials || []).length,
|
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
|
|||||||
@@ -448,8 +448,27 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
|
|||||||
return true;
|
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 {
|
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 {
|
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
|
||||||
@@ -501,7 +520,7 @@ async function putToS3(
|
|||||||
options: RemoteBackupFilePutOptions = {}
|
options: RemoteBackupFilePutOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
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);
|
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -594,7 +613,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
|
|||||||
throw new Error('Please select a backup file');
|
throw new Error('Please select a backup file');
|
||||||
}
|
}
|
||||||
const objectKey = normalizeS3ObjectKey(config, normalized);
|
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);
|
const response = await signedS3Request(config, 'GET', url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`S3 download failed: ${response.status}`);
|
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> {
|
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
|
||||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
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);
|
const response = await signedS3Request(config, 'DELETE', url);
|
||||||
if (!response.ok && response.status !== 404) {
|
if (!response.ok && response.status !== 404) {
|
||||||
throw new Error(`S3 delete failed: ${response.status}`);
|
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> {
|
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
||||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
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);
|
const response = await signedS3Request(config, 'HEAD', url);
|
||||||
if (response.status === 404) return false;
|
if (response.status === 404) return false;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
const [present, setPresent] = useState(props.open);
|
const [present, setPresent] = useState(props.open);
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
const cardRef = useRef<HTMLFormElement | null>(null);
|
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
const maskPointerStartedRef = useRef(false);
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||||
const titleId = `${dialogId}-title`;
|
const titleId = `${dialogId}-title`;
|
||||||
@@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
return createPortal((
|
return createPortal((
|
||||||
<div
|
<div
|
||||||
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
maskPointerStartedRef.current = event.target === event.currentTarget;
|
||||||
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target !== event.currentTarget || !canDismiss) return;
|
if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
|
||||||
props.onCancel();
|
props.onCancel();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export default function NetworkStatusBadge() {
|
|||||||
const Icon = status === 'online' ? Wifi : WifiOff;
|
const Icon = status === 'online' ? Wifi : WifiOff;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
|
|
||||||
const checkService = async () => {
|
const checkService = async () => {
|
||||||
@@ -31,10 +30,7 @@ export default function NetworkStatusBadge() {
|
|||||||
setCurrentNetworkStatus('offline');
|
setCurrentNetworkStatus('offline');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reachable = await probeNodeWardenService();
|
await probeNodeWardenService();
|
||||||
if (!cancelled) {
|
|
||||||
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleNextCheck = () => {
|
const scheduleNextCheck = () => {
|
||||||
@@ -62,7 +58,6 @@ export default function NetworkStatusBadge() {
|
|||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
window.removeEventListener('online', handleOnline);
|
window.removeEventListener('online', handleOnline);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { t } from '@/lib/i18n';
|
|||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import WebsiteIcon from '@/components/vault/WebsiteIcon';
|
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 {
|
interface TotpCodesPageProps {
|
||||||
ciphers: Cipher[];
|
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 }) {
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ import {
|
|||||||
createEmptyDraft,
|
createEmptyDraft,
|
||||||
creationTimeValue,
|
creationTimeValue,
|
||||||
draftFromCipher,
|
draftFromCipher,
|
||||||
buildCipherDuplicateSignature,
|
buildCipherDuplicateSignatures,
|
||||||
firstCipherUri,
|
firstCipherUri,
|
||||||
firstPasskeyCreationTime,
|
firstPasskeyCreationTime,
|
||||||
isCipherVisibleInArchive,
|
isCipherVisibleInArchive,
|
||||||
isCipherVisibleInNormalVault,
|
isCipherVisibleInNormalVault,
|
||||||
isCipherVisibleInTrash,
|
isCipherVisibleInTrash,
|
||||||
sortTimeValue,
|
sortTimeValue,
|
||||||
|
type DuplicateDetectionMode,
|
||||||
type SidebarFilter,
|
type SidebarFilter,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} from '@/components/vault/vault-page-helpers';
|
||||||
@@ -79,6 +80,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
||||||
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
|
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
|
||||||
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
|
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
|
||||||
|
const [duplicateMode, setDuplicateMode] = useState<DuplicateDetectionMode>('exact');
|
||||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
@@ -338,16 +340,41 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
|
|
||||||
const duplicateSignatureInfo = useMemo(() => {
|
const duplicateSignatureInfo = useMemo(() => {
|
||||||
if (sidebarFilter.kind !== 'duplicates') return null;
|
if (sidebarFilter.kind !== 'duplicates') return null;
|
||||||
const byId = new Map<string, string>();
|
const byId = new Map<string, string[]>();
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const cipher of props.ciphers) {
|
for (const cipher of props.ciphers) {
|
||||||
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
||||||
const signature = buildCipherDuplicateSignature(cipher);
|
const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode)));
|
||||||
byId.set(cipher.id, signature);
|
byId.set(cipher.id, signatures);
|
||||||
counts.set(signature, (counts.get(signature) || 0) + 1);
|
for (const signature of signatures) {
|
||||||
|
counts.set(signature, (counts.get(signature) || 0) + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { byId, counts };
|
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 filteredCiphers = useMemo(() => {
|
||||||
const next = props.ciphers.filter((cipher) => {
|
const next = props.ciphers.filter((cipher) => {
|
||||||
@@ -358,8 +385,11 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
if (!isCipherVisibleInArchive(cipher)) return false;
|
if (!isCipherVisibleInArchive(cipher)) return false;
|
||||||
} else {
|
} else {
|
||||||
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
||||||
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) {
|
if (sidebarFilter.kind === 'duplicates') {
|
||||||
return false;
|
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 === 'favorite' && !cipher.favorite) return false;
|
||||||
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) 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(() => {
|
const sidebarFilterKey = useMemo(() => {
|
||||||
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
||||||
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
|
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
|
||||||
|
if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`;
|
||||||
return sidebarFilter.kind;
|
return sidebarFilter.kind;
|
||||||
}, [sidebarFilter]);
|
}, [sidebarFilter, duplicateMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setListScrollTop(0);
|
setListScrollTop(0);
|
||||||
@@ -419,6 +450,10 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}
|
}
|
||||||
}, [sidebarFilter.kind, sortMode]);
|
}, [sidebarFilter.kind, sortMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
|
||||||
|
}, [sidebarFilter.kind, duplicateMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreating) return;
|
if (isCreating) return;
|
||||||
if (!filteredCiphers.length) {
|
if (!filteredCiphers.length) {
|
||||||
@@ -984,10 +1019,11 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
|
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
|
||||||
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
||||||
const handleSelectDuplicates = useCallback(() => {
|
const handleSelectDuplicates = useCallback(() => {
|
||||||
|
if (duplicateMode !== 'exact') return;
|
||||||
const map: Record<string, boolean> = {};
|
const map: Record<string, boolean> = {};
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const cipher of filteredCiphers) {
|
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)) {
|
if (seen.has(signature)) {
|
||||||
map[cipher.id] = true;
|
map[cipher.id] = true;
|
||||||
continue;
|
continue;
|
||||||
@@ -995,7 +1031,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
seen.add(signature);
|
seen.add(signature);
|
||||||
}
|
}
|
||||||
setSelectedMap(map);
|
setSelectedMap(map);
|
||||||
}, [filteredCiphers, duplicateSignatureInfo]);
|
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
|
||||||
const handleSelectAll = useCallback(() => {
|
const handleSelectAll = useCallback(() => {
|
||||||
const map: Record<string, boolean> = {};
|
const map: Record<string, boolean> = {};
|
||||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||||
@@ -1082,10 +1118,12 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
searchInput={searchInput}
|
searchInput={searchInput}
|
||||||
sortMode={sortMode}
|
sortMode={sortMode}
|
||||||
sortMenuOpen={sortMenuOpen}
|
sortMenuOpen={sortMenuOpen}
|
||||||
|
duplicateMode={duplicateMode}
|
||||||
selectedCount={selectedCount}
|
selectedCount={selectedCount}
|
||||||
totalCipherCount={totalCipherCount}
|
totalCipherCount={totalCipherCount}
|
||||||
filteredCiphers={filteredCiphers}
|
filteredCiphers={filteredCiphers}
|
||||||
visibleCiphers={visibleCiphers}
|
visibleCiphers={visibleCiphers}
|
||||||
|
duplicateGroupIndexById={duplicateGroupIndexById}
|
||||||
virtualRange={virtualRange}
|
virtualRange={virtualRange}
|
||||||
selectedCipherId={selectedCipherId}
|
selectedCipherId={selectedCipherId}
|
||||||
selectedMap={selectedMap}
|
selectedMap={selectedMap}
|
||||||
@@ -1102,6 +1140,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
onSearchCompositionEnd={handleSearchCompositionEnd}
|
onSearchCompositionEnd={handleSearchCompositionEnd}
|
||||||
onToggleSortMenu={handleToggleSortMenu}
|
onToggleSortMenu={handleToggleSortMenu}
|
||||||
onSelectSortMode={handleSelectSortMode}
|
onSelectSortMode={handleSelectSortMode}
|
||||||
|
onDuplicateModeChange={setDuplicateMode}
|
||||||
onSyncVault={handleSyncVault}
|
onSyncVault={handleSyncVault}
|
||||||
onOpenBulkDelete={handleOpenBulkDelete}
|
onOpenBulkDelete={handleOpenBulkDelete}
|
||||||
onSelectDuplicates={handleSelectDuplicates}
|
onSelectDuplicates={handleSelectDuplicates}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { CloudUpload, Save, Trash2 } from 'lucide-preact';
|
|||||||
import type {
|
import type {
|
||||||
BackupDestinationRecord,
|
BackupDestinationRecord,
|
||||||
RemoteBackupBrowserResponse,
|
RemoteBackupBrowserResponse,
|
||||||
|
S3BackupAddressingStyle,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '@/lib/api/backup';
|
} from '@/lib/api/backup';
|
||||||
@@ -401,7 +402,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
|
|
||||||
{props.selectedDestination.type === 's3' ? (
|
{props.selectedDestination.type === 's3' ? (
|
||||||
<div className="field-grid">
|
<div className="field-grid">
|
||||||
<label className="field field-span-2">
|
<label className="field">
|
||||||
<span>{t('txt_backup_s3_endpoint')}</span>
|
<span>{t('txt_backup_s3_endpoint')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -417,6 +418,24 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="field">
|
||||||
<span>{t('txt_backup_s3_bucket')}</span>
|
<span>{t('txt_backup_s3_bucket')}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { t } from '@/lib/i18n';
|
|||||||
import {
|
import {
|
||||||
CreateTypeIcon,
|
CreateTypeIcon,
|
||||||
getCreateTypeOptions,
|
getCreateTypeOptions,
|
||||||
|
getDuplicateDetectionOptions,
|
||||||
getVaultSortOptions,
|
getVaultSortOptions,
|
||||||
VaultListIcon,
|
VaultListIcon,
|
||||||
|
type DuplicateDetectionMode,
|
||||||
type SidebarFilter,
|
type SidebarFilter,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} from '@/components/vault/vault-page-helpers';
|
||||||
@@ -28,10 +30,12 @@ interface VaultListPanelProps {
|
|||||||
searchInput: string;
|
searchInput: string;
|
||||||
sortMode: VaultSortMode;
|
sortMode: VaultSortMode;
|
||||||
sortMenuOpen: boolean;
|
sortMenuOpen: boolean;
|
||||||
|
duplicateMode: DuplicateDetectionMode;
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
totalCipherCount: number;
|
totalCipherCount: number;
|
||||||
filteredCiphers: Cipher[];
|
filteredCiphers: Cipher[];
|
||||||
visibleCiphers: Cipher[];
|
visibleCiphers: Cipher[];
|
||||||
|
duplicateGroupIndexById: Map<string, number>;
|
||||||
virtualRange: VirtualRange;
|
virtualRange: VirtualRange;
|
||||||
selectedCipherId: string;
|
selectedCipherId: string;
|
||||||
selectedMap: Record<string, boolean>;
|
selectedMap: Record<string, boolean>;
|
||||||
@@ -48,6 +52,7 @@ interface VaultListPanelProps {
|
|||||||
onSearchCompositionEnd: (value: string) => void;
|
onSearchCompositionEnd: (value: string) => void;
|
||||||
onToggleSortMenu: () => void;
|
onToggleSortMenu: () => void;
|
||||||
onSelectSortMode: (value: VaultSortMode) => void;
|
onSelectSortMode: (value: VaultSortMode) => void;
|
||||||
|
onDuplicateModeChange: (value: DuplicateDetectionMode) => void;
|
||||||
onSyncVault: () => void;
|
onSyncVault: () => void;
|
||||||
onOpenBulkDelete: () => void;
|
onOpenBulkDelete: () => void;
|
||||||
onSelectDuplicates: () => void;
|
onSelectDuplicates: () => void;
|
||||||
@@ -69,15 +74,18 @@ interface CipherListItemProps {
|
|||||||
cipher: Cipher;
|
cipher: Cipher;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
|
duplicateGroupIndex: number | null;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||||
onSelectCipher: (cipherId: string) => void;
|
onSelectCipher: (cipherId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
||||||
|
const duplicateGroupHue = props.duplicateGroupIndex === null ? null : (props.duplicateGroupIndex * 137.508) % 360;
|
||||||
return (
|
return (
|
||||||
<div
|
<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) => {
|
onClick={(event) => {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (target.closest('.row-check')) return;
|
if (target.closest('.row-check')) return;
|
||||||
@@ -108,6 +116,7 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
|
|||||||
|
|
||||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||||
const createTypeOptions = getCreateTypeOptions();
|
const createTypeOptions = getCreateTypeOptions();
|
||||||
|
const duplicateDetectionOptions = getDuplicateDetectionOptions();
|
||||||
const vaultSortOptions = getVaultSortOptions();
|
const vaultSortOptions = getVaultSortOptions();
|
||||||
const createMenu = (
|
const createMenu = (
|
||||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
<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">
|
<section className="list-col">
|
||||||
<div className="list-head">
|
<div className="list-head">
|
||||||
<div className="search-input-wrap">
|
<div className="search-input-wrap">
|
||||||
<input
|
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||||
className="search-input"
|
<select
|
||||||
placeholder={t('txt_search_your_secure_vault')}
|
className="input duplicate-mode-select duplicate-mode-head-select"
|
||||||
value={props.searchInput}
|
value={props.duplicateMode}
|
||||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
aria-label={t('txt_duplicate_detection_mode')}
|
||||||
onCompositionStart={props.onSearchCompositionStart}
|
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
|
||||||
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} />
|
{duplicateDetectionOptions.map((option) => (
|
||||||
</button>
|
<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>
|
||||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
<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')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar actions">
|
<div className={`toolbar actions ${props.sidebarFilter.kind === 'duplicates' ? 'duplicates-toolbar' : ''}`}>
|
||||||
{props.sidebarFilter.kind === 'duplicates' && (
|
{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}>
|
<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')}
|
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||||
</button>
|
</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}>
|
<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')}
|
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
{props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||||
</button>
|
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||||
{props.isMobileLayout && typeof document !== 'undefined'
|
</button>
|
||||||
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
|
)}
|
||||||
: createMenu}
|
{props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
|
props.isMobileLayout && typeof document !== 'undefined'
|
||||||
|
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
|
||||||
|
: createMenu
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||||
@@ -255,6 +295,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
cipher={cipher}
|
cipher={cipher}
|
||||||
selected={props.selectedCipherId === cipher.id}
|
selected={props.selectedCipherId === cipher.id}
|
||||||
checked={!!props.selectedMap[cipher.id]}
|
checked={!!props.selectedMap[cipher.id]}
|
||||||
|
duplicateGroupIndex={props.sidebarFilter.kind === 'duplicates' ? props.duplicateGroupIndexById.get(cipher.id) ?? null : null}
|
||||||
subtitle={props.listSubtitle(cipher)}
|
subtitle={props.listSubtitle(cipher)}
|
||||||
onToggleSelected={props.onToggleSelected}
|
onToggleSelected={props.onToggleSelected}
|
||||||
onSelectCipher={props.onSelectCipher}
|
onSelectCipher={props.onSelectCipher}
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import {
|
|||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
|
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';
|
import WebsiteIcon from './WebsiteIcon';
|
||||||
|
|
||||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||||
|
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
|
||||||
export type SidebarFilter =
|
export type SidebarFilter =
|
||||||
| { kind: 'all' }
|
| { kind: 'all' }
|
||||||
| { kind: 'favorite' }
|
| { 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 MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
|
||||||
export const VAULT_LIST_ROW_HEIGHT = 74;
|
export const VAULT_LIST_ROW_HEIGHT = 74;
|
||||||
export const VAULT_LIST_OVERSCAN = 10;
|
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 }> {
|
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
|
||||||
return [
|
return [
|
||||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
{ 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';
|
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 {
|
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||||
return { uri: '', match: null, originalUri: '', extra: {} };
|
return { uri: '', match: null, originalUri: '', extra: {} };
|
||||||
@@ -257,6 +270,30 @@ function valueOrFallback(value: string | null | undefined): string {
|
|||||||
return String(value || '');
|
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 {
|
export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
||||||
const normalized = {
|
const normalized = {
|
||||||
type: Number(cipher.type || 1),
|
type: Number(cipher.type || 1),
|
||||||
@@ -326,13 +363,30 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
|||||||
linkedId: field.linkedId ?? null,
|
linkedId: field.linkedId ?? null,
|
||||||
})),
|
})),
|
||||||
passwordHistory: (cipher.passwordHistory || []).map((entry) => ({
|
passwordHistory: (cipher.passwordHistory || []).map((entry) => ({
|
||||||
password: valueOrFallback(entry.password),
|
password: valueOrFallback(entry.decPassword ?? entry.password),
|
||||||
lastUsedDate: valueOrFallback(entry.lastUsedDate),
|
lastUsedDate: valueOrFallback(entry.lastUsedDate),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
return JSON.stringify(normalized);
|
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 {
|
export function createEmptyDraft(type: number): VaultDraft {
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
@@ -453,8 +507,9 @@ export function maskSecret(value: string): string {
|
|||||||
export function formatTotp(code: string): string {
|
export function formatTotp(code: string): string {
|
||||||
if (!code) return code;
|
if (!code) return code;
|
||||||
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
||||||
if (code.length < 6) return code;
|
if (code.length <= 4) return code;
|
||||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
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 {
|
export function formatHistoryTime(value: string | null | undefined): string {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
|
|||||||
import {
|
import {
|
||||||
attachNodeWardenEncryptedAttachmentPayload,
|
attachNodeWardenEncryptedAttachmentPayload,
|
||||||
buildAccountEncryptedBitwardenJsonString,
|
buildAccountEncryptedBitwardenJsonString,
|
||||||
|
buildBitwardenCsvString,
|
||||||
buildBitwardenZipBytes,
|
buildBitwardenZipBytes,
|
||||||
buildExportFileName,
|
buildExportFileName,
|
||||||
buildNodeWardenAttachmentRecords,
|
buildNodeWardenAttachmentRecords,
|
||||||
@@ -1190,6 +1191,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
mimeType: 'application/json',
|
mimeType: 'application/json',
|
||||||
bytes: new TextEncoder().encode(await getPlainJson()),
|
bytes: new TextEncoder().encode(await getPlainJson()),
|
||||||
};
|
};
|
||||||
|
} else if (format === 'bitwarden_csv') {
|
||||||
|
result = {
|
||||||
|
fileName: buildExportFileName(format),
|
||||||
|
mimeType: 'text/csv;charset=utf-8',
|
||||||
|
bytes: new TextEncoder().encode(buildBitwardenCsvString(await getPlainJsonDoc())),
|
||||||
|
};
|
||||||
} else if (format === 'bitwarden_encrypted_json') {
|
} else if (format === 'bitwarden_encrypted_json') {
|
||||||
if (request.encryptedJsonMode === 'password') {
|
if (request.encryptedJsonMode === 'password') {
|
||||||
const plainJson = await getPlainJson();
|
const plainJson = await getPlainJson();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
TokenSuccess,
|
TokenSuccess,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
||||||
|
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
|
||||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||||
|
|
||||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||||
@@ -474,6 +475,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(input, { ...init, headers });
|
const response = await fetch(input, { ...init, headers });
|
||||||
|
recordNodeWardenReachable();
|
||||||
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
|
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -484,6 +486,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (attempt === maxAttempts - 1) {
|
if (attempt === maxAttempts - 1) {
|
||||||
|
recordNodeWardenUnreachable();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
BackupSettings as AdminBackupSettings,
|
BackupSettings as AdminBackupSettings,
|
||||||
|
S3BackupAddressingStyle,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '@shared/backup-schema';
|
} from '@shared/backup-schema';
|
||||||
@@ -26,6 +27,7 @@ export type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
AdminBackupSettings,
|
AdminBackupSettings,
|
||||||
|
S3BackupAddressingStyle,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
};
|
};
|
||||||
@@ -96,6 +98,7 @@ export interface AdminBackupImportCounts {
|
|||||||
users: number;
|
users: number;
|
||||||
domainSettings?: number;
|
domainSettings?: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
|
trustedTwoFactorDeviceTokens?: number;
|
||||||
webauthnCredentials?: number;
|
webauthnCredentials?: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
|
|||||||
@@ -279,20 +279,16 @@ export async function hydrateLockedSession(
|
|||||||
fallbackProfile: Profile | null = null
|
fallbackProfile: Profile | null = null
|
||||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||||
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
|
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
|
||||||
let serviceReachable = true;
|
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||||
if (hasOfflineUnlock) {
|
return {
|
||||||
serviceReachable = await probeNodeWardenService();
|
session,
|
||||||
if (!serviceReachable) {
|
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||||
return {
|
};
|
||||||
session,
|
|
||||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshedSession = await maybeRefreshSession(session);
|
const refreshedSession = await maybeRefreshSession(session);
|
||||||
if (!refreshedSession?.accessToken) {
|
if (!refreshedSession?.accessToken) {
|
||||||
if (hasOfflineUnlock && !serviceReachable) {
|
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||||
@@ -571,14 +567,8 @@ export async function performUnlock(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasOfflineUnlock) {
|
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||||
if (browserReportsOffline()) {
|
return unlockOffline();
|
||||||
return unlockOffline();
|
|
||||||
}
|
|
||||||
const serviceReachable = await probeNodeWardenService();
|
|
||||||
if (!serviceReachable) {
|
|
||||||
return unlockOffline();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||||
|
|||||||
@@ -220,6 +220,25 @@ function normalizeTotpSecret(secret: string): string {
|
|||||||
return secret.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
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 {
|
function parseSteamSecret(raw: string): string {
|
||||||
const match = raw.trim().match(/^steam:\/\/([^/?#]+)(?:[/?#].*)?$/i);
|
const match = raw.trim().match(/^steam:\/\/([^/?#]+)(?:[/?#].*)?$/i);
|
||||||
if (!match?.[1]) return '';
|
if (!match?.[1]) return '';
|
||||||
@@ -276,7 +295,8 @@ function parseTotpConfig(raw: string): TotpConfig {
|
|||||||
if (/^otpauth:\/\//i.test(s)) {
|
if (/^otpauth:\/\//i.test(s)) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(s);
|
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 };
|
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
}
|
}
|
||||||
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
|
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),
|
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
||||||
};
|
};
|
||||||
} catch {
|
} 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 };
|
return { secret: normalizeTotpSecret(s), steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ configureZipJs({ useWebWorkers: false });
|
|||||||
|
|
||||||
export const EXPORT_FORMATS = [
|
export const EXPORT_FORMATS = [
|
||||||
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
|
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
|
||||||
|
{ id: 'bitwarden_csv', label: 'Bitwarden (vault as csv)' },
|
||||||
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
|
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
|
||||||
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
|
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
|
||||||
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
|
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
|
||||||
@@ -70,6 +71,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function csvText(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvCell(value: unknown): string {
|
||||||
|
const text = csvText(value);
|
||||||
|
if (!/[",\r\n]/.test(text)) return text;
|
||||||
|
return `"${text.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsvString(rows: string[][]): string {
|
||||||
|
return `${rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n')}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSingleRowCsvString(values: string[]): string {
|
||||||
|
return values.map(escapeCsvCell).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
function isCipherString(value: string): boolean {
|
function isCipherString(value: string): boolean {
|
||||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||||
}
|
}
|
||||||
@@ -383,6 +409,106 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
|
|||||||
return JSON.stringify(doc, null, 2);
|
return JSON.stringify(doc, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BITWARDEN_CSV_HEADERS = [
|
||||||
|
'folder',
|
||||||
|
'favorite',
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'notes',
|
||||||
|
'fields',
|
||||||
|
'reprompt',
|
||||||
|
'login_uri',
|
||||||
|
'login_username',
|
||||||
|
'login_password',
|
||||||
|
'login_totp',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function bitwardenCsvType(type: number): 'login' | 'note' {
|
||||||
|
return type === 1 ? 'login' : 'note';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceTypeLabel(type: number): string {
|
||||||
|
if (type === 3) return 'card';
|
||||||
|
if (type === 4) return 'identity';
|
||||||
|
if (type === 5) return 'sshKey';
|
||||||
|
if (type === 2) return 'note';
|
||||||
|
return `type ${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendFieldLine(lines: string[], name: unknown, value: unknown): void {
|
||||||
|
const key = csvText(name).trim();
|
||||||
|
const text = csvText(value);
|
||||||
|
if (!key || !text) return;
|
||||||
|
lines.push(`${key}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
|
||||||
|
if (!isRecord(value)) return;
|
||||||
|
for (const [key, fieldValue] of Object.entries(value)) {
|
||||||
|
appendFieldLine(lines, `${prefix}.${key}`, fieldValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const fields = Array.isArray(item.fields) ? item.fields : [];
|
||||||
|
for (const field of fields) {
|
||||||
|
if (!isRecord(field)) continue;
|
||||||
|
appendFieldLine(lines, field.name, field.value);
|
||||||
|
}
|
||||||
|
if (type !== 1 && type !== 2) {
|
||||||
|
appendFieldLine(lines, 'nodewardenType', sourceTypeLabel(type));
|
||||||
|
appendRecordFieldLines(lines, sourceTypeLabel(type), item[sourceTypeLabel(type)]);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFolderNameById(foldersRaw: unknown): Map<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
const folders = Array.isArray(foldersRaw) ? foldersRaw : [];
|
||||||
|
for (const folder of folders) {
|
||||||
|
if (!isRecord(folder)) continue;
|
||||||
|
const id = csvText(folder.id).trim();
|
||||||
|
if (!id) continue;
|
||||||
|
out.set(id, csvText(folder.name));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBitwardenCsvLoginUri(login: Record<string, unknown> | null): string {
|
||||||
|
const uris = Array.isArray(login?.uris) ? login.uris : [];
|
||||||
|
return buildSingleRowCsvString(uris
|
||||||
|
.map((uri) => (isRecord(uri) ? csvText(uri.uri).trim() : ''))
|
||||||
|
.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBitwardenCsvString(bitwardenJsonDoc: Record<string, unknown>): string {
|
||||||
|
const folderNameById = buildFolderNameById(bitwardenJsonDoc.folders);
|
||||||
|
const rows: string[][] = [[...BITWARDEN_CSV_HEADERS]];
|
||||||
|
const items = Array.isArray(bitwardenJsonDoc.items) ? bitwardenJsonDoc.items : [];
|
||||||
|
for (const itemRaw of items) {
|
||||||
|
if (!isRecord(itemRaw)) continue;
|
||||||
|
const type = normalizeNumber(itemRaw.type, 1);
|
||||||
|
const isLogin = type === 1;
|
||||||
|
const login = isRecord(itemRaw.login) ? itemRaw.login : null;
|
||||||
|
const folderId = csvText(itemRaw.folderId).trim();
|
||||||
|
rows.push([
|
||||||
|
folderNameById.get(folderId) || '',
|
||||||
|
itemRaw.favorite ? '1' : '0',
|
||||||
|
bitwardenCsvType(type),
|
||||||
|
csvText(itemRaw.name) || '--',
|
||||||
|
csvText(itemRaw.notes),
|
||||||
|
buildBitwardenCsvFields(itemRaw, type),
|
||||||
|
String(normalizeNumber(itemRaw.reprompt, 0)),
|
||||||
|
isLogin ? buildBitwardenCsvLoginUri(login) : '',
|
||||||
|
isLogin ? csvText(login?.username) : '',
|
||||||
|
isLogin ? csvText(login?.password) : '',
|
||||||
|
isLogin ? csvText(login?.totp) : '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return `\uFEFF${buildCsvString(rows)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
||||||
const userEnc = base64ToBytes(args.userEncB64);
|
const userEnc = base64ToBytes(args.userEncB64);
|
||||||
const userMac = base64ToBytes(args.userMacB64);
|
const userMac = base64ToBytes(args.userMacB64);
|
||||||
@@ -566,11 +692,13 @@ function nowStamp(now = new Date()): string {
|
|||||||
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
|
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
|
||||||
const stamp = nowStamp();
|
const stamp = nowStamp();
|
||||||
if (
|
if (
|
||||||
|
format === 'bitwarden_csv' ||
|
||||||
format === 'bitwarden_json' ||
|
format === 'bitwarden_json' ||
|
||||||
format === 'bitwarden_encrypted_json' ||
|
format === 'bitwarden_encrypted_json' ||
|
||||||
format === 'nodewarden_json' ||
|
format === 'nodewarden_json' ||
|
||||||
format === 'nodewarden_encrypted_json'
|
format === 'nodewarden_encrypted_json'
|
||||||
) {
|
) {
|
||||||
|
if (format === 'bitwarden_csv') return `bitwarden_export_${stamp}.csv`;
|
||||||
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
|
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
|
||||||
return `bitwarden_export_${stamp}.json`;
|
return `bitwarden_export_${stamp}.json`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const en: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "WebDAV Password",
|
"txt_backup_webdav_password": "WebDAV Password",
|
||||||
"txt_backup_webdav_path": "Remote Folder",
|
"txt_backup_webdav_path": "Remote Folder",
|
||||||
"txt_backup_s3_endpoint": "S3 Endpoint",
|
"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_bucket": "Bucket",
|
||||||
"txt_backup_s3_region": "Region",
|
"txt_backup_s3_region": "Region",
|
||||||
"txt_backup_s3_access_key": "Access Key",
|
"txt_backup_s3_access_key": "Access Key",
|
||||||
@@ -447,6 +450,11 @@ const en: Record<string, string> = {
|
|||||||
"txt_favorite": "Favorite",
|
"txt_favorite": "Favorite",
|
||||||
"txt_favorites": "Favorites",
|
"txt_favorites": "Favorites",
|
||||||
"txt_duplicates": "Duplicates",
|
"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": "Field",
|
||||||
"txt_field_label": "Field Label",
|
"txt_field_label": "Field Label",
|
||||||
"txt_field_label_is_required": "Field label is required.",
|
"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_password": "Contraseña WebDAV",
|
||||||
"txt_backup_webdav_path": "Carpeta remota",
|
"txt_backup_webdav_path": "Carpeta remota",
|
||||||
"txt_backup_s3_endpoint": "Endpoint S3",
|
"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_bucket": "Bucket S3",
|
||||||
"txt_backup_s3_region": "Región",
|
"txt_backup_s3_region": "Región",
|
||||||
"txt_backup_s3_access_key": "Clave de acceso",
|
"txt_backup_s3_access_key": "Clave de acceso",
|
||||||
@@ -447,6 +450,11 @@ const es: Record<string, string> = {
|
|||||||
"txt_favorite": "Favorito",
|
"txt_favorite": "Favorito",
|
||||||
"txt_favorites": "Favoritos",
|
"txt_favorites": "Favoritos",
|
||||||
"txt_duplicates": "Duplicados",
|
"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": "Campo",
|
||||||
"txt_field_label": "Etiqueta del campo",
|
"txt_field_label": "Etiqueta del campo",
|
||||||
"txt_field_label_is_required": "La etiqueta del campo es obligatoria.",
|
"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_password": "Пароль WebDAV",
|
||||||
"txt_backup_webdav_path": "Удаленная папка",
|
"txt_backup_webdav_path": "Удаленная папка",
|
||||||
"txt_backup_s3_endpoint": "S3 endpoint",
|
"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_bucket": "Бакет",
|
||||||
"txt_backup_s3_region": "Регион",
|
"txt_backup_s3_region": "Регион",
|
||||||
"txt_backup_s3_access_key": "Ключ доступа",
|
"txt_backup_s3_access_key": "Ключ доступа",
|
||||||
@@ -447,6 +450,11 @@ const ru: Record<string, string> = {
|
|||||||
"txt_favorite": "Любимый",
|
"txt_favorite": "Любимый",
|
||||||
"txt_favorites": "Избранное",
|
"txt_favorites": "Избранное",
|
||||||
"txt_duplicates": "Дубликаты",
|
"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": "Поле",
|
||||||
"txt_field_label": "Метка поля",
|
"txt_field_label": "Метка поля",
|
||||||
"txt_field_label_is_required": "Метка поля обязательна.",
|
"txt_field_label_is_required": "Метка поля обязательна.",
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "WebDAV 密码",
|
"txt_backup_webdav_password": "WebDAV 密码",
|
||||||
"txt_backup_webdav_path": "远程目录",
|
"txt_backup_webdav_path": "远程目录",
|
||||||
"txt_backup_s3_endpoint": "S3 端点",
|
"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_bucket": "存储桶",
|
||||||
"txt_backup_s3_region": "区域",
|
"txt_backup_s3_region": "区域",
|
||||||
"txt_backup_s3_access_key": "访问密钥",
|
"txt_backup_s3_access_key": "访问密钥",
|
||||||
@@ -447,6 +450,11 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_favorite": "收藏",
|
"txt_favorite": "收藏",
|
||||||
"txt_favorites": "收藏",
|
"txt_favorites": "收藏",
|
||||||
"txt_duplicates": "重复项",
|
"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": "字段",
|
||||||
"txt_field_label": "字段标签",
|
"txt_field_label": "字段标签",
|
||||||
"txt_field_label_is_required": "字段标签不能为空",
|
"txt_field_label_is_required": "字段标签不能为空",
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "WebDAV 密碼",
|
"txt_backup_webdav_password": "WebDAV 密碼",
|
||||||
"txt_backup_webdav_path": "遠程目錄",
|
"txt_backup_webdav_path": "遠程目錄",
|
||||||
"txt_backup_s3_endpoint": "S3 端點",
|
"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_bucket": "儲存桶",
|
||||||
"txt_backup_s3_region": "區域",
|
"txt_backup_s3_region": "區域",
|
||||||
"txt_backup_s3_access_key": "存取金鑰",
|
"txt_backup_s3_access_key": "存取金鑰",
|
||||||
@@ -447,6 +450,11 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_favorite": "收藏",
|
"txt_favorite": "收藏",
|
||||||
"txt_favorites": "收藏",
|
"txt_favorites": "收藏",
|
||||||
"txt_duplicates": "重複項",
|
"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": "字段",
|
||||||
"txt_field_label": "字段標籤",
|
"txt_field_label": "字段標籤",
|
||||||
"txt_field_label_is_required": "字段標籤不能為空",
|
"txt_field_label_is_required": "字段標籤不能為空",
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
export type NetworkStatus = 'online' | 'offline';
|
export type NetworkStatus = 'online' | 'offline';
|
||||||
|
|
||||||
const STATUS_PROBE_TIMEOUT_MS = 3500;
|
const STATUS_PROBE_TIMEOUT_MS = 8000;
|
||||||
const STATUS_PROBE_CACHE_MS = 5000;
|
const STATUS_PROBE_CACHE_MS = 5000;
|
||||||
|
const PROBE_FAILURES_BEFORE_OFFLINE = 2;
|
||||||
const listeners = new Set<(status: NetworkStatus) => void>();
|
const listeners = new Set<(status: NetworkStatus) => void>();
|
||||||
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
||||||
let pendingProbe: Promise<boolean> | null = null;
|
let pendingProbe: Promise<boolean> | null = null;
|
||||||
let lastProbeAt = 0;
|
let lastProbeAt = 0;
|
||||||
let lastProbeResult = currentStatus === 'online';
|
let lastProbeResult = currentStatus === 'online';
|
||||||
|
let consecutiveProbeFailures = 0;
|
||||||
|
|
||||||
export function browserReportsOffline(): boolean {
|
export function browserReportsOffline(): boolean {
|
||||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||||
@@ -35,8 +37,23 @@ export function subscribeNetworkStatus(listener: (status: NetworkStatus) => void
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recordNodeWardenReachable(): void {
|
||||||
|
consecutiveProbeFailures = 0;
|
||||||
|
lastProbeResult = true;
|
||||||
|
setCurrentNetworkStatus('online');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordNodeWardenUnreachable(): void {
|
||||||
|
lastProbeResult = false;
|
||||||
|
consecutiveProbeFailures += 1;
|
||||||
|
if (browserReportsOffline() || consecutiveProbeFailures >= PROBE_FAILURES_BEFORE_OFFLINE) {
|
||||||
|
setCurrentNetworkStatus('offline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function probeNodeWardenService(): Promise<boolean> {
|
export async function probeNodeWardenService(): Promise<boolean> {
|
||||||
if (browserReportsOffline()) {
|
if (browserReportsOffline()) {
|
||||||
|
consecutiveProbeFailures = PROBE_FAILURES_BEFORE_OFFLINE;
|
||||||
setCurrentNetworkStatus('offline');
|
setCurrentNetworkStatus('offline');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -68,8 +85,11 @@ export async function probeNodeWardenService(): Promise<boolean> {
|
|||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
lastProbeAt = Date.now();
|
lastProbeAt = Date.now();
|
||||||
lastProbeResult = result;
|
if (result) {
|
||||||
setCurrentNetworkStatus(result ? 'online' : 'offline');
|
recordNodeWardenReachable();
|
||||||
|
} else {
|
||||||
|
recordNodeWardenUnreachable();
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Send } from './types';
|
import type { Send } from './types';
|
||||||
import { getCurrentNetworkStatus } from './network-status';
|
|
||||||
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
||||||
|
|
||||||
type WorkerSuccess<T> = { id: number; ok: true; result: T };
|
type WorkerSuccess<T> = { id: number; ok: true; result: T };
|
||||||
@@ -13,7 +12,6 @@ const pending = new Map<number, { resolve: (value: any) => void; reject: (error:
|
|||||||
function getWorker(): Worker | null {
|
function getWorker(): Worker | null {
|
||||||
if (typeof Worker === 'undefined') return null;
|
if (typeof Worker === 'undefined') return null;
|
||||||
if (worker) return worker;
|
if (worker) return worker;
|
||||||
if (getCurrentNetworkStatus() === 'offline') return null;
|
|
||||||
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
|
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
|
||||||
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
||||||
const message = event.data;
|
const message = event.data;
|
||||||
|
|||||||
@@ -810,6 +810,101 @@ h4 {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Typography refinement: stronger scan targets for dense vault/admin surfaces. */
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link,
|
||||||
|
.side-group-trigger,
|
||||||
|
.side-sub-link,
|
||||||
|
.tree-btn,
|
||||||
|
.mobile-settings-link,
|
||||||
|
.backup-destination-item,
|
||||||
|
.backup-browser-entry,
|
||||||
|
.sort-menu-item,
|
||||||
|
.create-menu-item,
|
||||||
|
.nav-layout-option {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link.active,
|
||||||
|
.side-group-trigger.active,
|
||||||
|
.side-sub-link.active,
|
||||||
|
.tree-btn.active,
|
||||||
|
.mobile-tab.active,
|
||||||
|
.mobile-settings-link.active,
|
||||||
|
.nav-layout-option.active {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title,
|
||||||
|
.list-count,
|
||||||
|
.field > span,
|
||||||
|
.table th,
|
||||||
|
.dialog-warning-kicker,
|
||||||
|
.backup-recommendation-group-title {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-sub,
|
||||||
|
.detail-sub,
|
||||||
|
.backup-destination-meta,
|
||||||
|
.totp-code-username,
|
||||||
|
.field-help,
|
||||||
|
.settings-field-note {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn,
|
||||||
|
.input,
|
||||||
|
.search-input,
|
||||||
|
.user-chip,
|
||||||
|
.network-status-badge {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-danger,
|
||||||
|
.btn.full,
|
||||||
|
.topbar-actions .btn,
|
||||||
|
.network-status-badge {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h4,
|
||||||
|
.settings-module h3,
|
||||||
|
.section-head h3,
|
||||||
|
.section-head h4,
|
||||||
|
.detail-title,
|
||||||
|
.totp-code-name,
|
||||||
|
.backup-destination-name,
|
||||||
|
.mobile-sidebar-title {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.toast-item,
|
.toast-item,
|
||||||
.dialog-card {
|
.dialog-card {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ body,
|
|||||||
@apply m-0 h-full w-full p-0;
|
@apply m-0 h-full w-full p-0;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-accent);
|
background: var(--bg-accent);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
|
font-family: var(--font-sans);
|
||||||
font-size: var(--font-base);
|
font-size: var(--font-base);
|
||||||
line-height: var(--leading-normal);
|
line-height: var(--leading-normal);
|
||||||
letter-spacing: var(--tracking-normal);
|
letter-spacing: var(--tracking-normal);
|
||||||
font-feature-settings: 'liga' 1, 'kern' 1;
|
font-feature-settings: 'liga' 1, 'kern' 1, 'calt' 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
@@ -33,8 +34,8 @@ body.dialog-open {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
letter-spacing: var(--tracking-tight);
|
letter-spacing: 0;
|
||||||
line-height: var(--leading-tight);
|
line-height: var(--leading-tight);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
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 {
|
:root[data-theme='dark'] .card-brand-icon {
|
||||||
color: #bfdbfe;
|
color: #bfdbfe;
|
||||||
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
||||||
|
|||||||
@@ -319,6 +319,10 @@
|
|||||||
@apply h-[42px] w-full min-w-0 rounded-[14px];
|
@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 {
|
.list-icon-btn {
|
||||||
@apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px];
|
@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);
|
gap: var(--actions-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar.actions.duplicates-toolbar {
|
||||||
|
@apply justify-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
gap: var(--actions-gap);
|
gap: var(--actions-gap);
|
||||||
}
|
}
|
||||||
@@ -883,6 +892,10 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.totp-code-main strong {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-module .field,
|
.settings-module .field,
|
||||||
.auth-card .field {
|
.auth-card .field {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
@@ -46,16 +46,22 @@
|
|||||||
--dur-slow: 350ms;
|
--dur-slow: 350ms;
|
||||||
--actions-gap: clamp(5px, calc((100vw - 520px) * 1), 10px);
|
--actions-gap: clamp(5px, calc((100vw - 520px) * 1), 10px);
|
||||||
|
|
||||||
|
/* Typography Families */
|
||||||
|
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||||
|
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei',
|
||||||
|
'Noto Sans CJK SC', 'Noto Sans SC', Arial, sans-serif;
|
||||||
|
--font-mono: 'SFMono-Regular', 'Cascadia Code', 'Cascadia Mono', Consolas, 'Liberation Mono', monospace;
|
||||||
|
|
||||||
/* Typography Scale */
|
/* Typography Scale */
|
||||||
--font-xs: 11px;
|
--font-xs: 11px;
|
||||||
--font-sm: 13px;
|
--font-sm: 14px;
|
||||||
--font-base: 14px;
|
--font-base: 15px;
|
||||||
--font-md: 15px;
|
--font-md: 16px;
|
||||||
--font-lg: 16px;
|
--font-lg: 17px;
|
||||||
--font-xl: 18px;
|
--font-xl: 19px;
|
||||||
--font-2xl: 20px;
|
--font-2xl: 21px;
|
||||||
--font-3xl: 24px;
|
--font-3xl: 25px;
|
||||||
--font-4xl: 28px;
|
--font-4xl: 29px;
|
||||||
|
|
||||||
/* Line Heights */
|
/* Line Heights */
|
||||||
--leading-tight: 1.25;
|
--leading-tight: 1.25;
|
||||||
@@ -65,11 +71,11 @@
|
|||||||
--leading-loose: 1.75;
|
--leading-loose: 1.75;
|
||||||
|
|
||||||
/* Letter Spacing */
|
/* Letter Spacing */
|
||||||
--tracking-tighter: -0.02em;
|
--tracking-tighter: 0;
|
||||||
--tracking-tight: -0.01em;
|
--tracking-tight: 0;
|
||||||
--tracking-normal: 0;
|
--tracking-normal: 0;
|
||||||
--tracking-wide: 0.01em;
|
--tracking-wide: 0;
|
||||||
--tracking-wider: 0.02em;
|
--tracking-wider: 0;
|
||||||
|
|
||||||
/* Spacing Scale */
|
/* Spacing Scale */
|
||||||
--space-1: 4px;
|
--space-1: 4px;
|
||||||
|
|||||||
@@ -184,8 +184,45 @@
|
|||||||
gap: var(--actions-gap);
|
gap: var(--actions-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar.actions.duplicates-toolbar {
|
||||||
|
@apply items-center;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar .btn.small {
|
.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 {
|
.list-head {
|
||||||
@@ -281,6 +318,11 @@
|
|||||||
overflow-anchor: none;
|
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 {
|
.list-item::before {
|
||||||
content: '';
|
content: '';
|
||||||
@apply absolute inset-0 opacity-0;
|
@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);
|
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 {
|
.list-item:hover::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
@@ -372,6 +419,11 @@
|
|||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42);
|
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 {
|
.list-item.active::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
@@ -900,7 +952,7 @@
|
|||||||
|
|
||||||
.totp-codes-list {
|
.totp-codes-list {
|
||||||
@apply grid w-full items-start gap-2.5;
|
@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 {
|
.totp-code-row {
|
||||||
@@ -925,7 +977,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.totp-code-main strong {
|
.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;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user