Compare commits
12 Commits
v1.6.1
...
8f2704fd41
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f2704fd41 | |||
| 7e0406f751 | |||
| 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) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 619 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Full-bleed background for any/maskable -->
|
||||||
|
<rect width="512" height="512" fill="#116FF9"/>
|
||||||
|
<!-- Logo scaled to ~50% centered in safe zone (inner 66% = Android adaptive icon guideline) -->
|
||||||
|
<g transform="translate(256,256) scale(0.5) translate(-380,-380)">
|
||||||
|
<path d="M386.5 183C497.785 183 588 271.2 588 380C588 419.877 575.879 456.986 555.046 488H17.6816C16.5766 481.834 16 475.484 16 469C16 413.617 58.0774 368.061 112.008 362.558C108.771 353.989 107 344.701 107 335C107 291.922 141.922 257 185 257C198.365 257 210.945 260.362 221.94 266.286C258.437 215.895 318.539 183 386.5 183Z" fill="#F6821F"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.6568 91.0069C88.7796 262.923 101.55 381.119 143.869 469.459C186.188 557.799 258.092 616.353 372.665 668.892C485.877 616.354 556.929 557.802 598.746 469.461C640.564 381.12 653.181 262.923 649.35 91.0069H92.6568ZM539.796 432.933C570.479 365.533 581.347 278.379 582.419 153.939L582.422 153.432H377.661V593.786L378.405 593.364C458.602 547.962 509.101 500.36 539.796 432.933Z" fill="white"/>
|
||||||
|
<path d="M604.465 305C680.976 305 743 367.233 743 444C743 459.378 740.509 474.172 735.913 488H379V423.553C391.721 397.751 418.287 380 449 380C459.483 380 469.482 382.068 478.613 385.818C500.559 338.11 548.658 305 604.465 305Z" fill="#FD9C33"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1535,7 +1535,7 @@ export default function App() {
|
|||||||
const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute;
|
const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute;
|
||||||
const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation));
|
const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation));
|
||||||
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
||||||
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
const showSidebarToggle = mobileLayout && location === '/sends';
|
||||||
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
||||||
const demoDomainRules = useMemo<DomainRules>(() => ({
|
const demoDomainRules = useMemo<DomainRules>(() => ({
|
||||||
equivalentDomains: [
|
equivalentDomains: [
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ export default function ToastHost({ toasts, onClose }: ToastHostProps) {
|
|||||||
{toasts.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<li key={toast.id} className={`toast-item ${toast.type}`}>
|
<li key={toast.id} className={`toast-item ${toast.type}`}>
|
||||||
<div className="toast-text">{toast.text}</div>
|
<div className="toast-text">{toast.text}</div>
|
||||||
<button type="button" className="toast-close" onClick={() => onClose(toast.id)}>
|
<button type="button" className="toast-close" onClick={() => onClose(toast.id)} aria-label="关闭通知">
|
||||||
x
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||||
|
<path d="M3 3l8 8M11 3l-8 8" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="toast-progress" />
|
<div className="toast-progress" />
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1079,13 +1115,16 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
loading={props.loading}
|
loading={props.loading}
|
||||||
error={props.error}
|
error={props.error}
|
||||||
|
folders={props.folders}
|
||||||
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 +1141,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
onSearchCompositionEnd={handleSearchCompositionEnd}
|
onSearchCompositionEnd={handleSearchCompositionEnd}
|
||||||
onToggleSortMenu={handleToggleSortMenu}
|
onToggleSortMenu={handleToggleSortMenu}
|
||||||
onSelectSortMode={handleSelectSortMode}
|
onSelectSortMode={handleSelectSortMode}
|
||||||
|
onDuplicateModeChange={setDuplicateMode}
|
||||||
|
onChangeFilter={setSidebarFilter}
|
||||||
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
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
import type { RefObject } from 'preact';
|
import type { ComponentChildren, RefObject } from 'preact';
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
import {
|
||||||
|
Archive,
|
||||||
|
ArrowUpDown,
|
||||||
|
Check,
|
||||||
|
CheckCheck,
|
||||||
|
ChevronDown,
|
||||||
|
Copy,
|
||||||
|
CreditCard,
|
||||||
|
Folder as FolderIcon,
|
||||||
|
FolderInput,
|
||||||
|
FolderX,
|
||||||
|
Globe,
|
||||||
|
KeyRound,
|
||||||
|
LayoutGrid,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
ShieldUser,
|
||||||
|
Star,
|
||||||
|
StickyNote,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-preact';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher, Folder } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
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';
|
||||||
@@ -25,13 +50,16 @@ interface VaultListPanelProps {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
folders: Folder[];
|
||||||
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 +76,8 @@ 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;
|
||||||
|
onChangeFilter: (filter: SidebarFilter) => void;
|
||||||
onSyncVault: () => void;
|
onSyncVault: () => void;
|
||||||
onOpenBulkDelete: () => void;
|
onOpenBulkDelete: () => void;
|
||||||
onSelectDuplicates: () => void;
|
onSelectDuplicates: () => void;
|
||||||
@@ -69,15 +99,28 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MobileFilterMenuKey = 'duplicate' | 'menu' | 'type' | 'folder';
|
||||||
|
|
||||||
|
interface MobileFilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
icon: ComponentChildren;
|
||||||
|
active: boolean;
|
||||||
|
onSelect: () => 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;
|
||||||
@@ -107,13 +150,116 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||||
|
const [mobileFilterOpen, setMobileFilterOpen] = useState<MobileFilterMenuKey | null>(null);
|
||||||
|
const mobileFilterRef = useRef<HTMLDivElement | null>(null);
|
||||||
const createTypeOptions = getCreateTypeOptions();
|
const createTypeOptions = getCreateTypeOptions();
|
||||||
|
const duplicateDetectionOptions = getDuplicateDetectionOptions();
|
||||||
const vaultSortOptions = getVaultSortOptions();
|
const vaultSortOptions = getVaultSortOptions();
|
||||||
const createMenu = (
|
const duplicateModeOptions: MobileFilterOption[] = duplicateDetectionOptions.map((option) => ({
|
||||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
icon: option.value === 'login-site' ? <Globe size={14} /> : option.value === 'exact' ? <Copy size={14} /> : <KeyRound size={14} />,
|
||||||
|
active: props.duplicateMode === option.value,
|
||||||
|
onSelect: () => props.onDuplicateModeChange(option.value),
|
||||||
|
}));
|
||||||
|
const menuFilterOptions: MobileFilterOption[] = [
|
||||||
|
{ value: 'all', label: t('txt_all_items'), icon: <LayoutGrid size={14} />, active: props.sidebarFilter.kind === 'all', onSelect: () => props.onChangeFilter({ kind: 'all' }) },
|
||||||
|
{ value: 'favorite', label: t('txt_favorites'), icon: <Star size={14} />, active: props.sidebarFilter.kind === 'favorite', onSelect: () => props.onChangeFilter({ kind: 'favorite' }) },
|
||||||
|
{ value: 'archive', label: t('txt_archive'), icon: <Archive size={14} />, active: props.sidebarFilter.kind === 'archive', onSelect: () => props.onChangeFilter({ kind: 'archive' }) },
|
||||||
|
{ value: 'trash', label: t('txt_trash'), icon: <Trash2 size={14} />, active: props.sidebarFilter.kind === 'trash', onSelect: () => props.onChangeFilter({ kind: 'trash' }) },
|
||||||
|
{ value: 'duplicates', label: t('txt_duplicates'), icon: <Copy size={14} />, active: props.sidebarFilter.kind === 'duplicates', onSelect: () => props.onChangeFilter({ kind: 'duplicates' }) },
|
||||||
|
];
|
||||||
|
const typeMobileFilterOptions: MobileFilterOption[] = [
|
||||||
|
{ value: 'login', label: t('txt_login'), icon: <Globe size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'login', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'login' }) },
|
||||||
|
{ value: 'card', label: t('txt_card'), icon: <CreditCard size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'card' }) },
|
||||||
|
{ value: 'identity', label: t('txt_identity'), icon: <ShieldUser size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'identity' }) },
|
||||||
|
{ value: 'note', label: t('txt_note'), icon: <StickyNote size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'note' }) },
|
||||||
|
{ value: 'ssh', label: t('txt_ssh_key'), icon: <KeyRound size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'ssh', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'ssh' }) },
|
||||||
|
];
|
||||||
|
const folderMobileFilterOptions: MobileFilterOption[] = [
|
||||||
|
{ value: '__none__', label: t('txt_no_folder'), icon: <FolderX size={14} />, active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null, onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: null }) },
|
||||||
|
...props.folders.map((folder) => ({
|
||||||
|
value: folder.id,
|
||||||
|
label: folder.decName || folder.name || folder.id,
|
||||||
|
icon: <FolderIcon size={14} />,
|
||||||
|
active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === folder.id,
|
||||||
|
onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: folder.id }),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const menuFilterSelected = menuFilterOptions.find((option) => option.active);
|
||||||
|
const typeFilterSelected = typeMobileFilterOptions.find((option) => option.active);
|
||||||
|
const folderFilterSelected = folderMobileFilterOptions.find((option) => option.active);
|
||||||
|
const duplicateModeSelected = duplicateModeOptions.find((option) => option.active);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointerDown = (event: Event) => {
|
||||||
|
if (!mobileFilterOpen) return;
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (mobileFilterRef.current && target && !mobileFilterRef.current.contains(target)) {
|
||||||
|
setMobileFilterOpen(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setMobileFilterOpen(null);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [mobileFilterOpen]);
|
||||||
|
|
||||||
|
const renderMobileFilterMenu = (
|
||||||
|
key: MobileFilterMenuKey,
|
||||||
|
label: string,
|
||||||
|
selected: MobileFilterOption | undefined,
|
||||||
|
fallbackIcon: ComponentChildren,
|
||||||
|
options: MobileFilterOption[]
|
||||||
|
) => (
|
||||||
|
<div className="mobile-vault-filter-control">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary small mobile-fab-trigger"
|
className={`mobile-vault-filter-trigger ${mobileFilterOpen === key ? 'active' : ''}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={mobileFilterOpen === key}
|
||||||
|
onClick={() => setMobileFilterOpen((open) => open === key ? null : key)}
|
||||||
|
>
|
||||||
|
<span className="mobile-vault-filter-trigger-icon">{selected?.icon || fallbackIcon}</span>
|
||||||
|
<span className="mobile-vault-filter-trigger-label">{selected?.label || label}</span>
|
||||||
|
<ChevronDown size={13} className="mobile-vault-filter-chevron" />
|
||||||
|
</button>
|
||||||
|
{mobileFilterOpen === key && (
|
||||||
|
<div className="sort-menu mobile-vault-filter-menu" role="menu">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`sort-menu-item mobile-vault-filter-item ${option.active ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
option.onSelect();
|
||||||
|
setMobileFilterOpen(null);
|
||||||
|
}}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={option.active}
|
||||||
|
>
|
||||||
|
<span className="mobile-vault-filter-item-main">
|
||||||
|
<span className="mobile-vault-filter-item-icon">{option.icon}</span>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</span>
|
||||||
|
{option.active ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMenu = (
|
||||||
|
<div className={`create-menu-wrap ${props.isMobileLayout ? 'mobile-fab-wrap' : 'desktop-create-menu-wrap'}`} ref={props.createMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-primary small ${props.isMobileLayout ? 'mobile-fab-trigger' : 'desktop-create-trigger'}`}
|
||||||
aria-label={t('txt_add')}
|
aria-label={t('txt_add')}
|
||||||
title={t('txt_add')}
|
title={t('txt_add')}
|
||||||
onClick={props.onToggleCreateMenu}
|
onClick={props.onToggleCreateMenu}
|
||||||
@@ -135,108 +281,127 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="list-col">
|
<section className="list-col">
|
||||||
<div className="list-head">
|
<div className="list-toolbar-stack" ref={mobileFilterRef}>
|
||||||
<div className="search-input-wrap">
|
<div className={`list-head ${props.selectedCount > 0 ? 'selection-mode' : ''}`}>
|
||||||
<input
|
{props.selectedCount > 0 ? (
|
||||||
className="search-input"
|
<>
|
||||||
placeholder={t('txt_search_your_secure_vault')}
|
{props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
value={props.searchInput}
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||||
onCompositionStart={props.onSearchCompositionStart}
|
|
||||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== 'Escape' || !props.searchInput) return;
|
|
||||||
e.preventDefault();
|
|
||||||
props.onClearSearch();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!!props.searchInput && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="search-clear-btn"
|
|
||||||
aria-label={t('txt_clear_search')}
|
|
||||||
title={t('txt_clear_search_esc')}
|
|
||||||
onClick={props.onClearSearch}
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn btn-secondary small sort-trigger ${props.sortMenuOpen ? 'active' : ''}`}
|
|
||||||
aria-label={t('txt_sort')}
|
|
||||||
title={t('txt_sort')}
|
|
||||||
onClick={props.onToggleSortMenu}
|
|
||||||
>
|
|
||||||
<ArrowUpDown size={14} className="btn-icon" />
|
|
||||||
</button>
|
|
||||||
{props.sortMenuOpen && (
|
|
||||||
<div className="sort-menu">
|
|
||||||
{vaultSortOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
|
|
||||||
onClick={() => props.onSelectSortMode(option.value)}
|
|
||||||
>
|
|
||||||
<span>{option.label}</span>
|
|
||||||
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
)}
|
||||||
</div>
|
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
||||||
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||||
|
</button>
|
||||||
|
{props.sidebarFilter.kind === 'trash' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.sidebarFilter.kind === 'archive' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
||||||
|
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
||||||
|
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
||||||
|
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-danger small" disabled={props.busy} onClick={props.onOpenBulkDelete}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="search-input-wrap">
|
||||||
|
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||||
|
<div className="duplicate-mode-head-menu">
|
||||||
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
||||||
|
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>
|
||||||
|
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
||||||
|
<div className="duplicate-mode-head-menu">
|
||||||
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-secondary small sort-trigger sort-trigger-labeled ${props.sortMenuOpen ? 'active' : ''}`}
|
||||||
|
aria-label={t('txt_sort')}
|
||||||
|
title={t('txt_sort')}
|
||||||
|
onClick={props.onToggleSortMenu}
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={14} className="btn-icon" /> <span>{t('txt_sort')}</span>
|
||||||
|
</button>
|
||||||
|
{props.sortMenuOpen && (
|
||||||
|
<div className="sort-menu">
|
||||||
|
{vaultSortOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => props.onSelectSortMode(option.value)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||||
|
</button>
|
||||||
|
{!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-count" title={t('txt_total_items_count', { count: props.totalCipherCount })}>
|
{props.isMobileLayout && (
|
||||||
{t('txt_total_items_count', { count: props.totalCipherCount })}
|
<div className="mobile-vault-filter-row" aria-label={t('txt_filter')}>
|
||||||
</div>
|
{renderMobileFilterMenu('menu', t('txt_menu'), menuFilterSelected, <LayoutGrid size={14} />, menuFilterOptions)}
|
||||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
{renderMobileFilterMenu('type', t('txt_type'), typeFilterSelected, <Globe size={14} />, typeMobileFilterOptions)}
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
{renderMobileFilterMenu('folder', t('txt_folder'), folderFilterSelected, <FolderIcon size={14} />, folderMobileFilterOptions)}
|
||||||
</button>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar actions">
|
{!props.selectedCount && props.isMobileLayout && props.sidebarFilter.kind !== 'duplicates' && typeof document !== 'undefined' && props.mobileFabVisible
|
||||||
{props.sidebarFilter.kind === 'duplicates' && (
|
? createPortal(createMenu, document.body)
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
|
: null}
|
||||||
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
|
||||||
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
|
||||||
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
|
||||||
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && (
|
|
||||||
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
|
||||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
|
||||||
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
|
||||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
|
||||||
</button>
|
|
||||||
{props.isMobileLayout && typeof document !== 'undefined'
|
|
||||||
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
|
|
||||||
: createMenu}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||||
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
|
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
|
||||||
{!props.loading && !!props.error && !props.filteredCiphers.length && (
|
{!props.loading && !!props.error && !props.filteredCiphers.length && (
|
||||||
@@ -255,6 +420,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.",
|
||||||
@@ -750,6 +758,7 @@ const en: Record<string, string> = {
|
|||||||
"txt_search_sends": "Search sends...",
|
"txt_search_sends": "Search sends...",
|
||||||
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
|
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
|
||||||
"txt_search_your_secure_vault": "Search your secure vault...",
|
"txt_search_your_secure_vault": "Search your secure vault...",
|
||||||
|
"txt_search_items_count": "Search within {count} items...",
|
||||||
"txt_clear_search": "Clear search",
|
"txt_clear_search": "Clear search",
|
||||||
"txt_clear_search_esc": "Clear search (Esc)",
|
"txt_clear_search_esc": "Clear search (Esc)",
|
||||||
"txt_sort": "Sort",
|
"txt_sort": "Sort",
|
||||||
|
|||||||
@@ -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.",
|
||||||
@@ -750,6 +758,7 @@ const es: Record<string, string> = {
|
|||||||
"txt_search_sends": "Buscar envíos...",
|
"txt_search_sends": "Buscar envíos...",
|
||||||
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
|
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
|
||||||
"txt_search_your_secure_vault": "Buscar en su bóveda segura...",
|
"txt_search_your_secure_vault": "Buscar en su bóveda segura...",
|
||||||
|
"txt_search_items_count": "Buscar entre {count} elementos...",
|
||||||
"txt_clear_search": "Limpiar búsqueda",
|
"txt_clear_search": "Limpiar búsqueda",
|
||||||
"txt_clear_search_esc": "Limpiar búsqueda (Esc)",
|
"txt_clear_search_esc": "Limpiar búsqueda (Esc)",
|
||||||
"txt_sort": "Ordenar",
|
"txt_sort": "Ordenar",
|
||||||
|
|||||||
@@ -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": "Метка поля обязательна.",
|
||||||
@@ -750,6 +758,7 @@ const ru: Record<string, string> = {
|
|||||||
"txt_search_sends": "Поиск отправляет...",
|
"txt_search_sends": "Поиск отправляет...",
|
||||||
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
|
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
|
||||||
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
|
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
|
||||||
|
"txt_search_items_count": "Поиск по {count} элементам...",
|
||||||
"txt_clear_search": "Очистить поиск",
|
"txt_clear_search": "Очистить поиск",
|
||||||
"txt_clear_search_esc": "Очистить поиск (Esc)",
|
"txt_clear_search_esc": "Очистить поиск (Esc)",
|
||||||
"txt_sort": "Сортировать",
|
"txt_sort": "Сортировать",
|
||||||
|
|||||||
@@ -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": "字段标签不能为空",
|
||||||
@@ -750,6 +758,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_search_sends": "搜索 Send...",
|
"txt_search_sends": "搜索 Send...",
|
||||||
"txt_session_refresh_failed": "会话刷新失败,请重新登录",
|
"txt_session_refresh_failed": "会话刷新失败,请重新登录",
|
||||||
"txt_search_your_secure_vault": "搜索你的密码库...",
|
"txt_search_your_secure_vault": "搜索你的密码库...",
|
||||||
|
"txt_search_items_count": "共 {count} 项中搜索...",
|
||||||
"txt_clear_search": "清空搜索",
|
"txt_clear_search": "清空搜索",
|
||||||
"txt_clear_search_esc": "清空搜索(Esc)",
|
"txt_clear_search_esc": "清空搜索(Esc)",
|
||||||
"txt_sort": "排序",
|
"txt_sort": "排序",
|
||||||
|
|||||||
@@ -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": "字段標籤不能為空",
|
||||||
@@ -750,6 +758,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_search_sends": "搜索 Send...",
|
"txt_search_sends": "搜索 Send...",
|
||||||
"txt_session_refresh_failed": "會話刷新失敗,請重新登入",
|
"txt_session_refresh_failed": "會話刷新失敗,請重新登入",
|
||||||
"txt_search_your_secure_vault": "搜索你的密碼庫...",
|
"txt_search_your_secure_vault": "搜索你的密碼庫...",
|
||||||
|
"txt_search_items_count": "在共 {count} 項中搜索...",
|
||||||
"txt_clear_search": "清空搜索",
|
"txt_clear_search": "清空搜索",
|
||||||
"txt_clear_search_esc": "清空搜索(Esc)",
|
"txt_clear_search_esc": "清空搜索(Esc)",
|
||||||
"txt_sort": "排序",
|
"txt_sort": "排序",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -596,7 +596,7 @@ h4 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-head {
|
.list-head {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-count {
|
.list-count {
|
||||||
@@ -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);
|
||||||
@@ -974,6 +1069,7 @@ h4 {
|
|||||||
.list-panel {
|
.list-panel {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item {
|
.list-item {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
:root[data-theme='dark'] .textarea,
|
:root[data-theme='dark'] .textarea,
|
||||||
:root[data-theme='dark'] select.input,
|
:root[data-theme='dark'] select.input,
|
||||||
:root[data-theme='dark'] .search-input,
|
:root[data-theme='dark'] .search-input,
|
||||||
|
:root[data-theme='dark'] .mobile-vault-filter-trigger,
|
||||||
:root[data-theme='dark'] .dialog input,
|
:root[data-theme='dark'] .dialog input,
|
||||||
:root[data-theme='dark'] .dialog textarea,
|
:root[data-theme='dark'] .dialog textarea,
|
||||||
:root[data-theme='dark'] .dialog select {
|
:root[data-theme='dark'] .dialog select {
|
||||||
@@ -79,6 +80,13 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .mobile-vault-filter-trigger:hover,
|
||||||
|
:root[data-theme='dark'] .mobile-vault-filter-trigger.active {
|
||||||
|
background: var(--panel-muted);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 42%, var(--line));
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .input::placeholder,
|
:root[data-theme='dark'] .input::placeholder,
|
||||||
:root[data-theme='dark'] .textarea::placeholder,
|
:root[data-theme='dark'] .textarea::placeholder,
|
||||||
:root[data-theme='dark'] input::placeholder,
|
:root[data-theme='dark'] input::placeholder,
|
||||||
@@ -200,6 +208,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%);
|
||||||
|
|||||||
@@ -31,19 +31,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
select.input {
|
select.input {
|
||||||
@apply py-0 pr-[42px];
|
@apply py-0 pr-3.5;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
appearance: none;
|
appearance: auto;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: auto;
|
||||||
-moz-appearance: none;
|
-moz-appearance: auto;
|
||||||
background-image:
|
background-image: none;
|
||||||
linear-gradient(45deg, transparent 50%, #365fa8 50%),
|
|
||||||
linear-gradient(135deg, #365fa8 50%, transparent 50%);
|
|
||||||
background-position:
|
|
||||||
calc(100% - 18px) calc(50% - 3px),
|
|
||||||
calc(100% - 12px) calc(50% - 3px);
|
|
||||||
background-size: 6px 6px, 6px 6px;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='file'].input {
|
input[type='file'].input {
|
||||||
|
|||||||
@@ -209,14 +209,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast-close {
|
.toast-close {
|
||||||
@apply cursor-pointer border-0 bg-transparent text-xl;
|
@apply flex cursor-pointer items-center justify-center border-0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 120ms ease, opacity 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-close:hover {
|
.toast-close:hover {
|
||||||
transform: scale(1.08);
|
background: rgba(0, 0, 0, 0.08);
|
||||||
opacity: 0.84;
|
}
|
||||||
|
|
||||||
|
.toast-close:active {
|
||||||
|
background: rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close:focus-visible {
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-progress {
|
.toast-progress {
|
||||||
|
|||||||
@@ -302,10 +302,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-head {
|
.list-head {
|
||||||
@apply grid items-center gap-2;
|
@apply grid items-center gap-1.5;
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-head.selection-mode {
|
||||||
|
@apply justify-stretch;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.list-count {
|
.list-count {
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
@apply w-auto whitespace-nowrap text-xs;
|
@apply w-auto whitespace-nowrap text-xs;
|
||||||
@@ -316,11 +321,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-head .search-input {
|
.list-head .search-input {
|
||||||
@apply h-[42px] w-full min-w-0 rounded-[14px];
|
@apply h-[34px] w-full min-w-0 rounded-[10px] px-3 py-0 text-[13px] font-semibold;
|
||||||
|
line-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-row {
|
||||||
|
@apply grid min-w-0 gap-1.5;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .duplicate-mode-head-select {
|
||||||
|
@apply h-[34px] min-w-0 w-auto max-w-full rounded-[10px];
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-icon-btn {
|
.list-icon-btn {
|
||||||
@apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px];
|
@apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-[10px] px-3 py-0 text-[13px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head.selection-mode > .btn.small {
|
||||||
|
@apply h-[34px] min-w-0 w-full justify-center gap-1 px-2 text-[12px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head.selection-mode > .btn.small .btn-icon {
|
||||||
|
@apply m-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger {
|
||||||
|
@apply h-[34px] w-[34px] min-w-[34px] rounded-[10px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger.sort-trigger-labeled {
|
||||||
|
@apply h-[34px] w-[34px] min-w-[34px] gap-0 px-0 text-[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-trigger.sort-trigger-labeled .btn-icon {
|
||||||
|
@apply m-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-create-menu-wrap {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-mode-head-menu .mobile-vault-filter-menu {
|
||||||
|
min-width: max(100%, 190px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar.actions {
|
.toolbar.actions {
|
||||||
@@ -329,6 +373,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);
|
||||||
}
|
}
|
||||||
@@ -337,6 +386,10 @@
|
|||||||
@apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-full px-3 py-0 text-[13px];
|
@apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-full px-3 py-0 text-[13px];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-count-status {
|
||||||
|
@apply mb-1 px-1;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-fab-wrap {
|
.mobile-fab-wrap {
|
||||||
@apply fixed right-3.5 z-[45];
|
@apply fixed right-3.5 z-[45];
|
||||||
bottom: calc(14px + var(--mobile-tabbar-height, 70px) + env(safe-area-inset-bottom));
|
bottom: calc(14px + var(--mobile-tabbar-height, 70px) + env(safe-area-inset-bottom));
|
||||||
@@ -883,6 +936,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,24 +184,147 @@
|
|||||||
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-head-menu {
|
||||||
|
@apply min-w-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-mode-toolbar-select {
|
||||||
|
@apply w-auto max-w-[170px] shrink-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head {
|
.list-head {
|
||||||
@apply mb-2 flex items-center gap-2.5;
|
@apply mb-1.5 flex items-center gap-2;
|
||||||
|
min-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head .search-input-wrap {
|
.list-head.selection-mode {
|
||||||
|
@apply gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head.selection-mode > .btn.small {
|
||||||
|
@apply min-w-0 flex-1 justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .search-input-wrap,
|
||||||
|
.duplicate-mode-head-menu {
|
||||||
@apply min-w-0 flex-auto;
|
@apply min-w-0 flex-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head .search-input {
|
.list-head .search-input {
|
||||||
@apply h-[42px];
|
@apply h-[34px] rounded-[10px] px-3 py-0 text-[13px] font-semibold;
|
||||||
|
line-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head .btn {
|
.list-head .btn {
|
||||||
@apply whitespace-nowrap;
|
@apply h-[34px] whitespace-nowrap rounded-[10px] px-3 py-0 text-[13px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-row {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-control {
|
||||||
|
@apply relative min-w-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-trigger {
|
||||||
|
@apply flex h-[34px] w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-[10px] border px-2.5 py-0 text-left text-[13px] font-semibold;
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: rgba(74, 103, 150, 0.28);
|
||||||
|
color: var(--muted-strong);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.74);
|
||||||
|
transition:
|
||||||
|
border-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
background-color var(--dur-fast) var(--ease-smooth),
|
||||||
|
color var(--dur-fast) var(--ease-smooth),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-trigger:hover,
|
||||||
|
.mobile-vault-filter-trigger.active {
|
||||||
|
border-color: rgba(43, 102, 217, 0.46);
|
||||||
|
background: #f8fbff;
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-trigger:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-trigger-icon,
|
||||||
|
.mobile-vault-filter-item-icon {
|
||||||
|
@apply inline-flex shrink-0 items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-trigger-label {
|
||||||
|
@apply min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-chevron {
|
||||||
|
@apply shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-trigger.active .mobile-vault-filter-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-menu {
|
||||||
|
@apply left-0 right-auto max-h-[280px] min-w-full overflow-auto;
|
||||||
|
min-width: max(100%, 168px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-control:last-child .mobile-vault-filter-menu {
|
||||||
|
@apply left-auto right-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-item {
|
||||||
|
@apply gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-item-main {
|
||||||
|
@apply flex min-w-0 items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-vault-filter-item-main span:last-child {
|
||||||
|
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-count {
|
.list-count {
|
||||||
@@ -225,6 +348,10 @@
|
|||||||
@apply w-9 min-w-9 justify-center gap-0 p-0;
|
@apply w-9 min-w-9 justify-center gap-0 p-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sort-trigger.sort-trigger-labeled {
|
||||||
|
@apply h-[34px] w-auto min-w-0 gap-1.5 rounded-[10px] px-3;
|
||||||
|
}
|
||||||
|
|
||||||
.sort-trigger.active {
|
.sort-trigger.active {
|
||||||
background: #e9f1ff;
|
background: #e9f1ff;
|
||||||
border-color: #a9c2ee;
|
border-color: #a9c2ee;
|
||||||
@@ -266,6 +393,30 @@
|
|||||||
@apply h-3.5 w-3.5 shrink-0;
|
@apply h-3.5 w-3.5 shrink-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-create-menu-wrap {
|
||||||
|
@apply shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-create-trigger {
|
||||||
|
@apply h-[34px] w-[34px] min-w-[34px] gap-0 rounded-[10px] p-0 text-[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-create-trigger .btn-icon {
|
||||||
|
@apply m-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicates-helper-toolbar {
|
||||||
|
@apply justify-start pb-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicates-helper-toolbar .btn.small {
|
||||||
|
@apply h-[34px] rounded-[10px] px-3 py-0 text-[13px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-count-status {
|
||||||
|
@apply mb-1 pl-1;
|
||||||
|
}
|
||||||
|
|
||||||
.list-panel {
|
.list-panel {
|
||||||
@apply min-h-0 overflow-auto p-2;
|
@apply min-h-0 overflow-auto p-2;
|
||||||
/* Virtualized rows update top/bottom spacers while scrolling. Chrome's scroll anchoring
|
/* Virtualized rows update top/bottom spacers while scrolling. Chrome's scroll anchoring
|
||||||
@@ -281,6 +432,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 +517,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 +533,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 +1066,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 +1091,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||