10 Commits

Author SHA1 Message Date
shuaiplus d5c2ab2b0f refactor: remove unused TOTP styling for cleaner code 2026-06-16 19:26:21 +08:00
shuaiplus 9e0908f43c feat: enhance TOTP formatting and improve responsive styles for TOTP codes display 2026-06-16 19:17:05 +08:00
shuaiplus 7b3be2c819 feat: add duplicate detection modes and UI enhancements for managing duplicates 2026-06-15 20:48:57 +08:00
shuaiplus a8183166ac fix: add S3 addressing style option
Add a configurable S3 addressing style for remote backups while keeping path-style as the default for existing configurations. Use virtual-hosted-style to support providers such as Tencent COS buckets that reject path-style requests.
2026-06-15 16:53:28 +08:00
shuaiplus f6169b7610 fix: add support for trusted two-factor device tokens in backup import and export 2026-06-13 17:45:01 +08:00
shuaiplus 493f901ec1 fix: refine typography styles for improved readability and consistency 2026-06-13 17:20:25 +08:00
shuaiplus b4dfb0409b fix: improve network status handling and probe logic 2026-06-13 17:05:30 +08:00
shuaiplus a06cb0ed71 fix: serialize Bitwarden CSV login URIs 2026-06-13 16:38:25 +08:00
DiaMeoww b0242265f4 fix(webapp): add CSV export and stabilize dialog dismissal
fix(webapp): 添加 CSV 导出并稳定弹窗关闭行为
2026-06-13 16:38:25 +08:00
Wankko Ree b444c0f4b8 fix: use decrypted password histories when duplicate handling 2026-06-12 17:21:23 +08:00
31 changed files with 727 additions and 112 deletions
+3
View File
@@ -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: '',
+21 -1
View File
@@ -68,6 +68,7 @@ export interface BackupPayload {
ciphers: SqlRow[]; ciphers: SqlRow[];
attachments: SqlRow[]; attachments: SqlRow[];
webauthn_credentials?: SqlRow[]; webauthn_credentials?: SqlRow[];
trusted_two_factor_device_tokens?: SqlRow[];
}; };
} }
@@ -302,6 +303,7 @@ export function validateBackupPayloadContents(
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers'); const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments'); const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials'); const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens');
const externalAttachmentKeys = new Set<string>( const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs options.allowExternalAttachmentBlobs
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`) ? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
@@ -390,6 +392,21 @@ export function validateBackupPayloadContents(
accountPasskeyIds.add(id); accountPasskeyIds.add(id);
accountPasskeyCredentialIds.add(credentialId); accountPasskeyCredentialIds.add(credentialId);
} }
const trustedTwoFactorTokens = new Set<string>();
for (const row of trustedTwoFactorTokenRows) {
const token = String(row.token || '').trim();
const userId = String(row.user_id || '').trim();
const deviceIdentifier = String(row.device_identifier || '').trim();
const expiresAt = Number(row.expires_at || 0);
if (!token || !userIds.has(userId) || !deviceIdentifier || !Number.isFinite(expiresAt) || expiresAt <= 0) {
throw new Error('Backup archive contains an invalid trusted two-factor device token row');
}
if (trustedTwoFactorTokens.has(token)) {
throw new Error(`Backup archive contains duplicate trusted two-factor device token: ${token}`);
}
trustedTwoFactorTokens.add(token);
}
} }
export async function buildBackupArchive( export async function buildBackupArchive(
@@ -408,7 +425,7 @@ export async function buildBackupArchive(
includeAttachments, includeAttachments,
}); });
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([ const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
@@ -417,6 +434,7 @@ export async function buildBackupArchive(
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'), queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
]); ]);
const exportedConfigRows = sanitizeConfigRowsForExport(configRows); const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
const exportedAttachmentRows = includeAttachments ? attachmentRows : []; const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
@@ -445,6 +463,7 @@ export async function buildBackupArchive(
ciphers: cipherRows.length, ciphers: cipherRows.length,
attachments: exportedAttachmentRows.length, attachments: exportedAttachmentRows.length,
webauthn_credentials: accountPasskeyRows.length, webauthn_credentials: accountPasskeyRows.length,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
}, },
includes: { includes: {
attachments: includeAttachments, attachments: includeAttachments,
@@ -468,6 +487,7 @@ export async function buildBackupArchive(
ciphers: cipherRows, ciphers: cipherRows,
attachments: exportedAttachmentRows, attachments: exportedAttachmentRows,
webauthn_credentials: accountPasskeyRows, webauthn_credentials: accountPasskeyRows,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
}, null, BACKUP_JSON_INDENT)), }, null, BACKUP_JSON_INDENT)),
}; };
+6
View File
@@ -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,
+21
View File
@@ -24,6 +24,7 @@ type BackupTableName =
| 'users' | 'users'
| 'domain_settings' | 'domain_settings'
| 'user_revisions' | 'user_revisions'
| 'trusted_two_factor_device_tokens'
| 'webauthn_credentials' | 'webauthn_credentials'
| 'folders' | 'folders'
| 'ciphers' | 'ciphers'
@@ -34,6 +35,7 @@ const BACKUP_TABLES: BackupTableName[] = [
'users', 'users',
'domain_settings', 'domain_settings',
'user_revisions', 'user_revisions',
'trusted_two_factor_device_tokens',
'webauthn_credentials', 'webauthn_credentials',
'folders', 'folders',
'ciphers', 'ciphers',
@@ -51,6 +53,7 @@ export interface BackupImportResultBody {
users: number; users: number;
domainSettings: number; domainSettings: number;
userRevisions: number; userRevisions: number;
trustedTwoFactorDeviceTokens: number;
webauthnCredentials: number; webauthnCredentials: number;
folders: number; folders: number;
ciphers: number; ciphers: number;
@@ -172,6 +175,7 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
'DELETE FROM ciphers', 'DELETE FROM ciphers',
'DELETE FROM folders', 'DELETE FROM folders',
'DELETE FROM webauthn_credentials', 'DELETE FROM webauthn_credentials',
'DELETE FROM trusted_two_factor_device_tokens',
'DELETE FROM domain_settings', 'DELETE FROM domain_settings',
'DELETE FROM user_revisions', 'DELETE FROM user_revisions',
'DELETE FROM users', 'DELETE FROM users',
@@ -296,6 +300,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []), user_revisions: cloneRows(payload.user_revisions || []),
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []), webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
folders: cloneRows(payload.folders || []), folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({ ciphers: cloneRows(payload.ciphers || []).map((row) => ({
@@ -634,6 +639,16 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
true true
) )
); );
await runInsertBatch(
db,
tableName('trusted_two_factor_device_tokens'),
buildInsertStatements(
db,
tableName('trusted_two_factor_device_tokens'),
['token', 'user_id', 'device_identifier', 'expires_at'],
payload.trusted_two_factor_device_tokens || []
)
);
await runInsertBatch( await runInsertBatch(
db, db,
tableName('webauthn_credentials'), tableName('webauthn_credentials'),
@@ -712,6 +727,7 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -735,6 +751,7 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -776,6 +793,7 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length, domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length, userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length, webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -853,6 +871,7 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -876,6 +895,7 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -923,6 +943,7 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length, domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length, userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length, webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
+24 -5
View File
@@ -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) {
+5 -1
View File
@@ -83,6 +83,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
const [present, setPresent] = useState(props.open); const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const cardRef = useRef<HTMLFormElement | null>(null); const cardRef = useRef<HTMLFormElement | null>(null);
const maskPointerStartedRef = useRef(false);
const restoreFocusRef = useRef<HTMLElement | null>(null); const restoreFocusRef = useRef<HTMLElement | null>(null);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []); const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`; const titleId = `${dialogId}-title`;
@@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
return createPortal(( return createPortal((
<div <div
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`} className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
onPointerDown={(event) => {
maskPointerStartedRef.current = event.target === event.currentTarget;
}}
onClick={(event) => { onClick={(event) => {
if (event.target !== event.currentTarget || !canDismiss) return; if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
props.onCancel(); props.onCancel();
}} }}
> >
+1 -6
View File
@@ -23,7 +23,6 @@ export default function NetworkStatusBadge() {
const Icon = status === 'online' ? Wifi : WifiOff; const Icon = status === 'online' ? Wifi : WifiOff;
useEffect(() => { useEffect(() => {
let cancelled = false;
let timer = 0; let timer = 0;
const checkService = async () => { const checkService = async () => {
@@ -31,10 +30,7 @@ export default function NetworkStatusBadge() {
setCurrentNetworkStatus('offline'); setCurrentNetworkStatus('offline');
return; return;
} }
const reachable = await probeNodeWardenService(); await probeNodeWardenService();
if (!cancelled) {
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
}
}; };
const scheduleNextCheck = () => { const scheduleNextCheck = () => {
@@ -62,7 +58,6 @@ export default function NetworkStatusBadge() {
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
return () => { return () => {
cancelled = true;
unsubscribe(); unsubscribe();
window.clearTimeout(timer); window.clearTimeout(timer);
window.removeEventListener('online', handleOnline); window.removeEventListener('online', handleOnline);
+1 -8
View File
@@ -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} />} />;
} }
+50 -11
View File
@@ -17,13 +17,14 @@ import {
createEmptyDraft, createEmptyDraft,
creationTimeValue, creationTimeValue,
draftFromCipher, draftFromCipher,
buildCipherDuplicateSignature, buildCipherDuplicateSignatures,
firstCipherUri, firstCipherUri,
firstPasskeyCreationTime, firstPasskeyCreationTime,
isCipherVisibleInArchive, isCipherVisibleInArchive,
isCipherVisibleInNormalVault, isCipherVisibleInNormalVault,
isCipherVisibleInTrash, isCipherVisibleInTrash,
sortTimeValue, sortTimeValue,
type DuplicateDetectionMode,
type SidebarFilter, type SidebarFilter,
type VaultSortMode, type VaultSortMode,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
@@ -79,6 +80,7 @@ export default function VaultPage(props: VaultPageProps) {
const [sortMenuOpen, setSortMenuOpen] = useState(false); const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name'); const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false); const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
const [duplicateMode, setDuplicateMode] = useState<DuplicateDetectionMode>('exact');
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' }); const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({}); const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
@@ -338,16 +340,41 @@ export default function VaultPage(props: VaultPageProps) {
const duplicateSignatureInfo = useMemo(() => { const duplicateSignatureInfo = useMemo(() => {
if (sidebarFilter.kind !== 'duplicates') return null; if (sidebarFilter.kind !== 'duplicates') return null;
const byId = new Map<string, string>(); const byId = new Map<string, string[]>();
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const cipher of props.ciphers) { for (const cipher of props.ciphers) {
if (!isCipherVisibleInNormalVault(cipher)) continue; if (!isCipherVisibleInNormalVault(cipher)) continue;
const signature = buildCipherDuplicateSignature(cipher); const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode)));
byId.set(cipher.id, signature); byId.set(cipher.id, signatures);
counts.set(signature, (counts.get(signature) || 0) + 1); for (const signature of signatures) {
counts.set(signature, (counts.get(signature) || 0) + 1);
}
} }
return { byId, counts }; return { byId, counts };
}, [props.ciphers, sidebarFilter.kind]); }, [props.ciphers, sidebarFilter.kind, duplicateMode]);
const duplicateGroupIndexById = useMemo(() => {
if (!duplicateSignatureInfo) return new Map<string, number>();
const groupKeyById = new Map<string, string>();
const groupKeys = new Set<string>();
for (const cipher of props.ciphers) {
const groupKey = (duplicateSignatureInfo.byId.get(cipher.id) || [])
.filter((signature) => (duplicateSignatureInfo.counts.get(signature) || 0) >= 2)
.sort()[0];
if (!groupKey) continue;
groupKeyById.set(cipher.id, groupKey);
groupKeys.add(groupKey);
}
const groupIndexByKey = new Map<string, number>();
Array.from(groupKeys).sort().forEach((groupKey, index) => {
groupIndexByKey.set(groupKey, index % 64);
});
const byId = new Map<string, number>();
for (const [cipherId, groupKey] of groupKeyById.entries()) {
byId.set(cipherId, groupIndexByKey.get(groupKey) || 0);
}
return byId;
}, [props.ciphers, duplicateSignatureInfo]);
const filteredCiphers = useMemo(() => { const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => { const next = props.ciphers.filter((cipher) => {
@@ -358,8 +385,11 @@ export default function VaultPage(props: VaultPageProps) {
if (!isCipherVisibleInArchive(cipher)) return false; if (!isCipherVisibleInArchive(cipher)) return false;
} else { } else {
if (!isCipherVisibleInNormalVault(cipher)) return false; if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) { if (sidebarFilter.kind === 'duplicates') {
return false; const signatures = duplicateSignatureInfo?.byId.get(cipher.id) || [];
if (!signatures.some((signature) => (duplicateSignatureInfo?.counts.get(signature) || 0) >= 2)) {
return false;
}
} }
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false; if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false; if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
@@ -404,8 +434,9 @@ export default function VaultPage(props: VaultPageProps) {
const sidebarFilterKey = useMemo(() => { const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`; if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`; if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`;
return sidebarFilter.kind; return sidebarFilter.kind;
}, [sidebarFilter]); }, [sidebarFilter, duplicateMode]);
useEffect(() => { useEffect(() => {
setListScrollTop(0); setListScrollTop(0);
@@ -419,6 +450,10 @@ export default function VaultPage(props: VaultPageProps) {
} }
}, [sidebarFilter.kind, sortMode]); }, [sidebarFilter.kind, sortMode]);
useEffect(() => {
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
}, [sidebarFilter.kind, duplicateMode]);
useEffect(() => { useEffect(() => {
if (isCreating) return; if (isCreating) return;
if (!filteredCiphers.length) { if (!filteredCiphers.length) {
@@ -984,10 +1019,11 @@ const folderName = useCallback((id: string | null | undefined): string => {
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]); const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []); const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
const handleSelectDuplicates = useCallback(() => { const handleSelectDuplicates = useCallback(() => {
if (duplicateMode !== 'exact') return;
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
const seen = new Set<string>(); const seen = new Set<string>();
for (const cipher of filteredCiphers) { for (const cipher of filteredCiphers) {
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher); const signature = duplicateSignatureInfo?.byId.get(cipher.id)?.[0] || buildCipherDuplicateSignatures(cipher, 'exact')[0];
if (seen.has(signature)) { if (seen.has(signature)) {
map[cipher.id] = true; map[cipher.id] = true;
continue; continue;
@@ -995,7 +1031,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
seen.add(signature); seen.add(signature);
} }
setSelectedMap(map); setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo]); }, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
const handleSelectAll = useCallback(() => { const handleSelectAll = useCallback(() => {
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true; for (const cipher of filteredCiphers) map[cipher.id] = true;
@@ -1082,10 +1118,12 @@ const folderName = useCallback((id: string | null | undefined): string => {
searchInput={searchInput} searchInput={searchInput}
sortMode={sortMode} sortMode={sortMode}
sortMenuOpen={sortMenuOpen} sortMenuOpen={sortMenuOpen}
duplicateMode={duplicateMode}
selectedCount={selectedCount} selectedCount={selectedCount}
totalCipherCount={totalCipherCount} totalCipherCount={totalCipherCount}
filteredCiphers={filteredCiphers} filteredCiphers={filteredCiphers}
visibleCiphers={visibleCiphers} visibleCiphers={visibleCiphers}
duplicateGroupIndexById={duplicateGroupIndexById}
virtualRange={virtualRange} virtualRange={virtualRange}
selectedCipherId={selectedCipherId} selectedCipherId={selectedCipherId}
selectedMap={selectedMap} selectedMap={selectedMap}
@@ -1102,6 +1140,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
onSearchCompositionEnd={handleSearchCompositionEnd} onSearchCompositionEnd={handleSearchCompositionEnd}
onToggleSortMenu={handleToggleSortMenu} onToggleSortMenu={handleToggleSortMenu}
onSelectSortMode={handleSelectSortMode} onSelectSortMode={handleSelectSortMode}
onDuplicateModeChange={setDuplicateMode}
onSyncVault={handleSyncVault} onSyncVault={handleSyncVault}
onOpenBulkDelete={handleOpenBulkDelete} onOpenBulkDelete={handleOpenBulkDelete}
onSelectDuplicates={handleSelectDuplicates} onSelectDuplicates={handleSelectDuplicates}
@@ -2,6 +2,7 @@ import { CloudUpload, Save, Trash2 } from 'lucide-preact';
import type { import type {
BackupDestinationRecord, BackupDestinationRecord,
RemoteBackupBrowserResponse, RemoteBackupBrowserResponse,
S3BackupAddressingStyle,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '@/lib/api/backup'; } from '@/lib/api/backup';
@@ -401,7 +402,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
{props.selectedDestination.type === 's3' ? ( {props.selectedDestination.type === 's3' ? (
<div className="field-grid"> <div className="field-grid">
<label className="field field-span-2"> <label className="field">
<span>{t('txt_backup_s3_endpoint')}</span> <span>{t('txt_backup_s3_endpoint')}</span>
<input <input
className="input" className="input"
@@ -417,6 +418,24 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
}))} }))}
/> />
</label> </label>
<label className="field">
<span>{t('txt_backup_s3_addressing_style')}</span>
<select
className="input"
value={(props.selectedDestination.destination as S3BackupDestination).addressingStyle || 'path-style'}
disabled={props.loadingSettings || props.disableWhileBusy}
onChange={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as S3BackupDestination),
addressingStyle: (event.currentTarget as HTMLSelectElement).value as S3BackupAddressingStyle,
},
}))}
>
<option value="path-style">{t('txt_backup_s3_addressing_path_style')}</option>
<option value="virtual-hosted-style">{t('txt_backup_s3_addressing_virtual_hosted_style')}</option>
</select>
</label>
<label className="field"> <label className="field">
<span>{t('txt_backup_s3_bucket')}</span> <span>{t('txt_backup_s3_bucket')}</span>
<input <input
+72 -31
View File
@@ -8,8 +8,10 @@ import { t } from '@/lib/i18n';
import { import {
CreateTypeIcon, CreateTypeIcon,
getCreateTypeOptions, getCreateTypeOptions,
getDuplicateDetectionOptions,
getVaultSortOptions, getVaultSortOptions,
VaultListIcon, VaultListIcon,
type DuplicateDetectionMode,
type SidebarFilter, type SidebarFilter,
type VaultSortMode, type VaultSortMode,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
@@ -28,10 +30,12 @@ interface VaultListPanelProps {
searchInput: string; searchInput: string;
sortMode: VaultSortMode; sortMode: VaultSortMode;
sortMenuOpen: boolean; sortMenuOpen: boolean;
duplicateMode: DuplicateDetectionMode;
selectedCount: number; selectedCount: number;
totalCipherCount: number; totalCipherCount: number;
filteredCiphers: Cipher[]; filteredCiphers: Cipher[];
visibleCiphers: Cipher[]; visibleCiphers: Cipher[];
duplicateGroupIndexById: Map<string, number>;
virtualRange: VirtualRange; virtualRange: VirtualRange;
selectedCipherId: string; selectedCipherId: string;
selectedMap: Record<string, boolean>; selectedMap: Record<string, boolean>;
@@ -48,6 +52,7 @@ interface VaultListPanelProps {
onSearchCompositionEnd: (value: string) => void; onSearchCompositionEnd: (value: string) => void;
onToggleSortMenu: () => void; onToggleSortMenu: () => void;
onSelectSortMode: (value: VaultSortMode) => void; onSelectSortMode: (value: VaultSortMode) => void;
onDuplicateModeChange: (value: DuplicateDetectionMode) => void;
onSyncVault: () => void; onSyncVault: () => void;
onOpenBulkDelete: () => void; onOpenBulkDelete: () => void;
onSelectDuplicates: () => void; onSelectDuplicates: () => void;
@@ -69,15 +74,18 @@ interface CipherListItemProps {
cipher: Cipher; cipher: Cipher;
selected: boolean; selected: boolean;
checked: boolean; checked: boolean;
duplicateGroupIndex: number | null;
subtitle: string; subtitle: string;
onToggleSelected: (cipherId: string, checked: boolean) => void; onToggleSelected: (cipherId: string, checked: boolean) => void;
onSelectCipher: (cipherId: string) => void; onSelectCipher: (cipherId: string) => void;
} }
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) { const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
const duplicateGroupHue = props.duplicateGroupIndex === null ? null : (props.duplicateGroupIndex * 137.508) % 360;
return ( return (
<div <div
className={`list-item ${props.selected ? 'active' : ''}`} className={`list-item ${props.selected ? 'active' : ''} ${duplicateGroupHue === null ? '' : 'duplicate-group-item'}`}
style={duplicateGroupHue === null ? undefined : { '--duplicate-group-hue': `${duplicateGroupHue}deg` }}
onClick={(event) => { onClick={(event) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.closest('.row-check')) return; if (target.closest('.row-check')) return;
@@ -108,6 +116,7 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
export default function VaultListPanel(props: VaultListPanelProps) { export default function VaultListPanel(props: VaultListPanelProps) {
const createTypeOptions = getCreateTypeOptions(); const createTypeOptions = getCreateTypeOptions();
const duplicateDetectionOptions = getDuplicateDetectionOptions();
const vaultSortOptions = getVaultSortOptions(); const vaultSortOptions = getVaultSortOptions();
const createMenu = ( const createMenu = (
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}> <div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
@@ -137,29 +146,44 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<section className="list-col"> <section className="list-col">
<div className="list-head"> <div className="list-head">
<div className="search-input-wrap"> <div className="search-input-wrap">
<input {props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
className="search-input" <select
placeholder={t('txt_search_your_secure_vault')} className="input duplicate-mode-select duplicate-mode-head-select"
value={props.searchInput} value={props.duplicateMode}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)} aria-label={t('txt_duplicate_detection_mode')}
onCompositionStart={props.onSearchCompositionStart} onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !props.searchInput) return;
e.preventDefault();
props.onClearSearch();
}}
/>
{!!props.searchInput && (
<button
type="button"
className="search-clear-btn"
aria-label={t('txt_clear_search')}
title={t('txt_clear_search_esc')}
onClick={props.onClearSearch}
> >
<X size={14} /> {duplicateDetectionOptions.map((option) => (
</button> <option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
) : (
<>
<input
className="search-input"
placeholder={t('txt_search_your_secure_vault')}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !props.searchInput) return;
e.preventDefault();
props.onClearSearch();
}}
/>
{!!props.searchInput && (
<button
type="button"
className="search-clear-btn"
aria-label={t('txt_clear_search')}
title={t('txt_clear_search_esc')}
onClick={props.onClearSearch}
>
<X size={14} />
</button>
)}
</>
)} )}
</div> </div>
<div className="sort-menu-wrap" ref={props.sortMenuRef}> <div className="sort-menu-wrap" ref={props.sortMenuRef}>
@@ -195,8 +219,20 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')} <RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
</button> </button>
</div> </div>
<div className="toolbar actions"> <div className={`toolbar actions ${props.sidebarFilter.kind === 'duplicates' ? 'duplicates-toolbar' : ''}`}>
{props.sidebarFilter.kind === 'duplicates' && ( {props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
<select
className="input duplicate-mode-select duplicate-mode-toolbar-select"
value={props.duplicateMode}
aria-label={t('txt_duplicate_detection_mode')}
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
>
{duplicateDetectionOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
{props.sidebarFilter.kind === 'duplicates' && props.duplicateMode === 'exact' && (
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}> <button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')} <Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button> </button>
@@ -229,12 +265,16 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}> <button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')} <Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button> </button>
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}> {props.sidebarFilter.kind !== 'duplicates' && (
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')} <button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
</button> <CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
{props.isMobileLayout && typeof document !== 'undefined' </button>
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null )}
: createMenu} {props.sidebarFilter.kind !== 'duplicates' && (
props.isMobileLayout && typeof document !== 'undefined'
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
: createMenu
)}
</div> </div>
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> <div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
@@ -255,6 +295,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
cipher={cipher} cipher={cipher}
selected={props.selectedCipherId === cipher.id} selected={props.selectedCipherId === cipher.id}
checked={!!props.selectedMap[cipher.id]} checked={!!props.selectedMap[cipher.id]}
duplicateGroupIndex={props.sidebarFilter.kind === 'duplicates' ? props.duplicateGroupIndexById.get(cipher.id) ?? null : null}
subtitle={props.listSubtitle(cipher)} subtitle={props.listSubtitle(cipher)}
onToggleSelected={props.onToggleSelected} onToggleSelected={props.onToggleSelected}
onSelectCipher={props.onSelectCipher} onSelectCipher={props.onSelectCipher}
@@ -10,10 +10,13 @@ import {
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types'; import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
import WebsiteIcon from './WebsiteIcon'; import WebsiteIcon from './WebsiteIcon';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
export type VaultSortMode = 'edited' | 'created' | 'name'; export type VaultSortMode = 'edited' | 'created' | 'name';
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
export type SidebarFilter = export type SidebarFilter =
| { kind: 'all' } | { kind: 'all' }
| { kind: 'favorite' } | { kind: 'favorite' }
@@ -126,6 +129,16 @@ export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1';
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)'; export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
export const VAULT_LIST_ROW_HEIGHT = 74; export const VAULT_LIST_ROW_HEIGHT = 74;
export const VAULT_LIST_OVERSCAN = 10; export const VAULT_LIST_OVERSCAN = 10;
export function getDuplicateDetectionOptions(): Array<{ value: DuplicateDetectionMode; label: string }> {
return [
{ value: 'exact', label: t('txt_duplicate_mode_exact') },
{ value: 'login-site', label: t('txt_duplicate_mode_login_site') },
{ value: 'login-credentials', label: t('txt_duplicate_mode_login_credentials') },
{ value: 'password', label: t('txt_duplicate_mode_password') },
];
}
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> { export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
return [ return [
{ value: 'edited', label: t('txt_sort_last_edited') }, { value: 'edited', label: t('txt_sort_last_edited') },
@@ -242,7 +255,7 @@ export function toBooleanFieldValue(raw: string): boolean {
return v === '1' || v === 'true' || v === 'yes' || v === 'on'; return v === '1' || v === 'true' || v === 'yes' || v === 'on';
} }
export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils'; export { firstCipherUri, hostFromUri, websiteIconUrl };
export function createEmptyLoginUri(): VaultDraftLoginUri { export function createEmptyLoginUri(): VaultDraftLoginUri {
return { uri: '', match: null, originalUri: '', extra: {} }; return { uri: '', match: null, originalUri: '', extra: {} };
@@ -257,6 +270,30 @@ function valueOrFallback(value: string | null | undefined): string {
return String(value || ''); return String(value || '');
} }
function duplicateLoginUsername(cipher: Cipher): string {
return valueOrFallback(cipher.login?.decUsername ?? cipher.login?.username).trim().toLowerCase();
}
function duplicateLoginPassword(cipher: Cipher): string {
return valueOrFallback(cipher.login?.decPassword ?? cipher.login?.password);
}
function duplicateLoginSites(cipher: Cipher): string[] {
const sites = new Set<string>();
for (const uri of cipher.login?.uris || []) {
const raw = valueOrFallback(uri.decUri ?? uri.uri).trim();
if (!raw) continue;
const host = hostFromUri(raw).trim().toLowerCase().replace(/^www\./, '');
const site = normalizeEquivalentDomain(raw) || host;
if (site) sites.add(site);
}
return Array.from(sites).sort();
}
function duplicateSignature(parts: string[]): string {
return JSON.stringify(parts);
}
export function buildCipherDuplicateSignature(cipher: Cipher): string { export function buildCipherDuplicateSignature(cipher: Cipher): string {
const normalized = { const normalized = {
type: Number(cipher.type || 1), type: Number(cipher.type || 1),
@@ -326,13 +363,30 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
linkedId: field.linkedId ?? null, linkedId: field.linkedId ?? null,
})), })),
passwordHistory: (cipher.passwordHistory || []).map((entry) => ({ passwordHistory: (cipher.passwordHistory || []).map((entry) => ({
password: valueOrFallback(entry.password), password: valueOrFallback(entry.decPassword ?? entry.password),
lastUsedDate: valueOrFallback(entry.lastUsedDate), lastUsedDate: valueOrFallback(entry.lastUsedDate),
})), })),
}; };
return JSON.stringify(normalized); return JSON.stringify(normalized);
} }
export function buildCipherDuplicateSignatures(cipher: Cipher, mode: DuplicateDetectionMode): string[] {
if (mode === 'exact') return [buildCipherDuplicateSignature(cipher)];
if (Number(cipher.type || 1) !== 1 || !cipher.login) return [];
const username = duplicateLoginUsername(cipher);
const password = duplicateLoginPassword(cipher);
if (mode === 'password') {
return password ? [duplicateSignature(['password', password])] : [];
}
if (!username || !password) return [];
if (mode === 'login-credentials') {
return [duplicateSignature(['login-credentials', username, password])];
}
return duplicateLoginSites(cipher).map((site) => duplicateSignature(['login-site', site, username, password]));
}
export function createEmptyDraft(type: number): VaultDraft { export function createEmptyDraft(type: number): VaultDraft {
return { return {
type, type,
@@ -453,8 +507,9 @@ export function maskSecret(value: string): string {
export function formatTotp(code: string): string { export function formatTotp(code: string): string {
if (!code) return code; if (!code) return code;
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`; if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
if (code.length < 6) return code; if (code.length <= 4) return code;
return `${code.slice(0, 3)} ${code.slice(3, 6)}`; if (code.length === 8) return `${code.slice(0, 4)} ${code.slice(4)}`;
return code.replace(/(.{3})(?=.)/g, '$1 ');
} }
export function formatHistoryTime(value: string | null | undefined): string { export function formatHistoryTime(value: string | null | undefined): string {
+7
View File
@@ -4,6 +4,7 @@ import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
import { import {
attachNodeWardenEncryptedAttachmentPayload, attachNodeWardenEncryptedAttachmentPayload,
buildAccountEncryptedBitwardenJsonString, buildAccountEncryptedBitwardenJsonString,
buildBitwardenCsvString,
buildBitwardenZipBytes, buildBitwardenZipBytes,
buildExportFileName, buildExportFileName,
buildNodeWardenAttachmentRecords, buildNodeWardenAttachmentRecords,
@@ -1190,6 +1191,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
mimeType: 'application/json', mimeType: 'application/json',
bytes: new TextEncoder().encode(await getPlainJson()), bytes: new TextEncoder().encode(await getPlainJson()),
}; };
} else if (format === 'bitwarden_csv') {
result = {
fileName: buildExportFileName(format),
mimeType: 'text/csv;charset=utf-8',
bytes: new TextEncoder().encode(buildBitwardenCsvString(await getPlainJsonDoc())),
};
} else if (format === 'bitwarden_encrypted_json') { } else if (format === 'bitwarden_encrypted_json') {
if (request.encryptedJsonMode === 'password') { if (request.encryptedJsonMode === 'password') {
const plainJson = await getPlainJson(); const plainJson = await getPlainJson();
+3
View File
@@ -9,6 +9,7 @@ import type {
TokenSuccess, TokenSuccess,
} from '../types'; } from '../types';
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys'; import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
import { parseJson, type AuthedFetch, type SessionSetter } from './shared'; import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
const SESSION_KEY = 'nodewarden.web.session.v4'; const SESSION_KEY = 'nodewarden.web.session.v4';
@@ -474,6 +475,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
for (let attempt = 0; attempt < maxAttempts; attempt += 1) { for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try { try {
const response = await fetch(input, { ...init, headers }); const response = await fetch(input, { ...init, headers });
recordNodeWardenReachable();
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) { if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
return response; return response;
} }
@@ -484,6 +486,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
} catch (error) { } catch (error) {
lastError = error; lastError = error;
if (attempt === maxAttempts - 1) { if (attempt === maxAttempts - 1) {
recordNodeWardenUnreachable();
throw error; throw error;
} }
} }
+3
View File
@@ -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;
+8 -18
View File
@@ -279,20 +279,16 @@ export async function hydrateLockedSession(
fallbackProfile: Profile | null = null fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> { ): Promise<{ session: SessionState | null; profile: Profile | null }> {
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email); const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
let serviceReachable = true; if (hasOfflineUnlock && browserReportsOffline()) {
if (hasOfflineUnlock) { return {
serviceReachable = await probeNodeWardenService(); session,
if (!serviceReachable) { profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
return { };
session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
};
}
} }
const refreshedSession = await maybeRefreshSession(session); const refreshedSession = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) { if (!refreshedSession?.accessToken) {
if (hasOfflineUnlock && !serviceReachable) { if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
return { return {
session, session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email), profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
@@ -571,14 +567,8 @@ export async function performUnlock(
} }
}; };
if (hasOfflineUnlock) { if (hasOfflineUnlock && browserReportsOffline()) {
if (browserReportsOffline()) { return unlockOffline();
return unlockOffline();
}
const serviceReachable = await probeNodeWardenService();
if (!serviceReachable) {
return unlockOffline();
}
} }
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string }; let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
+31 -2
View File
@@ -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 };
+128
View File
@@ -9,6 +9,7 @@ configureZipJs({ useWebWorkers: false });
export const EXPORT_FORMATS = [ export const EXPORT_FORMATS = [
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' }, { id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
{ id: 'bitwarden_csv', label: 'Bitwarden (vault as csv)' },
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' }, { id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' }, { id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' }, { id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
@@ -70,6 +71,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'; return !!value && typeof value === 'object';
} }
function csvText(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function escapeCsvCell(value: unknown): string {
const text = csvText(value);
if (!/[",\r\n]/.test(text)) return text;
return `"${text.replace(/"/g, '""')}"`;
}
function buildCsvString(rows: string[][]): string {
return `${rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n')}\r\n`;
}
function buildSingleRowCsvString(values: string[]): string {
return values.map(escapeCsvCell).join(',');
}
function isCipherString(value: string): boolean { function isCipherString(value: string): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
} }
@@ -383,6 +409,106 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
return JSON.stringify(doc, null, 2); return JSON.stringify(doc, null, 2);
} }
const BITWARDEN_CSV_HEADERS = [
'folder',
'favorite',
'type',
'name',
'notes',
'fields',
'reprompt',
'login_uri',
'login_username',
'login_password',
'login_totp',
] as const;
function bitwardenCsvType(type: number): 'login' | 'note' {
return type === 1 ? 'login' : 'note';
}
function sourceTypeLabel(type: number): string {
if (type === 3) return 'card';
if (type === 4) return 'identity';
if (type === 5) return 'sshKey';
if (type === 2) return 'note';
return `type ${type}`;
}
function appendFieldLine(lines: string[], name: unknown, value: unknown): void {
const key = csvText(name).trim();
const text = csvText(value);
if (!key || !text) return;
lines.push(`${key}: ${text}`);
}
function appendRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
if (!isRecord(value)) return;
for (const [key, fieldValue] of Object.entries(value)) {
appendFieldLine(lines, `${prefix}.${key}`, fieldValue);
}
}
function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): string {
const lines: string[] = [];
const fields = Array.isArray(item.fields) ? item.fields : [];
for (const field of fields) {
if (!isRecord(field)) continue;
appendFieldLine(lines, field.name, field.value);
}
if (type !== 1 && type !== 2) {
appendFieldLine(lines, 'nodewardenType', sourceTypeLabel(type));
appendRecordFieldLines(lines, sourceTypeLabel(type), item[sourceTypeLabel(type)]);
}
return lines.join('\n');
}
function buildFolderNameById(foldersRaw: unknown): Map<string, string> {
const out = new Map<string, string>();
const folders = Array.isArray(foldersRaw) ? foldersRaw : [];
for (const folder of folders) {
if (!isRecord(folder)) continue;
const id = csvText(folder.id).trim();
if (!id) continue;
out.set(id, csvText(folder.name));
}
return out;
}
function buildBitwardenCsvLoginUri(login: Record<string, unknown> | null): string {
const uris = Array.isArray(login?.uris) ? login.uris : [];
return buildSingleRowCsvString(uris
.map((uri) => (isRecord(uri) ? csvText(uri.uri).trim() : ''))
.filter(Boolean));
}
export function buildBitwardenCsvString(bitwardenJsonDoc: Record<string, unknown>): string {
const folderNameById = buildFolderNameById(bitwardenJsonDoc.folders);
const rows: string[][] = [[...BITWARDEN_CSV_HEADERS]];
const items = Array.isArray(bitwardenJsonDoc.items) ? bitwardenJsonDoc.items : [];
for (const itemRaw of items) {
if (!isRecord(itemRaw)) continue;
const type = normalizeNumber(itemRaw.type, 1);
const isLogin = type === 1;
const login = isRecord(itemRaw.login) ? itemRaw.login : null;
const folderId = csvText(itemRaw.folderId).trim();
rows.push([
folderNameById.get(folderId) || '',
itemRaw.favorite ? '1' : '0',
bitwardenCsvType(type),
csvText(itemRaw.name) || '--',
csvText(itemRaw.notes),
buildBitwardenCsvFields(itemRaw, type),
String(normalizeNumber(itemRaw.reprompt, 0)),
isLogin ? buildBitwardenCsvLoginUri(login) : '',
isLogin ? csvText(login?.username) : '',
isLogin ? csvText(login?.password) : '',
isLogin ? csvText(login?.totp) : '',
]);
}
return `\uFEFF${buildCsvString(rows)}`;
}
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> { export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
const userEnc = base64ToBytes(args.userEncB64); const userEnc = base64ToBytes(args.userEncB64);
const userMac = base64ToBytes(args.userMacB64); const userMac = base64ToBytes(args.userMacB64);
@@ -566,11 +692,13 @@ function nowStamp(now = new Date()): string {
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string { export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
const stamp = nowStamp(); const stamp = nowStamp();
if ( if (
format === 'bitwarden_csv' ||
format === 'bitwarden_json' || format === 'bitwarden_json' ||
format === 'bitwarden_encrypted_json' || format === 'bitwarden_encrypted_json' ||
format === 'nodewarden_json' || format === 'nodewarden_json' ||
format === 'nodewarden_encrypted_json' format === 'nodewarden_encrypted_json'
) { ) {
if (format === 'bitwarden_csv') return `bitwarden_export_${stamp}.csv`;
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`; if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
return `bitwarden_export_${stamp}.json`; return `bitwarden_export_${stamp}.json`;
} }
+8
View File
@@ -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.",
+8
View File
@@ -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.",
+8
View File
@@ -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": "Метка поля обязательна.",
+8
View File
@@ -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": "字段标签不能为空",
+8
View File
@@ -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": "字段標籤不能為空",
+23 -3
View File
@@ -1,12 +1,14 @@
export type NetworkStatus = 'online' | 'offline'; export type NetworkStatus = 'online' | 'offline';
const STATUS_PROBE_TIMEOUT_MS = 3500; const STATUS_PROBE_TIMEOUT_MS = 8000;
const STATUS_PROBE_CACHE_MS = 5000; const STATUS_PROBE_CACHE_MS = 5000;
const PROBE_FAILURES_BEFORE_OFFLINE = 2;
const listeners = new Set<(status: NetworkStatus) => void>(); const listeners = new Set<(status: NetworkStatus) => void>();
let currentStatus: NetworkStatus = getInitialNetworkStatus(); let currentStatus: NetworkStatus = getInitialNetworkStatus();
let pendingProbe: Promise<boolean> | null = null; let pendingProbe: Promise<boolean> | null = null;
let lastProbeAt = 0; let lastProbeAt = 0;
let lastProbeResult = currentStatus === 'online'; let lastProbeResult = currentStatus === 'online';
let consecutiveProbeFailures = 0;
export function browserReportsOffline(): boolean { export function browserReportsOffline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine === false; return typeof navigator !== 'undefined' && navigator.onLine === false;
@@ -35,8 +37,23 @@ export function subscribeNetworkStatus(listener: (status: NetworkStatus) => void
}; };
} }
export function recordNodeWardenReachable(): void {
consecutiveProbeFailures = 0;
lastProbeResult = true;
setCurrentNetworkStatus('online');
}
export function recordNodeWardenUnreachable(): void {
lastProbeResult = false;
consecutiveProbeFailures += 1;
if (browserReportsOffline() || consecutiveProbeFailures >= PROBE_FAILURES_BEFORE_OFFLINE) {
setCurrentNetworkStatus('offline');
}
}
export async function probeNodeWardenService(): Promise<boolean> { export async function probeNodeWardenService(): Promise<boolean> {
if (browserReportsOffline()) { if (browserReportsOffline()) {
consecutiveProbeFailures = PROBE_FAILURES_BEFORE_OFFLINE;
setCurrentNetworkStatus('offline'); setCurrentNetworkStatus('offline');
return false; return false;
} }
@@ -68,8 +85,11 @@ export async function probeNodeWardenService(): Promise<boolean> {
.catch(() => false) .catch(() => false)
.then((result) => { .then((result) => {
lastProbeAt = Date.now(); lastProbeAt = Date.now();
lastProbeResult = result; if (result) {
setCurrentNetworkStatus(result ? 'online' : 'offline'); recordNodeWardenReachable();
} else {
recordNodeWardenUnreachable();
}
return result; return result;
}) })
.finally(() => { .finally(() => {
-2
View File
@@ -1,5 +1,4 @@
import type { Send } from './types'; import type { Send } from './types';
import { getCurrentNetworkStatus } from './network-status';
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt'; import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
type WorkerSuccess<T> = { id: number; ok: true; result: T }; type WorkerSuccess<T> = { id: number; ok: true; result: T };
@@ -13,7 +12,6 @@ const pending = new Map<number, { resolve: (value: any) => void; reject: (error:
function getWorker(): Worker | null { function getWorker(): Worker | null {
if (typeof Worker === 'undefined') return null; if (typeof Worker === 'undefined') return null;
if (worker) return worker; if (worker) return worker;
if (getCurrentNetworkStatus() === 'offline') return null;
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' }); worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => { worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
const message = event.data; const message = event.data;
+95
View File
@@ -810,6 +810,101 @@ h4 {
background: transparent; background: transparent;
} }
/* Typography refinement: stronger scan targets for dense vault/admin surfaces. */
body {
font-family: var(--font-sans);
font-size: var(--font-base);
font-weight: 400;
}
button,
input,
select,
textarea {
font: inherit;
}
.side-link,
.side-group-trigger,
.side-sub-link,
.tree-btn,
.mobile-settings-link,
.backup-destination-item,
.backup-browser-entry,
.sort-menu-item,
.create-menu-item,
.nav-layout-option {
font-size: var(--font-sm);
font-weight: 600;
letter-spacing: 0;
}
.side-link.active,
.side-group-trigger.active,
.side-sub-link.active,
.tree-btn.active,
.mobile-tab.active,
.mobile-settings-link.active,
.nav-layout-option.active {
font-weight: 700;
}
.sidebar-title,
.list-count,
.field > span,
.table th,
.dialog-warning-kicker,
.backup-recommendation-group-title {
font-weight: 700;
letter-spacing: 0;
}
.list-title {
color: var(--text);
font-size: var(--font-base);
font-weight: 600;
letter-spacing: 0;
}
.list-sub,
.detail-sub,
.backup-destination-meta,
.totp-code-username,
.field-help,
.settings-field-note {
font-size: var(--font-sm);
line-height: var(--leading-snug);
}
.btn,
.input,
.search-input,
.user-chip,
.network-status-badge {
font-weight: 600;
letter-spacing: 0;
}
.btn-primary,
.btn-danger,
.btn.full,
.topbar-actions .btn,
.network-status-badge {
font-weight: 700;
}
.card h4,
.settings-module h3,
.section-head h3,
.section-head h4,
.detail-title,
.totp-code-name,
.backup-destination-name,
.mobile-sidebar-title {
font-weight: 700;
letter-spacing: 0;
}
.toast-item, .toast-item,
.dialog-card { .dialog-card {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
+5 -4
View File
@@ -8,11 +8,12 @@ body,
@apply m-0 h-full w-full p-0; @apply m-0 h-full w-full p-0;
color: var(--text); color: var(--text);
background: var(--bg-accent); background: var(--bg-accent);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif; font-family: var(--font-sans);
font-size: var(--font-base); font-size: var(--font-base);
line-height: var(--leading-normal); line-height: var(--leading-normal);
letter-spacing: var(--tracking-normal); letter-spacing: var(--tracking-normal);
font-feature-settings: 'liga' 1, 'kern' 1; font-feature-settings: 'liga' 1, 'kern' 1, 'calt' 1;
font-variant-numeric: tabular-nums;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@@ -33,8 +34,8 @@ body.dialog-open {
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
font-weight: 600; font-weight: 700;
letter-spacing: var(--tracking-tight); letter-spacing: 0;
line-height: var(--leading-tight); line-height: var(--leading-tight);
margin: 0; margin: 0;
} }
+15
View File
@@ -200,6 +200,21 @@
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
} }
:root[data-theme='dark'] .list-item.duplicate-group-item {
background: hsl(var(--duplicate-group-hue) 34% 18%);
border-color: hsl(var(--duplicate-group-hue) 28% 30%);
}
:root[data-theme='dark'] .list-item.duplicate-group-item:hover {
background: hsl(var(--duplicate-group-hue) 36% 21%);
border-color: hsl(var(--duplicate-group-hue) 34% 38%);
}
:root[data-theme='dark'] .list-item.duplicate-group-item.active {
background: hsl(var(--duplicate-group-hue) 38% 24%);
border-color: hsl(var(--duplicate-group-hue) 42% 48%);
}
:root[data-theme='dark'] .card-brand-icon { :root[data-theme='dark'] .card-brand-icon {
color: #bfdbfe; color: #bfdbfe;
background: linear-gradient(180deg, #1f2937 0%, #111827 100%); background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
+13
View File
@@ -319,6 +319,10 @@
@apply h-[42px] w-full min-w-0 rounded-[14px]; @apply h-[42px] w-full min-w-0 rounded-[14px];
} }
.list-head .duplicate-mode-head-select {
@apply h-[34px] min-w-0 w-auto max-w-full rounded-full;
}
.list-icon-btn { .list-icon-btn {
@apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px]; @apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px];
} }
@@ -329,6 +333,11 @@
gap: var(--actions-gap); gap: var(--actions-gap);
} }
.toolbar.actions.duplicates-toolbar {
@apply justify-start;
flex-wrap: wrap;
}
.actions { .actions {
gap: var(--actions-gap); gap: var(--actions-gap);
} }
@@ -883,6 +892,10 @@
font-size: 13px; font-size: 13px;
} }
.totp-code-main strong {
font-size: 20px;
}
.settings-module .field, .settings-module .field,
.auth-card .field { .auth-card .field {
margin-bottom: 8px; margin-bottom: 8px;
+18 -12
View File
@@ -46,16 +46,22 @@
--dur-slow: 350ms; --dur-slow: 350ms;
--actions-gap: clamp(5px, calc((100vw - 520px) * 1), 10px); --actions-gap: clamp(5px, calc((100vw - 520px) * 1), 10px);
/* Typography Families */
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei',
'Noto Sans CJK SC', 'Noto Sans SC', Arial, sans-serif;
--font-mono: 'SFMono-Regular', 'Cascadia Code', 'Cascadia Mono', Consolas, 'Liberation Mono', monospace;
/* Typography Scale */ /* Typography Scale */
--font-xs: 11px; --font-xs: 11px;
--font-sm: 13px; --font-sm: 14px;
--font-base: 14px; --font-base: 15px;
--font-md: 15px; --font-md: 16px;
--font-lg: 16px; --font-lg: 17px;
--font-xl: 18px; --font-xl: 19px;
--font-2xl: 20px; --font-2xl: 21px;
--font-3xl: 24px; --font-3xl: 25px;
--font-4xl: 28px; --font-4xl: 29px;
/* Line Heights */ /* Line Heights */
--leading-tight: 1.25; --leading-tight: 1.25;
@@ -65,11 +71,11 @@
--leading-loose: 1.75; --leading-loose: 1.75;
/* Letter Spacing */ /* Letter Spacing */
--tracking-tighter: -0.02em; --tracking-tighter: 0;
--tracking-tight: -0.01em; --tracking-tight: 0;
--tracking-normal: 0; --tracking-normal: 0;
--tracking-wide: 0.01em; --tracking-wide: 0;
--tracking-wider: 0.02em; --tracking-wider: 0;
/* Spacing Scale */ /* Spacing Scale */
--space-1: 4px; --space-1: 4px;
+55 -3
View File
@@ -184,8 +184,45 @@
gap: var(--actions-gap); gap: var(--actions-gap);
} }
.toolbar.actions.duplicates-toolbar {
@apply items-center;
}
.toolbar .btn.small { .toolbar .btn.small {
@apply h-[30px] rounded-full text-xs; @apply h-[32px] rounded-full text-xs;
}
.duplicate-mode-select {
@apply h-8 min-w-[150px] rounded-full py-0 pl-3 pr-6 text-xs;
border-color: rgba(74, 103, 150, 0.26);
box-shadow: none;
line-height: 32px;
background-position:
calc(100% - 10px) calc(50% - 2px),
calc(100% - 6px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
select.input.duplicate-mode-toolbar-select {
height: 32px;
padding-top: 0;
padding-right: 24px;
padding-bottom: 0;
line-height: 32px;
background-position:
calc(100% - 10px) calc(50% - 2px),
calc(100% - 6px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.duplicate-mode-head-select {
@apply h-[34px] w-auto min-w-[156px] max-w-full;
}
.duplicate-mode-toolbar-select {
@apply w-auto max-w-[170px] shrink-0;
} }
.list-head { .list-head {
@@ -281,6 +318,11 @@
overflow-anchor: none; overflow-anchor: none;
} }
.list-item.duplicate-group-item {
background: hsl(var(--duplicate-group-hue) 84% 94%);
border-color: hsl(var(--duplicate-group-hue) 42% 78%);
}
.list-item::before { .list-item::before {
content: ''; content: '';
@apply absolute inset-0 opacity-0; @apply absolute inset-0 opacity-0;
@@ -361,6 +403,11 @@
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(37, 99, 235, 0.04); box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(37, 99, 235, 0.04);
} }
.list-item.duplicate-group-item:hover {
background: hsl(var(--duplicate-group-hue) 88% 92%);
border-color: hsl(var(--duplicate-group-hue) 52% 68%);
}
.list-item:hover::before { .list-item:hover::before {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
@@ -372,6 +419,11 @@
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42);
} }
.list-item.duplicate-group-item.active {
background: hsl(var(--duplicate-group-hue) 88% 89%);
border-color: hsl(var(--duplicate-group-hue) 58% 58%);
}
.list-item.active::before { .list-item.active::before {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
@@ -900,7 +952,7 @@
.totp-codes-list { .totp-codes-list {
@apply grid w-full items-start gap-2.5; @apply grid w-full items-start gap-2.5;
grid-template-columns: repeat(var(--totp-columns, 1), minmax(300px, 1fr)); grid-template-columns: repeat(var(--totp-columns, 1), minmax(0, 1fr));
} }
.totp-code-row { .totp-code-row {
@@ -925,7 +977,7 @@
} }
.totp-code-main strong { .totp-code-main strong {
@apply whitespace-nowrap text-[22px] leading-none; @apply min-w-0 whitespace-nowrap text-[22px] leading-none;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }