28 Commits

Author SHA1 Message Date
shuaiplus 77a9faac88 fix(i18n): update password updated value translation in Simplified and Traditional Chinese locales 2026-05-01 02:04:10 +08:00
shuaiplus 0c00114cc8 Update localization files for backup destinations and API client credentials
- Changed references from E3 to S3 in Russian, Simplified Chinese, and Traditional Chinese localization files.
- Updated the corresponding keys and descriptions to reflect the change in backup destination protocols.
- Improved the Vite configuration to dynamically match locale files, simplifying the code for locale handling.
2026-04-30 15:03:05 +08:00
shuaiplus 9c5fbda374 feat: refactor vault component helpers to use dedicated functions for options retrieval 2026-04-29 15:28:23 +08:00
shuaiplus 85147e1569 Refactor code structure for improved readability and maintainability 2026-04-29 03:23:04 +08:00
shuaiplus 29a846c562 feat(i18n): initialize internationalization and update Vite config for locale handling
- Added `initI18n` function call in `main.tsx` to bootstrap internationalization before rendering the app.
- Updated Vite configuration to handle specific locale files for English and Chinese.
2026-04-29 02:49:45 +08:00
shuaiplus 3c5f43ecc2 feat: refactor website icon handling by moving utility functions to a dedicated module 2026-04-29 00:20:17 +08:00
shuaiplus 68ded534a4 feat: enhance backup process with lease management and attachment deletion
- Implemented a backup runner lease mechanism to prevent concurrent backup executions.
- Added `deleteAllAttachmentsForCiphers` function to delete attachments for multiple ciphers efficiently.
- Introduced `bulkDeleteAttachmentsByIds` method in storage to handle batch deletion of attachments.
- Updated backup execution logic to utilize the new lease management and ensure timely updates during the backup process.
- Refactored cipher deletion to handle attachments more effectively.
- Improved website icon loading with a dedicated caching mechanism for better performance.
- Added new index on `ciphers` table for `folder_id` to optimize queries related to folder management.
- Enhanced response handling for CORS policy to allow credentials for specific origins.
2026-04-28 23:40:43 +08:00
shuaiplus 69b98f9e67 refactor: Remove unused APIs and data structures, optimize loading state component styles 2026-04-28 23:01:23 +08:00
shuaiplus 1b0386bf78 feat: implement vault synchronization and decryption improvements
- Added background synchronization for vault core data, including optional folder updates.
- Introduced a new API endpoint to retrieve the vault revision date.
- Enhanced vault synchronization logic to utilize a caching mechanism for improved performance.
- Created a new vault cache module to handle IndexedDB storage for vault core snapshots.
- Implemented a worker for asynchronous decryption of vault data, improving UI responsiveness.
- Updated main application settings to adjust query stale time for better data freshness.
- Refactored vault-related API functions to support cache keys for more efficient data retrieval.
2026-04-28 22:10:34 +08:00
shuaiplus aa6f9210b4 feat: implement cipher decryption functionality and update related API methods 2026-04-28 00:34:52 +08:00
shuaiplus 3be6a16d90 refactor: clean up vault components by removing unused drag-and-drop functionality and optimizing icon loading logic 2026-04-27 23:37:35 +08:00
shuaiplus fdb4cb91bf feat: implement caching for cryptographic keys to improve performance and reduce overhead 2026-04-27 22:49:52 +08:00
shuaiplus 4b69f71ddb refactor: optimize TOTP and vault components with useMemo for performance improvements 2026-04-27 15:14:32 +08:00
qaz741wsd856 44020541e8 refactor: make notifyUserVaultSync and notifyUserLogout functions non-blocking by using waitUntil 2026-04-27 14:53:27 +08:00
shuaiplus 5869755c74 feat: update favicon and logo images to improve visual quality 2026-04-27 02:29:08 +08:00
shuaiplus 5b62d2142e fix: correct typo in README description 2026-04-27 02:25:53 +08:00
shuaiplus 575cf7ca79 feat: add TOTP secret input actions and enhance dark mode styles 2026-04-27 02:15:41 +08:00
shuaiplus bfd347a52c feat: update SVG logos and enhance brand wordmark styling 2026-04-27 02:01:27 +08:00
shuaiplus 7ab836d0f3 feat: enhance sync functionality by adding excludeSends option and refactor related API calls 2026-04-27 01:41:56 +08:00
shuaiplus d589b15123 feat: replace PNG logos with SVG for better scalability and update styles for improved responsiveness 2026-04-27 00:57:45 +08:00
shuaiplus f48f3d0c8e feat: implement drag-and-drop reordering for vault items and enhance sorting functionality 2026-04-26 20:32:55 +08:00
shuaiplus 2f7e66ee69 feat: enhance mobile layout with FAB visibility and responsive adjustments 2026-04-26 19:59:50 +08:00
shuaiplus 0cffbcd1f8 feat: update .gitignore to include settings.json 2026-04-26 19:40:06 +08:00
shuaiplus 64b4da4035 feat: add folder creation date and sorting functionality in Vault components 2026-04-26 19:28:49 +08:00
shuaiplus 3d2285e7af feat: refine styles for improved UI consistency and responsiveness across themes 2026-04-26 00:03:45 +08:00
shuaiplus 62f0aedc27 feat: enhance OnePassword CSV parsing with improved field handling and new category type support 2026-04-25 23:45:22 +08:00
shuaiplus 193e0ca189 feat: enhance cipher import process by preserving source ID during payload construction 2026-04-25 19:01:51 +08:00
shuaiplus 4a63c077f5 feat: enhance URI handling and TOTP field extraction in import functions 2026-04-25 16:35:35 +08:00
104 changed files with 8083 additions and 3233 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npx vite *)",
"Bash(npx tsc *)"
]
}
}
+2 -1
View File
@@ -42,4 +42,5 @@ tmp/
.tmp/
nodewarden.wiki/
AGENTS.md
AGENTS.md
settings.json
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg width="960" height="180" viewBox="0 0 1240 220" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(0 30) scale(0.276)">
<path d="M370.5 93C481.785 93 572 181.2 572 290C572 329.877 559.879 366.986 539.046 398H1.68164C0.576599 391.834 0 385.484 0 379C0 323.617 42.0774 278.061 96.0078 272.558C92.7712 263.989 91 254.701 91 245C91 201.922 125.922 167 169 167C182.365 167 194.945 170.362 205.94 176.286C242.437 125.895 302.539 93 370.5 93Z" fill="#F6821F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.6568 1.00686C72.7796 172.923 85.5495 291.119 127.869 379.459C170.188 467.799 242.092 526.353 356.665 578.892C469.877 526.354 540.929 467.802 582.746 379.461C624.564 291.12 637.181 172.923 633.35 1.00686H76.6568ZM523.796 342.933C554.479 275.533 565.347 188.379 566.419 63.9394L566.422 63.432H361.661V503.786L362.405 503.364C442.602 457.962 493.101 410.36 523.796 342.933Z" fill="#116FF9"/>
<path d="M588.465 215C664.976 215 727 277.233 727 354C727 369.378 724.509 384.172 719.913 398H363V333.553C375.721 307.751 402.287 290 433 290C443.483 290 453.482 292.068 462.613 295.818C484.559 248.11 532.658 215 588.465 215Z" fill="#FD9C33"/>
</g>
<g transform="translate(225 50) scale(0.112)" fill="#116FF9">
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z"/>
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z"/>
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z"/>
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z"/>
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z"/>
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z"/>
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z"/>
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z"/>
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z"/>
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+18 -12
View File
@@ -1,23 +1,29 @@
<p align="center">
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
运行在 Cloudflare Workers 上的第三方 Bitwarden 兼容服务端
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
</p>
[![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
[![License: LGPL-3.0](https://img.shields.io/badge/License-LGPL--3.0-2ea44f)](./LICENSE)
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest)
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
</p>
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
<p align="center">
<a href="./RELEASE_NOTES.md">更新日志</a> |
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">提交问题</a> |
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">最新发布</a><br />
<a href="./nodewarden.wiki/Home.md">文档首页</a> |
<a href="./nodewarden.wiki/快速开始.md">快速开始</a><br />
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a><br />
</p>
[文档首页](./nodewarden.wiki/Home.md) | [快速开始](./nodewarden.wiki/快速开始.md)
[Telegram 频道](https://t.me/NodeWarden_News) | [Telegram 群组](https://t.me/NodeWarden_Official)
English: [`README_EN.md`](./README_EN.md)
English: <a href="./README_EN.md"><code>README_EN.md</code></a>
> **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。
+21 -9
View File
@@ -1,17 +1,29 @@
<p align="center">
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
A third-party Bitwarden-compatible server running on Cloudflare Workers.
Bitwarden-compatible server running on Cloudflare Workers
</p>
[![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
[![License: LGPL-3.0](https://img.shields.io/badge/License-LGPL--3.0-2ea44f)](./LICENSE)
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest)
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
[Telegram Channel](https://t.me/NodeWarden_News) | [Telegram Group](https://t.me/NodeWarden_Official)
中文说明:[`README.md`](./README.md)
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
</p>
<p align="center">
<a href="./RELEASE_NOTES.md">Release Notes</a> |
<a href="https://github.com/shuaiplus/NodeWarden/issues/new/choose">Report an Issue</a> |
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest">Latest Release</a><br />
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a><br />
</p>
中文说明:<a href="./README.md"><code>README.md</code></a>
> **Disclaimer**
>
+1 -8
View File
@@ -61,6 +61,7 @@ CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
@@ -182,14 +183,6 @@ CREATE TABLE IF NOT EXISTS login_attempts_ip (
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS api_rate_limits (
identifier TEXT NOT NULL,
window_start INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY (identifier, window_start)
);
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
jti TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
+8
View File
@@ -26,6 +26,7 @@
"@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.3",
"autoprefixer": "^10.4.21",
"opencc-js": "^1.0.5",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tsx": "^4.21.0",
@@ -3246,6 +3247,13 @@
"node": ">= 6"
}
},
"node_modules/opencc-js": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/opencc-js/-/opencc-js-1.0.5.tgz",
"integrity": "sha512-LD+1SoNnZdlRwtYTjnQdFrSVCAaYpuDqL5CkmOaHOkKoKh7mFxUicLTRVNLU5C+Jmi1vXQ3QL4jWdgSaa4sKjg==",
"dev": true,
"license": "MIT"
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
+3
View File
@@ -10,6 +10,8 @@
"dev": "wrangler dev -c wrangler.toml",
"dev:kv": "wrangler dev -c wrangler.kv.toml",
"build": "vite build --config webapp/vite.config.ts",
"i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs",
"deploy": "wrangler deploy",
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
},
@@ -41,6 +43,7 @@
"@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.3",
"autoprefixer": "^10.4.21",
"opencc-js": "^1.0.5",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tsx": "^4.21.0",
+41
View File
@@ -0,0 +1,41 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const localeDir = path.join(__dirname, '..', 'webapp', 'src', 'lib', 'i18n', 'locales');
const localeFiles = [
['en', 'en.ts', 'en', 'English'],
['zh-CN', 'zh-CN.ts', 'zhCN', 'Simplified Chinese'],
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
['ru', 'ru.ts', 'ru', 'Russian'],
['es', 'es.ts', 'es', 'Spanish'],
];
function readLocale(fileName, variableName) {
let code = fs.readFileSync(path.join(localeDir, fileName), 'utf8');
code = code
.replace(/const (\w+): Record<string, string> =/g, 'const $1 =')
.replace(/export default \w+;\s*$/m, '');
code += `\nresult = ${variableName};`;
const sandbox = { result: null };
vm.createContext(sandbox);
vm.runInContext(code, sandbox, { filename: fileName });
return sandbox.result;
}
function writeLocale(fileName, variableName, table, header) {
const body = JSON.stringify(table, null, 2);
fs.writeFileSync(
path.join(localeDir, fileName),
`${header}\nconst ${variableName}: Record<string, string> = ${body};\n\nexport default ${variableName};\n`,
'utf8'
);
}
module.exports = {
localeFiles,
localeDir,
readLocale,
writeLocale,
};
+57
View File
@@ -0,0 +1,57 @@
const { localeFiles, readLocale } = require('./i18n-utils.cjs');
const locales = Object.fromEntries(
localeFiles.map(([locale, fileName, variableName]) => [locale, readLocale(fileName, variableName)])
);
const base = locales.en;
const baseKeys = Object.keys(base).sort();
const placeholderRe = /\{\w+\}/g;
const errors = [];
const intentionallyEnglishKeys = new Set([
'txt_backup_destination_detail_note',
'txt_backup_protocol_webdav',
'txt_backup_protocol_s3',
'txt_backup_recommend_group_webdav',
'txt_backup_recommend_group_s3',
'txt_backup_destination_name_default_webdav',
'txt_backup_destination_name_default_s3',
'txt_dash',
'txt_text_3',
]);
for (const [locale, table] of Object.entries(locales)) {
const keys = Object.keys(table).sort();
const missing = baseKeys.filter((key) => !(key in table));
const extra = keys.filter((key) => !baseKeys.includes(key));
if (missing.length || extra.length) {
errors.push({ locale, missing, extra });
}
for (const key of baseKeys) {
const basePlaceholders = Array.from(String(base[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
const localePlaceholders = Array.from(String(table[key]).matchAll(placeholderRe), (match) => match[0]).sort().join('|');
if (basePlaceholders !== localePlaceholders) {
errors.push({ locale, key, basePlaceholders, localePlaceholders });
}
}
if (locale !== 'en') {
const sameAsEnglish = baseKeys.filter((key) => table[key] === base[key] && !intentionallyEnglishKeys.has(key));
if (sameAsEnglish.length > 40) {
errors.push({
locale,
sameAsEnglishCount: sameAsEnglish.length,
sameAsEnglishSample: sameAsEnglish.slice(0, 25),
});
}
}
}
console.log(JSON.stringify({
counts: Object.fromEntries(Object.entries(locales).map(([locale, table]) => [locale, Object.keys(table).length])),
errors,
}, null, 2));
if (errors.length) {
process.exit(1);
}
+7 -7
View File
@@ -1,13 +1,13 @@
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
export const BACKUP_DEFAULT_E3_REGION = 'auto';
export const BACKUP_DEFAULT_S3_REGION = 'auto';
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export const BACKUP_DEFAULT_START_TIME = '03:00';
export type BackupDestinationType = 'e3' | 'webdav';
export type BackupDestinationType = 's3' | 'webdav';
export interface E3BackupDestination {
export interface S3BackupDestination {
endpoint: string;
bucket: string;
region: string;
@@ -24,7 +24,7 @@ export interface WebDavBackupDestination {
}
export type BackupDestinationConfig =
| E3BackupDestination
| S3BackupDestination
| WebDavBackupDestination;
export interface BackupRuntimeState {
@@ -91,11 +91,11 @@ export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFA
}
export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig {
if (type === 'e3') {
if (type === 's3') {
return {
endpoint: '',
bucket: '',
region: BACKUP_DEFAULT_E3_REGION,
region: BACKUP_DEFAULT_S3_REGION,
accessKeyId: '',
secretAccessKey: '',
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
@@ -110,7 +110,7 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
}
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
if (type === 'e3') return `E3 ${index}`;
if (type === 's3') return `S3 ${index}`;
return `WebDAV ${index}`;
}
+7 -7
View File
@@ -1,4 +1,4 @@
import { DurableObject } from 'cloudflare:workers';
import { DurableObject, waitUntil } from 'cloudflare:workers';
import type { Env } from '../types';
const SIGNALR_RECORD_SEPARATOR = 0x1e;
@@ -362,21 +362,21 @@ export class NotificationsHub extends DurableObject<Env> {
}
}
export async function notifyUserVaultSync(
export function notifyUserVaultSync(
env: Env,
userId: string,
revisionDate: string,
contextId?: string | null
): Promise<void> {
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null);
): void {
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
}
export async function notifyUserLogout(
export function notifyUserLogout(
env: Env,
userId: string,
targetDeviceIdentifier?: string | null
): Promise<void> {
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null);
): void {
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null));
}
export async function getOnlineUserDevices(env: Env, userId: string): Promise<string[]> {
+11 -5
View File
@@ -808,12 +808,18 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
// Generate a random alphanumeric string of the given length using crypto.getRandomValues.
function randomStringAlphanum(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const array = new Uint8Array(length);
crypto.getRandomValues(array);
let result = '';
for (let i = 0; i < length; i++) {
result += chars[array[i] % chars.length];
const maxUnbiased = Math.floor(256 / chars.length) * chars.length;
const bytes = new Uint8Array(Math.max(16, length));
while (result.length < length) {
crypto.getRandomValues(bytes);
for (const value of bytes) {
if (value >= maxUnbiased) continue;
result += chars[value % chars.length];
if (result.length >= length) break;
}
}
return result;
}
+23 -13
View File
@@ -21,13 +21,13 @@ import {
putBlobObject,
} from '../services/blob-store';
async function notifyVaultSyncForRequest(
function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): Promise<void> {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
): void {
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
// Format file size to human readable
@@ -93,7 +93,7 @@ async function processAttachmentUpload(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
return new Response(null, { status: 201 });
@@ -153,7 +153,7 @@ export async function handleCreateAttachment(
// Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
// Get updated cipher for response
@@ -324,7 +324,7 @@ export async function handleUpdateAttachmentMetadata(
await storage.saveAttachment(attachment);
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
return jsonResponse({
@@ -426,13 +426,10 @@ export async function handleDeleteAttachment(
// Delete attachment metadata
await storage.deleteAttachment(attachmentId);
// Remove attachment from cipher
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
// Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
// Get updated cipher for response
@@ -448,12 +445,25 @@ export async function handleDeleteAttachment(
export async function deleteAllAttachmentsForCipher(
env: Env,
cipherId: string
): Promise<void> {
await deleteAllAttachmentsForCiphers(env, [cipherId]);
}
export async function deleteAllAttachmentsForCiphers(
env: Env,
cipherIds: string[]
): Promise<void> {
const storage = new StorageService(env.DB);
const attachments = await storage.getAttachmentsByCipher(cipherId);
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => {
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(cipherIds);
const attachments = Array.from(attachmentsByCipher.entries()).flatMap(([ownedCipherId, items]) =>
items.map((attachment) => ({ attachment, cipherId: ownedCipherId }))
);
if (!attachments.length) return;
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async ({ attachment, cipherId }) => {
const path = getAttachmentObjectKey(cipherId, attachment.id);
await deleteBlobObject(env, path);
await storage.deleteAttachment(attachment.id);
});
await storage.bulkDeleteAttachmentsByIds(attachments.map(({ attachment }) => attachment.id));
}
+156 -15
View File
@@ -14,6 +14,7 @@ import {
getBackupLocalDateKey,
getDefaultBackupSettings,
getBackupSettingsRepairState,
hasBackupSlotBetween,
isBackupDueNow,
loadBackupSettings,
normalizeBackupSettingsInput,
@@ -80,6 +81,98 @@ function getBackupDestinationSummary(destination: BackupDestinationRecord | null
};
}
const BACKUP_RUNNER_LOCK_KEY = 'backup.runner.lock.v1';
const BACKUP_RUNNER_LEASE_MS = 10 * 60 * 1000;
const BACKUP_RUNNER_HEARTBEAT_MS = 30 * 1000;
interface BackupRunnerLease {
token: string;
touch: () => Promise<void>;
release: () => Promise<void>;
}
async function acquireBackupRunnerLease(env: Env, reason: string): Promise<BackupRunnerLease | null> {
const token = generateUUID();
const nowMs = Date.now();
const expiresAtMs = nowMs + BACKUP_RUNNER_LEASE_MS;
const value = JSON.stringify({
token,
reason,
acquiredAt: new Date(nowMs).toISOString(),
touchedAt: new Date(nowMs).toISOString(),
expiresAtMs,
});
const result = await env.DB
.prepare(
`INSERT INTO config(key, value) VALUES(?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
WHERE COALESCE(CAST(json_extract(config.value, '$.expiresAtMs') AS INTEGER), 0) <= ?`
)
.bind(BACKUP_RUNNER_LOCK_KEY, value, nowMs)
.run();
if ((result.meta?.changes || 0) < 1) {
return null;
}
return {
token,
touch: async () => {
const nextNowMs = Date.now();
const nextValue = JSON.stringify({
token,
reason,
acquiredAt: new Date(nowMs).toISOString(),
touchedAt: new Date(nextNowMs).toISOString(),
expiresAtMs: nextNowMs + BACKUP_RUNNER_LEASE_MS,
});
await env.DB
.prepare(
`UPDATE config
SET value = ?
WHERE key = ?
AND json_extract(value, '$.token') = ?`
)
.bind(nextValue, BACKUP_RUNNER_LOCK_KEY, token)
.run();
},
release: async () => {
await env.DB
.prepare(
`DELETE FROM config
WHERE key = ?
AND json_extract(value, '$.token') = ?`
)
.bind(BACKUP_RUNNER_LOCK_KEY, token)
.run();
},
};
}
async function withBackupRunnerLease<T>(
env: Env,
reason: string,
task: (keepAlive: () => Promise<void>) => Promise<T>
): Promise<T | null> {
const lease = await acquireBackupRunnerLease(env, reason);
if (!lease) return null;
let lastHeartbeatAt = 0;
const keepAlive = async () => {
const nowMs = Date.now();
if (nowMs - lastHeartbeatAt < BACKUP_RUNNER_HEARTBEAT_MS) return;
lastHeartbeatAt = nowMs;
await lease.touch();
};
try {
await keepAlive();
return await task(keepAlive);
} finally {
await lease.release();
}
}
function ensureBackupBlobName(value: string): string {
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalized) {
@@ -160,6 +253,7 @@ async function executeConfiguredBackup(
actorUserId: string | null,
trigger: 'manual' | 'scheduled',
destinationId?: string | null,
keepAlive?: (() => Promise<void>) | null,
progress?: ((event: {
operation: 'backup-remote-run';
step: string;
@@ -172,6 +266,9 @@ async function executeConfiguredBackup(
}) => Promise<void>) | null
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
const maxArchiveUploadAttempts = 3;
const touchLease = async () => {
await keepAlive?.();
};
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
const destination = requireBackupDestination(currentSettings, destinationId);
@@ -180,9 +277,11 @@ async function executeConfiguredBackup(
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
destination.runtime.lastErrorAt = null;
destination.runtime.lastErrorMessage = null;
await touchLease();
await saveBackupSettings(storage, env, currentSettings);
try {
await touchLease();
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_prepare',
@@ -190,6 +289,7 @@ async function executeConfiguredBackup(
stageTitle: 'txt_backup_remote_run_progress_prepare_title',
stageDetail: 'txt_backup_remote_run_progress_prepare_detail',
});
await touchLease();
const archive = await buildBackupArchive(env, now, {
includeAttachments: destination.includeAttachments,
timeZone: destination.schedule.timezone,
@@ -219,9 +319,11 @@ async function executeConfiguredBackup(
});
const remoteSession = createRemoteBackupTransferSession(destination);
if (destination.includeAttachments) {
await touchLease();
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
let attachmentIndexChanged = false;
for (const attachment of archive.manifest.attachmentBlobs || []) {
await touchLease();
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
continue;
}
@@ -238,11 +340,13 @@ async function executeConfiguredBackup(
attachmentIndexChanged = true;
}
if (attachmentIndexChanged) {
await touchLease();
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
}
}
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
await touchLease();
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_upload_archive',
@@ -252,6 +356,7 @@ async function executeConfiguredBackup(
});
upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName);
try {
await touchLease();
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_verify_archive',
@@ -282,6 +387,7 @@ async function executeConfiguredBackup(
let prunedFileCount = 0;
let pruneErrorMessage: string | null = null;
try {
await touchLease();
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_cleanup',
@@ -300,8 +406,10 @@ async function executeConfiguredBackup(
destination.runtime.lastUploadedFileName = archive.fileName;
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
destination.runtime.lastUploadedDestination = upload.remotePath;
await touchLease();
await saveBackupSettings(storage, env, currentSettings);
await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
...getBackupDestinationSummary(destination),
provider: upload.provider,
@@ -332,8 +440,10 @@ async function executeConfiguredBackup(
} catch (error) {
destination.runtime.lastErrorAt = new Date().toISOString();
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
await touchLease();
await saveBackupSettings(storage, env, currentSettings);
await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage,
@@ -404,13 +514,30 @@ async function runImportAndAudit(
}
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
const storage = new StorageService(env.DB);
const settings = await loadBackupSettings(storage, env, 'UTC');
const now = new Date();
for (const destination of settings.destinations) {
if (!isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)) continue;
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id);
}
await withBackupRunnerLease(env, 'scheduled', async (keepAlive) => {
const storage = new StorageService(env.DB);
let scanStartMs = Date.now();
while (true) {
await keepAlive();
const settings = await loadBackupSettings(storage, env, 'UTC');
const now = new Date();
const dueDestinations = settings.destinations.filter((destination) =>
isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
|| hasBackupSlotBetween(destination, new Date(scanStartMs), now)
);
if (!dueDestinations.length) {
return;
}
scanStartMs = now.getTime();
for (const destination of dueDestinations) {
await keepAlive();
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id, keepAlive);
}
}
});
}
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
@@ -512,7 +639,6 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const storage = new StorageService(env.DB);
try {
let body: { destinationId?: string } | null = null;
try {
@@ -536,17 +662,32 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
}) => {
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
};
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null, progress);
const settings = await loadBackupSettings(storage, env, 'UTC');
const outcome = await withBackupRunnerLease(env, `manual:${actorUser.id}`, async (keepAlive) => {
const storage = new StorageService(env.DB);
const result = await executeConfiguredBackup(
env,
storage,
actorUser.id,
'manual',
body?.destinationId || null,
keepAlive,
progress
);
const settings = await loadBackupSettings(storage, env, 'UTC');
return { result, settings };
});
if (!outcome) {
return errorResponse('Another backup run is already in progress', 409);
}
return jsonResponse({
object: 'backup-run',
result: {
fileName: result.fileName,
fileSize: result.fileSize,
provider: result.provider,
remotePath: result.remotePath,
fileName: outcome.result.fileName,
fileSize: outcome.result.fileSize,
provider: outcome.result.provider,
remotePath: outcome.result.remotePath,
},
settings,
settings: outcome.settings,
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
+194 -33
View File
@@ -14,7 +14,7 @@ import { StorageService } from '../services/storage';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher } from './attachments';
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device';
@@ -24,13 +24,13 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : null;
}
async function notifyVaultSyncForRequest(
function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): Promise<void> {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
): void {
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
@@ -78,6 +78,43 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher {
return cipher;
}
function isValidEncString(value: unknown): value is string {
if (typeof value !== 'string') return false;
const trimmed = value.trim();
const dot = trimmed.indexOf('.');
if (dot <= 0) return false;
const type = Number(trimmed.slice(0, dot));
if (!Number.isInteger(type) || type < 0) return false;
const parts = trimmed.slice(dot + 1).split('|');
if (parts.some((part) => part.length === 0)) return false;
// Bitwarden's legacy symmetric EncString variants require IV + data,
// while the authenticated AES-CBC-HMAC variant requires IV + data + MAC.
if (type === 0 || type === 1 || type === 4) return parts.length >= 2;
if (type === 2) return parts.length === 3;
// Keep newer one-part formats, such as COSE Encrypt0, future-compatible.
return parts.length >= 1;
}
function optionalEncString(value: unknown): string | null {
if (value == null || value === '') return null;
return isValidEncString(value) ? value.trim() : null;
}
function sanitizeEncryptedObject<T extends Record<string, any>>(
source: T | null | undefined,
encryptedKeys: readonly string[]
): T | null {
if (!source || typeof source !== 'object') return source ?? null;
const next: Record<string, any> = { ...source };
for (const key of encryptedKeys) {
if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
next[key] = optionalEncString(next[key]);
}
return next as T;
}
function normalizeCipherForStorage(cipher: Cipher): Cipher {
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
@@ -100,7 +137,53 @@ export function normalizeCipherLoginForStorage(login: any): any {
export function normalizeCipherLoginForCompatibility(login: any): any {
const normalized = normalizeCipherLoginForStorage(login);
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
return normalized;
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']);
if (!next) return null;
next.uris = Array.isArray(next.uris)
? next.uris
.map((uri: any) => sanitizeEncryptedObject(uri, ['uri', 'uriChecksum']))
.filter((uri: any) => !!uri && (uri.uri || uri.uriChecksum || uri.match != null))
: null;
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
return next;
}
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
if (!Array.isArray(credentials) || credentials.length === 0) return null;
const requiredEncryptedKeys = [
'credentialId',
'keyType',
'keyAlgorithm',
'keyCurve',
'keyValue',
'rpId',
'counter',
'discoverable',
];
const optionalEncryptedKeys = ['userHandle', 'userName', 'rpName', 'userDisplayName'];
const out: any[] = [];
for (const credential of credentials) {
if (!credential || typeof credential !== 'object') continue;
const next: Record<string, any> = { ...credential };
let valid = true;
for (const key of requiredEncryptedKeys) {
if (!isValidEncString(next[key])) {
valid = false;
break;
}
next[key] = String(next[key]).trim();
}
if (!valid) continue;
for (const key of optionalEncryptedKeys) {
if (Object.prototype.hasOwnProperty.call(next, key)) {
next[key] = optionalEncString(next[key]);
}
}
out.push(next);
}
return out.length ? out : null;
}
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
@@ -118,8 +201,18 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
? ''
: String(candidate);
if (
!isValidEncString(sshKey.privateKey) ||
!isValidEncString(sshKey.publicKey) ||
!isValidEncString(normalizedFingerprint)
) {
return null;
}
return {
...sshKey,
privateKey: String(sshKey.privateKey).trim(),
publicKey: String(sshKey.publicKey).trim(),
keyFingerprint: normalizedFingerprint,
fingerprint: normalizedFingerprint,
};
@@ -128,16 +221,52 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
// Format attachments for API response
export function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null;
return attachments.map(a => ({
id: a.id,
fileName: a.fileName,
// Bitwarden clients decode attachment size as string in cipher payloads.
size: String(Number(a.size) || 0),
sizeName: a.sizeName,
key: a.key,
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
object: 'attachment',
}));
const formatted = attachments
.filter((a) => isValidEncString(a.fileName))
.map(a => ({
id: a.id,
fileName: a.fileName.trim(),
// Bitwarden clients decode attachment size as string in cipher payloads.
size: String(Number(a.size) || 0),
sizeName: a.sizeName,
key: optionalEncString(a.key),
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
object: 'attachment',
}));
return formatted.length ? formatted : null;
}
function normalizeCipherFieldsForCompatibility(fields: any): any[] | null {
if (!Array.isArray(fields) || fields.length === 0) return null;
const out = fields
.map((field: any) => {
if (!field || typeof field !== 'object') return null;
return {
...field,
name: optionalEncString(field.name),
value: optionalEncString(field.value),
type: Number(field.type) || 0,
linkedId: field.linkedId ?? null,
};
})
.filter(Boolean);
return out.length ? out : null;
}
function normalizePasswordHistoryForCompatibility(passwordHistory: any): PasswordHistory[] | null {
if (!Array.isArray(passwordHistory) || passwordHistory.length === 0) return null;
const out = passwordHistory
.filter((entry: any) => entry && typeof entry === 'object' && isValidEncString(entry.password))
.map((entry: any) => ({
...entry,
password: String(entry.password).trim(),
lastUsedDate: normalizeCipherTimestamp(entry.lastUsedDate) ?? new Date().toISOString(),
}));
return out.length ? out : null;
}
export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean {
return isValidEncString(cipher.name);
}
// Convert internal cipher to API response format.
@@ -151,6 +280,27 @@ export function cipherToResponse(
// Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
'title',
'firstName',
'middleName',
'lastName',
'address1',
'address2',
'address3',
'city',
'state',
'postalCode',
'country',
'company',
'email',
'phone',
'ssn',
'username',
'passportNumber',
'licenseNumber',
]);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
return {
@@ -174,8 +324,15 @@ export function cipherToResponse(
object: 'cipherDetails',
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
attachments: formatAttachments(attachments),
name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name,
notes: optionalEncString(cipher.notes),
login: normalizedLogin,
card: normalizedCard,
identity: normalizedIdentity,
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
sshKey: normalizedSshKey,
key: optionalEncString(cipher.key),
encryptedFor: (passthrough as any).encryptedFor ?? null,
};
}
@@ -304,7 +461,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, []),
@@ -398,7 +555,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
@@ -421,7 +578,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [])
@@ -445,7 +602,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
await deleteAllAttachmentsForCipher(env, id);
await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 });
}
@@ -466,7 +623,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 });
}
@@ -485,7 +642,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [])
@@ -524,7 +681,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [])
@@ -554,7 +711,7 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
const revisionDate = await storage.bulkMoveCiphers(body.ids, folderId, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
@@ -600,7 +757,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
normalizeCipherForStorage(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
@@ -622,7 +779,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
normalizeCipherForStorage(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
@@ -648,7 +805,7 @@ export async function handleBulkArchiveCiphers(request: Request, env: Env, userI
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return buildCipherListResponse(request, storage, userId, ids);
@@ -672,7 +829,7 @@ export async function handleBulkUnarchiveCiphers(request: Request, env: Env, use
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return buildCipherListResponse(request, storage, userId, ids);
@@ -695,7 +852,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
@@ -718,7 +875,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
@@ -744,13 +901,17 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
return new Response(null, { status: 204 });
}
for (const id of ids) {
await deleteAllAttachmentsForCipher(env, id);
const ownedCiphers = await storage.getCiphersByIds(ids, userId);
const ownedIds = ownedCiphers.map((cipher) => cipher.id);
if (!ownedIds.length) {
return new Response(null, { status: 204 });
}
const revisionDate = await storage.bulkDeleteCiphers(ids, userId);
await deleteAllAttachmentsForCiphers(env, ownedIds);
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
+3 -3
View File
@@ -284,7 +284,7 @@ export async function handleDeleteDevice(
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
await notifyUserLogout(env, userId, normalized);
notifyUserLogout(env, userId, normalized);
}
return jsonResponse({ success: deleted });
}
@@ -327,7 +327,7 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await notifyUserLogout(env, userId, null);
notifyUserLogout(env, userId, null);
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
}
@@ -458,7 +458,7 @@ export async function handleDeactivateDevice(
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
await notifyUserLogout(env, userId, normalized);
notifyUserLogout(env, userId, normalized);
}
return jsonResponse({ success: deleted });
}
+8 -7
View File
@@ -6,13 +6,13 @@ import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
async function notifyVaultSyncForRequest(
function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): Promise<void> {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
): void {
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
// Convert internal folder to API response format
@@ -21,6 +21,7 @@ function folderToResponse(folder: Folder): FolderResponse {
id: folder.id,
name: folder.name,
revisionDate: folder.updatedAt,
creationDate: folder.createdAt,
object: 'folder',
};
}
@@ -87,7 +88,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(folderToResponse(folder), 200);
}
@@ -115,7 +116,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(folderToResponse(folder));
}
@@ -132,7 +133,7 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
await storage.clearFolderFromCiphers(userId, id);
await storage.deleteFolder(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 });
}
@@ -155,7 +156,7 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
+1 -1
View File
@@ -289,7 +289,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
// Update revision date
const revisionDate = await storage.updateRevisionDate(userId);
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
if (returnCipherMap) {
return jsonResponse({
+8 -8
View File
@@ -76,7 +76,7 @@ async function processSendFileUpload(
const storage = new StorageService(env.DB);
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
return new Response(null, { status: 201 });
}
@@ -226,7 +226,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
@@ -349,7 +349,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
@@ -596,7 +596,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
@@ -619,7 +619,7 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
await storage.deleteSend(sendId, userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 200 });
}
@@ -650,7 +650,7 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 200 });
@@ -668,7 +668,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
@@ -686,7 +686,7 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
+4 -4
View File
@@ -89,7 +89,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
}
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
}
const creatorIdentifier = await getCreatorIdentifier(storage, send);
@@ -162,7 +162,7 @@ export async function handleAccessSendFile(
}
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
const token = await createSendFileDownloadToken(send.id, fileId, secret);
const url = new URL(request.url);
@@ -202,7 +202,7 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise<Re
}
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
}
const creatorIdentifier = await getCreatorIdentifier(storage, send);
@@ -241,7 +241,7 @@ export async function handleAccessSendFileV2(request: Request, env: Env, fileId:
}
send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
const url = new URL(request.url);
+3 -3
View File
@@ -9,13 +9,13 @@ export const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer availa
const SEND_PASSWORD_ITERATIONS = 100_000;
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
export async function notifyVaultSyncForRequest(
export function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): Promise<void> {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
): void {
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
+12 -6
View File
@@ -1,7 +1,7 @@
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response';
import { cipherToResponse } from './ciphers';
import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers';
import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits';
import {
@@ -10,10 +10,10 @@ import {
buildUserDecryptionOptions,
} from '../utils/user-decryption';
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request {
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
const url = new URL(request.url);
const cacheUrl = new URL(
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`,
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}`,
url.origin
);
return new Request(cacheUrl.toString(), { method: 'GET' });
@@ -35,6 +35,8 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const url = new URL(request.url);
const excludeDomainsParam = url.searchParams.get('excludeDomains');
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
const excludeSendsParam = url.searchParams.get('excludeSends');
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
const user = await storage.getUserById(userId);
if (!user) {
@@ -42,7 +44,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
}
const revisionDate = await storage.getRevisionDate(userId);
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains);
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends);
const cachedResponse = await readSyncCache(cacheRequest);
if (cachedResponse) {
return cachedResponse;
@@ -51,7 +53,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
storage.getAllCiphers(userId),
storage.getAllFolders(userId),
storage.getAllSends(userId),
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
storage.getAttachmentsByUserId(userId),
]);
const accountKeys = buildAccountKeys(user);
@@ -84,7 +86,10 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) {
cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []));
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []);
if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response);
}
}
const folderResponses: FolderResponse[] = [];
@@ -93,6 +98,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
id: folder.id,
name: folder.name,
revisionDate: folder.updatedAt,
creationDate: folder.createdAt,
object: 'folder',
});
}
+2 -2
View File
@@ -61,7 +61,7 @@ function handleNwFavicon(): Response {
status: 200,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
},
});
}
@@ -181,7 +181,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
status: 200,
headers: {
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
},
});
}
+68 -2
View File
@@ -6,6 +6,17 @@ import { StorageService } from './storage';
// The client already does heavy PBKDF2 (600k iterations).
// This second layer only needs to be non-trivial, not expensive.
const SERVER_HASH_ITERATIONS = 100_000;
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
interface CachedUserEntry {
user: User | null;
expiresAt: number;
}
interface CachedDeviceEntry {
device: Awaited<ReturnType<StorageService['getDevice']>>;
expiresAt: number;
}
export interface VerifiedAccessContext {
payload: JWTPayload;
@@ -14,11 +25,65 @@ export interface VerifiedAccessContext {
export class AuthService {
private storage: StorageService;
private static userCache = new Map<string, CachedUserEntry>();
private static deviceCache = new Map<string, CachedDeviceEntry>();
constructor(private env: Env) {
this.storage = new StorageService(env.DB);
}
private readCachedUser(userId: string): User | null | undefined {
const cached = AuthService.userCache.get(userId);
if (!cached) return undefined;
if (cached.expiresAt <= Date.now()) {
AuthService.userCache.delete(userId);
return undefined;
}
return cached.user;
}
private writeCachedUser(userId: string, user: User | null): void {
AuthService.userCache.set(userId, {
user,
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
});
}
private async getCachedUser(userId: string): Promise<User | null> {
const cached = this.readCachedUser(userId);
if (cached !== undefined) return cached;
const user = await this.storage.getUserById(userId);
this.writeCachedUser(userId, user);
return user;
}
private readCachedDevice(userId: string, deviceId: string) {
const cacheKey = `${userId}:${deviceId}`;
const cached = AuthService.deviceCache.get(cacheKey);
if (!cached) return undefined;
if (cached.expiresAt <= Date.now()) {
AuthService.deviceCache.delete(cacheKey);
return undefined;
}
return cached.device;
}
private writeCachedDevice(userId: string, deviceId: string, device: Awaited<ReturnType<StorageService['getDevice']>>): void {
const cacheKey = `${userId}:${deviceId}`;
AuthService.deviceCache.set(cacheKey, {
device,
expiresAt: Date.now() + AUTH_CONTEXT_CACHE_TTL_MS,
});
}
private async getCachedDevice(userId: string, deviceId: string) {
const cached = this.readCachedDevice(userId, deviceId);
if (cached !== undefined) return cached;
const device = await this.storage.getDevice(userId, deviceId);
this.writeCachedDevice(userId, deviceId, device);
return device;
}
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
@@ -97,15 +162,16 @@ export class AuthService {
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
if (!payload) return null;
const user = await this.storage.getUserById(payload.sub);
const user = await this.getCachedUser(payload.sub);
if (!user) return null;
if (user.status !== 'active') return null;
if (payload.sstamp !== user.securityStamp) {
return null;
}
if (payload.did) {
const device = await this.storage.getDevice(user.id, payload.did);
const device = await this.getCachedDevice(user.id, payload.did);
if (!device) return null;
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
}
+57 -12
View File
@@ -16,7 +16,7 @@ import {
type BackupRuntimeState,
type BackupScheduleConfig,
type BackupSettings,
type E3BackupDestination,
type S3BackupDestination,
type WebDavBackupDestination,
createBackupRandomId,
createDefaultBackupDestinationName,
@@ -35,7 +35,7 @@ export type {
BackupRuntimeState,
BackupScheduleConfig,
BackupSettings,
E3BackupDestination,
S3BackupDestination,
WebDavBackupDestination,
} from '../../shared/backup-schema';
@@ -105,7 +105,7 @@ function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_ST
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
function normalizeS3Destination(value: unknown, allowIncomplete = false): S3BackupDestination {
const source = isPlainObject(value) ? value : {};
const endpoint = asTrimmedString(source.endpoint);
const bucket = asTrimmedString(source.bucket);
@@ -115,17 +115,17 @@ function normalizeE3Destination(value: unknown, allowIncomplete = false): E3Back
const rootPath = normalizePath(source.rootPath);
if (!allowIncomplete || endpoint) {
if (!endpoint) throw new Error('E3 endpoint is required');
if (!/^https?:\/\//i.test(endpoint)) throw new Error('E3 endpoint must start with http:// or https://');
if (!endpoint) throw new Error('S3 endpoint is required');
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
}
if (!allowIncomplete || bucket) {
if (!bucket) throw new Error('E3 bucket is required');
if (!bucket) throw new Error('S3 bucket is required');
}
if (!allowIncomplete || accessKeyId) {
if (!accessKeyId) throw new Error('E3 access key is required');
if (!accessKeyId) throw new Error('S3 access key is required');
}
if (!allowIncomplete || secretAccessKey) {
if (!secretAccessKey) throw new Error('E3 secret key is required');
if (!secretAccessKey) throw new Error('S3 secret key is required');
}
return {
@@ -169,7 +169,7 @@ function normalizeDestination(
destination: unknown,
allowIncomplete = false
): BackupDestinationConfig {
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete);
if (destinationType === 's3') return normalizeS3Destination(destination, allowIncomplete);
return normalizeWebDavDestination(destination, allowIncomplete);
}
@@ -204,7 +204,8 @@ function defaultDestinationName(type: BackupDestinationType, index: number): str
function getDestinationType(raw: unknown): BackupDestinationType {
const value = asTrimmedString(raw);
if (value === 'e3' || value === 'webdav') return value;
if (value === 'e3') return 's3';
if (value === 's3' || value === 'webdav') return value;
throw new Error('Backup destination type is invalid');
}
@@ -266,8 +267,8 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
: BACKUP_DEFAULT_INTERVAL_HOURS;
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
const destinationType: BackupDestinationType =
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
? destinationTypeRaw
destinationTypeRaw === 'e3' || destinationTypeRaw === 's3' || destinationTypeRaw === 'webdav'
? getDestinationType(destinationTypeRaw)
: 'webdav';
const destination = {
id: createBackupRandomId(),
@@ -598,6 +599,50 @@ function getBackupSlotStartsForLocalDay(
return slots;
}
export function hasBackupSlotBetween(
destination: BackupDestinationRecord,
startInclusive: Date,
endExclusive: Date
): boolean {
if (!destination.schedule.enabled) return false;
const startMs = startInclusive.getTime();
const endMs = endExclusive.getTime();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
? lastAttemptAt.getTime()
: Number.NEGATIVE_INFINITY;
const dayCursor = new Date(startMs);
dayCursor.setUTCHours(0, 0, 0, 0);
const endDay = new Date(endMs);
endDay.setUTCHours(0, 0, 0, 0);
const checkedLocalDateKeys = new Set<string>();
while (dayCursor.getTime() <= endDay.getTime() + 24 * 60 * 60 * 1000) {
const localDateKey = getBackupLocalDateKey(dayCursor, destination.schedule.timezone);
if (!checkedLocalDateKeys.has(localDateKey)) {
checkedLocalDateKeys.add(localDateKey);
const slotStarts = getBackupSlotStartsForLocalDay(
localDateKey,
destination.schedule.timezone,
destination.schedule.startTime,
destination.schedule.intervalHours
);
for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime();
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
if (lastAttemptMs >= slotStartMs) continue;
return true;
}
}
dayCursor.setUTCDate(dayCursor.getUTCDate() + 1);
}
return false;
}
export function isBackupDueNow(
destination: BackupDestinationRecord,
now: Date,
+62 -62
View File
@@ -1,7 +1,7 @@
import {
BackupDestinationRecord,
BackupDestinationType,
E3BackupDestination,
S3BackupDestination,
WebDavBackupDestination,
} from './backup-config';
@@ -213,13 +213,13 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
if (!String(config.password || '')) throw new Error('WebDAV password is required');
return;
}
if (destination.type === 'e3') {
const config = destination.destination as E3BackupDestination;
if (!String(config.endpoint || '').trim()) throw new Error('E3 endpoint is required');
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('E3 endpoint must start with http:// or https://');
if (!String(config.bucket || '').trim()) throw new Error('E3 bucket is required');
if (!String(config.accessKeyId || '').trim()) throw new Error('E3 access key is required');
if (!String(config.secretAccessKey || '')) throw new Error('E3 secret key is required');
if (destination.type === 's3') {
const config = destination.destination as S3BackupDestination;
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('S3 endpoint must start with http:// or https://');
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
}
}
@@ -448,16 +448,16 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
return true;
}
function e3BucketBaseUrl(config: E3BackupDestination): URL {
function s3BucketBaseUrl(config: S3BackupDestination): URL {
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
}
function normalizeE3ObjectKey(config: E3BackupDestination, relativePath: string): string {
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
}
async function signedE3Request(
config: E3BackupDestination,
async function signedS3Request(
config: S3BackupDestination,
method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',
url: URL,
body?: Uint8Array,
@@ -494,41 +494,41 @@ async function signedE3Request(
});
}
async function putToE3(
config: E3BackupDestination,
async function putToS3(
config: S3BackupDestination,
relativePath: string,
bytes: Uint8Array,
options: RemoteBackupFilePutOptions = {}
): Promise<void> {
const objectKey = normalizeE3ObjectKey(config, relativePath);
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedE3Request(config, 'PUT', url, bytes, options.contentType);
const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
if (!response.ok) {
throw new Error(`E3 upload failed: ${response.status}`);
throw new Error(`S3 upload failed: ${response.status}`);
}
}
async function uploadToE3(config: E3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
await putToE3(config, fileName, archive, { contentType: 'application/zip' });
async function uploadToS3(config: S3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
await putToS3(config, fileName, archive, { contentType: 'application/zip' });
return {
provider: 'e3',
remotePath: normalizeE3ObjectKey(config, fileName),
provider: 's3',
remotePath: normalizeS3ObjectKey(config, fileName),
};
}
async function listE3Entries(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
async function listS3Entries(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
const currentPath = normalizeRelativePath(relativePath);
const targetPrefixBase = normalizeE3ObjectKey(config, currentPath);
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
const url = e3BucketBaseUrl(config);
const url = s3BucketBaseUrl(config);
url.searchParams.set('list-type', '2');
url.searchParams.set('delimiter', '/');
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
const response = await signedE3Request(config, 'GET', url);
const response = await signedS3Request(config, 'GET', url);
if (!response.ok) {
throw new Error(`E3 listing failed: ${response.status}`);
throw new Error(`S3 listing failed: ${response.status}`);
}
const xml = await response.text();
@@ -581,26 +581,26 @@ async function listE3Entries(config: E3BackupDestination, relativePath: string):
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
return {
provider: 'e3',
provider: 's3',
currentPath,
parentPath: parentPath(currentPath),
items: sortRemoteItems(Array.from(deduped.values())),
};
}
async function downloadFromE3(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
async function downloadFromS3(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
const normalized = normalizeRelativePath(relativePath);
if (!normalized || normalized.endsWith('/')) {
throw new Error('Please select a backup file');
}
const objectKey = normalizeE3ObjectKey(config, normalized);
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedE3Request(config, 'GET', url);
const objectKey = normalizeS3ObjectKey(config, normalized);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedS3Request(config, 'GET', url);
if (!response.ok) {
throw new Error(`E3 download failed: ${response.status}`);
throw new Error(`S3 download failed: ${response.status}`);
}
return {
provider: 'e3',
provider: 's3',
remotePath: normalized,
fileName: basename(normalized) || 'backup.zip',
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
@@ -608,35 +608,35 @@ async function downloadFromE3(config: E3BackupDestination, relativePath: string)
};
}
async function deleteFromE3(config: E3BackupDestination, relativePath: string): Promise<void> {
const objectKey = normalizeE3ObjectKey(config, relativePath);
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedE3Request(config, 'DELETE', url);
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedS3Request(config, 'DELETE', url);
if (!response.ok && response.status !== 404) {
throw new Error(`E3 delete failed: ${response.status}`);
throw new Error(`S3 delete failed: ${response.status}`);
}
}
async function existsInE3(config: E3BackupDestination, relativePath: string): Promise<boolean> {
const objectKey = normalizeE3ObjectKey(config, relativePath);
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedE3Request(config, 'HEAD', url);
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedS3Request(config, 'HEAD', url);
if (response.status === 404) return false;
if (!response.ok) {
throw new Error(`E3 existence check failed: ${response.status}`);
throw new Error(`S3 existence check failed: ${response.status}`);
}
return true;
}
interface ConfiguredDestinationAdapter {
provider: 'webdav' | 'e3';
config: WebDavBackupDestination | E3BackupDestination;
upload: (config: WebDavBackupDestination | E3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
putFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<void>;
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
provider: 'webdav' | 's3';
config: WebDavBackupDestination | S3BackupDestination;
upload: (config: WebDavBackupDestination | S3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
putFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
list: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
}
export interface RemoteBackupTransferSession {
@@ -666,16 +666,16 @@ function resolveConfiguredDestinationAdapter(
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
};
}
if (destination.type === 'e3') {
if (destination.type === 's3') {
return {
provider: 'e3',
config: destination.destination as E3BackupDestination,
upload: (config, archive, fileName) => uploadToE3(config as E3BackupDestination, archive, fileName),
putFile: (config, relativePath, bytes, options) => putToE3(config as E3BackupDestination, relativePath, bytes, options),
list: (config, relativePath) => listE3Entries(config as E3BackupDestination, relativePath),
download: (config, relativePath) => downloadFromE3(config as E3BackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromE3(config as E3BackupDestination, relativePath),
exists: (config, relativePath) => existsInE3(config as E3BackupDestination, relativePath),
provider: 's3',
config: destination.destination as S3BackupDestination,
upload: (config, archive, fileName) => uploadToS3(config as S3BackupDestination, archive, fileName),
putFile: (config, relativePath, bytes, options) => putToS3(config as S3BackupDestination, relativePath, bytes, options),
list: (config, relativePath) => listS3Entries(config as S3BackupDestination, relativePath),
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
};
}
@@ -703,7 +703,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
provider: adapter.provider,
remotePath: adapter.provider === 'webdav'
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
: normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName),
: normalizeS3ObjectKey(adapter.config as S3BackupDestination, fileName),
};
},
putFile,
+16 -5
View File
@@ -34,6 +34,22 @@ export async function deleteAttachment(db: D1Database, id: string): Promise<void
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
}
export async function bulkDeleteAttachmentsByIds(
db: D1Database,
sqlChunkSize: SqlChunkSize,
attachmentIds: string[]
): Promise<void> {
const uniqueIds = [...new Set(attachmentIds.map((id) => String(id || '').trim()).filter(Boolean))];
if (!uniqueIds.length) return;
const chunkSize = sqlChunkSize(0);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db.prepare(`DELETE FROM attachments WHERE id IN (${placeholders})`).bind(...chunk).run();
}
}
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
const res = await db
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
@@ -119,11 +135,6 @@ export async function addAttachmentToCipher(db: D1Database, cipherId: string, at
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
}
export async function removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
void cipherId;
void attachmentId;
}
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
}
+58 -24
View File
@@ -27,6 +27,43 @@ interface CipherRow {
deleted_at: string | null;
}
const CIPHER_SCALAR_DATA_KEYS = new Set([
'id',
'userId',
'user_id',
'type',
'folderId',
'folder_id',
'name',
'notes',
'favorite',
'reprompt',
'key',
'createdAt',
'created_at',
'creationDate',
'updatedAt',
'updated_at',
'revisionDate',
'archivedAt',
'archived_at',
'archivedDate',
'deletedAt',
'deleted_at',
'deletedDate',
]);
function buildCipherData(cipher: Cipher, folderId: string | null): string {
const payload: Record<string, unknown> = {
...cipher,
folderId,
};
for (const key of CIPHER_SCALAR_DATA_KEYS) {
delete payload[key];
}
return JSON.stringify(payload);
}
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
if (!row?.data) return null;
try {
@@ -68,10 +105,7 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
const folderId = normalizeOptionalId(cipher.folderId);
const data = JSON.stringify({
...cipher,
folderId,
});
const data = buildCipherData(cipher, folderId);
const stmt = db.prepare(
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
@@ -117,8 +151,7 @@ export async function bulkSoftDeleteCiphers(
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({ deletedAt: now, updatedAt: now });
const chunkSize = sqlChunkSize(4);
const chunkSize = sqlChunkSize(3);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -126,10 +159,11 @@ export async function bulkSoftDeleteCiphers(
await db
.prepare(
`UPDATE ciphers
SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?)
SET deleted_at = ?, updated_at = ?,
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, now, patch, userId, ...chunk)
.bind(now, now, userId, ...chunk)
.run();
}
@@ -148,8 +182,7 @@ export async function bulkRestoreCiphers(
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({ deletedAt: null, updatedAt: now });
const chunkSize = sqlChunkSize(3);
const chunkSize = sqlChunkSize(2);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -157,10 +190,11 @@ export async function bulkRestoreCiphers(
await db
.prepare(
`UPDATE ciphers
SET deleted_at = NULL, updated_at = ?, data = json_patch(data, ?)
SET deleted_at = NULL, updated_at = ?,
data = json_remove(data, '$.deletedAt', '$.deletedDate', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, patch, userId, ...chunk)
.bind(now, userId, ...chunk)
.run();
}
@@ -262,8 +296,7 @@ export async function bulkMoveCiphers(
const now = new Date().toISOString();
const normalizedFolderId = normalizeOptionalId(folderId);
const uniqueIds = sanitizeIds(ids);
const patch = JSON.stringify({ folderId: normalizedFolderId, updatedAt: now });
const chunkSize = sqlChunkSize(4);
const chunkSize = sqlChunkSize(3);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -271,10 +304,11 @@ export async function bulkMoveCiphers(
await db
.prepare(
`UPDATE ciphers
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
SET folder_id = ?, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(normalizedFolderId, now, patch, userId, ...chunk)
.bind(normalizedFolderId, now, userId, ...chunk)
.run();
}
@@ -293,8 +327,7 @@ export async function bulkArchiveCiphers(
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now });
const chunkSize = sqlChunkSize(4);
const chunkSize = sqlChunkSize(3);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -302,10 +335,11 @@ export async function bulkArchiveCiphers(
await db
.prepare(
`UPDATE ciphers
SET archived_at = ?, updated_at = ?, data = json_patch(data, ?)
SET archived_at = ?, updated_at = ?,
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
)
.bind(now, now, patch, userId, ...chunk)
.bind(now, now, userId, ...chunk)
.run();
}
@@ -324,8 +358,7 @@ export async function bulkUnarchiveCiphers(
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now });
const chunkSize = sqlChunkSize(3);
const chunkSize = sqlChunkSize(2);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -333,10 +366,11 @@ export async function bulkUnarchiveCiphers(
await db
.prepare(
`UPDATE ciphers
SET archived_at = NULL, updated_at = ?, data = json_patch(data, ?)
SET archived_at = NULL, updated_at = ?,
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, patch, userId, ...chunk)
.bind(now, userId, ...chunk)
.run();
}
+21 -37
View File
@@ -1,4 +1,4 @@
import type { Cipher, Folder } from '../types';
import type { Folder } from '../types';
function mapFolderRow(row: any): Folder {
return {
@@ -36,26 +36,18 @@ export async function deleteFolder(db: D1Database, id: string, userId: string):
export async function clearFolderFromCiphers(
db: D1Database,
userId: string,
folderId: string,
saveCipher: (cipher: Cipher) => Promise<void>
folderId: string
): Promise<void> {
const now = new Date().toISOString();
const res = await db
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
.bind(userId, folderId)
.all<{ data: string }>();
for (const row of (res.results || [])) {
let cipher: Cipher;
try {
cipher = JSON.parse(row.data) as Cipher;
} catch {
continue;
}
cipher.folderId = null;
cipher.updatedAt = now;
await saveCipher(cipher);
}
await db
.prepare(
`UPDATE ciphers
SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id = ?`
)
.bind(now, userId, folderId)
.run();
}
export async function bulkDeleteFolders(
@@ -63,34 +55,26 @@ export async function bulkDeleteFolders(
userId: string,
ids: string[],
sqlChunkSize: (fixedBindCount: number) => number,
saveCipher: (cipher: Cipher) => Promise<void>,
updateRevisionDate: (userId: string) => Promise<string>
): Promise<string | null> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return null;
const chunkSize = sqlChunkSize(1);
const now = new Date().toISOString();
const chunkSize = sqlChunkSize(2);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
const res = await db
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
.bind(userId, ...chunk)
.all<{ data: string }>();
for (const row of res.results || []) {
let cipher: Cipher;
try {
cipher = JSON.parse(row.data) as Cipher;
} catch {
continue;
}
cipher.folderId = null;
cipher.updatedAt = now;
await saveCipher(cipher);
}
await db
.prepare(
`UPDATE ciphers
SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id IN (${placeholders})`
)
.bind(now, userId, ...chunk)
.run();
await db
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
+1 -5
View File
@@ -29,6 +29,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id)',
'CREATE TABLE IF NOT EXISTS folders (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
@@ -94,11 +95,6 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
'PRIMARY KEY (identifier, window_start))',
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
+7 -8
View File
@@ -51,13 +51,13 @@ import {
} from './storage-cipher-repo';
import {
addAttachmentToCipher as attachStoredAttachmentToCipher,
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
deleteAttachment as deleteStoredAttachment,
getAttachment as findStoredAttachment,
getAttachmentsByCipher as listStoredAttachmentsByCipher,
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
getAttachmentsByUserId as listStoredAttachmentsByUserId,
removeAttachmentFromCipher as detachStoredAttachmentFromCipher,
saveAttachment as saveStoredAttachment,
updateCipherRevisionDate as updateStoredCipherRevisionDate,
} from './storage-attachment-repo';
@@ -108,7 +108,7 @@ import {
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
const STORAGE_SCHEMA_VERSION = '2026-04-22';
const STORAGE_SCHEMA_VERSION = '2026-04-28';
// D1-backed storage.
// Contract:
@@ -340,7 +340,6 @@ export class StorageService {
userId,
ids,
this.sqlChunkSize.bind(this),
this.saveCipher.bind(this),
this.updateRevisionDate.bind(this)
);
}
@@ -348,7 +347,7 @@ export class StorageService {
// Clear folder references from all ciphers owned by the user.
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
await clearStoredFolderFromCiphers(this.db, userId, folderId, this.saveCipher.bind(this));
await clearStoredFolderFromCiphers(this.db, userId, folderId);
}
async getAllFolders(userId: string): Promise<Folder[]> {
@@ -373,6 +372,10 @@ export class StorageService {
await deleteStoredAttachment(this.db, id);
}
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
}
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
return listStoredAttachmentsByCipher(this.db, cipherId);
}
@@ -389,10 +392,6 @@ export class StorageService {
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
}
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
await detachStoredAttachmentFromCipher(cipherId, attachmentId);
}
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
await deleteStoredAttachmentsByCipher(this.db, cipherId);
}
+1
View File
@@ -450,6 +450,7 @@ export interface FolderResponse {
id: string;
name: string;
revisionDate: string;
creationDate: string;
object: string;
}
+31 -84
View File
@@ -1,6 +1,8 @@
import { JWTPayload } from '../types';
import { LIMITS } from '../config/limits';
const hmacKeyCache = new Map<string, Promise<CryptoKey>>();
// Base64 URL encode
function base64UrlEncode(data: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...data));
@@ -19,6 +21,23 @@ function base64UrlDecode(str: string): Uint8Array {
return bytes;
}
function getHmacKey(secret: string): Promise<CryptoKey> {
const cacheKey = secret;
let cached = hmacKeyCache.get(cacheKey);
if (cached) return cached;
const encoder = new TextEncoder();
cached = crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify']
);
hmacKeyCache.set(cacheKey, cached);
return cached;
}
// Create JWT
export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = LIMITS.auth.accessTokenTtlSeconds): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
@@ -40,13 +59,7 @@ export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss'
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const key = await getHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
@@ -63,13 +76,7 @@ export async function verifyJWT(token: string, secret: string): Promise<JWTPaylo
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const key = await getHmacKey(secret);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
@@ -133,13 +140,7 @@ export async function createFileDownloadToken(
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const key = await getHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
@@ -159,13 +160,7 @@ export async function verifyFileDownloadToken(
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const key = await getHmacKey(secret);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
@@ -205,13 +200,7 @@ export async function createAttachmentUploadToken(
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const key = await getHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
@@ -229,13 +218,7 @@ export async function verifyAttachmentUploadToken(
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const key = await getHmacKey(secret);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
@@ -285,13 +268,7 @@ export async function createSendFileDownloadToken(
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const key = await getHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
@@ -309,13 +286,7 @@ export async function verifySendFileDownloadToken(
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const key = await getHmacKey(secret);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
@@ -361,13 +332,7 @@ export async function createSendFileUploadToken(
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const key = await getHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
@@ -385,13 +350,7 @@ export async function verifySendFileUploadToken(
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const key = await getHmacKey(secret);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
@@ -430,13 +389,7 @@ export async function createSendAccessToken(sendId: string, secret: string): Pro
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const key = await getHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`;
@@ -450,13 +403,7 @@ export async function verifySendAccessToken(token: string, secret: string): Prom
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const key = await getHmacKey(secret);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
+1 -1
View File
@@ -48,7 +48,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
return { allowOrigin: origin, allowCredentials: true };
}
if (isExtensionOrigin(origin)) {
return { allowOrigin: origin, allowCredentials: false };
return { allowOrigin: origin, allowCredentials: true };
}
return { allowOrigin: null, allowCredentials: false };
}
+16 -3
View File
@@ -3,13 +3,26 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cloudflareinsights.com https://*.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cloudflareinsights.com https://*.cloudflareinsights.com; connect-src 'self' https://api.pwnedpasswords.com https://cloudflareinsights.com https://*.cloudflareinsights.com; font-src 'self'; form-action 'self'; base-uri 'self';" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self';
font-src 'self';
form-action 'self';
base-uri 'self';
" />
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>NodeWarden</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="760" height="760" viewBox="0 0 760 760" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="760" height="760" fill="#116FF9"/>
<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"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="727" height="580" viewBox="0 0 727 580" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M370.5 93C481.785 93 572 181.2 572 290C572 329.877 559.879 366.986 539.046 398H1.68164C0.576599 391.834 0 385.484 0 379C0 323.617 42.0774 278.061 96.0078 272.558C92.7712 263.989 91 254.701 91 245C91 201.922 125.922 167 169 167C182.365 167 194.945 170.362 205.94 176.286C242.437 125.895 302.539 93 370.5 93Z" fill="#F6821F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M76.6568 1.00686C72.7796 172.923 85.5495 291.119 127.869 379.459C170.188 467.799 242.092 526.353 356.665 578.892C469.877 526.354 540.929 467.802 582.746 379.461C624.564 291.12 637.181 172.923 633.35 1.00686H76.6568ZM523.796 342.933C554.479 275.533 565.347 188.379 566.419 63.9394L566.422 63.432H361.661V503.786L362.405 503.364C442.602 457.962 493.101 410.36 523.796 342.933Z" fill="#116FF9"/>
<path d="M588.465 215C664.976 215 727 277.233 727 354C727 369.378 724.509 384.172 719.913 398H363V333.553C375.721 307.751 402.287 290 433 290C443.483 290 453.482 292.068 462.613 295.818C484.559 248.11 532.658 215 588.465 215Z" fill="#FD9C33"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+11 -11
View File
@@ -1,12 +1,12 @@
<svg width="862" height="101" viewBox="0 0 8620 1017" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z" fill="#006DF4"/>
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z" fill="#006DF4"/>
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z" fill="#006DF4"/>
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z" fill="#006DF4"/>
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z" fill="#006DF4"/>
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z" fill="#006DF4"/>
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z" fill="#006DF4"/>
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z" fill="#006DF4"/>
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z" fill="#006DF4"/>
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z" fill="#006DF4"/>
<svg width="862" height="102" viewBox="0 0 8620 1017" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z" fill="black"/>
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z" fill="black"/>
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z" fill="black"/>
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z" fill="black"/>
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z" fill="black"/>
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z" fill="black"/>
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z" fill="black"/>
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z" fill="black"/>
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z" fill="black"/>
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

+191 -300
View File
@@ -13,6 +13,7 @@ import {
clearProfileSnapshot,
getCurrentDeviceIdentifier,
getPasswordHint,
getProfile,
loadProfileSnapshot,
saveProfileSnapshot,
revokeCurrentSession,
@@ -21,19 +22,10 @@ import {
stripProfileSecrets,
} from '@/lib/api/auth';
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
import { buildSendShareKey, getSends } from '@/lib/api/send';
import {
getCiphers,
getFolders,
repairCipherAttachmentMetadata,
updateFolder,
} from '@/lib/api/vault';
import { getSends } from '@/lib/api/send';
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
import { base64ToBytes, decryptBw, decryptStr, encryptBw } from '@/lib/crypto';
import {
buildPublicSendUrl,
deriveSendKeyParts,
looksLikeCipherString,
parseSignalRTextFrames,
readInviteCodeFromUrl,
} from '@/lib/app-support';
@@ -58,7 +50,10 @@ import { useToastManager } from '@/hooks/useToastManager';
import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
import type { VaultCoreSnapshot } from '@/lib/vault-cache';
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
if (!value || typeof value !== 'object') return false;
@@ -76,6 +71,10 @@ const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
const SETTINGS_HOME_ROUTE = '/settings';
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
function isAdminProfile(profile: Profile | null): profile is Profile {
return String(profile?.role || '').toLowerCase() === 'admin';
}
const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
@@ -90,7 +89,6 @@ type SessionTimeoutAction = 'lock' | 'logout';
const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1';
const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1';
const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]);
function readThemePreference(): ThemePreference {
if (typeof window === 'undefined') return 'system';
const stored = String(window.localStorage.getItem(THEME_STORAGE_KEY) || '').trim();
@@ -169,11 +167,15 @@ export default function App() {
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
const [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null);
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>('');
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
const notificationRefreshTimerRef = useRef<number | null>(null);
const { toasts, pushToast, removeToast } = useToastManager();
useEffect(() => {
@@ -329,6 +331,7 @@ export default function App() {
},
[authedFetch]
);
const vaultCacheKey = String(profile?.id || session?.email || '').trim();
const backupActions = useBackupActions({
authedFetch,
onImported: () => {
@@ -727,50 +730,97 @@ export default function App() {
);
}
const ciphersQuery = useQuery({
queryKey: ['ciphers', session?.accessToken],
queryFn: () => getCiphers(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
});
const foldersQuery = useQuery({
queryKey: ['folders', session?.accessToken],
queryFn: () => getFolders(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
useEffect(() => {
let cancelled = false;
if (phase !== 'app' || !session?.symEncKey || !session?.symMacKey || !vaultCacheKey) {
setCachedVaultCore(null);
return;
}
void (async () => {
const snapshot = await getCachedVaultCoreSnapshot(vaultCacheKey);
if (!cancelled) {
setCachedVaultCore(snapshot);
}
})();
return () => {
cancelled = true;
};
}, [phase, session?.symEncKey, session?.symMacKey, vaultCacheKey]);
async function refetchVaultCoreData() {
if (pendingVaultCoreQueryRefreshRef.current) {
return pendingVaultCoreQueryRefreshRef.current;
}
const request = vaultCoreQuery.refetch().finally(() => {
if (pendingVaultCoreQueryRefreshRef.current === request) {
pendingVaultCoreQueryRefreshRef.current = null;
}
});
pendingVaultCoreQueryRefreshRef.current = request;
return request;
}
const vaultCoreQuery = useQuery({
queryKey: ['vault-core', vaultCacheKey],
queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, vaultCacheKey),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
staleTime: 30_000,
});
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
const encryptedFolders = encryptedVaultCore?.folders;
const encryptedCiphers = encryptedVaultCore?.ciphers;
const sendsQuery = useQuery({
queryKey: ['sends', session?.accessToken],
queryKey: ['sends', vaultCacheKey || session?.email],
queryFn: () => getSends(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && (vaultInitialDecryptDone || location === '/sends'),
staleTime: 30_000,
});
const profileQuery = useQuery({
queryKey: ['profile', vaultCacheKey || session?.email],
queryFn: () => getProfile(authedFetch),
enabled: phase === 'app' && !!session?.accessToken,
staleTime: 30_000,
});
useEffect(() => {
if (!profileQuery.data) return;
setProfile(profileQuery.data);
}, [profileQuery.data]);
const isAdmin = isAdminProfile(profile);
const usersQuery = useQuery({
queryKey: ['admin-users', session?.accessToken],
queryKey: ['admin-users', vaultCacheKey],
queryFn: () => listAdminUsers(authedFetch),
enabled: phase === 'app' && profile?.role === 'admin',
enabled: phase === 'app' && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000,
});
const invitesQuery = useQuery({
queryKey: ['admin-invites', session?.accessToken],
queryKey: ['admin-invites', vaultCacheKey],
queryFn: () => listAdminInvites(authedFetch),
enabled: phase === 'app' && profile?.role === 'admin',
enabled: phase === 'app' && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000,
});
const totpStatusQuery = useQuery({
queryKey: ['totp-status', session?.accessToken],
queryKey: ['totp-status', vaultCacheKey || session?.email],
queryFn: () => getTotpStatus(authedFetch),
enabled: phase === 'app' && !!session?.accessToken,
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000,
});
const authorizedDevicesQuery = useQuery({
queryKey: ['authorized-devices', session?.accessToken],
queryKey: ['authorized-devices', vaultCacheKey || session?.email],
queryFn: () => getAuthorizedDevices(authedFetch),
enabled: phase === 'app' && !!session?.accessToken,
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000,
});
useEffect(() => {
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
if (!profile?.role || profile.role !== 'admin') return;
if (!vaultInitialDecryptDone) return;
if (!isAdminProfile(profile)) return;
if (repairAttemptRef.current === session.accessToken) return;
repairAttemptRef.current = session.accessToken;
void silentlyRepairBackupSettingsIfNeeded(session, profile);
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile]);
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
useEffect(() => {
if (session?.accessToken) return;
@@ -782,241 +832,74 @@ export default function App() {
setDecryptedFolders([]);
setDecryptedCiphers([]);
setDecryptedSends([]);
setVaultInitialDecryptDone(false);
return;
}
if (!foldersQuery.data || !ciphersQuery.data || !sendsQuery.data) return;
if (!encryptedFolders || !encryptedCiphers) return;
let active = true;
(async () => {
try {
const encKey = base64ToBytes(session.symEncKey!);
const macKey = base64ToBytes(session.symMacKey!);
const decryptField = async (
value: string | null | undefined,
fieldEnc: Uint8Array = encKey,
fieldMac: Uint8Array = macKey
): Promise<string> => {
if (!value || typeof value !== 'string') return '';
try {
return await decryptStr(value, fieldEnc, fieldMac);
} catch {
// Backward-compatibility: some records may already be plain text.
return value;
}
};
const sameBytes = (a: Uint8Array, b: Uint8Array) => {
if (a.byteLength !== b.byteLength) return false;
for (let i = 0; i < a.byteLength; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
};
const decryptFieldWithSource = async (
value: string | null | undefined,
itemEnc: Uint8Array,
itemMac: Uint8Array
): Promise<{ text: string; source: 'item' | 'user' | 'plain' }> => {
const raw = String(value || '').trim();
if (!raw) return { text: '', source: 'plain' };
try {
return { text: await decryptStr(raw, itemEnc, itemMac), source: 'item' };
} catch {
// 继续尝试旧 user key 数据。
}
if (!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey)) {
try {
return { text: await decryptStr(raw, encKey, macKey), source: 'user' };
} catch {
// 保留原文。
}
}
return { text: raw, source: 'plain' };
};
const folders = await Promise.all(
foldersQuery.data.map(async (folder) => ({
...folder,
decName: await decryptField(folder.name, encKey, macKey),
}))
);
const ciphers = await Promise.all(
ciphersQuery.data.map(async (cipher) => {
let itemEnc = encKey;
let itemMac = macKey;
if (cipher.key) {
try {
const itemKey = await decryptBw(cipher.key, encKey, macKey);
itemEnc = itemKey.slice(0, 32);
itemMac = itemKey.slice(32, 64);
} catch {
// keep user key when item key decrypt fails
}
}
const nextCipher: Cipher = {
...cipher,
decName: await decryptField(cipher.name || '', itemEnc, itemMac),
decNotes: await decryptField(cipher.notes || '', itemEnc, itemMac),
};
if (cipher.login) {
nextCipher.login = {
...cipher.login,
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
uris: await Promise.all(
(cipher.login.uris || []).map(async (u) => ({
...u,
decUri: await decryptField(u.uri || '', itemEnc, itemMac),
}))
),
};
}
if (Array.isArray(cipher.passwordHistory)) {
nextCipher.passwordHistory = await Promise.all(
cipher.passwordHistory.map(async (entry) => ({
...entry,
decPassword: await decryptField(entry?.password || '', itemEnc, itemMac),
}))
);
}
if (cipher.card) {
nextCipher.card = {
...cipher.card,
decCardholderName: await decryptField(cipher.card.cardholderName || '', itemEnc, itemMac),
decNumber: await decryptField(cipher.card.number || '', itemEnc, itemMac),
decBrand: await decryptField(cipher.card.brand || '', itemEnc, itemMac),
decExpMonth: await decryptField(cipher.card.expMonth || '', itemEnc, itemMac),
decExpYear: await decryptField(cipher.card.expYear || '', itemEnc, itemMac),
decCode: await decryptField(cipher.card.code || '', itemEnc, itemMac),
};
}
if (cipher.identity) {
nextCipher.identity = {
...cipher.identity,
decTitle: await decryptField(cipher.identity.title || '', itemEnc, itemMac),
decFirstName: await decryptField(cipher.identity.firstName || '', itemEnc, itemMac),
decMiddleName: await decryptField(cipher.identity.middleName || '', itemEnc, itemMac),
decLastName: await decryptField(cipher.identity.lastName || '', itemEnc, itemMac),
decUsername: await decryptField(cipher.identity.username || '', itemEnc, itemMac),
decCompany: await decryptField(cipher.identity.company || '', itemEnc, itemMac),
decSsn: await decryptField(cipher.identity.ssn || '', itemEnc, itemMac),
decPassportNumber: await decryptField(cipher.identity.passportNumber || '', itemEnc, itemMac),
decLicenseNumber: await decryptField(cipher.identity.licenseNumber || '', itemEnc, itemMac),
decEmail: await decryptField(cipher.identity.email || '', itemEnc, itemMac),
decPhone: await decryptField(cipher.identity.phone || '', itemEnc, itemMac),
decAddress1: await decryptField(cipher.identity.address1 || '', itemEnc, itemMac),
decAddress2: await decryptField(cipher.identity.address2 || '', itemEnc, itemMac),
decAddress3: await decryptField(cipher.identity.address3 || '', itemEnc, itemMac),
decCity: await decryptField(cipher.identity.city || '', itemEnc, itemMac),
decState: await decryptField(cipher.identity.state || '', itemEnc, itemMac),
decPostalCode: await decryptField(cipher.identity.postalCode || '', itemEnc, itemMac),
decCountry: await decryptField(cipher.identity.country || '', itemEnc, itemMac),
};
}
if (cipher.sshKey) {
const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || '';
nextCipher.sshKey = {
...cipher.sshKey,
decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac),
decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac),
keyFingerprint: encryptedFingerprint || null,
fingerprint: encryptedFingerprint || null,
decFingerprint: await decryptField(encryptedFingerprint, itemEnc, itemMac),
};
}
if (cipher.fields) {
nextCipher.fields = await Promise.all(
cipher.fields.map(async (field) => ({
...field,
decName: await decryptField(field.name || '', itemEnc, itemMac),
decValue: await decryptField(field.value || '', itemEnc, itemMac),
}))
);
}
if (Array.isArray(cipher.attachments)) {
nextCipher.attachments = await Promise.all(
cipher.attachments.map(async (attachment) => {
const attachmentId = String(attachment?.id || '').trim();
const fileNameResult = await decryptFieldWithSource(attachment.fileName || '', itemEnc, itemMac);
const metadata: { fileName?: string; key?: string | null } = {};
if (attachmentId && fileNameResult.source === 'user') {
metadata.fileName = await encryptBw(new TextEncoder().encode(fileNameResult.text), itemEnc, itemMac);
}
const attachmentKey = String(attachment?.key || '').trim();
if (
attachmentId &&
attachmentKey &&
looksLikeCipherString(attachmentKey) &&
(!sameBytes(itemEnc, encKey) || !sameBytes(itemMac, macKey))
) {
try {
await decryptBw(attachmentKey, itemEnc, itemMac);
} catch {
try {
const rawAttachmentKey = await decryptBw(attachmentKey, encKey, macKey);
if (rawAttachmentKey.length >= 64) {
metadata.key = await encryptBw(rawAttachmentKey, itemEnc, itemMac);
}
} catch {
// 文件下载时会继续尝试旧格式。
}
}
}
if (attachmentId && Object.keys(metadata).length > 0) {
void repairCipherAttachmentMetadata(authedFetch, cipher.id, attachmentId, metadata);
}
return {
...attachment,
decFileName: fileNameResult.text,
};
})
);
}
return nextCipher;
})
);
const sends = await Promise.all(
sendsQuery.data.map(async (send) => {
const nextSend: Send = { ...send };
try {
if (send.key) {
const sendKeyRaw = await decryptBw(send.key, encKey, macKey);
const derived = await deriveSendKeyParts(sendKeyRaw);
nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac);
nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac);
nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac);
if (send.file?.fileName) {
const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac);
nextSend.file = {
...(send.file || {}),
fileName: decFileName || send.file.fileName,
};
}
const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!);
nextSend.decShareKey = shareKey;
nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey);
} else {
nextSend.decName = '';
nextSend.decNotes = '';
nextSend.decText = '';
}
} catch {
nextSend.decName = t('txt_decrypt_failed');
}
return nextSend;
})
);
let result;
try {
result = await decryptVaultCoreInWorker({
folders: encryptedFolders,
ciphers: encryptedCiphers,
symEncKeyB64: session.symEncKey!,
symMacKeyB64: session.symMacKey!,
});
} catch {
result = await decryptVaultCore({
folders: encryptedFolders,
ciphers: encryptedCiphers,
symEncKeyB64: session.symEncKey!,
symMacKeyB64: session.symMacKey!,
});
}
if (!active) return;
setDecryptedFolders(result.folders);
setDecryptedCiphers(result.ciphers);
setVaultInitialDecryptDone(true);
} catch (error) {
if (!active) return;
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
}
})();
return () => {
active = false;
};
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]);
useEffect(() => {
if (!session?.symEncKey || !session?.symMacKey) {
setDecryptedSends([]);
return;
}
if (!sendsQuery.data) return;
let active = true;
(async () => {
try {
let sends;
try {
sends = await decryptSendsInWorker({
sends: sendsQuery.data,
symEncKeyB64: session.symEncKey!,
symMacKeyB64: session.symMacKey!,
origin: window.location.origin,
});
} catch {
sends = await decryptSends({
sends: sendsQuery.data,
symEncKeyB64: session.symEncKey!,
symMacKeyB64: session.symMacKey!,
origin: window.location.origin,
});
}
if (!active) return;
setDecryptedFolders(folders);
setDecryptedCiphers(ciphers);
setDecryptedSends(sends);
} catch (error) {
if (!active) return;
@@ -1027,41 +910,30 @@ export default function App() {
return () => {
active = false;
};
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]);
useEffect(() => {
if (!session?.symEncKey || !session?.symMacKey || !foldersQuery.data?.length) return;
let cancelled = false;
(async () => {
const pending = foldersQuery.data.filter((folder) => {
if (!folder?.id || !folder?.name) return false;
if (migratedPlainFolderIdsRef.current.has(folder.id)) return false;
return !looksLikeCipherString(String(folder.name));
});
if (!pending.length) return;
for (const folder of pending) {
try {
await updateFolder(authedFetch, session, folder.id, String(folder.name));
migratedPlainFolderIdsRef.current.add(folder.id);
} catch {
// keep silent; web still supports plaintext fallback display
}
}
if (!cancelled) await foldersQuery.refetch();
})();
return () => {
cancelled = true;
};
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, authedFetch]);
}, [session?.symEncKey, session?.symMacKey, sendsQuery.data]);
async function refreshVaultSilently() {
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]);
if (pendingVaultCoreRefreshRef.current) {
await pendingVaultCoreRefreshRef.current;
return;
}
const tasks: Promise<unknown>[] = [refetchVaultCoreData()];
if (location === '/sends') {
tasks.push(sendsQuery.refetch());
}
const request = Promise.all(tasks).finally(() => {
if (pendingVaultCoreRefreshRef.current === request) {
pendingVaultCoreRefreshRef.current = null;
}
});
pendingVaultCoreRefreshRef.current = request;
await request;
}
silentRefreshVaultRef.current = refreshVaultSilently;
useEffect(() => {
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return;
let disposed = false;
let socket: WebSocket | null = null;
@@ -1152,7 +1024,13 @@ export default function App() {
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
void silentRefreshVaultRef.current();
if (notificationRefreshTimerRef.current !== null) {
window.clearTimeout(notificationRefreshTimerRef.current);
}
notificationRefreshTimerRef.current = window.setTimeout(() => {
notificationRefreshTimerRef.current = null;
void silentRefreshVaultRef.current();
}, 250);
}
});
@@ -1176,6 +1054,10 @@ export default function App() {
return () => {
disposed = true;
if (notificationRefreshTimerRef.current !== null) {
window.clearTimeout(notificationRefreshTimerRef.current);
notificationRefreshTimerRef.current = null;
}
clearReconnectTimer();
if (socket) {
const s = socket;
@@ -1187,7 +1069,7 @@ export default function App() {
}
}
};
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey]);
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, vaultInitialDecryptDone]);
const vaultSendActions = useVaultSendActions({
authedFetch,
@@ -1195,12 +1077,20 @@ export default function App() {
session,
profile,
defaultKdfIterations,
encryptedCiphers: ciphersQuery.data,
encryptedFolders: foldersQuery.data,
refetchCiphers: ciphersQuery.refetch,
refetchFolders: foldersQuery.refetch,
encryptedCiphers,
encryptedFolders,
refetchCiphers: async () => {
const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot };
return { data: result.data?.ciphers };
},
refetchFolders: async () => {
const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot };
return { data: result.data?.folders };
},
refetchSends: sendsQuery.refetch,
onNotify: pushToast,
patchDecryptedCiphers: setDecryptedCiphers,
patchDecryptedFolders: setDecryptedFolders,
});
const accountSecurityActions = useAccountSecurityActions({
authedFetch,
@@ -1227,6 +1117,7 @@ export default function App() {
});
refreshAuthorizedDevicesRef.current = async () => {
if (!vaultInitialDecryptDone) return;
await authorizedDevicesQuery.refetch();
};
@@ -1274,10 +1165,10 @@ export default function App() {
}, [phase, isImportHashRoute, location, navigate]);
useEffect(() => {
if (phase === 'app' && profile?.role !== 'admin' && location === '/backup') {
if (phase === 'app' && !isAdminProfile(profile) && location === '/backup' && !profileQuery.isFetching) {
navigate('/vault');
}
}, [phase, profile?.role, location, navigate]);
}, [phase, profile?.role, profileQuery.isFetching, location, navigate]);
useEffect(() => {
if (phase === 'app' && !mobileLayout && location === SETTINGS_HOME_ROUTE) {
@@ -1296,9 +1187,9 @@ export default function App() {
decryptedCiphers,
decryptedFolders,
decryptedSends,
ciphersLoading: ciphersQuery.isFetching,
foldersLoading: foldersQuery.isFetching,
sendsLoading: sendsQuery.isFetching,
ciphersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore,
foldersLoading: vaultCoreQuery.isFetching && !encryptedVaultCore,
sendsLoading: sendsQuery.isFetching && !sendsQuery.data,
users: usersQuery.data || [],
invites: invitesQuery.data || [],
totpEnabled: !!totpStatusQuery.data?.enabled,
@@ -25,16 +25,21 @@ interface AppAuthenticatedShellProps {
mainRoutesProps: AppMainRoutesProps;
}
function isAdminProfile(profile: Profile | null): boolean {
return String(profile?.role || '').toLowerCase() === 'admin';
}
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
const isAdmin = isAdminProfile(props.profile);
return (
<div className="app-page">
<div className="app-shell">
<header className="topbar">
<div className="brand">
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
<img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="brand-wordmark" />
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="brand-logo" />
<span className="brand-wordmark" role="img" aria-label="NodeWarden" />
<span className="mobile-page-title">{props.currentPageTitle}</span>
</div>
<div className="topbar-actions">
@@ -83,7 +88,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<SendIcon size={16} />
<span>{t('nav_sends')}</span>
</Link>
{props.profile?.role === 'admin' && (
{isAdmin && (
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
<ShieldUser size={16} />
<span>{t('nav_admin_panel')}</span>
@@ -97,7 +102,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<Shield size={16} />
<span>{t('nav_device_management')}</span>
</Link>
{props.profile?.role === 'admin' && (
{isAdmin && (
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
<Cloud size={16} />
<span>{t('nav_backup_strategy')}</span>
+5 -4
View File
@@ -9,9 +9,9 @@ import { t } from '@/lib/i18n';
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage'));
const SendsPage = lazy(() => import('@/components/SendsPage'));
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
const VaultPage = lazy(() => import('@/components/VaultPage'));
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
const AdminPage = lazy(() => import('@/components/AdminPage'));
@@ -129,6 +129,7 @@ export interface AppMainRoutesProps {
export default function AppMainRoutes(props: AppMainRoutesProps) {
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
const isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin';
const importPageContent = (
<Suspense fallback={<RouteContentFallback />}>
<ImportPage
@@ -262,13 +263,13 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
</Link>
{props.profile.role === 'admin' && (
{isAdmin && (
<Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span>
</Link>
)}
{props.profile.role === 'admin' && (
{isAdmin && (
<Link href="/backup" className="mobile-settings-link">
<Cloud size={18} />
<span>{t('nav_backup_strategy')}</span>
@@ -340,7 +341,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<LegacyBackupRedirect onNavigate={props.onNavigate} />
</Route>
<Route path="/backup">
{props.profile?.role === 'admin' ? (
{isAdmin ? (
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
+23
View File
@@ -0,0 +1,23 @@
interface LoadingStateProps {
lines?: number;
compact?: boolean;
card?: boolean;
className?: string;
}
export default function LoadingState(props: LoadingStateProps) {
const lines = Math.max(1, props.lines || 4);
return (
<div className={`${props.card ? 'loading-state-card card' : 'loading-state'}${props.compact ? ' compact' : ''}${props.className ? ` ${props.className}` : ''}`} aria-hidden="true">
{Array.from({ length: lines }, (_, index) => (
<div key={index} className="loading-state-row">
<div className="loading-state-icon shimmer" />
<div className="loading-state-text">
<div className="loading-state-line shimmer" />
<div className="loading-state-line short shimmer" />
</div>
</div>
))}
</div>
);
}
+8 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import LoadingState from '@/components/LoadingState';
import type { Send, SendDraft } from '@/lib/types';
import { t } from '@/lib/i18n';
@@ -79,6 +80,7 @@ export default function SendsPage(props: SendsPageProps) {
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey);
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
try {
return localStorage.getItem(AUTO_COPY_KEY) === '1';
@@ -108,7 +110,8 @@ export default function SendsPage(props: SendsPageProps) {
}, []);
useEffect(() => {
if (!props.mobileSidebarToggleKey) return;
if (props.mobileSidebarToggleKey === mobileSidebarToggleKeyRef.current) return;
mobileSidebarToggleKeyRef.current = props.mobileSidebarToggleKey;
setMobileSidebarOpen((open) => !open);
}, [props.mobileSidebarToggleKey]);
@@ -320,6 +323,7 @@ export default function SendsPage(props: SendsPageProps) {
</button>
</div>
<div className="list-panel">
{props.loading && !filteredSends.length && <LoadingState lines={6} compact />}
{filteredSends.map((send, index) => (
<div
key={send.id}
@@ -373,7 +377,7 @@ export default function SendsPage(props: SendsPageProps) {
</button>
</div>
))}
{!filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
{!props.loading && !filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
</div>
</section>
@@ -551,6 +555,7 @@ export default function SendsPage(props: SendsPageProps) {
</div>
</div>
)}
{!isEditing && !selectedSend && props.loading && <LoadingState card lines={4} />}
</section>
</div>
);
+57 -24
View File
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, Lightbulb, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types';
import { t } from '@/lib/i18n';
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog';
interface SettingsPageProps {
@@ -79,6 +79,7 @@ export default function SettingsPage(props: SettingsPageProps) {
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<null | 'recovery' | 'apiKey' | 'rotateApiKey'>(null);
const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState('');
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
const [selectedLocale, setSelectedLocale] = useState<Locale>(() => getLocale());
useEffect(() => {
clearLegacyTotpSetupSecrets();
@@ -167,6 +168,13 @@ export default function SettingsPage(props: SettingsPageProps) {
return parsed.toLocaleString();
}
async function changeLocale(next: Locale): Promise<void> {
if (next === getLocale()) return;
setSelectedLocale(next);
await setLocale(next);
window.location.reload();
}
return (
<div className="settings-modules-grid">
<section className="card settings-module">
@@ -200,9 +208,23 @@ export default function SettingsPage(props: SettingsPageProps) {
</div>
</section>
<section className="card settings-module settings-module-placeholder">
<Lightbulb size={26} aria-hidden="true" />
<span>{t('txt_in_planning')}</span>
<section className="card settings-module">
<h3>{t('txt_language')}</h3>
<label className="field">
<span>{t('txt_display_language')}</span>
<select
className="input"
value={selectedLocale}
onInput={(e) => void changeLocale((e.currentTarget as HTMLSelectElement).value as Locale)}
>
{AVAILABLE_LOCALES.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div className="field-help">{t('txt_language_saved_locally')}</div>
</label>
</section>
<section className="card settings-module">
@@ -269,7 +291,33 @@ export default function SettingsPage(props: SettingsPageProps) {
<div>
<label className="field">
<span>{t('txt_authenticator_key')}</span>
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
<div className="totp-secret-input-wrap">
<input className="input totp-secret-input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
<div className="totp-secret-actions">
<button
type="button"
className="btn btn-secondary small totp-secret-icon-btn"
disabled={totpLocked}
title={t('txt_regenerate')}
aria-label={t('txt_regenerate')}
onClick={() => setSecret(randomBase32Secret(32))}
>
<RefreshCw size={14} className="btn-icon" />
</button>
<button
type="button"
className="btn btn-secondary small totp-secret-icon-btn"
disabled={totpLocked}
title={t('txt_copy_secret')}
aria-label={t('txt_copy_secret')}
onClick={() => {
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
}}
>
<Clipboard size={14} className="btn-icon" />
</button>
</div>
</div>
</label>
<label className="field">
<span>{t('txt_verification_code')}</span>
@@ -280,29 +328,14 @@ export default function SettingsPage(props: SettingsPageProps) {
<ShieldCheck size={14} className="btn-icon" />
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')}
</button>
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_regenerate')}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={totpLocked}
onClick={() => {
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
}}
>
<Clipboard size={14} className="btn-icon" />
{t('txt_copy_secret')}
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
<ShieldOff size={14} className="btn-icon" />
{t('txt_disable_totp')}
</button>
</div>
</div>
</div>
</div>
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
<ShieldOff size={14} className="btn-icon" />
{t('txt_disable_totp')}
</button>
</section>
<section className="card settings-module">
@@ -10,9 +10,9 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
return (
<div className="standalone-shell">
<div className="standalone-brand standalone-brand-outside">
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="standalone-brand-logo" />
<div>
<img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="standalone-brand-wordmark" />
<span className="standalone-brand-wordmark" role="img" aria-label="NodeWarden" />
</div>
</div>
+57 -87
View File
@@ -21,7 +21,9 @@ import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
import { calcTotpNow } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
import { isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers';
import LoadingState from '@/components/LoadingState';
import WebsiteIcon from '@/components/vault/WebsiteIcon';
import { isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
interface TotpCodesPageProps {
ciphers: Cipher[];
@@ -33,8 +35,7 @@ const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
const failedIconHosts = new Set<string>();
const TOTP_REFRESH_BATCH_SIZE = 16;
function getTotpTimeState(): { windowId: number; remain: number } {
const epoch = Math.floor(Date.now() / 1000);
return {
@@ -50,71 +51,8 @@ function formatTotp(code: string): string {
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
}
function firstCipherUri(cipher: Cipher): string {
const uris = cipher.login?.uris || [];
for (const uri of uris) {
const raw = uri.decUri || uri.uri || '';
if (raw.trim()) return raw.trim();
}
return '';
}
function hostFromUri(uri: string): string {
if (!uri.trim()) return '';
try {
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
return new URL(normalized).hostname || '';
} catch {
return '';
}
}
function TotpListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher);
const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
const [loaded, setLoaded] = useState(false);
const markIconError = () => {
if (host) failedIconHosts.add(host);
setErrored(true);
};
const syncCachedIconState = (img: HTMLImageElement | null) => {
if (!img || !img.complete) return;
if (img.naturalWidth > 0) {
setLoaded(true);
return;
}
markIconError();
};
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
setLoaded(false);
}, [host]);
if (host && !errored) {
return (
<span className="list-icon-stack">
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
<Globe size={18} />
</span>
<img
className={`list-icon ${loaded ? 'loaded' : ''}`}
src={websiteIconUrl(host)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
ref={syncCachedIconState}
onLoad={() => setLoaded(true)}
onError={markIconError}
/>
</span>
);
}
return (
<span className="list-icon-fallback">
<Globe size={18} />
</span>
);
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
}
interface SortableTotpRowProps {
@@ -226,16 +164,21 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
}
const nameCollator = useMemo(
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
[]
);
const baseTotpItems = useMemo(
() =>
props.ciphers
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
.sort((a, b) => {
const nameA = (a.decName || a.name || '').trim().toLowerCase();
const nameB = (b.decName || b.name || '').trim().toLowerCase();
return nameA.localeCompare(nameB);
const nameA = (a.decName || a.name || '').trim();
const nameB = (b.decName || b.name || '').trim();
return nameCollator.compare(nameA, nameB);
}),
[props.ciphers]
[props.ciphers, nameCollator]
);
const totpItems = useMemo(() => {
@@ -247,11 +190,13 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
if (orderA != null && orderB != null) return orderA - orderB;
if (orderA != null) return -1;
if (orderB != null) return 1;
const nameA = (a.decName || a.name || '').trim().toLowerCase();
const nameB = (b.decName || b.name || '').trim().toLowerCase();
return nameA.localeCompare(nameB);
const nameA = (a.decName || a.name || '').trim();
const nameB = (b.decName || b.name || '').trim();
return nameCollator.compare(nameA, nameB);
});
}, [baseTotpItems, orderedIds]);
}, [baseTotpItems, orderedIds, nameCollator]);
const sortableTotpItems = useMemo(() => totpItems.map((cipher) => cipher.id), [totpItems]);
useEffect(() => {
if (!baseTotpItems.length) return;
@@ -288,17 +233,41 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
const refreshCodes = async () => {
const runId = ++activeRun;
const entries = await Promise.all(
totpItems.map(async (cipher) => {
try {
const next = await calcTotpNow(cipher.login?.decTotp || '');
return [cipher.id, next?.code || null] as const;
} catch {
return [cipher.id, null] as const;
}
})
);
if (!stopped && runId === activeRun) setTotpCodes(Object.fromEntries(entries));
const nextCodes: Record<string, string | null> = {};
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
if (stopped || runId !== activeRun) return;
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
const entries = await Promise.all(
batch.map(async (cipher) => {
try {
const next = await calcTotpNow(cipher.login?.decTotp || '');
return [cipher.id, next?.code || null] as const;
} catch {
return [cipher.id, null] as const;
}
})
);
for (const [id, code] of entries) nextCodes[id] = code;
if (start + TOTP_REFRESH_BATCH_SIZE < totpItems.length) {
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
}
}
if (stopped || runId !== activeRun) return;
setTotpCodes((prev) => {
let changed = false;
const next: Record<string, string | null> = { ...prev };
for (const id of Object.keys(next)) {
if (id in nextCodes) continue;
delete next[id];
changed = true;
}
for (const [id, code] of Object.entries(nextCodes)) {
if (next[id] === code) continue;
next[id] = code;
changed = true;
}
return changed ? next : prev;
});
};
const tick = () => {
@@ -359,9 +328,10 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
className="totp-codes-list"
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
>
{!totpItems.length && props.loading && <LoadingState lines={6} />}
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={totpItems.map((cipher) => cipher.id)} strategy={rectSortingStrategy}>
<SortableContext items={sortableTotpItems} strategy={rectSortingStrategy}>
{totpItems.map((cipher) => (
<SortableTotpRow
key={cipher.id}
+244 -106
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import LoadingState from '@/components/LoadingState';
import VaultDialogs from '@/components/vault/VaultDialogs';
import VaultDetailView from '@/components/vault/VaultDetailView';
import VaultEditor from '@/components/vault/VaultEditor';
@@ -8,6 +9,7 @@ import {
MOBILE_LAYOUT_QUERY,
VAULT_LIST_OVERSCAN,
VAULT_LIST_ROW_HEIGHT,
FOLDER_SORT_STORAGE_KEY,
VAULT_SORT_STORAGE_KEY,
cipherTypeKey,
cipherTypeLabel,
@@ -72,6 +74,8 @@ export default function VaultPage(props: VaultPageProps) {
const [searchComposing, setSearchComposing] = useState(false);
const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
@@ -111,10 +115,14 @@ export default function VaultPage(props: VaultPageProps) {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const createMenuRef = useRef<HTMLDivElement | null>(null);
const sortMenuRef = useRef<HTMLDivElement | null>(null);
const folderSortMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const listPanelRef = useRef<HTMLDivElement | null>(null);
const mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey);
const sshSeedTicketRef = useRef(0);
const sshFingerprintTicketRef = useRef(0);
const listScrollBucketRef = useRef(0);
const [listScrollTop, setListScrollTop] = useState(0);
const [listViewportHeight, setListViewportHeight] = useState(0);
@@ -132,7 +140,8 @@ export default function VaultPage(props: VaultPageProps) {
}, []);
useEffect(() => {
if (!props.mobileSidebarToggleKey) return;
if (props.mobileSidebarToggleKey === mobileSidebarToggleKeyRef.current) return;
mobileSidebarToggleKeyRef.current = props.mobileSidebarToggleKey;
setMobileSidebarOpen((open) => !open);
}, [props.mobileSidebarToggleKey]);
@@ -163,6 +172,25 @@ export default function VaultPage(props: VaultPageProps) {
}
}, [sortMode]);
useEffect(() => {
try {
const saved = String(localStorage.getItem(FOLDER_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
if (saved === 'edited' || saved === 'created' || saved === 'name') {
setFolderSortMode(saved);
}
} catch {
// ignore storage read failures
}
}, []);
useEffect(() => {
try {
localStorage.setItem(FOLDER_SORT_STORAGE_KEY, folderSortMode);
} catch {
// ignore storage write failures
}
}, [folderSortMode]);
useEffect(() => {
const node = listPanelRef.current;
if (!node) return;
@@ -211,6 +239,25 @@ export default function VaultPage(props: VaultPageProps) {
};
}, [sortMenuOpen]);
useEffect(() => {
const onPointerDown = (event: Event) => {
if (!folderSortMenuOpen) return;
const target = event.target as Node | null;
if (folderSortMenuRef.current && target && !folderSortMenuRef.current.contains(target)) {
setFolderSortMenuOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setFolderSortMenuOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [folderSortMenuOpen]);
useEffect(() => {
setRepromptApprovedCipherId(null);
setRepromptPassword('');
@@ -241,29 +288,75 @@ export default function VaultPage(props: VaultPageProps) {
void recalculateSshFingerprint(draft.sshPublicKey);
}, [isEditing, draft?.id, draft?.type]);
const duplicateSignatureCounts = useMemo(() => {
const cipherMetaById = useMemo(() => {
const meta = new Map<string, {
name: string;
searchText: string;
firstUri: string;
typeKey: string;
sortTime: number;
creationTime: number;
}>();
for (const cipher of props.ciphers) {
const name = String(cipher.decName || cipher.name || '');
const username = String(cipher.login?.decUsername || '');
const uri = firstCipherUri(cipher);
meta.set(cipher.id, {
name,
searchText: `${name}\n${username}\n${uri}`.toLowerCase(),
firstUri: uri,
typeKey: cipherTypeKey(Number(cipher.type || 1)),
sortTime: sortTimeValue(cipher),
creationTime: creationTimeValue(cipher),
});
}
return meta;
}, [props.ciphers]);
const cipherById = useMemo(() => {
const map = new Map<string, Cipher>();
for (const cipher of props.ciphers) map.set(cipher.id, cipher);
return map;
}, [props.ciphers]);
const folderById = useMemo(() => {
const map = new Map<string, Folder>();
for (const folder of props.folders) map.set(folder.id, folder);
return map;
}, [props.folders]);
const nameCollator = useMemo(
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
[]
);
const duplicateSignatureInfo = useMemo(() => {
if (sidebarFilter.kind !== 'duplicates') return null;
const byId = new Map<string, string>();
const counts = new Map<string, number>();
for (const cipher of props.ciphers) {
if (!isCipherVisibleInNormalVault(cipher)) continue;
const signature = buildCipherDuplicateSignature(cipher);
byId.set(cipher.id, signature);
counts.set(signature, (counts.get(signature) || 0) + 1);
}
return counts;
}, [props.ciphers]);
return { byId, counts };
}, [props.ciphers, sidebarFilter.kind]);
const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => {
const meta = cipherMetaById.get(cipher.id);
if (sidebarFilter.kind === 'trash') {
if (!isCipherVisibleInTrash(cipher)) return false;
} else if (sidebarFilter.kind === 'archive') {
if (!isCipherVisibleInArchive(cipher)) return false;
} else {
if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) {
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) {
return false;
}
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false;
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
if (sidebarFilter.kind === 'folder') {
if (sidebarFilter.folderId === null) {
if (cipher.folderId) return false;
@@ -273,24 +366,20 @@ export default function VaultPage(props: VaultPageProps) {
}
}
if (!searchQuery) return true;
const name = (cipher.decName || '').toLowerCase();
const username = (cipher.login?.decUsername || '').toLowerCase();
const uri = firstCipherUri(cipher).toLowerCase();
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
return !!meta?.searchText.includes(searchQuery);
});
next.sort((a, b) => {
const metaA = cipherMetaById.get(a.id);
const metaB = cipherMetaById.get(b.id);
if (sortMode === 'edited') {
const diff = sortTimeValue(b) - sortTimeValue(a);
const diff = (metaB?.sortTime || 0) - (metaA?.sortTime || 0);
if (diff !== 0) return diff;
} else if (sortMode === 'created') {
const diff = creationTimeValue(b) - creationTimeValue(a);
const diff = (metaB?.creationTime || 0) - (metaA?.creationTime || 0);
if (diff !== 0) return diff;
} else {
const nameDiff = String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || ''), undefined, {
sensitivity: 'base',
numeric: true,
});
const nameDiff = nameCollator.compare(metaA?.name || '', metaB?.name || '');
if (nameDiff !== 0) return nameDiff;
}
@@ -298,7 +387,13 @@ export default function VaultPage(props: VaultPageProps) {
});
return next;
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts]);
}, [props.ciphers, cipherMetaById, sidebarFilter, searchQuery, sortMode, duplicateSignatureInfo, nameCollator]);
const filteredCipherIds = useMemo(() => {
const ids = new Set<string>();
for (const cipher of filteredCiphers) ids.add(cipher.id);
return ids;
}, [filteredCiphers]);
const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
@@ -308,6 +403,7 @@ export default function VaultPage(props: VaultPageProps) {
useEffect(() => {
setListScrollTop(0);
listScrollBucketRef.current = 0;
listPanelRef.current?.scrollTo({ top: 0 });
}, [searchQuery, sortMode, sidebarFilterKey]);
@@ -323,15 +419,12 @@ export default function VaultPage(props: VaultPageProps) {
if (selectedCipherId) setSelectedCipherId('');
return;
}
if (!selectedCipherId || !filteredCiphers.some((x) => x.id === selectedCipherId)) {
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
setSelectedCipherId(filteredCiphers[0].id);
}
}, [filteredCiphers, selectedCipherId, isCreating]);
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]);
const selectedCipher = useMemo(
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
[props.ciphers, selectedCipherId]
);
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
const virtualRange = useMemo(() => {
if (!filteredCiphers.length) {
return { start: 0, end: 0, padTop: 0, padBottom: 0 };
@@ -395,20 +488,27 @@ export default function VaultPage(props: VaultPageProps) {
);
const totalCipherCount = filteredCiphers.length;
function folderName(id: string | null | undefined): string {
const folderName = useCallback((id: string | null | undefined): string => {
if (!id) return t('txt_no_folder');
const folder = props.folders.find((x) => x.id === id);
const folder = folderById.get(id);
return folder?.decName || folder?.name || id;
}
}, [folderById]);
function listSubtitle(cipher: Cipher): string {
const listSubtitle = useCallback((cipher: Cipher): string => {
if (Number(cipher.type || 1) === 1) {
return cipher.login?.decUsername || firstCipherUri(cipher) || '';
return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
}
return cipherTypeLabel(Number(cipher.type || 1));
}
}, [cipherMetaById]);
function startCreate(type: number): void {
const handleListScroll = useCallback((top: number): void => {
const bucket = Math.floor(Math.max(0, top) / VAULT_LIST_ROW_HEIGHT);
if (bucket === listScrollBucketRef.current) return;
listScrollBucketRef.current = bucket;
setListScrollTop(top);
}, []);
const startCreate = useCallback((type: number): void => {
setDraft(createEmptyDraft(type));
setIsCreating(true);
setIsEditing(true);
@@ -421,9 +521,9 @@ function folderName(id: string | null | undefined): string {
if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false);
if (type === 5) void seedSshDefaults();
}
}, [isMobileLayout]);
function startEdit(): void {
const startEdit = useCallback((): void => {
if (!selectedCipher) return;
setDraft(draftFromCipher(selectedCipher));
setIsCreating(false);
@@ -434,9 +534,9 @@ function folderName(id: string | null | undefined): string {
setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false);
}
}, [selectedCipher, isMobileLayout]);
function cancelEdit(): void {
const cancelEdit = useCallback((): void => {
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
setDraft(null);
setIsEditing(false);
@@ -446,11 +546,11 @@ function folderName(id: string | null | undefined): string {
setRemovedAttachmentIds({});
setPendingDeletePasskeyIndex(null);
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
}
}, [isMobileLayout, isCreating, selectedCipher]);
function updateDraft(patch: Partial<VaultDraft>): void {
const updateDraft = useCallback((patch: Partial<VaultDraft>): void => {
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
}
}, []);
function confirmDeleteLoginPasskey(): void {
if (pendingDeletePasskeyIndex == null) return;
@@ -815,16 +915,88 @@ function folderName(id: string | null | undefined): string {
}
}
const handleClearSearch = useCallback(() => setSearchInput(''), []);
const handleSearchCompositionStart = useCallback(() => setSearchComposing(true), []);
const handleSearchCompositionEnd = useCallback((value: string) => {
setSearchComposing(false);
setSearchInput(value);
}, []);
const handleToggleSortMenu = useCallback(() => setSortMenuOpen((open) => !open), []);
const handleSelectSortMode = useCallback((value: VaultSortMode) => {
setSortMode(value);
setSortMenuOpen(false);
}, []);
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
const handleSelectDuplicates = useCallback(() => {
const map: Record<string, boolean> = {};
const seen = new Set<string>();
for (const cipher of filteredCiphers) {
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
if (seen.has(signature)) {
map[cipher.id] = true;
continue;
}
seen.add(signature);
}
setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo]);
const handleSelectAll = useCallback(() => {
const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true;
setSelectedMap(map);
}, [filteredCiphers]);
const handleToggleCreateMenu = useCallback(() => setCreateMenuOpen((open) => !open), []);
const handleBulkRestore = useCallback(() => { void confirmBulkRestore(); }, [selectedMap, props.onBulkRestore]);
const handleBulkArchive = useCallback(() => setBulkArchiveOpen(true), []);
const handleBulkUnarchive = useCallback(() => { void confirmBulkUnarchive(); }, [selectedMap, props.onBulkUnarchive]);
const handleOpenMove = useCallback(() => {
setMoveFolderId('__none__');
setMoveOpen(true);
}, []);
const handleClearSelection = useCallback(() => setSelectedMap({}), []);
const handleToggleSelected = useCallback((cipherId: string, checked: boolean) =>
setSelectedMap((prev) => {
if (checked) return { ...prev, [cipherId]: true };
if (!prev[cipherId]) return prev;
const next = { ...prev };
delete next[cipherId];
return next;
})
, []);
const handleSelectCipher = useCallback((cipherId: string) => {
if (isEditing || isCreating) {
cancelEdit();
}
setSelectedCipherId(cipherId);
setRepromptApprovedCipherId(null);
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}, [isEditing, isCreating, cancelEdit, isMobileLayout]);
const handleCloseMobileSidebar = useCallback(() => setMobileSidebarOpen(false), []);
const handleOpenDeleteAllFolders = useCallback(() => setDeleteAllFoldersOpen(true), []);
const handleOpenCreateFolder = useCallback(() => setCreateFolderOpen(true), []);
const handleOpenRenameFolder = useCallback((folder: Folder) => {
setPendingRenameFolder(folder);
setRenameFolderName(folder.decName || folder.name || '');
}, []);
const handleToggleFolderSortMenu = useCallback(() => setFolderSortMenuOpen((open) => !open), []);
const handleSelectFolderSortMode = useCallback((value: VaultSortMode) => {
setFolderSortMode(value);
setFolderSortMenuOpen(false);
}, []);
const handleMobileSidebarMaskClick = useCallback(() => {
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}, [mobileSidebarOpen]);
return (
<>
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
{isMobileLayout && (
<div
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
onClick={() => {
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}}
onClick={handleMobileSidebarMaskClick}
/>
)}
<VaultSidebar
@@ -833,15 +1005,17 @@ function folderName(id: string | null | undefined): string {
busy={busy}
isMobileLayout={isMobileLayout}
mobileSidebarOpen={mobileSidebarOpen}
onCloseMobileSidebar={() => setMobileSidebarOpen(false)}
folderSortMode={folderSortMode}
folderSortMenuOpen={folderSortMenuOpen}
folderSortMenuRef={folderSortMenuRef}
onCloseMobileSidebar={handleCloseMobileSidebar}
onChangeFilter={setSidebarFilter}
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
onOpenCreateFolder={() => setCreateFolderOpen(true)}
onOpenRenameFolder={(folder) => {
setPendingRenameFolder(folder);
setRenameFolderName(folder.decName || folder.name || '');
}}
onOpenDeleteAllFolders={handleOpenDeleteAllFolders}
onOpenCreateFolder={handleOpenCreateFolder}
onOpenRenameFolder={handleOpenRenameFolder}
onOpenDeleteFolder={setPendingDeleteFolder}
onToggleFolderSortMenu={handleToggleFolderSortMenu}
onSelectFolderSortMode={handleSelectFolderSortMode}
/>
<VaultListPanel
@@ -858,68 +1032,32 @@ function folderName(id: string | null | undefined): string {
selectedCipherId={selectedCipherId}
selectedMap={selectedMap}
sidebarFilter={sidebarFilter}
isMobileLayout={isMobileLayout}
mobileFabVisible={!isMobileLayout || mobilePanel === 'list'}
createMenuOpen={createMenuOpen}
createMenuRef={createMenuRef}
sortMenuRef={sortMenuRef}
listPanelRef={listPanelRef}
onSearchInput={setSearchInput}
onClearSearch={() => setSearchInput('')}
onSearchCompositionStart={() => setSearchComposing(true)}
onSearchCompositionEnd={(value) => {
setSearchComposing(false);
setSearchInput(value);
}}
onToggleSortMenu={() => setSortMenuOpen((open) => !open)}
onSelectSortMode={(value) => {
setSortMode(value);
setSortMenuOpen(false);
}}
onSyncVault={() => void syncVault()}
onOpenBulkDelete={() => setBulkDeleteOpen(true)}
onSelectDuplicates={() => {
const map: Record<string, boolean> = {};
const seen = new Set<string>();
for (const cipher of filteredCiphers) {
const signature = buildCipherDuplicateSignature(cipher);
if (seen.has(signature)) {
map[cipher.id] = true;
continue;
}
seen.add(signature);
}
setSelectedMap(map);
}}
onSelectAll={() => {
const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true;
setSelectedMap(map);
}}
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
onClearSearch={handleClearSearch}
onSearchCompositionStart={handleSearchCompositionStart}
onSearchCompositionEnd={handleSearchCompositionEnd}
onToggleSortMenu={handleToggleSortMenu}
onSelectSortMode={handleSelectSortMode}
onSyncVault={handleSyncVault}
onOpenBulkDelete={handleOpenBulkDelete}
onSelectDuplicates={handleSelectDuplicates}
onSelectAll={handleSelectAll}
onToggleCreateMenu={handleToggleCreateMenu}
onStartCreate={startCreate}
onBulkRestore={() => void confirmBulkRestore()}
onBulkArchive={() => setBulkArchiveOpen(true)}
onBulkUnarchive={() => void confirmBulkUnarchive()}
onOpenMove={() => {
setMoveFolderId('__none__');
setMoveOpen(true);
}}
onClearSelection={() => setSelectedMap({})}
onScroll={setListScrollTop}
onToggleSelected={(cipherId, checked) =>
setSelectedMap((prev) => ({
...prev,
[cipherId]: checked,
}))
}
onSelectCipher={(cipherId) => {
if (isEditing || isCreating) {
cancelEdit();
}
setSelectedCipherId(cipherId);
setRepromptApprovedCipherId(null);
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}}
onBulkRestore={handleBulkRestore}
onBulkArchive={handleBulkArchive}
onBulkUnarchive={handleBulkUnarchive}
onOpenMove={handleOpenMove}
onClearSelection={handleClearSelection}
onScroll={handleListScroll}
onToggleSelected={handleToggleSelected}
onSelectCipher={handleSelectCipher}
listSubtitle={listSubtitle}
/>
@@ -1002,7 +1140,7 @@ function folderName(id: string | null | undefined): string {
</div>
)}
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>}
{!isEditing && !selectedCipher && (props.loading ? <LoadingState card lines={5} /> : <div className="empty card">{t('txt_select_an_item')}</div>)}
</section>
</div>
@@ -1,8 +1,8 @@
import { CloudUpload, Save, Trash2 } from 'lucide-preact';
import type {
BackupDestinationRecord,
E3BackupDestination,
RemoteBackupBrowserResponse,
S3BackupDestination,
WebDavBackupDestination,
} from '@/lib/api/backup';
import { COMMON_TIME_ZONES, getDestinationTypeLabel } from '@/lib/backup-center';
@@ -399,97 +399,97 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
</div>
) : null}
{props.selectedDestination.type === 'e3' ? (
{props.selectedDestination.type === 's3' ? (
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_backup_e3_endpoint')}</span>
<span>{t('txt_backup_s3_endpoint')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).endpoint}
value={(props.selectedDestination.destination as S3BackupDestination).endpoint}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="https://s3.example.com"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
...(destination.destination as S3BackupDestination),
endpoint: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_e3_bucket')}</span>
<span>{t('txt_backup_s3_bucket')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).bucket}
value={(props.selectedDestination.destination as S3BackupDestination).bucket}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
...(destination.destination as S3BackupDestination),
bucket: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_e3_region')}</span>
<span>{t('txt_backup_s3_region')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).region}
value={(props.selectedDestination.destination as S3BackupDestination).region}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="auto"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
...(destination.destination as S3BackupDestination),
region: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_e3_access_key')}</span>
<span>{t('txt_backup_s3_access_key')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).accessKeyId}
value={(props.selectedDestination.destination as S3BackupDestination).accessKeyId}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
...(destination.destination as S3BackupDestination),
accessKeyId: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field">
<span>{t('txt_backup_e3_secret_key')}</span>
<span>{t('txt_backup_s3_secret_key')}</span>
<input
className="input"
type="password"
value={(props.selectedDestination.destination as E3BackupDestination).secretAccessKey}
value={(props.selectedDestination.destination as S3BackupDestination).secretAccessKey}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
...(destination.destination as S3BackupDestination),
secretAccessKey: (event.currentTarget as HTMLInputElement).value,
},
}))}
/>
</label>
<label className="field field-span-2">
<span>{t('txt_backup_e3_path')}</span>
<span>{t('txt_backup_s3_path')}</span>
<input
className="input"
value={(props.selectedDestination.destination as E3BackupDestination).rootPath}
value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="nodewarden/backups"
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as E3BackupDestination),
...(destination.destination as S3BackupDestination),
rootPath: (event.currentTarget as HTMLInputElement).value,
},
}))}
@@ -60,8 +60,8 @@ export function BackupDestinationSidebar(props: BackupDestinationSidebarProps) {
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('webdav')}>
{t('txt_backup_protocol_webdav')}
</button>
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('e3')}>
{t('txt_backup_protocol_e3')}
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('s3')}>
{t('txt_backup_protocol_s3')}
</button>
</div>
) : null}
+3 -2
View File
@@ -1,6 +1,6 @@
import ConfirmDialog from '@/components/ConfirmDialog';
import type { CustomFieldType, Folder } from '@/lib/types';
import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
import { getFieldTypeOptions, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
import { t } from '@/lib/i18n';
interface VaultDialogsProps {
@@ -61,6 +61,7 @@ interface VaultDialogsProps {
}
export default function VaultDialogs(props: VaultDialogsProps) {
const fieldTypeOptions = getFieldTypeOptions();
return (
<>
<ConfirmDialog
@@ -75,7 +76,7 @@ export default function VaultDialogs(props: VaultDialogsProps) {
<label className="field">
<span>{t('txt_field_type')}</span>
<select className="input" value={props.fieldType} onInput={(e) => props.onFieldTypeChange(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}>
{FIELD_TYPE_OPTIONS.map((option) => (
{fieldTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
+6 -4
View File
@@ -21,13 +21,13 @@ import { CSS } from '@dnd-kit/utilities';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
CREATE_TYPE_OPTIONS,
cipherTypeLabel,
createEmptyLoginUri,
formatAttachmentSize,
formatHistoryTime,
getCreateTypeOptions,
getWebsiteMatchOptions,
toBooleanFieldValue,
WEBSITE_MATCH_OPTIONS,
} from '@/components/vault/vault-page-helpers';
interface VaultEditorProps {
@@ -77,6 +77,7 @@ interface SortableWebsiteRowProps {
}
function SortableWebsiteRow(props: SortableWebsiteRowProps) {
const websiteMatchOptions = getWebsiteMatchOptions();
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id,
});
@@ -117,7 +118,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
props.onUpdateMatch(props.index, raw === '' ? null : Number(raw));
}}
>
{WEBSITE_MATCH_OPTIONS.map((option) => (
{websiteMatchOptions.map((option) => (
<option key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}>
{option.label}
</option>
@@ -134,6 +135,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
}
export default function VaultEditor(props: VaultEditorProps) {
const createTypeOptions = getCreateTypeOptions();
const uriIdSeedRef = useRef(0);
const [uriItemIds, setUriItemIds] = useState<string[]>([]);
const [activeUriId, setActiveUriId] = useState<string | null>(null);
@@ -232,7 +234,7 @@ export default function VaultEditor(props: VaultEditorProps) {
if (nextType === 5) props.onSeedSshDefaults();
}}
>
{CREATE_TYPE_OPTIONS.map((option) => (
{createTypeOptions.map((option) => (
<option key={option.type} value={option.type}>
{option.label}
</option>
+88 -52
View File
@@ -1,11 +1,14 @@
import type { RefObject } from 'preact';
import { memo } from 'preact/compat';
import { createPortal } from 'preact/compat';
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
import LoadingState from '@/components/LoadingState';
import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
CREATE_TYPE_OPTIONS,
CreateTypeIcon,
VAULT_SORT_OPTIONS,
getCreateTypeOptions,
getVaultSortOptions,
VaultListIcon,
type SidebarFilter,
type VaultSortMode,
@@ -32,6 +35,8 @@ interface VaultListPanelProps {
selectedCipherId: string;
selectedMap: Record<string, boolean>;
sidebarFilter: SidebarFilter;
isMobileLayout: boolean;
mobileFabVisible: boolean;
createMenuOpen: boolean;
createMenuRef: RefObject<HTMLDivElement>;
sortMenuRef: RefObject<HTMLDivElement>;
@@ -59,7 +64,74 @@ interface VaultListPanelProps {
listSubtitle: (cipher: Cipher) => string;
}
interface CipherListItemProps {
cipher: Cipher;
selected: boolean;
checked: boolean;
subtitle: string;
onToggleSelected: (cipherId: string, checked: boolean) => void;
onSelectCipher: (cipherId: string) => void;
}
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
return (
<div
className={`list-item ${props.selected ? 'active' : ''}`}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
props.onSelectCipher(props.cipher.id);
}}
>
<input
type="checkbox"
className="row-check"
checked={props.checked}
onClick={(event) => event.stopPropagation()}
onInput={(e) => props.onToggleSelected(props.cipher.id, (e.currentTarget as HTMLInputElement).checked)}
/>
<button type="button" className="row-main" onClick={() => props.onSelectCipher(props.cipher.id)}>
<div className="list-icon-wrap">
<VaultListIcon cipher={props.cipher} />
</div>
<div className="list-text">
<span className="list-title" title={props.cipher.decName || t('txt_no_name')}>
<span className="list-title-text">{props.cipher.decName || t('txt_no_name')}</span>
</span>
<span className="list-sub" title={props.subtitle}>{props.subtitle}</span>
</div>
</button>
</div>
);
});
export default function VaultListPanel(props: VaultListPanelProps) {
const createTypeOptions = getCreateTypeOptions();
const vaultSortOptions = getVaultSortOptions();
const createMenu = (
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
<button
type="button"
className="btn btn-primary small mobile-fab-trigger"
aria-label={t('txt_add')}
title={t('txt_add')}
onClick={props.onToggleCreateMenu}
>
<Plus size={14} className="btn-icon" />
</button>
{props.createMenuOpen && (
<div className="create-menu">
{createTypeOptions.map((option) => (
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
<CreateTypeIcon type={option.type} />
<span>{option.label}</span>
</button>
))}
</div>
)}
</div>
);
return (
<section className="list-col">
<div className="list-head">
@@ -101,7 +173,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</button>
{props.sortMenuOpen && (
<div className="sort-menu">
{VAULT_SORT_OPTIONS.map((option) => (
{vaultSortOptions.map((option) => (
<button
key={option.value}
type="button"
@@ -159,65 +231,29 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<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>
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
<button
type="button"
className="btn btn-primary small mobile-fab-trigger"
aria-label={t('txt_add')}
title={t('txt_add')}
onClick={props.onToggleCreateMenu}
>
<Plus size={14} className="btn-icon" />
</button>
{props.createMenuOpen && (
<div className="create-menu">
{CREATE_TYPE_OPTIONS.map((option) => (
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
<CreateTypeIcon type={option.type} />
<span>{option.label}</span>
</button>
))}
</div>
)}
</div>
{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)}>
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
{!!props.filteredCiphers.length && (
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
{props.visibleCiphers.map((cipher) => (
<div
<CipherListItem
key={cipher.id}
className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
props.onSelectCipher(cipher.id);
}}
>
<input
type="checkbox"
className="row-check"
checked={!!props.selectedMap[cipher.id]}
onClick={(event) => event.stopPropagation()}
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
/>
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
<div className="list-icon-wrap">
<VaultListIcon cipher={cipher} />
</div>
<div className="list-text">
<span className="list-title" title={cipher.decName || t('txt_no_name')}>
<span className="list-title-text">{cipher.decName || t('txt_no_name')}</span>
</span>
<span className="list-sub" title={props.listSubtitle(cipher)}>{props.listSubtitle(cipher)}</span>
</div>
</button>
</div>
cipher={cipher}
selected={props.selectedCipherId === cipher.id}
checked={!!props.selectedMap[cipher.id]}
subtitle={props.listSubtitle(cipher)}
onToggleSelected={props.onToggleSelected}
onSelectCipher={props.onSelectCipher}
/>
))}
</div>
)}
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
{!props.loading && !props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
</div>
</section>
);
+75 -2
View File
@@ -1,5 +1,9 @@
import { useMemo } from 'preact/hooks';
import type { RefObject } from 'preact';
import {
Archive,
ArrowUpDown,
Check,
Copy,
CreditCard,
Folder as FolderIcon,
@@ -17,7 +21,7 @@ import {
} from 'lucide-preact';
import type { Folder } from '@/lib/types';
import { t } from '@/lib/i18n';
import type { SidebarFilter } from '@/components/vault/vault-page-helpers';
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
interface VaultSidebarProps {
folders: Folder[];
@@ -25,15 +29,58 @@ interface VaultSidebarProps {
busy: boolean;
isMobileLayout: boolean;
mobileSidebarOpen: boolean;
folderSortMode: VaultSortMode;
folderSortMenuOpen: boolean;
folderSortMenuRef: RefObject<HTMLDivElement>;
onCloseMobileSidebar: () => void;
onChangeFilter: (filter: SidebarFilter) => void;
onOpenDeleteAllFolders: () => void;
onOpenCreateFolder: () => void;
onOpenRenameFolder: (folder: Folder) => void;
onOpenDeleteFolder: (folder: Folder) => void;
onToggleFolderSortMenu: () => void;
onSelectFolderSortMode: (value: VaultSortMode) => void;
}
export default function VaultSidebar(props: VaultSidebarProps) {
const folderSortOptions = getFolderSortOptions();
const nameCollator = useMemo(
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
[]
);
const sortedFolders = useMemo(() => {
const sorted = [...props.folders];
sorted.sort((a, b) => {
if (props.folderSortMode === 'edited') {
const aTime = new Date(String(a.revisionDate || a.creationDate || '')).getTime();
const bTime = new Date(String(b.revisionDate || b.creationDate || '')).getTime();
const aValid = Number.isFinite(aTime);
const bValid = Number.isFinite(bTime);
if (aValid && bValid) {
const diff = bTime - aTime;
if (diff !== 0) return diff;
}
if (aValid !== bValid) return aValid ? -1 : 1;
} else if (props.folderSortMode === 'created') {
const aTime = new Date(String(a.creationDate || '')).getTime();
const bTime = new Date(String(b.creationDate || '')).getTime();
const aValid = Number.isFinite(aTime);
const bValid = Number.isFinite(bTime);
if (aValid && bValid) {
const diff = bTime - aTime;
if (diff !== 0) return diff;
}
if (aValid !== bValid) return aValid ? -1 : 1;
}
const nameDiff = nameCollator.compare(
String(a.decName || a.name || ''), String(b.decName || b.name || '')
);
if (nameDiff !== 0) return nameDiff;
return String(a.id || '').localeCompare(String(b.id || ''));
});
return sorted;
}, [props.folders, props.folderSortMode, nameCollator]);
return (
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
{props.isMobileLayout && (
@@ -85,6 +132,32 @@ export default function VaultSidebar(props: VaultSidebarProps) {
<div className="sidebar-title-row">
<div className="sidebar-title">{t('txt_folders')}</div>
<div className="folder-title-actions">
<div className="sort-menu-wrap" ref={props.folderSortMenuRef}>
<button
type="button"
className={`folder-sort-btn ${props.folderSortMenuOpen ? 'active' : ''}`}
title={t('txt_sort')}
aria-label={t('txt_sort')}
onClick={props.onToggleFolderSortMenu}
>
<ArrowUpDown size={13} />
</button>
{props.folderSortMenuOpen && (
<div className="sort-menu">
{folderSortOptions.map((option) => (
<button
key={option.value}
type="button"
className={`sort-menu-item ${props.folderSortMode === option.value ? 'active' : ''}`}
onClick={() => props.onSelectFolderSortMode(option.value)}
>
<span>{option.label}</span>
{props.folderSortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
</button>
))}
</div>
)}
</div>
<button
type="button"
className="folder-delete-btn"
@@ -103,7 +176,7 @@ export default function VaultSidebar(props: VaultSidebarProps) {
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'folder', folderId: null })}>
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
</button>
{props.folders.map((folder) => (
{sortedFolders.map((folder) => (
<div key={folder.id} className="folder-row">
<button
type="button"
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { Globe } from 'lucide-preact';
import type { Cipher } from '@/lib/types';
import {
getWebsiteIconStatus,
markWebsiteIconErrored,
markWebsiteIconLoaded,
preloadWebsiteIcon,
subscribeWebsiteIconStatus,
} from '@/lib/website-icon-cache';
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
interface WebsiteIconProps {
cipher: Cipher;
fallback?: ComponentChildren;
}
export default function WebsiteIcon(props: WebsiteIconProps) {
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
const src = host ? websiteIconUrl(host) : '';
const nodeRef = useRef<HTMLSpanElement | null>(null);
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
useEffect(() => {
if (!host) {
setShouldLoad(true);
setStatus('idle');
return;
}
const nextStatus = getWebsiteIconStatus(host);
setShouldLoad(nextStatus === 'loaded');
setStatus(nextStatus);
return subscribeWebsiteIconStatus(host, setStatus);
}, [host]);
useEffect(() => {
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
const node = nodeRef.current;
if (!node) return;
if (typeof IntersectionObserver !== 'function') {
setShouldLoad(true);
return;
}
let cancelled = false;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting && entry.intersectionRatio <= 0) continue;
if (!cancelled) setShouldLoad(true);
observer.disconnect();
break;
}
},
{ rootMargin: ICON_LOAD_ROOT_MARGIN }
);
observer.observe(node);
return () => {
cancelled = true;
observer.disconnect();
};
}, [host, shouldLoad, status]);
useEffect(() => {
if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return;
let disposed = false;
void preloadWebsiteIcon(host, src).then((nextStatus) => {
if (!disposed) setStatus(nextStatus);
});
return () => {
disposed = true;
};
}, [host, src, shouldLoad, status]);
if (!host || status === 'error') {
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
}
return (
<span className="list-icon-stack" ref={nodeRef}>
{status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>}
{status === 'loaded' && (
<img
className="list-icon loaded"
src={src}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onLoad={() => markWebsiteIconLoaded(host)}
onError={() => markWebsiteIconErrored(host)}
/>
)}
</span>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'preact/hooks';
import { useMemo } from 'preact/hooks';
import {
CreditCard,
FileKey2,
@@ -10,6 +10,7 @@ import {
import { copyTextToClipboard } from '@/lib/clipboard';
import { t } from '@/lib/i18n';
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
import WebsiteIcon from './WebsiteIcon';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
export type VaultSortMode = 'edited' | 'created' | 'name';
@@ -27,39 +28,56 @@ interface TypeOption {
label: string;
}
export const CREATE_TYPE_OPTIONS: TypeOption[] = [
{ type: 1, label: t('txt_login') },
{ type: 3, label: t('txt_card') },
{ type: 4, label: t('txt_identity') },
{ type: 2, label: t('txt_note') },
{ type: 5, label: t('txt_ssh_key') },
];
export function getCreateTypeOptions(): TypeOption[] {
return [
{ type: 1, label: t('txt_login') },
{ type: 3, label: t('txt_card') },
{ type: 4, label: t('txt_identity') },
{ type: 2, label: t('txt_note') },
{ type: 5, label: t('txt_ssh_key') },
];
}
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1';
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
export const VAULT_LIST_ROW_HEIGHT = 74;
export const VAULT_LIST_OVERSCAN = 10;
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
{ value: 'edited', label: t('txt_sort_last_edited') },
{ value: 'created', label: t('txt_sort_created') },
{ value: 'name', label: t('txt_sort_name') },
];
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
return [
{ value: 'edited', label: t('txt_sort_last_edited') },
{ value: 'created', label: t('txt_sort_created') },
{ value: 'name', label: t('txt_sort_name') },
];
}
export const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [
{ value: 0, label: t('txt_text') },
{ value: 1, label: t('txt_hidden') },
{ value: 2, label: t('txt_boolean') },
];
export function getFolderSortOptions(): Array<{ value: VaultSortMode; label: string }> {
return [
{ value: 'edited', label: t('txt_sort_last_edited') },
{ value: 'created', label: t('txt_sort_created') },
{ value: 'name', label: t('txt_sort_name') },
];
}
export const WEBSITE_MATCH_OPTIONS: Array<{ value: number | null; label: string }> = [
{ value: null, label: t('txt_uri_match_default_base_domain') },
{ value: 0, label: t('txt_uri_match_base_domain') },
{ value: 1, label: t('txt_uri_match_host') },
{ value: 3, label: t('txt_uri_match_exact') },
{ value: 5, label: t('txt_uri_match_never') },
{ value: 2, label: t('txt_uri_match_starts_with') },
{ value: 4, label: t('txt_uri_match_regular_expression') },
];
export function getFieldTypeOptions(): Array<{ value: CustomFieldType; label: string }> {
return [
{ value: 0, label: t('txt_text') },
{ value: 1, label: t('txt_hidden') },
{ value: 2, label: t('txt_boolean') },
];
}
export function getWebsiteMatchOptions(): Array<{ value: number | null; label: string }> {
return [
{ value: null, label: t('txt_uri_match_default_base_domain') },
{ value: 0, label: t('txt_uri_match_base_domain') },
{ value: 1, label: t('txt_uri_match_host') },
{ value: 3, label: t('txt_uri_match_exact') },
{ value: 5, label: t('txt_uri_match_never') },
{ value: 2, label: t('txt_uri_match_starts_with') },
{ value: 4, label: t('txt_uri_match_regular_expression') },
];
}
export const TOTP_PERIOD_SECONDS = 30;
export const TOTP_RING_RADIUS = 14;
@@ -141,28 +159,7 @@ export function toBooleanFieldValue(raw: string): boolean {
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
}
export function firstCipherUri(cipher: Cipher): string {
const uris = cipher.login?.uris || [];
for (const uri of uris) {
const raw = uri.decUri || uri.uri || '';
if (raw.trim()) return raw.trim();
}
return '';
}
export function hostFromUri(uri: string): string {
if (!uri.trim()) return '';
try {
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
return new URL(normalized).hostname || '';
} catch {
return '';
}
}
export function websiteIconUrl(host: string): string {
return `/icons/${encodeURIComponent(host)}/icon.png?fallback=404`;
}
export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
export function createEmptyLoginUri(): VaultDraftLoginUri {
return { uri: '', match: null, originalUri: '', extra: {} };
@@ -170,7 +167,7 @@ export function createEmptyLoginUri(): VaultDraftLoginUri {
export function websiteMatchLabel(value: number | null | undefined): string {
const normalized = typeof value === 'number' && Number.isFinite(value) ? value : null;
return WEBSITE_MATCH_OPTIONS.find((option) => option.value === normalized)?.label || t('txt_uri_match_default_base_domain');
return getWebsiteMatchOptions().find((option) => option.value === normalized)?.label || t('txt_uri_match_default_base_domain');
}
function valueOrFallback(value: string | null | undefined): string {
@@ -427,54 +424,8 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
return null;
}
const failedIconHosts = new Set<string>();
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher);
const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
const [loaded, setLoaded] = useState(false);
const markIconError = () => {
if (host) failedIconHosts.add(host);
setErrored(true);
};
const syncCachedIconState = (img: HTMLImageElement | null) => {
if (!img || !img.complete) return;
if (img.naturalWidth > 0) {
setLoaded(true);
return;
}
markIconError();
};
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
setLoaded(false);
}, [host]);
if (host && !errored) {
return (
<span className="list-icon-stack">
<span className={`list-icon-fallback ${loaded ? 'hidden' : ''}`}>
<Globe size={18} />
</span>
<img
className={`list-icon ${loaded ? 'loaded' : ''}`}
src={websiteIconUrl(host)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
ref={syncCachedIconState}
onLoad={() => setLoaded(true)}
onError={markIconError}
/>
</span>
);
}
return (
<span className="list-icon-fallback">
<TypeIcon type={Number(cipher.type || 1)} />
</span>
);
return <WebsiteIcon cipher={cipher} fallback={<TypeIcon type={Number(cipher.type || 1)} />} />;
}
export function copyToClipboard(value: string): void {
+318 -21
View File
@@ -13,6 +13,7 @@ import {
encryptZipBytesWithPassword,
} from '@/lib/export-formats';
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr } from '@/lib/crypto';
import { decryptSingleCipher } from '@/lib/decrypt-cipher';
import { t } from '@/lib/i18n';
import {
buildPublicSendUrl,
@@ -66,6 +67,8 @@ interface UseVaultSendActionsOptions {
refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>;
refetchSends: () => Promise<unknown>;
onNotify: Notify;
patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void;
}
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
@@ -82,6 +85,144 @@ function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
return { byIndex, bySourceId };
}
function createOptimisticCipherId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `optimistic:${crypto.randomUUID()}`;
}
return `optimistic:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 10)}`;
}
function optimisticCipherFromDraft(draft: VaultDraft, current?: Cipher | null): Cipher {
const now = new Date().toISOString();
const type = Number(draft.type || current?.type || 1) || 1;
const next: Cipher = {
...(current || {}),
id: current?.id || createOptimisticCipherId(),
type,
folderId: draft.folderId || null,
favorite: !!draft.favorite,
reprompt: draft.reprompt ? 1 : 0,
name: draft.name || '',
notes: draft.notes || '',
decName: draft.name || '',
decNotes: draft.notes || '',
creationDate: current?.creationDate || now,
revisionDate: now,
deletedDate: current?.deletedDate || null,
archivedDate: current?.archivedDate || null,
};
if (type === 1) {
next.login = {
...(current?.login || {}),
username: draft.loginUsername || '',
password: draft.loginPassword || '',
totp: draft.loginTotp || '',
decUsername: draft.loginUsername || '',
decPassword: draft.loginPassword || '',
decTotp: draft.loginTotp || '',
uris: draft.loginUris.map((uri) => ({
...(uri.extra || {}),
uri: uri.uri || '',
decUri: uri.uri || '',
match: uri.match ?? null,
})),
fido2Credentials: draft.loginFido2Credentials.map((credential) => ({ ...credential })),
};
} else {
next.login = null;
}
if (type === 3) {
next.card = {
...(current?.card || {}),
cardholderName: draft.cardholderName || '',
number: draft.cardNumber || '',
brand: draft.cardBrand || '',
expMonth: draft.cardExpMonth || '',
expYear: draft.cardExpYear || '',
code: draft.cardCode || '',
decCardholderName: draft.cardholderName || '',
decNumber: draft.cardNumber || '',
decBrand: draft.cardBrand || '',
decExpMonth: draft.cardExpMonth || '',
decExpYear: draft.cardExpYear || '',
decCode: draft.cardCode || '',
};
} else {
next.card = null;
}
if (type === 4) {
next.identity = {
...(current?.identity || {}),
title: draft.identTitle || '',
firstName: draft.identFirstName || '',
middleName: draft.identMiddleName || '',
lastName: draft.identLastName || '',
username: draft.identUsername || '',
company: draft.identCompany || '',
ssn: draft.identSsn || '',
passportNumber: draft.identPassportNumber || '',
licenseNumber: draft.identLicenseNumber || '',
email: draft.identEmail || '',
phone: draft.identPhone || '',
address1: draft.identAddress1 || '',
address2: draft.identAddress2 || '',
address3: draft.identAddress3 || '',
city: draft.identCity || '',
state: draft.identState || '',
postalCode: draft.identPostalCode || '',
country: draft.identCountry || '',
decTitle: draft.identTitle || '',
decFirstName: draft.identFirstName || '',
decMiddleName: draft.identMiddleName || '',
decLastName: draft.identLastName || '',
decUsername: draft.identUsername || '',
decCompany: draft.identCompany || '',
decSsn: draft.identSsn || '',
decPassportNumber: draft.identPassportNumber || '',
decLicenseNumber: draft.identLicenseNumber || '',
decEmail: draft.identEmail || '',
decPhone: draft.identPhone || '',
decAddress1: draft.identAddress1 || '',
decAddress2: draft.identAddress2 || '',
decAddress3: draft.identAddress3 || '',
decCity: draft.identCity || '',
decState: draft.identState || '',
decPostalCode: draft.identPostalCode || '',
decCountry: draft.identCountry || '',
};
} else {
next.identity = null;
}
if (type === 5) {
next.sshKey = {
...(current?.sshKey || {}),
privateKey: draft.sshPrivateKey || '',
publicKey: draft.sshPublicKey || '',
keyFingerprint: draft.sshFingerprint || '',
fingerprint: draft.sshFingerprint || '',
decPrivateKey: draft.sshPrivateKey || '',
decPublicKey: draft.sshPublicKey || '',
decFingerprint: draft.sshFingerprint || '',
};
} else {
next.sshKey = null;
}
next.fields = draft.customFields.map((field) => ({
type: field.type,
name: field.label,
value: field.value,
decName: field.label,
decValue: field.value,
}));
return next;
}
export default function useVaultSendActions(options: UseVaultSendActionsOptions) {
const {
authedFetch,
@@ -95,6 +236,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
refetchFolders,
refetchSends,
onNotify,
patchDecryptedCiphers,
patchDecryptedFolders,
} = options;
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
@@ -108,6 +251,91 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]);
};
const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => {
const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())];
if (options?.includeFolders) {
tasks.push(Promise.resolve(refetchFolders()));
}
void Promise.all(tasks).catch((err) => {
console.warn('Background vault sync failed:', err);
});
};
async function decryptAndPatch(encrypted: Cipher) {
if (!session?.symEncKey || !session?.symMacKey) {
await refetchCiphers();
return;
}
const encKey = base64ToBytes(session.symEncKey);
const macKey = base64ToBytes(session.symMacKey);
const decrypted = await decryptSingleCipher(encrypted, encKey, macKey);
patchDecryptedCiphers((prev) => {
const idx = prev.findIndex((c) => c.id === decrypted.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = decrypted;
return next;
}
return [decrypted, ...prev];
});
}
async function decryptAndReplaceOptimistic(optimisticId: string, encrypted: Cipher) {
if (!session?.symEncKey || !session?.symMacKey) {
await refetchCiphers();
return;
}
const encKey = base64ToBytes(session.symEncKey);
const macKey = base64ToBytes(session.symMacKey);
const decrypted = await decryptSingleCipher(encrypted, encKey, macKey);
patchDecryptedCiphers((prev) => {
const next = prev.filter((cipher) => cipher.id !== optimisticId && cipher.id !== decrypted.id);
return [decrypted, ...next];
});
}
function removeCipherFromState(id: string) {
patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id));
}
function patchCipherBatch(ids: string[], updater: (cipher: Cipher) => Cipher | null) {
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
if (!idSet.size) return;
patchDecryptedCiphers((prev) => {
let changed = false;
const next: Cipher[] = [];
for (const cipher of prev) {
if (!idSet.has(cipher.id)) {
next.push(cipher);
continue;
}
const updated = updater(cipher);
changed = true;
if (updated) next.push(updated);
}
return changed ? next : prev;
});
}
function patchFolderBatch(ids: string[], updater: (folder: VaultFolder) => VaultFolder | null) {
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
if (!idSet.size) return;
patchDecryptedFolders((prev) => {
let changed = false;
const next: VaultFolder[] = [];
for (const folder of prev) {
if (!idSet.has(folder.id)) {
next.push(folder);
continue;
}
const updated = updater(folder);
changed = true;
if (updated) next.push(updated);
}
return changed ? next : prev;
});
}
const uploadImportedAttachments = async (
attachments: ImportAttachmentFile[],
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
@@ -168,6 +396,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async createVaultItem(draft: VaultDraft, attachments: File[] = []) {
if (!session) return;
const optimistic = optimisticCipherFromDraft(draft, null);
patchDecryptedCiphers((prev) => [optimistic, ...prev.filter((cipher) => cipher.id !== optimistic.id)]);
try {
const created = await createCipher(authedFetch, session, draft);
for (const file of attachments) {
@@ -175,9 +405,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
setAttachmentUploadPercent(0);
await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent);
}
await Promise.all([refetchCiphers(), refetchFolders()]);
await decryptAndReplaceOptimistic(optimistic.id, created);
syncVaultCoreInBackground({ includeFolders: !!draft.folderId || attachments.length > 0 });
onNotify('success', t('txt_item_created'));
} catch (error) {
patchDecryptedCiphers((prev) => prev.filter((cipher) => cipher.id !== optimistic.id));
onNotify('error', error instanceof Error ? error.message : t('txt_create_item_failed'));
throw error;
} finally {
@@ -190,8 +422,26 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
if (!session) return;
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
const previousCipher: Cipher = {
...cipher,
login: cipher.login ? { ...cipher.login, uris: cipher.login.uris ? [...cipher.login.uris] : cipher.login.uris } : cipher.login,
card: cipher.card ? { ...cipher.card } : cipher.card,
identity: cipher.identity ? { ...cipher.identity } : cipher.identity,
sshKey: cipher.sshKey ? { ...cipher.sshKey } : cipher.sshKey,
fields: cipher.fields ? cipher.fields.map((field) => ({ ...field })) : cipher.fields,
attachments: cipher.attachments ? cipher.attachments.map((attachment) => ({ ...attachment })) : cipher.attachments,
passwordHistory: cipher.passwordHistory ? cipher.passwordHistory.map((entry) => ({ ...entry })) : cipher.passwordHistory,
};
const optimistic = optimisticCipherFromDraft(draft, cipher);
if (removeAttachmentIds.length || addFiles.length) {
const removedSet = new Set(removeAttachmentIds.map((id) => String(id || '').trim()).filter(Boolean));
optimistic.attachments = (cipher.attachments || [])
.filter((attachment) => !removedSet.has(String(attachment?.id || '').trim()))
.map((attachment) => ({ ...attachment }));
}
patchCipherBatch([cipher.id], () => optimistic);
try {
await updateCipher(authedFetch, session, cipher, draft);
const updated = await updateCipher(authedFetch, session, cipher, draft);
for (const attachmentId of removeAttachmentIds) {
const id = String(attachmentId || '').trim();
if (!id) continue;
@@ -202,9 +452,16 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
setAttachmentUploadPercent(0);
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent);
}
await Promise.all([refetchCiphers(), refetchFolders()]);
await decryptAndPatch(updated);
syncVaultCoreInBackground({
includeFolders:
draft.folderId !== (cipher.folderId || '')
|| addFiles.length > 0
|| removeAttachmentIds.length > 0,
});
onNotify('success', t('txt_item_updated'));
} catch (error) {
patchCipherBatch([cipher.id], () => previousCipher);
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
throw error;
} finally {
@@ -232,33 +489,48 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
},
async deleteVaultItem(cipher: Cipher) {
const previousCipher = { ...cipher };
const deletedDate = new Date().toISOString();
patchCipherBatch([cipher.id], (current) => ({ ...current, deletedDate, archivedDate: null, revisionDate: deletedDate }));
try {
await deleteCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]);
const deleted = await deleteCipher(authedFetch, cipher.id);
await decryptAndPatch(deleted);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_item_deleted'));
} catch (error) {
patchCipherBatch([cipher.id], () => previousCipher);
onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
throw error;
}
},
async archiveVaultItem(cipher: Cipher) {
const previousCipher = { ...cipher };
const archivedDate = new Date().toISOString();
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate, deletedDate: null, revisionDate: archivedDate }));
try {
await archiveCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]);
const archived = await archiveCipher(authedFetch, cipher.id);
await decryptAndPatch(archived);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_item_archived'));
} catch (error) {
patchCipherBatch([cipher.id], () => previousCipher);
onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed'));
throw error;
}
},
async unarchiveVaultItem(cipher: Cipher) {
const previousCipher = { ...cipher };
const revisionDate = new Date().toISOString();
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate: null, revisionDate }));
try {
await unarchiveCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]);
const unarchived = await unarchiveCipher(authedFetch, cipher.id);
await decryptAndPatch(unarchived);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_item_unarchived'));
} catch (error) {
patchCipherBatch([cipher.id], () => previousCipher);
onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed'));
throw error;
}
@@ -267,7 +539,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkDeleteVaultItems(ids: string[]) {
try {
await bulkDeleteCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]);
const deletedDate = new Date().toISOString();
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate, archivedDate: null }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_deleted_selected_items'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
@@ -278,7 +552,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkArchiveVaultItems(ids: string[]) {
try {
await bulkArchiveCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]);
const archivedDate = new Date().toISOString();
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate, deletedDate: null }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_archived_selected_items'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed'));
@@ -289,7 +565,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkUnarchiveVaultItems(ids: string[]) {
try {
await bulkUnarchiveCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]);
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_unarchived_selected_items'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed'));
@@ -300,7 +577,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkMoveVaultItems(ids: string[], folderId: string | null) {
try {
await bulkMoveCiphers(authedFetch, ids, folderId);
await Promise.all([refetchCiphers(), refetchFolders()]);
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_moved_selected_items'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
@@ -316,8 +594,16 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}
try {
if (!session) throw new Error(t('txt_vault_key_unavailable'));
await createFolder(authedFetch, session, folderName);
await refetchFolders();
const created = await createFolder(authedFetch, session, folderName);
patchDecryptedFolders((prev) => [
{
id: created.id,
name: created.name || folderName,
decName: folderName,
},
...prev,
]);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_folder_created'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
@@ -333,7 +619,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}
try {
await deleteFolder(authedFetch, id);
await Promise.all([refetchCiphers(), refetchFolders()]);
patchFolderBatch([id], () => null);
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher)));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_folder_deleted'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
@@ -355,7 +643,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try {
if (!session) throw new Error(t('txt_vault_key_unavailable'));
await updateFolder(authedFetch, session, id, nextName);
await refetchFolders();
patchFolderBatch([id], (folder) => ({ ...folder, decName: nextName }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_folder_updated'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
@@ -366,7 +655,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkRestoreVaultItems(ids: string[]) {
try {
await bulkRestoreCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]);
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_restored_selected_items'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
@@ -377,7 +667,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkPermanentDeleteVaultItems(ids: string[]) {
try {
await bulkPermanentDeleteCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]);
patchCipherBatch(ids, () => null);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_deleted_selected_items_permanently'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
@@ -390,7 +681,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
if (!ids.length) return;
try {
await bulkDeleteFolders(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]);
const removedIds = new Set(ids);
patchDecryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id)));
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher)));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_folders_deleted'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
@@ -524,7 +818,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
for (let i = 0; i < payload.ciphers.length; i++) {
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
const draft = importCipherToDraft(raw, mode === 'target' ? targetFolderId : null);
nextPayload.ciphers.push(await buildCipherImportPayload(session, draft));
const cipherPayload = await buildCipherImportPayload(session, draft);
const sourceId = String(raw.id || '').trim();
if (sourceId) cipherPayload.id = sourceId;
nextPayload.ciphers.push(cipherPayload);
}
const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, {
+62 -3
View File
@@ -134,7 +134,28 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
export function saveProfileSnapshot(profile: Profile | null): void {
if (!profile) return;
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(stripProfileSecrets(profile)));
const nextSnapshot = stripProfileSecrets(profile);
try {
const rawExisting = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
if (rawExisting) {
const existing = stripProfileSecrets(JSON.parse(rawExisting) as Profile);
if (
existing
&& existing.email === nextSnapshot?.email
&& existing.role === 'admin'
&& nextSnapshot?.role !== 'admin'
) {
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify({
...nextSnapshot,
role: 'admin',
}));
return;
}
}
} catch {
// Fall back to writing the normalized snapshot below.
}
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(nextSnapshot));
}
export function clearProfileSnapshot(): void {
@@ -382,12 +403,37 @@ export async function getPasswordHint(email: string): Promise<{ masterPasswordHi
export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) {
return async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
const retryableRequest = async (headers: Headers): Promise<Response> => {
const maxAttempts = 3;
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const response = await fetch(input, { ...init, headers });
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
return response;
}
lastError = new Error(`HTTP ${response.status}`);
if (attempt === maxAttempts - 1) {
return response;
}
} catch (error) {
lastError = error;
if (attempt === maxAttempts - 1) {
throw error;
}
}
const delayMs = 250 * (2 ** attempt) + Math.floor(Math.random() * 120);
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
throw lastError instanceof Error ? lastError : new Error('Request failed');
};
const session = getSession();
if (!session?.accessToken) throw new Error('Unauthorized');
const headers = new Headers(init.headers || {});
headers.set('Authorization', `Bearer ${session.accessToken}`);
let resp = await fetch(input, { ...init, headers });
let resp = await retryableRequest(headers);
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
const refreshed = await refreshAccessToken(session);
@@ -410,7 +456,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
const retryHeaders = new Headers(init.headers || {});
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
resp = await fetch(input, { ...init, headers: retryHeaders });
resp = await retryableRequest(retryHeaders);
return resp;
};
}
@@ -518,6 +564,19 @@ export async function verifyMasterPassword(
}
}
export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<number> {
const resp = await authedFetch('/api/accounts/revision-date');
if (!resp.ok) {
throw new Error('Failed to load revision date');
}
const body = await parseJson<number>(resp);
const stamp = Number(body);
if (!Number.isFinite(stamp) || stamp <= 0) {
throw new Error('Invalid revision date');
}
return stamp;
}
export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> {
const resp = await authedFetch('/api/accounts/totp');
if (!resp.ok) throw new Error('Failed to load TOTP status');
+2 -2
View File
@@ -6,7 +6,7 @@ import type {
BackupRuntimeState,
BackupScheduleConfig,
BackupSettings as AdminBackupSettings,
E3BackupDestination,
S3BackupDestination,
WebDavBackupDestination,
} from '@shared/backup-schema';
import {
@@ -26,7 +26,7 @@ export type {
BackupRuntimeState,
BackupScheduleConfig,
AdminBackupSettings,
E3BackupDestination,
S3BackupDestination,
WebDavBackupDestination,
};
+4 -3
View File
@@ -1,7 +1,6 @@
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
import type { Send, SendDraft, SessionState } from '../types';
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
import { loadVaultSyncSnapshot } from './vault-sync';
function toIsoDateFromDays(value: string, required: boolean): string | null {
const raw = String(value || '').trim();
@@ -62,8 +61,10 @@ function parseMaxAccessCountRaw(value: string): number | null {
}
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
const body = await loadVaultSyncSnapshot(authedFetch);
return body.sends || [];
const resp = await authedFetch('/api/sends');
if (!resp.ok) throw new Error('Failed to load sends');
const body = await parseJson<{ data?: Send[] }>(resp);
return body?.data || [];
}
export async function createSend(
+53 -8
View File
@@ -1,4 +1,6 @@
import type { Cipher, Folder, Send } from '../types';
import { getVaultRevisionDate } from './auth';
import { loadCachedVaultCoreSnapshot, saveCachedVaultCoreSnapshot, type VaultCoreSnapshot } from '../vault-cache';
import { parseJson, type AuthedFetch } from './shared';
interface VaultSyncResponse {
@@ -7,14 +9,54 @@ interface VaultSyncResponse {
sends?: Send[];
}
const pendingSyncRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>();
const pendingVaultCoreRequests = new Map<string, Promise<VaultCoreSnapshot>>();
const memoryVaultCoreCache = new Map<string, { revisionStamp: number; snapshot: VaultCoreSnapshot }>();
export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> {
const existing = pendingSyncRequests.get(authedFetch);
function normalizeSnapshot(body: VaultSyncResponse | null | undefined): VaultCoreSnapshot {
return {
ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [],
folders: Array.isArray(body?.folders) ? body!.folders! : [],
};
}
export async function getCachedVaultCoreSnapshot(cacheKey: string): Promise<VaultCoreSnapshot | null> {
const normalizedKey = String(cacheKey || '').trim();
if (!normalizedKey) return null;
const memory = memoryVaultCoreCache.get(normalizedKey);
if (memory) return memory.snapshot;
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
if (!cached?.snapshot) return null;
memoryVaultCoreCache.set(normalizedKey, {
revisionStamp: cached.revisionStamp,
snapshot: cached.snapshot,
});
return cached.snapshot;
}
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
const normalizedKey = String(cacheKey || '').trim();
if (!normalizedKey) return { ciphers: [], folders: [] };
const existing = pendingVaultCoreRequests.get(normalizedKey);
if (existing) return existing;
const request = (async () => {
const resp = await authedFetch('/api/sync', {
const revisionStamp = await getVaultRevisionDate(authedFetch);
const memory = memoryVaultCoreCache.get(normalizedKey);
if (memory?.revisionStamp === revisionStamp) {
return memory.snapshot;
}
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
if (cached?.revisionStamp === revisionStamp && cached.snapshot) {
memoryVaultCoreCache.set(normalizedKey, {
revisionStamp,
snapshot: cached.snapshot,
});
return cached.snapshot;
}
const resp = await authedFetch('/api/sync?excludeSends=true&excludeDomains=true', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
@@ -23,15 +65,18 @@ export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<V
});
if (!resp.ok) throw new Error('Failed to load vault');
const body = await parseJson<VaultSyncResponse>(resp);
return body || {};
const snapshot = normalizeSnapshot(body);
memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot });
void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot);
return snapshot;
})();
pendingSyncRequests.set(authedFetch, request);
pendingVaultCoreRequests.set(normalizedKey, request);
try {
return await request;
} finally {
if (pendingSyncRequests.get(authedFetch) === request) {
pendingSyncRequests.delete(authedFetch);
if (pendingVaultCoreRequests.get(normalizedKey) === request) {
pendingVaultCoreRequests.delete(normalizedKey);
}
}
}
+20 -12
View File
@@ -17,10 +17,10 @@ import {
type AuthedFetch,
} from './shared';
import { readResponseBytesWithProgress } from '../download';
import { loadVaultSyncSnapshot } from './vault-sync';
import { loadVaultCoreSyncSnapshot } from './vault-sync';
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
const body = await loadVaultSyncSnapshot(authedFetch);
export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise<Folder[]> {
const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
return body.folders || [];
}
@@ -92,8 +92,8 @@ export async function updateFolder(
if (!resp.ok) throw new Error('Update folder failed');
}
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
const body = await loadVaultSyncSnapshot(authedFetch);
export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Promise<Cipher[]> {
const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
return body.ciphers || [];
}
@@ -563,9 +563,13 @@ async function encryptUris(
mac: Uint8Array
): Promise<Array<Record<string, unknown>>> {
const out: Array<Record<string, unknown>> = [];
const seen = new Set<string>();
for (const entry of uris || []) {
const trimmed = String(entry?.uri || '').trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
const preservedExtra =
entry?.extra && typeof entry.extra === 'object'
? { ...entry.extra }
@@ -762,7 +766,7 @@ export async function createCipher(
authedFetch: AuthedFetch,
session: SessionState,
draft: VaultDraft
): Promise<{ id: string }> {
): Promise<Cipher> {
const payload = await buildCipherPayload(session, draft, null);
const resp = await authedFetch('/api/ciphers', {
@@ -771,9 +775,9 @@ export async function createCipher(
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error('Create item failed');
const body = await parseJson<{ id?: string }>(resp);
const body = await parseJson<Cipher>(resp);
if (!body?.id) throw new Error('Create item failed');
return { id: body.id };
return body;
}
export async function updateCipher(
@@ -781,7 +785,7 @@ export async function updateCipher(
session: SessionState,
cipher: Cipher,
draft: VaultDraft
): Promise<void> {
): Promise<Cipher> {
const payload = await buildCipherPayload(session, draft, cipher);
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
@@ -790,25 +794,29 @@ export async function updateCipher(
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error('Update item failed');
return (await parseJson<Cipher>(resp))!;
}
export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Delete item failed');
return (await parseJson<Cipher>(resp))!;
}
export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/archive`, { method: 'PUT' });
if (!resp.ok) throw new Error('Archive item failed');
return (await parseJson<Cipher>(resp))!;
}
export async function unarchiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
export async function unarchiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/unarchive`, { method: 'PUT' });
if (!resp.ok) throw new Error('Unarchive item failed');
return (await parseJson<Cipher>(resp))!;
}
export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
+6 -3
View File
@@ -144,7 +144,7 @@ function decodeAccessTokenClaims(accessToken: string): AccessTokenClaims {
}
}
function buildTransientProfile(token: TokenSuccess, email: string): Profile {
function buildTransientProfile(token: TokenSuccess, email: string, fallbackProfile: Profile | null = null): Profile {
const claims = decodeAccessTokenClaims(token.access_token);
const normalizedEmail = String(claims.email || email || '').trim().toLowerCase();
const accountKeys = token.accountKeys ?? token.AccountKeys ?? null;
@@ -154,9 +154,11 @@ function buildTransientProfile(token: TokenSuccess, email: string): Profile {
name: String(claims.name || normalizedEmail || ''),
key: String(token.Key || ''),
privateKey: token.PrivateKey ?? null,
role: 'user',
role: fallbackProfile?.role === 'admin' ? 'admin' : 'user',
premium: !!claims.premium,
accountKeys,
masterPasswordHint: fallbackProfile?.masterPasswordHint ?? null,
publicKey: fallbackProfile?.publicKey ?? null,
object: 'profile',
};
}
@@ -256,6 +258,7 @@ export async function completeLogin(
masterKey: Uint8Array
): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
const baseSession: SessionState = {
accessToken: token.access_token,
refreshToken: token.refresh_token,
@@ -266,7 +269,7 @@ export async function completeLogin(
() => baseSession,
() => {}
);
const profile = buildTransientProfile(token, normalizedEmail);
const profile = buildTransientProfile(token, normalizedEmail, fallbackProfile);
if (!profile.key) {
throw new Error('Missing profile key');
}
+37 -7
View File
@@ -1,6 +1,6 @@
import { hkdf } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Cipher, VaultDraft } from '@/lib/types';
import type { VaultDraft } from '@/lib/types';
import type { ImportResultSummary } from '@/components/ImportPage';
const SEND_KEY_SALT = 'bitwarden-send';
@@ -26,11 +26,30 @@ export function looksLikeCipherString(value: string): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
}
export function asText(value: unknown): string {
function asText(value: unknown): string {
if (value === null || value === undefined) return '';
return String(value);
}
function isImportTotpFieldName(value: unknown): boolean {
const name = asText(value).trim().toLowerCase().replace(/[\s_-]+/g, '');
return [
'totp',
'totpuri',
'otp',
'otpuri',
'otpurl',
'otpauth',
'onetimepassword',
'onetimepasscode',
'2fa',
'twofactor',
'twofactorauthentication',
'authenticator',
'verificationcode',
].includes(name);
}
export function readInviteCodeFromUrl(): string {
if (typeof window === 'undefined') return '';
@@ -87,7 +106,7 @@ export function summarizeImportResult(
};
}
export function buildEmptyImportDraft(type: number): VaultDraft {
function buildEmptyImportDraft(type: number): VaultDraft {
return {
type,
favorite: false,
@@ -162,6 +181,7 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
draft.loginPassword = asText(login.password);
draft.loginTotp = asText(login.totp);
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
const seenUris = new Set<string>();
const uris = urisRaw
.map((u) => {
const row = (u || {}) as Record<string, unknown>;
@@ -176,8 +196,21 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
),
};
})
.filter((u) => !!u.uri);
.filter((u) => {
if (!u.uri) return false;
const key = u.uri.toLowerCase();
if (seenUris.has(key)) return false;
seenUris.add(key);
return true;
});
draft.loginUris = uris.length ? uris : [{ uri: '', match: null, originalUri: '', extra: {} }];
if (!draft.loginTotp) {
const totpFieldIndex = draft.customFields.findIndex((field) => isImportTotpFieldName(field.label));
if (totpFieldIndex >= 0) {
draft.loginTotp = asText(draft.customFields[totpFieldIndex].value);
draft.customFields = draft.customFields.filter((_, index) => index !== totpFieldIndex);
}
}
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
? login.fido2Credentials.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
: [];
@@ -246,6 +279,3 @@ export async function deriveSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{
return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) };
}
export function findCipherById(ciphers: Cipher[], id: string): Cipher | null {
return ciphers.find((cipher) => cipher.id === id) || null;
}
+2 -2
View File
@@ -51,7 +51,7 @@ export function detectBrowserTimeZone(): string {
}
function createLocalizedDestinationName(type: BackupDestinationType, index: number): string {
if (type === 'e3') return t('txt_backup_destination_name_default_e3', { index: String(index) });
if (type === 's3') return t('txt_backup_destination_name_default_s3', { index: String(index) });
return t('txt_backup_destination_name_default_webdav', { index: String(index) });
}
@@ -207,6 +207,6 @@ export function getFirstVisibleDestinationId(settings: BackupSettings | null | u
}
export function getDestinationTypeLabel(type: BackupDestinationType): string {
if (type === 'e3') return t('txt_backup_protocol_e3');
if (type === 's3') return t('txt_backup_protocol_s3');
return t('txt_backup_protocol_webdav');
}
+46 -3
View File
@@ -22,6 +22,49 @@ export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
return new Uint8Array(bytes).buffer;
}
const hmacSha256KeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
const aesCbcEncryptKeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
const aesCbcDecryptKeyCache = new WeakMap<Uint8Array, Promise<CryptoKey>>();
function getCachedCryptoKey(
cache: WeakMap<Uint8Array, Promise<CryptoKey>>,
keyBytes: Uint8Array,
create: () => Promise<CryptoKey>
): Promise<CryptoKey> {
const cached = cache.get(keyBytes);
if (cached) return cached;
const pending = create().catch((error) => {
cache.delete(keyBytes);
throw error;
});
cache.set(keyBytes, pending);
return pending;
}
function getHmacSha256Key(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey(
hmacSha256KeyCache,
keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
);
}
function getAesCbcEncryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey(
aesCbcEncryptKeyCache,
keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['encrypt'])
);
}
function getAesCbcDecryptKey(keyBytes: Uint8Array): Promise<CryptoKey> {
return getCachedCryptoKey(
aesCbcDecryptKeyCache,
keyBytes,
() => crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'AES-CBC' }, false, ['decrypt'])
);
}
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
@@ -91,17 +134,17 @@ export async function hkdf(
}
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const key = await getHmacSha256Key(keyBytes);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes)));
}
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['encrypt']);
const cryptoKey = await getAesCbcEncryptKey(key);
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
}
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['decrypt']);
const cryptoKey = await getAesCbcDecryptKey(key);
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
}
+115
View File
@@ -0,0 +1,115 @@
import { decryptStr, decryptBw } from './crypto';
import type { Cipher } from './types';
async function decryptField(
value: string | null | undefined,
enc: Uint8Array,
mac: Uint8Array,
): Promise<string> {
if (!value || typeof value !== 'string') return '';
try { return await decryptStr(value, enc, mac); } catch { return value; }
}
export async function decryptSingleCipher(
encrypted: Cipher,
userEnc: Uint8Array,
userMac: Uint8Array,
): Promise<Cipher> {
let itemEnc = userEnc;
let itemMac = userMac;
if (encrypted.key) {
try {
const itemKey = await decryptBw(encrypted.key, userEnc, userMac);
itemEnc = itemKey.slice(0, 32);
itemMac = itemKey.slice(32, 64);
} catch { /* keep user key */ }
}
const decrypted: Cipher = {
...encrypted,
decName: await decryptField(encrypted.name, itemEnc, itemMac),
decNotes: await decryptField(encrypted.notes, itemEnc, itemMac),
};
if (encrypted.login) {
decrypted.login = {
...encrypted.login,
decUsername: await decryptField(encrypted.login.username, itemEnc, itemMac),
decPassword: await decryptField(encrypted.login.password, itemEnc, itemMac),
decTotp: await decryptField(encrypted.login.totp, itemEnc, itemMac),
uris: await Promise.all((encrypted.login.uris || []).map(async (u) => ({
...u,
decUri: await decryptField(u.uri, itemEnc, itemMac),
}))),
};
}
if (Array.isArray(encrypted.passwordHistory)) {
decrypted.passwordHistory = await Promise.all(
encrypted.passwordHistory.map(async (entry) => ({
...entry,
decPassword: await decryptField(entry?.password, itemEnc, itemMac),
}))
);
}
if (encrypted.card) {
decrypted.card = {
...encrypted.card,
decCardholderName: await decryptField(encrypted.card.cardholderName, itemEnc, itemMac),
decNumber: await decryptField(encrypted.card.number, itemEnc, itemMac),
decBrand: await decryptField(encrypted.card.brand, itemEnc, itemMac),
decExpMonth: await decryptField(encrypted.card.expMonth, itemEnc, itemMac),
decExpYear: await decryptField(encrypted.card.expYear, itemEnc, itemMac),
decCode: await decryptField(encrypted.card.code, itemEnc, itemMac),
};
}
if (encrypted.identity) {
decrypted.identity = {
...encrypted.identity,
decTitle: await decryptField(encrypted.identity.title, itemEnc, itemMac),
decFirstName: await decryptField(encrypted.identity.firstName, itemEnc, itemMac),
decMiddleName: await decryptField(encrypted.identity.middleName, itemEnc, itemMac),
decLastName: await decryptField(encrypted.identity.lastName, itemEnc, itemMac),
decUsername: await decryptField(encrypted.identity.username, itemEnc, itemMac),
decCompany: await decryptField(encrypted.identity.company, itemEnc, itemMac),
decSsn: await decryptField(encrypted.identity.ssn, itemEnc, itemMac),
decPassportNumber: await decryptField(encrypted.identity.passportNumber, itemEnc, itemMac),
decLicenseNumber: await decryptField(encrypted.identity.licenseNumber, itemEnc, itemMac),
decEmail: await decryptField(encrypted.identity.email, itemEnc, itemMac),
decPhone: await decryptField(encrypted.identity.phone, itemEnc, itemMac),
decAddress1: await decryptField(encrypted.identity.address1, itemEnc, itemMac),
decAddress2: await decryptField(encrypted.identity.address2, itemEnc, itemMac),
decAddress3: await decryptField(encrypted.identity.address3, itemEnc, itemMac),
decCity: await decryptField(encrypted.identity.city, itemEnc, itemMac),
decState: await decryptField(encrypted.identity.state, itemEnc, itemMac),
decPostalCode: await decryptField(encrypted.identity.postalCode, itemEnc, itemMac),
decCountry: await decryptField(encrypted.identity.country, itemEnc, itemMac),
};
}
if (encrypted.sshKey) {
const fingerprint = encrypted.sshKey.keyFingerprint || encrypted.sshKey.fingerprint || '';
decrypted.sshKey = {
...encrypted.sshKey,
decPrivateKey: await decryptField(encrypted.sshKey.privateKey, itemEnc, itemMac),
decPublicKey: await decryptField(encrypted.sshKey.publicKey, itemEnc, itemMac),
keyFingerprint: fingerprint || null,
fingerprint: fingerprint || null,
decFingerprint: await decryptField(fingerprint, itemEnc, itemMac),
};
}
if (encrypted.fields) {
decrypted.fields = await Promise.all(
encrypted.fields.map(async (field) => ({
...field,
decName: await decryptField(field.name, itemEnc, itemMac),
decValue: await decryptField(field.value, itemEnc, itemMac),
}))
);
}
return decrypted;
}
-79
View File
@@ -380,85 +380,6 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
return JSON.stringify(doc, null, 2);
}
export async function buildBitwardenCsvString(args: BuildPlainJsonArgs): Promise<string> {
const doc = await buildPlainBitwardenJsonDocument(args);
const folders = Array.isArray(doc.folders) ? (doc.folders as Array<Record<string, unknown>>) : [];
const items = Array.isArray(doc.items) ? (doc.items as Array<Record<string, unknown>>) : [];
const folderNameById = new Map<string, string>();
for (const folder of folders) {
const id = normalizeString(folder.id);
if (!id) continue;
folderNameById.set(id, normalizeString(folder.name) || '');
}
const header = [
'folder',
'favorite',
'type',
'name',
'notes',
'fields',
'reprompt',
'archivedDate',
'login_uri',
'login_username',
'login_password',
'login_totp',
];
const rows: string[][] = [header];
for (const item of items) {
const type = normalizeNumber(item.type, 1);
if (type !== 1 && type !== 2) continue;
const folderId = normalizeString(item.folderId);
const folderName = folderId ? folderNameById.get(folderId) || '' : '';
const fields = Array.isArray(item.fields)
? (item.fields as Array<Record<string, unknown>>)
.map((field) => {
const name = normalizeString(field.name) || '';
const value = normalizeString(field.value) || '';
if (!name && !value) return '';
return `${name}: ${value}`;
})
.filter((line) => !!line)
.join('\n')
: '';
const login = isRecord(item.login) ? (item.login as Record<string, unknown>) : null;
const loginUris = login && Array.isArray(login.uris)
? (login.uris as Array<Record<string, unknown>>)
.map((uri) => normalizeString(uri.uri) || '')
.filter((uri) => !!uri)
.join('\n')
: '';
rows.push([
folderName,
item.favorite ? '1' : '',
type === 1 ? 'login' : 'note',
normalizeString(item.name) || '',
normalizeString(item.notes) || '',
fields,
String(normalizeNumber(item.reprompt, 0)),
normalizeString(item.archivedDate) || '',
loginUris,
normalizeString(login?.username) || '',
normalizeString(login?.password) || '',
normalizeString(login?.totp) || '',
]);
}
const escapeCsv = (value: string): string => {
if (/[",\n\r]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
};
return rows.map((row) => row.map((cell) => escapeCsv(String(cell || ''))).join(',')).join('\n');
}
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
const userEnc = base64ToBytes(args.userEncB64);
const userMac = base64ToBytes(args.userMacB64);
+71 -1695
View File
File diff suppressed because it is too large Load Diff
+848
View File
@@ -0,0 +1,848 @@
// Complete English locale. Translate the values in this file to add a new language. Keep keys and placeholders unchanged.
const en: Record<string, string> = {
"nav_account_settings": "Account Settings",
"nav_admin_panel": "Admin Panel",
"nav_device_management": "Device Management",
"nav_my_vault": "My Vault",
"nav_sends": "Sends",
"nav_backup_strategy": "Cloud Backup",
"nav_import_export": "Import & Export",
"backup_strategy_title": "Cloud Backup",
"backup_strategy_under_construction": "Under construction.",
"import_export_title": "Import & Export",
"import_export_under_construction": "Under construction.",
"txt_backup_export": "Export Backup",
"txt_backup_import": "Restore",
"txt_backup_include_attachments": "Include attachments",
"txt_backup_export_description": "Download a full instance backup ZIP for manual safekeeping.",
"txt_backup_import_description": "Upload a previously exported backup ZIP and restore it into this instance.",
"txt_backup_exporting": "Exporting...",
"txt_backup_importing": "Restoring...",
"txt_backup_restoring": "Restoring...",
"txt_backup_export_success": "Backup exported",
"txt_backup_import_success_relogin": "Backup restored. Please sign in again.",
"txt_backup_restore_success_relogin": "Backup restored. Please sign in again.",
"txt_backup_restore_completed_verified": "Backup file integrity verification passed.",
"txt_backup_restore_completed_without_checksum": "Backup restored. No filename integrity marker was available for verification.",
"txt_backup_remote_restore_completed_verified": "Remote backup integrity verification passed.",
"txt_backup_remote_restore_completed_without_checksum": "Remote backup restored. No filename integrity marker was available for verification.",
"txt_backup_restore_skipped_summary": "{reason}. Skipped {attachments} attachment(s).",
"txt_backup_restore_skipped_reason_default": "Some files could not be restored",
"txt_backup_export_failed": "Backup export failed",
"txt_backup_import_failed": "Backup restore failed",
"txt_backup_restore_failed": "Backup restore failed",
"txt_backup_integrity_check_failed": "Backup integrity verification failed",
"txt_backup_center_title": "Instance Backup",
"txt_backup_center_description": "Keep local exports for manual restore, and configure one daily remote backup target for unattended protection.",
"txt_backup_restore_note": "Restoring will overwrite the current instance if you choose the replace flow.",
"txt_backup_manual": "Manual Backup",
"txt_backup_manual_description": "Export a ZIP right now, or import a ZIP back into this instance.",
"txt_backup_destinations_title": "Backup Destinations",
"txt_backup_destinations_description": "Keep multiple WebDAV and S3 targets here. Select one on the left to edit or browse it.",
"txt_backup_recommend_title": "Recommended Storage",
"txt_backup_recommend_open_signup": "Open Signup",
"txt_backup_recommend_open_signup_aff": "Open Signup (AFF)",
"txt_backup_recommend_open_guide": "Open Guide",
"txt_backup_recommend_empty": "No recommendations yet.",
"txt_backup_recommend_referral_label": "Referral Code",
"txt_backup_recommend_referral_note": "Use it during signup to get 5 GB extra. The author receives 2 GB.",
"txt_backup_recommend_infinicloud_summary": "Only an email address is needed. 20 GB free, 25 GB total with the referral code.",
"txt_backup_recommend_infinicloud_step_1": "Register an InfiniCLOUD account with just your email address.",
"txt_backup_recommend_infinicloud_step_2_prefix": "Open",
"txt_backup_recommend_infinicloud_step_2_suffix": "and turn on Apps Connection.",
"txt_backup_recommend_infinicloud_step_3": "Use Connection ID as your WebDAV username and Apps Password as your WebDAV password.",
"txt_backup_recommend_infinicloud_step_4": "Enter referral code 2HC5E in Referral Bonus at the bottom of My Page to receive 5 GB extra.",
"txt_backup_recommend_open_password": "Password Settings",
"txt_backup_recommend_open_storage": "Open Storage",
"txt_backup_recommend_koofr_summary": "Only an email address is needed. 10 GB free, and it can bridge Google Drive, OneDrive, and Dropbox through WebDAV.",
"txt_backup_recommend_koofr_password_link": "Password Settings",
"txt_backup_recommend_koofr_storage_link": "Storage",
"txt_backup_recommend_koofr_step_1": "Register a Koofr account with just your email address.",
"txt_backup_recommend_koofr_step_2_prefix": "Open",
"txt_backup_recommend_koofr_step_2_suffix": ", generate a new app password, use your email address as the WebDAV username, and use the app password as the WebDAV password.",
"txt_backup_recommend_koofr_step_3": "Koofr's own WebDAV address is https://app.koofr.net/dav/Koofr.",
"txt_backup_recommend_koofr_step_4": "Koofr can also connect Google Drive, OneDrive, and Dropbox. Free users can connect up to two storage accounts.",
"txt_backup_recommend_koofr_step_5_prefix": "Open",
"txt_backup_recommend_koofr_step_5_suffix": ", click Connect in the left sidebar, and choose the cloud storage you want to attach.",
"txt_backup_recommend_koofr_dav_intro": "After a storage account is connected, keep the same email and app password, and only switch the WebDAV address:",
"txt_backup_recommend_koofr_dav_self": "Koofr",
"txt_backup_recommend_pcloud_summary": "Only an email address is needed. Up to 10 GB free, with standard WebDAV access.",
"txt_backup_recommend_pcloud_step_1": "Register a pCloud account with just your email address.",
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ as the WebDAV server URL.",
"txt_backup_recommend_pcloud_step_3": "Use your registration email as the WebDAV username and your account password as the WebDAV password.",
"txt_backup_add_destination": "Add Destination",
"txt_backup_schedule_panel_title": "Automatic Schedule",
"txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.",
"txt_backup_scheduled_target": "Scheduled Target",
"txt_backup_destination_active_badge": "Auto On",
"txt_backup_destination_idle_badge": "Auto Off",
"txt_backup_destination_last_success": "Last success: {time}",
"txt_backup_destination_never_run": "No successful run yet",
"txt_backup_destination_detail_title": "Destination Details",
"txt_backup_destination_detail_note": "",
"txt_backup_destination_name": "Destination Name",
"txt_backup_set_scheduled_target": "Use For Daily Backup",
"txt_backup_delete_destination": "Delete",
"txt_backup_destination_deleted": "Backup destination deleted",
"txt_backup_delete_destination_confirm_message": "Delete backup destination \"{name}\"? This cannot be undone.",
"txt_backup_select_destination": "Select a backup destination from the list first.",
"txt_backup_remote_save_first": "Save this destination first before browsing its remote backup files.",
"txt_backup_automation": "Automatic Backup",
"txt_backup_automation_description": "Pick a destination, save the credentials, and let the worker upload one backup every day.",
"txt_backup_settings_saved": "Backup settings saved",
"txt_backup_settings_save_failed": "Saving backup settings failed",
"txt_backup_settings_load_failed": "Loading backup settings failed",
"txt_backup_save_settings": "Save Settings",
"txt_backup_saving": "Saving...",
"txt_backup_enable_action": "Enable",
"txt_backup_disable_action": "Disable",
"txt_backup_run_now": "Run Remote Backup Now",
"txt_backup_run_manual": "Run Manually",
"txt_backup_running_now": "Running...",
"txt_backup_remote_run_success": "Remote backup completed",
"txt_backup_remote_run_success_verified": "Remote backup completed and integrity verification passed.",
"txt_backup_remote_run_failed": "Remote backup failed",
"txt_backup_remote_title": "Remote Backups",
"txt_backup_remote_note": "Browse the saved destination and choose a backup ZIP to download or restore.",
"txt_backup_remote_saved_basis": "Remote browsing uses the last saved destination settings, not unsaved form edits.",
"txt_backup_remote_refresh": "Refresh",
"txt_backup_remote_root": "Root",
"txt_backup_remote_up": "Up",
"txt_backup_remote_open": "Open",
"txt_backup_remote_download": "Download",
"txt_backup_remote_downloading": "Downloading...",
"txt_backup_remote_restore": "Restore",
"txt_backup_remote_restore_stage_prepare": "Preparing remote backup restore...",
"txt_backup_remote_restore_stage_replace": "Clearing current data and restoring remote backup...",
"txt_backup_progress_kicker": "Backup Task",
"txt_backup_progress_subject": "Current item: {name}",
"txt_backup_restore_progress_kicker": "Restore Progress",
"txt_backup_restore_progress_local_title": "Restoring local backup",
"txt_backup_restore_progress_remote_title": "Restoring remote backup",
"txt_backup_export_progress_title": "Exporting backup",
"txt_backup_remote_run_progress_title": "Running remote backup",
"txt_backup_restore_progress_file": "Current file: {name}",
"txt_backup_restore_progress_elapsed": "{seconds}s elapsed",
"txt_backup_archive_progress_collect_title": "Collecting vault data",
"txt_backup_archive_progress_collect_detail": "The server is reading database tables and assembling the backup payload.",
"txt_backup_archive_progress_collect_with_attachments_detail": "The server is reading database tables and collecting attachment metadata for the backup payload.",
"txt_backup_archive_progress_package_title": "Packaging backup archive",
"txt_backup_archive_progress_package_detail": "The server is generating the backup ZIP and computing its checksum prefix.",
"txt_backup_archive_progress_package_with_attachments_detail": "The server is generating the backup ZIP metadata and computing its checksum prefix for the attachment-aware export.",
"txt_backup_archive_progress_ready_title": "Preparing download",
"txt_backup_archive_progress_ready_detail": "The backup archive is ready and is being returned to the browser.",
"txt_backup_export_progress_fetch_attachments_title": "Downloading attachment files",
"txt_backup_export_progress_fetch_attachments_detail": "The browser is fetching attachment objects and adding them into the export package.",
"txt_backup_export_progress_rebuild_title": "Rebuilding export archive",
"txt_backup_export_progress_rebuild_detail": "The browser is rebuilding the final ZIP and refreshing its checksum suffix.",
"txt_backup_export_progress_save_title": "Saving export file",
"txt_backup_export_progress_save_detail": "The browser is preparing the final backup file for download.",
"txt_backup_export_progress_complete_title": "Export completed",
"txt_backup_export_progress_complete_detail": "The backup export is ready.",
"txt_backup_export_progress_failed_title": "Export failed",
"txt_backup_export_progress_failed_detail": "The backup export could not be completed.",
"txt_backup_remote_run_progress_prepare_title": "Preparing remote backup",
"txt_backup_remote_run_progress_prepare_detail": "The server is loading the selected destination and preparing this backup run.",
"txt_backup_remote_run_progress_sync_attachments_title": "Checking attachment index",
"txt_backup_remote_run_progress_sync_attachments_detail": "The server is comparing attachment metadata so only missing attachment objects are uploaded.",
"txt_backup_remote_run_progress_sync_attachments_skipped_detail": "This backup does not include attachments, so attachment synchronization is skipped.",
"txt_backup_remote_run_progress_upload_title": "Uploading backup archive",
"txt_backup_remote_run_progress_upload_detail": "The server is uploading the backup ZIP to the remote destination.",
"txt_backup_remote_run_progress_verify_title": "Verifying uploaded archive",
"txt_backup_remote_run_progress_verify_detail": "The server is downloading the uploaded ZIP back and verifying its checksum and size.",
"txt_backup_remote_run_progress_cleanup_title": "Cleaning older backups",
"txt_backup_remote_run_progress_cleanup_detail": "The server is pruning older backup files according to the retention policy.",
"txt_backup_remote_run_progress_complete_title": "Remote backup completed",
"txt_backup_remote_run_progress_complete_detail": "The remote backup has been uploaded and verified successfully.",
"txt_backup_remote_run_progress_failed_title": "Remote backup failed",
"txt_backup_remote_run_progress_failed_detail": "The remote backup could not be completed.",
"txt_backup_restore_progress_local_upload_title": "Uploading backup archive",
"txt_backup_restore_progress_local_upload_detail": "The selected ZIP is being sent to the server for processing.",
"txt_backup_restore_progress_local_shadow_title": "Creating shadow workspace",
"txt_backup_restore_progress_local_shadow_detail": "The server is preparing an isolated restore area so the current data remains untouched until validation passes.",
"txt_backup_restore_progress_local_data_title": "Writing vault data",
"txt_backup_restore_progress_local_data_detail": "The server is importing users, folders, vault items, and related metadata into shadow tables.",
"txt_backup_restore_progress_local_files_title": "Restoring attachment files",
"txt_backup_restore_progress_local_files_detail": "The server is writing attachment objects back to storage and removing any attachment rows that cannot be restored.",
"txt_backup_restore_progress_local_finalize_title": "Validating and switching data",
"txt_backup_restore_progress_local_finalize_detail": "The server is performing final validation and then swapping the verified restore data into the live tables.",
"txt_backup_restore_progress_remote_fetch_title": "Reading remote backup",
"txt_backup_restore_progress_remote_fetch_detail": "The server is downloading the selected backup package from the remote destination.",
"txt_backup_restore_progress_remote_shadow_title": "Creating shadow workspace",
"txt_backup_restore_progress_remote_shadow_detail": "The server is preparing an isolated restore area so the current data remains untouched until validation passes.",
"txt_backup_restore_progress_remote_data_title": "Writing vault data",
"txt_backup_restore_progress_remote_data_detail": "The server is importing users, folders, vault items, and related metadata into shadow tables.",
"txt_backup_restore_progress_remote_files_title": "Restoring remote attachments",
"txt_backup_restore_progress_remote_files_detail": "The server is fetching required attachment objects from remote storage and writing them back into local storage.",
"txt_backup_restore_progress_remote_finalize_title": "Validating and switching data",
"txt_backup_restore_progress_remote_finalize_detail": "The server is performing final validation and then switching the verified restore data into the live tables.",
"txt_backup_remote_loading": "Loading remote backups...",
"txt_backup_remote_cached_empty": "Click Refresh to load this destination.",
"txt_backup_remote_empty": "No backup files found in this folder.",
"txt_backup_remote_folder": "Folder",
"txt_backup_remote_unknown_time": "Unknown time",
"txt_backup_remote_current_path": "Current Folder",
"txt_backup_remote_load_failed": "Loading remote backups failed",
"txt_backup_remote_invalid_response": "Invalid remote backup response",
"txt_backup_remote_download_failed": "Downloading remote backup failed",
"txt_backup_remote_delete_success": "Remote backup deleted",
"txt_backup_remote_delete_failed": "Deleting remote backup failed",
"txt_backup_remote_delete_confirm_message": "Delete backup file \"{name}\"? This cannot be undone.",
"txt_backup_remote_deleting": "Deleting...",
"txt_backup_remote_restore_failed": "Restoring remote backup failed",
"txt_backup_restore_checksum_warning_title": "Backup Integrity Warning",
"txt_backup_restore_checksum_warning_message": "The selected backup file \"{name}\" failed filename integrity verification. Expected prefix {expected}, actual prefix {actual}. The file may be incomplete or corrupted. Continuing may restore damaged data.",
"txt_backup_remote_restore_checksum_warning_message": "The remote backup file \"{name}\" failed filename integrity verification. Expected prefix {expected}, actual prefix {actual}. The file may be corrupted during upload or storage. Continuing may restore damaged data and may cause serious data loss.",
"txt_backup_restore_checksum_warning_message_fallback": "The selected backup file failed integrity verification. Continuing may restore damaged data.",
"txt_backup_restore_checksum_warning_confirm": "Continue Restore",
"txt_backup_remote_restore_invalid_response": "Invalid remote backup restore response",
"txt_backup_remote_run_invalid_response": "Invalid remote backup run response",
"txt_backup_settings_invalid_response": "Invalid backup settings response",
"txt_backup_import_invalid_response": "Invalid backup import response",
"txt_backup_destination": "Backup Destination",
"txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3",
"txt_backup_recommend_group_webdav": "WebDAV",
"txt_backup_recommend_group_s3": "S3",
"txt_backup_destination_name_default_webdav": "WebDAV {index}",
"txt_backup_destination_name_default_s3": "S3 {index}",
"txt_backup_type": "Backup Type",
"txt_backup_destination_reserved": "Reserved Slot",
"txt_backup_time": "Backup Time",
"txt_backup_start_time": "Start Time",
"txt_backup_timezone": "Timezone",
"txt_backup_interval_hours": "Every",
"txt_backup_interval_hours_suffix": "hours",
"txt_backup_interval_hours_presets": "Quick interval presets",
"txt_backup_frequency": "Frequency",
"txt_backup_frequency_daily": "Daily",
"txt_backup_frequency_weekly": "Weekly",
"txt_backup_frequency_monthly": "Monthly",
"txt_backup_day_of_week": "Day of Week",
"txt_backup_day_of_month": "Day of Month",
"txt_backup_weekday_monday": "Monday",
"txt_backup_weekday_tuesday": "Tuesday",
"txt_backup_weekday_wednesday": "Wednesday",
"txt_backup_weekday_thursday": "Thursday",
"txt_backup_weekday_friday": "Friday",
"txt_backup_weekday_saturday": "Saturday",
"txt_backup_weekday_sunday": "Sunday",
"txt_backup_retention_count": "Keep",
"txt_backup_retention_count_suffix": "items",
"txt_backup_retention_count_hint": "Leave empty to keep all backup files. New destinations default to 30.",
"txt_backup_destination_include_attachments": "Include attachments",
"txt_backup_include_attachments_help_button": "Attachment backup help",
"txt_backup_include_attachments_help": "Attachments are stored incrementally in the remote attachments folder, so later backups usually only upload new files. Deleting an attachment locally does not remove earlier remote copies. During restore, NodeWarden reads the required files from the attachments folder and skips any attachment that is no longer available.",
"txt_backup_enable_schedule": "Enable automatic daily backup",
"txt_backup_schedule_note": "The worker checks the schedule every 5 minutes. It starts at the selected time in the selected timezone, then repeats by the chosen hour interval, and resets from that start time each day.",
"txt_backup_schedule_disabled": "Disabled",
"txt_backup_schedule_status": "Schedule",
"txt_backup_schedule_summary": "Start at {time}, every {interval} hours ({timezone})",
"txt_backup_schedule_empty": "No automatic backup plans are enabled yet.",
"txt_backup_last_success": "Last Success",
"txt_backup_last_target": "Last Target",
"txt_backup_last_file": "Last File",
"txt_backup_last_error_prefix": "Last Error",
"txt_backup_none_yet": "No remote backup has completed yet",
"txt_backup_not_configured": "Not configured",
"txt_backup_never": "Never",
"txt_backup_unknown_size": "Unknown size",
"txt_backup_webdav_url": "WebDAV Server URL",
"txt_backup_webdav_username": "WebDAV Username",
"txt_backup_webdav_password": "WebDAV Password",
"txt_backup_webdav_path": "Remote Folder",
"txt_backup_s3_endpoint": "S3 Endpoint",
"txt_backup_s3_bucket": "Bucket",
"txt_backup_s3_region": "Region",
"txt_backup_s3_access_key": "Access Key",
"txt_backup_s3_secret_key": "Secret Key",
"txt_backup_s3_path": "Remote Path",
"txt_backup_reserved_name": "Reserved Provider Name",
"txt_backup_reserved_notes": "Reserved Notes",
"txt_backup_reserved_notes_placeholder": "Leave a note for the next destination type",
"txt_backup_reserved_hint": "This slot is reserved for a future destination. You can save notes now, but automatic uploads stay disabled.",
"txt_backup_file": "Backup File",
"txt_backup_file_required": "Please select a backup file",
"txt_backup_no_file_selected": "No backup file selected",
"txt_backup_selected_file_name": "Selected file: {name}",
"txt_backup_replace_confirm_title": "Replace Current Instance Data",
"txt_backup_replace_confirm_message": "The current instance already contains data. Continue restoring and replace the current instance data with the selected backup after verification succeeds?",
"txt_backup_clear_and_import": "Replace and Import",
"txt_backup_clear_and_restore": "Replace and Restore",
"txt_access_count": "Access Count",
"txt_accessed_count_times": "Accessed {count} times",
"txt_actions": "Actions",
"txt_add": "Add",
"txt_add_field": "Add Field",
"txt_add_website": "Add Website",
"txt_added": "Added",
"txt_additional_options": "Additional Options",
"txt_address": "Address",
"txt_address_1": "Address 1",
"txt_address_2": "Address 2",
"txt_address_3": "Address 3",
"txt_all_device_authorizations_revoked": "All device trust revoked",
"txt_all_invites_deleted": "All invites deleted",
"txt_all_items": "All Items",
"txt_all_sends": "All Sends",
"txt_android": "Android",
"txt_are_you_sure_you_want_to_delete_count_selected_items": "Are you sure you want to delete {count} selected items?",
"txt_are_you_sure_you_want_to_delete_count_selected_items_permanently": "Are you sure you want to permanently delete {count} selected items?",
"txt_are_you_sure_you_want_to_delete_this_item": "Are you sure you want to delete this item?",
"txt_are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?",
"txt_are_you_sure_you_want_to_log_out": "Are you sure you want to log out?",
"txt_authenticator_key": "Authenticator Key",
"txt_authorized_devices": "Authorized Devices",
"txt_auto_copy_link_after_save": "Auto copy link after save",
"txt_autofill_options": "Autofill Options",
"txt_back_to_login": "Back To Login",
"txt_ban": "Ban",
"txt_boolean": "Boolean",
"txt_brand": "Brand",
"txt_bulk_delete_failed": "Bulk delete failed",
"txt_bulk_permanent_delete_failed": "Bulk permanent delete failed",
"txt_bulk_restore_failed": "Bulk restore failed",
"txt_bulk_delete_sends_failed": "Bulk delete sends failed",
"txt_bulk_move_failed": "Bulk move failed",
"txt_cancel": "Cancel",
"txt_continue": "Continue",
"txt_card": "Card",
"txt_card_details": "Card Details",
"txt_cardholder_name": "Cardholder Name",
"txt_change_master_password": "Change Master Password",
"txt_change_password": "Change Password",
"txt_change_password_failed": "Change password failed",
"txt_change_password_confirm_and_sign_out_all_devices": "Changing the master password will sign out all devices, including this web session. Continue?",
"txt_copy_failed": "Copy failed",
"txt_checked": "Checked",
"txt_choose_destination_folder": "Choose destination folder.",
"txt_chrome_browser": "Chrome Browser",
"txt_chrome_extension": "Chrome Extension",
"txt_city_town": "City / Town",
"txt_code": "Code",
"txt_company": "Company",
"txt_configure_custom_field_values": "Configure custom field values.",
"txt_confirm": "Confirm",
"txt_confirm_master_password": "Confirm Master Password",
"txt_confirm_password": "Confirm Password",
"txt_copy": "Copy",
"txt_code_copied": "Code copied",
"txt_copy_code": "Copy Code",
"txt_copy_link": "Copy Link",
"txt_copy_secret": "Copy Secret",
"txt_country": "Country",
"txt_create": "Create",
"txt_create_account": "Create Account",
"txt_registering": "Creating account...",
"txt_create_folder": "Create Folder",
"txt_create_folder_failed": "Create folder failed",
"txt_create_item_failed": "Create item failed",
"txt_create_send_failed": "Create send failed",
"txt_create_timed_invite": "Create Timed Invite",
"txt_created_value": "Created: {value}",
"txt_current_new_password_is_required": "Current/new password is required",
"txt_current_password": "Current Password",
"txt_custom_fields": "Custom Fields",
"txt_decrypt_failed": "(Decrypt failed)",
"txt_decrypt_failed_2": "Decrypt failed",
"txt_delete": "Delete",
"txt_delete_all": "Delete All",
"txt_delete_all_invite_codes_active_inactive": "Delete all invite codes (active/inactive)?",
"txt_delete_all_invites": "Delete all invites",
"txt_delete_item": "Delete Item",
"txt_delete_passkey": "Delete Passkey",
"txt_delete_item_failed": "Delete item failed",
"txt_delete_permanently": "Delete Permanently",
"txt_archive": "Archive",
"txt_archive_item": "Archive Item",
"txt_archive_item_message": "After archiving, this item will be excluded from general search results and autofill suggestions.",
"txt_archive_selected_items": "Archive Items",
"txt_archive_selected_items_message": "After archiving, {count} selected items will be excluded from general search results and autofill suggestions.",
"txt_archived": "Archived",
"txt_archive_selected": "Archive",
"txt_item_archived": "Item archived",
"txt_item_unarchived": "Item unarchived",
"txt_archived_selected_items": "Archived selected items",
"txt_unarchived_selected_items": "Unarchived selected items",
"txt_archive_item_failed": "Archive item failed",
"txt_unarchive_item_failed": "Unarchive item failed",
"txt_bulk_archive_failed": "Bulk archive failed",
"txt_bulk_unarchive_failed": "Bulk unarchive failed",
"txt_unarchive": "Unarchive",
"txt_delete_selected": "Delete",
"txt_delete_selected_items": "Delete Selected Items",
"txt_delete_selected_items_permanently": "Delete Selected Items Permanently",
"txt_delete_send_failed": "Delete send failed",
"txt_delete_this_user_and_all_user_data": "Delete this user and all user data?",
"txt_delete_user": "Delete user",
"txt_deleted_selected_items": "Deleted selected items",
"txt_deleted_selected_items_permanently": "Permanently deleted selected items",
"txt_restored_selected_items": "Restored selected items",
"txt_deleted_selected_sends": "Deleted selected sends",
"txt_deletion_date": "Deletion Date",
"txt_deletion_days": "Deletion Days",
"txt_device": "Device",
"txt_device_authorization_revoked": "Device trust revoked",
"txt_device_management": "Device Management",
"txt_device_note": "Device Note",
"txt_device_note_required": "Device name is required",
"txt_device_note_updated": "Device name updated",
"txt_device_removed": "Device removed",
"txt_load_devices_failed": "Failed to load devices",
"txt_disable_this_send": "Disable this send",
"txt_disable_totp": "Disable TOTP",
"txt_disable_totp_failed": "Disable TOTP failed",
"txt_download": "Download",
"txt_downloading": "Downloading...",
"txt_downloading_percent": "Downloading {percent}%",
"txt_attachment": "Attachment",
"txt_uploading_attachment_named": "Uploading {name}...",
"txt_uploading_attachment_named_percent": "Uploading {name} {percent}%",
"txt_uploading_file_named": "Uploading {name}...",
"txt_uploading_file_named_percent": "Uploading {name} {percent}%",
"txt_download_failed": "Download failed",
"txt_edge_browser": "Edge Browser",
"txt_edge_extension": "Edge Extension",
"txt_edit": "Edit",
"txt_edit_send": "Edit Send",
"txt_email": "Email",
"txt_email_password_and_recovery_code_are_required": "Email, password and recovery code are required",
"txt_enable_totp": "Enable TOTP",
"txt_enable_totp_failed": "Enable TOTP failed",
"txt_enabled": "Enabled",
"txt_encrypted_file": "Encrypted File",
"txt_encrypted_file_2": "Encrypted file",
"txt_enter_a_folder_name": "Enter a folder name.",
"txt_enter_master_password_to_disable_two_step_verification": "Enter master password to disable two-step verification.",
"txt_enter_master_password_to_continue": "Enter your master password to continue.",
"txt_enter_master_password_to_view_this_item": "Enter master password to view this item.",
"txt_expiration_date": "Expiration Date",
"txt_expiration_days_0_never": "Expiration Days (0 = never)",
"txt_expires_at": "Expires At",
"txt_expires_at_value": "Expires at: {value}",
"txt_expiry": "Expiry",
"txt_expiry_month": "Expiry Month",
"txt_expiry_year": "Expiry Year",
"txt_failed_to_open_send": "Failed to open send",
"txt_favorite": "Favorite",
"txt_favorites": "Favorites",
"txt_duplicates": "Duplicates",
"txt_field": "Field",
"txt_field_label": "Field Label",
"txt_field_label_is_required": "Field label is required.",
"txt_field_type": "Field Type",
"txt_field_value": "Field Value",
"txt_file": "File",
"txt_file_name": "File Name",
"txt_file_send": "File Send",
"txt_file_size": "File Size",
"txt_fingerprint": "Fingerprint",
"txt_firefox_browser": "Firefox Browser",
"txt_firefox_extension": "Firefox Extension",
"txt_first_name": "First Name",
"txt_folder": "Folder",
"txt_folder_created": "Folder created",
"txt_folder_name": "Folder Name",
"txt_folder_name_is_required": "Folder name is required",
"txt_folders": "Folders",
"txt_hidden": "Hidden",
"txt_hide": "Hide",
"txt_identity": "Identity",
"txt_identity_details": "Identity Details",
"txt_ie_browser": "IE Browser",
"txt_invite_code_optional": "Invite Code (Not required for the first account; required for all others)",
"txt_invite_created": "Invite created",
"txt_invite_revoked": "Invite revoked",
"txt_invite_validity_hours": "Invite validity (hours)",
"txt_invites": "Invites",
"txt_ios": "iOS",
"txt_item": "Item",
"txt_item_created": "Item created",
"txt_item_deleted": "Item deleted",
"txt_item_history": "Item History",
"txt_password_history": "Password History",
"txt_password_updated_value": "Password updated: {value}",
"txt_item_name_is_required": "Item name is required.",
"txt_item_updated": "Item updated",
"txt_last_edited_value": "Last edited: {value}",
"txt_last_name": "Last Name",
"txt_last_seen": "Last Seen",
"txt_license_number": "License Number",
"txt_link_copied": "Link copied",
"txt_linked": "Linked",
"txt_linux_desktop": "Linux Desktop",
"txt_loading": "Loading...",
"txt_loading_nodewarden": "Loading NodeWarden...",
"txt_jwt_warning_title": "Server Security Warning",
"txt_jwt_warning_subtitle": "JWT secret is not configured safely.",
"txt_jwt_title_missing": "JWT_SECRET is missing",
"txt_jwt_title_too_short": "JWT_SECRET is too short",
"txt_jwt_title_default": "JWT_SECRET is using the default value",
"txt_jwt_reason_missing": "JWT secret is missing.",
"txt_jwt_reason_default": "JWT secret is still the default/sample value.",
"txt_jwt_reason_too_short": "JWT secret is too short. Minimum length is {min}.",
"txt_jwt_how_to_fix_add": "How to add JWT_SECRET",
"txt_jwt_how_to_fix_replace": "How to replace JWT_SECRET",
"txt_jwt_add_step_1": "Use the 32-character generator below and copy a new key.",
"txt_jwt_add_step_2_prefix": "Go to Cloudflare Dashboard -> Workers & Pages -> Your Service -> ",
"txt_jwt_add_step_2_suffix": " -> Variables and Secrets -> Add",
"txt_jwt_add_step_3": "Save and wait for redeploy, then refresh this page.",
"txt_jwt_replace_step_1": "Use the 32-character generator below and create a stronger key (minimum {min} characters).",
"txt_jwt_replace_step_2_prefix": "Go to Cloudflare Dashboard -> Workers & Pages -> Your Service -> ",
"txt_jwt_replace_step_2_suffix": " -> Variables and Secrets -> Update JWT_SECRET",
"txt_jwt_replace_step_3": "Save and wait for redeploy, then refresh this page.",
"txt_jwt_secret_type_label": "Type:",
"txt_jwt_secret_type_value": "Secret",
"txt_jwt_secret_name_label": "Variable name:",
"txt_jwt_secret_value_label": "Value:",
"txt_jwt_secret_value_requirement": "Random string with at least {min} characters",
"txt_jwt_what_is": "What is JWT?",
"txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing, too short, or still using the sample value, the instance is not safe to use normally.",
"txt_how_to_fix": "How to fix",
"txt_jwt_fix_step_1": "Open your deployment environment variables.",
"txt_jwt_fix_step_2": "If your current key is not random enough, use the 32-character generator below.",
"txt_jwt_fix_step_3": "Cloudflare Dashboard -> Workers & Pages -> Your Service -> Settings -> Variables and Secrets, update JWT_SECRET.",
"txt_jwt_fix_step_4": "Save and wait for redeploy, then refresh this page to verify.",
"txt_random_secret_generator": "Random Secret Generator",
"txt_copied": "Copied",
"txt_log_in": "Log In",
"txt_logging_in": "Logging in...",
"txt_log_out": "Log Out",
"txt_lock": "Lock",
"txt_menu": "Menu",
"txt_settings": "Settings",
"txt_back": "Back",
"txt_login": "Login",
"txt_login_credentials": "Login Credentials",
"txt_login_failed": "Login failed",
"txt_login_success": "Login success",
"txt_macos_desktop": "macOS Desktop",
"txt_manage_authorized_devices_and_30_day_totp_trusted_sessions": "Manage authorized devices and 30-day TOTP trusted sessions.",
"txt_manage_device_sessions_and_30_day_totp_trusted_sessions": "Manage device sessions and 30-day TOTP trusted sessions.",
"txt_master_password": "Master Password",
"txt_master_password_changed_please_login_again": "Master password changed. Please login again.",
"txt_master_password_changed_signing_out_everywhere": "Master password changed. Signing out all devices.",
"txt_master_password_is_required": "Master password is required",
"txt_master_password_is_required_2": "Master password is required.",
"txt_master_password_must_be_at_least_12_chars": "Master password must be at least 12 chars",
"txt_master_password_reprompt": "Master password reprompt",
"txt_master_password_reprompt_2": "Master Password Reprompt",
"txt_max_access_count": "Max Access Count",
"txt_middle_name": "Middle Name",
"txt_drag_to_reorder": "Drag to reorder",
"txt_move": "Move",
"txt_move_selected_items": "Move Selected Items",
"txt_moved_selected_items": "Moved selected items",
"txt_name": "Name",
"txt_name_is_required": "Name is required",
"txt_new_password": "New Password",
"txt_nothing_to_copy": "Nothing to copy",
"txt_new_password_must_be_at_least_12_chars": "New password must be at least 12 chars",
"txt_new_passwords_do_not_match": "New passwords do not match",
"txt_new_send": "New Send",
"txt_next": "Next",
"txt_no": "No",
"txt_no_devices_found": "No devices found.",
"txt_no_folder": "No Folder",
"txt_no_items": "No items",
"txt_no_username": "(No username)",
"txt_no_verification_codes": "No verification codes",
"txt_no_name": "(No Name)",
"txt_no_sends": "No sends",
"txt_nodewarden_send": "NodeWarden Send",
"txt_not_trusted": "Not trusted",
"txt_note": "Note",
"txt_notes": "Notes",
"txt_replace_device_name_with_note": "Set a custom name for this device without changing its detected system type.",
"txt_number": "Number",
"txt_open": "Open",
"txt_opera_browser": "Opera Browser",
"txt_opera_extension": "Opera Extension",
"txt_or": "or",
"txt_options": "Options",
"txt_passport_number": "Passport Number",
"txt_password": "Password",
"txt_password_is_already_verified": "Password is already verified.",
"txt_passwords_do_not_match": "Passwords do not match",
"txt_password_hint": "Password Hint",
"txt_password_hint_optional": "Password Hint (optional)",
"txt_password_hint_placeholder": "A clue only you would understand",
"txt_password_hint_register_placeholder": "This hint can be shown directly on the web login page.",
"txt_password_hint_register_help": "This hint can be shown directly on the web login page. Do not include your master password, recovery code, or anything that can reveal it outright.",
"txt_password_hint_login_help": "Forgot the master password? Reveal the hint you saved during registration.",
"txt_password_hint_login_note": "Only a hint is shown here. It should help you remember the password, not expose it.",
"txt_show_password_hint": "Show Password Hint",
"txt_hide_password_hint": "Hide Password Hint",
"txt_loading_password_hint": "Loading hint...",
"txt_password_hint_not_set": "No password hint is available for this email.",
"txt_password_hint_load_failed": "Failed to load password hint",
"txt_password_hint_too_long": "Password hint must be 120 characters or fewer",
"txt_passkey": "Passkey",
"txt_passkeys": "Passkeys",
"txt_passkey_created_at_value": "Created on {value}",
"txt_phone": "Phone",
"txt_please_input_email_and_password": "Please input email and password",
"txt_please_input_master_password": "Please input master password",
"txt_please_input_totp_code": "Please input TOTP code",
"txt_please_select_a_file": "Please select a file",
"txt_postal_code": "Postal Code",
"txt_prev": "Prev",
"txt_private_key": "Private Key",
"txt_profile": "Profile",
"txt_profile_unavailable": "Profile unavailable",
"txt_profile_updated": "Profile updated",
"txt_public_key": "Public Key",
"txt_recover_2fa_failed": "Recover 2FA failed",
"txt_recover_two_step_login": "Recover Two-step Login",
"txt_recovered_but_auto_login_failed_please_sign_in": "Recovered but auto-login failed, please sign in.",
"txt_recovery_code": "Recovery Code",
"txt_recovery_code_and_api_key": "Recovery Code and API Key",
"txt_recovery_code_copied": "Recovery code copied",
"txt_recovery_code_is_empty": "Recovery code is empty",
"txt_recovery_code_loaded": "Recovery code loaded",
"txt_api_key": "API Key",
"txt_view_api_key": "View API Key",
"txt_rotate_api_key": "Rotate API Key",
"txt_api_key_copied": "API key copied",
"txt_api_key_loaded": "API key loaded",
"txt_api_key_rotated": "API key rotated",
"txt_rotate_api_key_confirm": "Rotate API key? The current key will stop working immediately.",
"txt_api_key_is_empty": "API key is empty",
"txt_api_key_dialog_intro": "Your API key can be used to authenticate with the Bitwarden CLI.",
"txt_api_key_warning_body": "Your API key is an alternative authentication mechanism. Keep it secret.",
"txt_oauth_client_credentials": "OAuth 2.0 Client Credentials",
"txt_client_id": "client_id",
"txt_client_secret": "client_secret",
"txt_scope": "scope",
"txt_grant_type": "grant_type",
"txt_refresh": "Refresh",
"txt_refresh_in_seconds_s": "Refresh in {seconds}s",
"txt_regenerate": "Regenerate",
"txt_registration_succeeded_please_sign_in": "Registration succeeded. Please sign in.",
"txt_remove": "Remove",
"txt_remove_device": "Remove device",
"txt_remove_device_2": "Remove Device",
"txt_remove_all_devices": "Remove all devices",
"txt_remove_all_devices_and_clear_all_2fa_trust": "Remove all devices and clear all 2FA trust?",
"txt_remove_all_devices_and_sign_out_all_sessions": "Remove all devices, clear all trust, and sign out every device?",
"txt_remove_device_name_and_clear_its_2fa_trust": "Remove device \"{name}\" and clear its 2FA trust?",
"txt_remove_device_and_sign_out_name": "Remove device \"{name}\", clear its trust, and sign it out?",
"txt_reveal": "Reveal",
"txt_restore": "Restore",
"txt_revoke": "Revoke",
"txt_revoke_30_day_totp_trust_for_name": "Revoke 30-day TOTP trust for \"{name}\"?",
"txt_revoke_30_day_totp_trust_from_all_devices": "Revoke 30-day TOTP trust from all devices?",
"txt_revoke_all_trusted": "Revoke All Trusted",
"txt_revoke_all_trusted_devices": "Revoke all device trust",
"txt_revoke_device_authorization": "Revoke device trust",
"txt_revoke_device_trust_failed": "Failed to revoke device trust",
"txt_revoke_all_device_trust_failed": "Failed to revoke all device trust",
"txt_revoke_trust": "Revoke Trust",
"txt_untrust": "Untrust",
"txt_update_device_note_failed": "Update device note failed",
"txt_role": "Role",
"txt_save": "Save",
"txt_save_profile": "Save Profile",
"txt_save_profile_failed": "Save profile failed",
"txt_search_sends": "Search sends...",
"txt_search_your_secure_vault": "Search your secure vault...",
"txt_clear_search": "Clear search",
"txt_clear_search_esc": "Clear search (Esc)",
"txt_sort": "Sort",
"txt_sort_last_edited": "Modified",
"txt_sort_created": "Created",
"txt_sort_name": "A-Z",
"txt_secret_and_code_are_required": "Secret and code are required",
"txt_secret_copied": "Secret copied",
"txt_secure_note": "Secure Note",
"txt_security_code": "Security Code",
"txt_security_code_cvv": "Security Code (CVV)",
"txt_select_all": "Select All",
"txt_select_duplicate_items": "Select Duplicates",
"txt_select_an_item": "Select an item",
"txt_send_created": "Send created",
"txt_send_deleted": "Send deleted",
"txt_send_details": "Send Details",
"txt_send_file": "send-file",
"txt_send_unavailable": "Send unavailable.",
"txt_send_updated": "Send updated",
"txt_sign_out": "Sign Out",
"txt_ssh_key": "SSH Key",
"txt_ssn": "SSN",
"txt_state_province": "State / Province",
"txt_status": "Status",
"txt_online": "Online",
"txt_offline": "Offline",
"txt_submit": "Submit",
"txt_sync": "Sync",
"txt_sync_vault": "Sync Vault",
"txt_switch_to_dark_mode": "Switch to dark mode",
"txt_switch_to_light_mode": "Switch to light mode",
"txt_dash": "-",
"txt_text": "Text",
"txt_text_2fa_recovered": "2FA recovered",
"txt_text_2fa_recovered_new_recovery_code_code": "2FA recovered. New recovery code: {code}",
"txt_text_3": "------",
"txt_text_is_required": "Text is required",
"txt_text_send": "Text Send",
"txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically": "This is a one-time code. After it is used, a new code is generated automatically.",
"txt_this_item_requires_master_password_every_time_before_viewing_details": "This item requires master password every time before viewing details.",
"txt_this_link_is_missing_decryption_key": "This link is missing decryption key.",
"txt_this_send_is_password_protected": "This send is password protected.",
"txt_title": "Title",
"txt_totp": "TOTP",
"txt_totp_code": "TOTP Code",
"txt_totp_disabled": "TOTP disabled",
"txt_totp_enabled": "TOTP enabled",
"txt_totp_is_enabled_for_this_account": "TOTP is enabled for this account.",
"txt_total_items_count": "{count} items",
"txt_totp_secret": "TOTP Secret",
"txt_totp_verify_failed": "TOTP verify failed",
"txt_attachments": "Attachments",
"txt_upload_attachments": "Upload attachments",
"txt_new_attachments": "New attachments",
"txt_marked_for_removal_count": "{count} attachment(s) will be removed on save",
"txt_trash": "Trash",
"txt_trust_this_device_for_30_days": "Trust this device for 30 days",
"txt_trusted_until": "Trusted Until",
"txt_two_step_verification": "Two-step verification",
"txt_type": "Type",
"txt_type_type": "Type {type}",
"txt_unban": "Unban",
"txt_unchecked": "Unchecked",
"txt_unknown_device": "Unknown device",
"txt_unlock": "Unlock",
"txt_unlocking": "Unlocking...",
"txt_unlock_details": "Unlock Details",
"txt_unlock_failed": "Unlock failed",
"txt_unlock_failed_master_password_is_incorrect": "Unlock failed. Master password is incorrect.",
"txt_unlock_item": "Unlock Item",
"txt_unlock_send": "Unlock Send",
"txt_unlock_vault": "Unlock Vault",
"txt_unlocked": "Unlocked",
"txt_all_devices_removed": "All devices removed",
"txt_remove_device_failed": "Failed to remove device",
"txt_remove_all_devices_failed": "Failed to remove all devices",
"txt_update_item_failed": "Update item failed",
"txt_update_send_failed": "Update send failed",
"txt_use_recovery_code": "Use Recovery Code",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "Use your one-time recovery code to disable two-step verification.",
"txt_user_deleted": "User deleted",
"txt_user_status_updated": "User status updated",
"txt_username": "Username",
"txt_uri_match_default_base_domain": "Default (Base Domain)",
"txt_uri_match_base_domain": "Base Domain",
"txt_uri_match_host": "Host",
"txt_uri_match_exact": "Exact",
"txt_uri_match_never": "Never",
"txt_uri_match_starts_with": "Starts With",
"txt_uri_match_regular_expression": "Regular Expression",
"txt_users": "Users",
"txt_vault_synced": "Vault synced",
"txt_verification_code": "Verification Code",
"txt_verify": "Verify",
"txt_warning": "Warning",
"txt_view_recovery_code": "View Recovery Code",
"txt_web": "Web",
"txt_website": "Website",
"txt_websites": "Websites",
"txt_windows_desktop": "Windows Desktop",
"txt_yes": "Yes",
"txt_auto_lock": "Auto-lock",
"txt_auto_lock_description": "Locks after inactivity. Closing and reopening the page always starts locked.",
"txt_auto_lock_updated": "Auto-lock updated",
"txt_session_timeout": "Session timeout",
"txt_session_timeout_updated": "Session timeout updated",
"txt_timeout_time": "Timeout time",
"txt_timeout_action": "Timeout action",
"txt_timeout_action_logout": "Log out",
"txt_timeout_action_lock": "Lock",
"txt_in_planning": "In planning",
"txt_security_preferences": "Security Preferences",
"txt_timeout_1_minute": "1 minute",
"txt_timeout_5_minutes": "5 minutes",
"txt_timeout_15_minutes": "15 minutes",
"txt_timeout_30_minutes": "30 minutes",
"txt_timeout_never": "Never",
"txt_lock_after_1_minute": "After 1 minute",
"txt_lock_after_5_minutes": "After 5 minutes",
"txt_lock_after_15_minutes": "After 15 minutes",
"txt_lock_after_30_minutes": "After 30 minutes",
"txt_lock_after_never": "Never for inactivity",
"txt_import": "Import",
"txt_export": "Export",
"txt_format": "Format",
"txt_source_file": "Source file",
"txt_folder_handling": "Folder handling",
"txt_import_folder_mode_original": "Original path from import file",
"txt_import_folder_mode_none": "No folder",
"txt_import_folder_mode_target": "One selected folder",
"txt_target_folder": "Target folder",
"txt_select_folder_placeholder": "-- Select folder --",
"txt_import_vault_data_hint": "Import vault data into your current account.",
"txt_export_vault_data_hint": "Export vault data from your current account.",
"txt_import_export_title": "Import & Export",
"txt_encrypted_mode": "Encrypted mode",
"txt_account_verification": "Account verification",
"txt_password_verification": "Password verification",
"txt_file_password": "File password",
"txt_zip_password_optional": "ZIP password (optional)",
"txt_zip_password": "ZIP password",
"txt_close": "Close",
"txt_total": "Total",
"txt_import_success": "Import successful",
"txt_import_success_number_of_items": "Imported {count} item(s) in total.",
"txt_import_attachment_summary": "Imported {imported} of {total} attachment(s).",
"txt_import_failed_attachments_title": "{count} attachment(s) were not imported:",
"txt_import_attachment_target_not_found": "Matching imported item not found.",
"txt_upload_attachment_failed": "Attachment upload failed.",
"txt_import_file_password_required": "Please enter file password.",
"txt_import_invalid_zip_password": "Invalid ZIP password.",
"txt_export_completed": "Export completed",
"txt_export_failed": "Export failed",
"txt_import_invalid_password_protected_file": "Invalid password-protected export file.",
"txt_import_decrypt_failed": "Failed to decrypt import file.",
"txt_import_empty_zip_archive": "Empty zip archive.",
"txt_import_no_json_found_in_zip": "No importable JSON data found in zip archive.",
"txt_import_data_json_not_found": "data.json not found in zip archive.",
"txt_import_zip_password_required": "ZIP password is required.",
"txt_import_invalid_json_file": "Invalid JSON file",
"txt_import_failed": "Import failed",
"txt_import_encrypted_file_title": "Import encrypted file",
"txt_import_encrypted_file_message": "This Bitwarden export is password-protected. Enter the export file password to continue.",
"txt_import_encrypted_zip_title": "Import encrypted ZIP",
"txt_import_encrypted_zip_message": "This ZIP archive is password-protected. Enter the ZIP password to continue.",
"txt_new_type_header": "New {type}",
"txt_edit_type_header": "Edit {type}",
"txt_delete_folder": "Delete Folder",
"txt_delete_folder_message": "Delete folder \"{name}\"? Items inside will move to No Folder.",
"txt_delete_all_folders": "Delete All Folders",
"txt_delete_all_folders_message": "Delete all folders? Items inside will move to No Folder.",
"txt_folder_not_found": "Folder not found",
"txt_folder_deleted": "Folder deleted",
"txt_folder_updated": "Folder updated",
"txt_folders_deleted": "Folders deleted",
"txt_update_folder_failed": "Update folder failed",
"txt_delete_folder_failed": "Delete folder failed",
"txt_delete_all_folders_failed": "Delete all folders failed",
"txt_other": "Other",
"txt_vault_key_unavailable": "Vault key unavailable. Please unlock vault and try again.",
"txt_vault_not_ready": "Vault is not ready yet",
"txt_unsupported_export_format": "Unsupported export format",
"txt_invalid_encrypted_export": "Invalid encrypted export file.",
"txt_export_belongs_to_another_account": "This encrypted export belongs to another account.",
"txt_invalid_argon2id_params": "Invalid Argon2id parameters in export file.",
"txt_unsupported_kdf_type": "Unsupported kdfType: {type}",
"txt_invalid_file_password": "Invalid file password.",
"txt_failed_to_map_attachments": "Failed to map {count} attachment(s) to imported items.",
"txt_role_admin": "Admin",
"txt_role_user": "User",
"txt_status_active": "Active",
"txt_status_banned": "Banned",
"txt_status_inactive": "Inactive",
"txt_language": "Language",
"txt_display_language": "Display language",
"txt_language_saved_locally": "This preference is saved in this browser and used before the app loads next time."
};
export default en;
+848
View File
@@ -0,0 +1,848 @@
// Localización completa en español. Mantener claves y marcadores de posición sin cambios.
const es: Record<string, string> = {
"nav_account_settings": "Configuración de la cuenta",
"nav_admin_panel": "Panel de administración",
"nav_device_management": "Gestión de dispositivos",
"nav_my_vault": "Mi bóveda",
"nav_sends": "Envíos",
"nav_backup_strategy": "Copia de seguridad en la nube",
"nav_import_export": "Importar y exportar",
"backup_strategy_title": "Copia de seguridad en la nube",
"backup_strategy_under_construction": "En construcción.",
"import_export_title": "Importar y exportar",
"import_export_under_construction": "En construcción.",
"txt_backup_export": "Exportar copia de seguridad",
"txt_backup_import": "Restaurar",
"txt_backup_include_attachments": "Incluir archivos adjuntos",
"txt_backup_export_description": "Descargar un ZIP de copia de seguridad de la instancia completa para su custodia manual.",
"txt_backup_import_description": "Subir un ZIP de copia de seguridad previamente exportado y restaurarlo en esta instancia.",
"txt_backup_exporting": "Exportando...",
"txt_backup_importing": "Restaurando...",
"txt_backup_restoring": "Restaurando...",
"txt_backup_export_success": "Copia de seguridad exportada",
"txt_backup_import_success_relogin": "Copia de seguridad restaurada. Por favor, inicie sesión de nuevo.",
"txt_backup_restore_success_relogin": "Copia de seguridad restaurada. Por favor, inicie sesión de nuevo.",
"txt_backup_restore_completed_verified": "Verificación de integridad del archivo de copia de seguridad superada.",
"txt_backup_restore_completed_without_checksum": "Copia de seguridad restaurada. No se disponía de un marcador de integridad en el nombre del archivo para la verificación.",
"txt_backup_remote_restore_completed_verified": "Verificación de integridad de la copia de seguridad remota superada.",
"txt_backup_remote_restore_completed_without_checksum": "Copia de seguridad remota restaurada. No se disponía de un marcador de integridad en el nombre del archivo para la verificación.",
"txt_backup_restore_skipped_summary": "{reason}. Se omitieron {attachments} adjunto(s).",
"txt_backup_restore_skipped_reason_default": "Algunos archivos no pudieron ser restaurados",
"txt_backup_export_failed": "Error al exportar la copia de seguridad",
"txt_backup_import_failed": "Error al restaurar la copia de seguridad",
"txt_backup_restore_failed": "Error al restaurar la copia de seguridad",
"txt_backup_integrity_check_failed": "Error en la verificación de integridad de la copia de seguridad",
"txt_backup_center_title": "Copia de seguridad de la instancia",
"txt_backup_center_description": "Conserve exportaciones locales para restauración manual y configure un destino de copia de seguridad remota diaria para protección desatendida.",
"txt_backup_restore_note": "La restauración sobrescribirá la instancia actual si elige el flujo de reemplazo.",
"txt_backup_manual": "Copia manual",
"txt_backup_manual_description": "Exportar a ZIP ahora mismo, o importar un ZIP de vuelta a esta instancia.",
"txt_backup_destinations_title": "Destinos de copia de seguridad",
"txt_backup_destinations_description": "Mantenga aquí varios destinos WebDAV y S3. Seleccione uno a la izquierda para editarlo o explorarlo.",
"txt_backup_recommend_title": "Almacenamiento recomendado",
"txt_backup_recommend_open_signup": "Abrir registro",
"txt_backup_recommend_open_signup_aff": "Abrir registro (AFF)",
"txt_backup_recommend_open_guide": "Abrir guía",
"txt_backup_recommend_empty": "Aún no hay recomendaciones.",
"txt_backup_recommend_referral_label": "Código de referido",
"txt_backup_recommend_referral_note": "Úselo durante el registro para obtener 5 GB extra. El autor recibe 2 GB.",
"txt_backup_recommend_infinicloud_summary": "Solo se necesita una dirección de correo. 20 GB gratis, 25 GB en total con el código de referido.",
"txt_backup_recommend_infinicloud_step_1": "Registre una cuenta InfiniCLOUD solo con su dirección de correo.",
"txt_backup_recommend_infinicloud_step_2_prefix": "Abra",
"txt_backup_recommend_infinicloud_step_2_suffix": "y active Apps Connection.",
"txt_backup_recommend_infinicloud_step_3": "Use Connection ID como su nombre de usuario WebDAV y Apps Password como su contraseña WebDAV.",
"txt_backup_recommend_infinicloud_step_4": "Ingrese el código de referido 2HC5E en Referral Bonus al final de My Page para recibir 5 GB extra.",
"txt_backup_recommend_open_password": "Configuración de contraseña",
"txt_backup_recommend_open_storage": "Abrir almacenamiento",
"txt_backup_recommend_koofr_summary": "Solo se necesita una dirección de correo. 10 GB gratis, y puede conectar Google Drive, OneDrive y Dropbox a través de WebDAV.",
"txt_backup_recommend_koofr_password_link": "Configuración de contraseña",
"txt_backup_recommend_koofr_storage_link": "Almacenamiento",
"txt_backup_recommend_koofr_step_1": "Registre una cuenta Koofr solo con su dirección de correo.",
"txt_backup_recommend_koofr_step_2_prefix": "Abra",
"txt_backup_recommend_koofr_step_2_suffix": ", genere una nueva contraseña de aplicación, use su dirección de correo como nombre de usuario WebDAV y use la contraseña de aplicación como contraseña WebDAV.",
"txt_backup_recommend_koofr_step_3": "La dirección WebDAV propia de Koofr es https://app.koofr.net/dav/Koofr.",
"txt_backup_recommend_koofr_step_4": "Koofr también puede conectar Google Drive, OneDrive y Dropbox. Los usuarios gratuitos pueden conectar hasta dos cuentas de almacenamiento.",
"txt_backup_recommend_koofr_step_5_prefix": "Abra",
"txt_backup_recommend_koofr_step_5_suffix": ", haga clic en Conectar en la barra lateral izquierda y elija el almacenamiento en la nube que quiere adjuntar.",
"txt_backup_recommend_koofr_dav_intro": "Después de conectar una cuenta de almacenamiento, mantenga el mismo correo y contraseña de aplicación, y solo cambie la dirección WebDAV:",
"txt_backup_recommend_koofr_dav_self": "Koofr",
"txt_backup_recommend_pcloud_summary": "Solo se necesita una dirección de correo. Hasta 10 GB gratis, con acceso WebDAV estándar.",
"txt_backup_recommend_pcloud_step_1": "Registre una cuenta pCloud solo con su dirección de correo.",
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ como URL del servidor WebDAV.",
"txt_backup_recommend_pcloud_step_3": "Use su correo de registro como nombre de usuario WebDAV y su contraseña de cuenta como contraseña WebDAV.",
"txt_backup_add_destination": "Añadir destino",
"txt_backup_schedule_panel_title": "Programación automática",
"txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.",
"txt_backup_scheduled_target": "Destino programado",
"txt_backup_destination_active_badge": "Automático activado",
"txt_backup_destination_idle_badge": "Automático desactivado",
"txt_backup_destination_last_success": "Último correcto: {time}",
"txt_backup_destination_never_run": "Aún no hay ejecuciones correctas",
"txt_backup_destination_detail_title": "Detalles del destino",
"txt_backup_destination_detail_note": "",
"txt_backup_destination_name": "Nombre del destino",
"txt_backup_set_scheduled_target": "Usar para copia diaria",
"txt_backup_delete_destination": "Eliminar",
"txt_backup_destination_deleted": "Destino de copia eliminado",
"txt_backup_delete_destination_confirm_message": "¿Eliminar el destino de copia de seguridad \"{name}\"? Esto no se puede deshacer.",
"txt_backup_select_destination": "Seleccione primero un destino de copia de seguridad de la lista.",
"txt_backup_remote_save_first": "Guarde este destino primero antes de explorar sus archivos de copia de seguridad remotos.",
"txt_backup_automation": "Copia automática",
"txt_backup_automation_description": "Elija un destino, guarde las credenciales y deje que el worker suba una copia de seguridad cada día.",
"txt_backup_settings_saved": "Configuración de copia guardada",
"txt_backup_settings_save_failed": "Error al guardar la configuración de copia",
"txt_backup_settings_load_failed": "Error al cargar la configuración de copia",
"txt_backup_save_settings": "Guardar configuración",
"txt_backup_saving": "Guardando...",
"txt_backup_enable_action": "Activar",
"txt_backup_disable_action": "Desactivar",
"txt_backup_run_now": "Ejecutar copia remota ahora",
"txt_backup_run_manual": "Ejecutar manualmente",
"txt_backup_running_now": "Ejecutando...",
"txt_backup_remote_run_success": "Copia remota completada",
"txt_backup_remote_run_success_verified": "Copia de seguridad remota completada y verificación de integridad superada.",
"txt_backup_remote_run_failed": "Error en la copia remota",
"txt_backup_remote_title": "Copias remotas",
"txt_backup_remote_note": "Explore el destino guardado y elija un ZIP de copia de seguridad para descargar o restaurar.",
"txt_backup_remote_saved_basis": "La exploración remota usa la última configuración guardada del destino, no las ediciones no guardadas del formulario.",
"txt_backup_remote_refresh": "Actualizar",
"txt_backup_remote_root": "Raíz",
"txt_backup_remote_up": "Subir",
"txt_backup_remote_open": "Abrir",
"txt_backup_remote_download": "Descargar",
"txt_backup_remote_downloading": "Descargando...",
"txt_backup_remote_restore": "Restaurar",
"txt_backup_remote_restore_stage_prepare": "Preparando la restauración de la copia de seguridad remota...",
"txt_backup_remote_restore_stage_replace": "Limpiando datos actuales y restaurando copia de seguridad remota...",
"txt_backup_progress_kicker": "Tarea de copia",
"txt_backup_progress_subject": "Elemento actual: {name}",
"txt_backup_restore_progress_kicker": "Progreso de restauración",
"txt_backup_restore_progress_local_title": "Restaurando copia de seguridad local",
"txt_backup_restore_progress_remote_title": "Restaurando copia de seguridad remota",
"txt_backup_export_progress_title": "Exportando copia de seguridad",
"txt_backup_remote_run_progress_title": "Ejecutando copia de seguridad remota",
"txt_backup_restore_progress_file": "Archivo actual: {name}",
"txt_backup_restore_progress_elapsed": "{seconds} s transcurridos",
"txt_backup_archive_progress_collect_title": "Recopilando datos de la bóveda",
"txt_backup_archive_progress_collect_detail": "El servidor está leyendo las tablas de la base de datos y ensamblando la carga útil de la copia de seguridad.",
"txt_backup_archive_progress_collect_with_attachments_detail": "El servidor está leyendo las tablas de la base de datos y recopilando metadatos de adjuntos para la carga útil de la copia de seguridad.",
"txt_backup_archive_progress_package_title": "Empaquetando archivo de copia de seguridad",
"txt_backup_archive_progress_package_detail": "El servidor está generando el ZIP de copia de seguridad y calculando su prefijo de suma de verificación.",
"txt_backup_archive_progress_package_with_attachments_detail": "El servidor está generando los metadatos del ZIP de copia de seguridad y calculando su prefijo de suma de verificación para la exportación con reconocimiento de adjuntos.",
"txt_backup_archive_progress_ready_title": "Preparando descarga",
"txt_backup_archive_progress_ready_detail": "El archivo de copia de seguridad está listo y se está devolviendo al navegador.",
"txt_backup_export_progress_fetch_attachments_title": "Descargando archivos adjuntos",
"txt_backup_export_progress_fetch_attachments_detail": "El navegador está obteniendo objetos adjuntos y añadiéndolos al paquete de exportación.",
"txt_backup_export_progress_rebuild_title": "Reconstruyendo archivo de exportación",
"txt_backup_export_progress_rebuild_detail": "El navegador está reconstruyendo el ZIP final y actualizando su sufijo de suma de verificación.",
"txt_backup_export_progress_save_title": "Guardando archivo de exportación",
"txt_backup_export_progress_save_detail": "El navegador está preparando el archivo de copia de seguridad final para su descarga.",
"txt_backup_export_progress_complete_title": "Exportación completada",
"txt_backup_export_progress_complete_detail": "La exportación de copia de seguridad está lista.",
"txt_backup_export_progress_failed_title": "Error de exportación",
"txt_backup_export_progress_failed_detail": "La exportación de copia de seguridad no pudo completarse.",
"txt_backup_remote_run_progress_prepare_title": "Preparando copia de seguridad remota",
"txt_backup_remote_run_progress_prepare_detail": "El servidor está cargando el destino seleccionado y preparando esta ejecución de copia de seguridad.",
"txt_backup_remote_run_progress_sync_attachments_title": "Verificando índice de adjuntos",
"txt_backup_remote_run_progress_sync_attachments_detail": "El servidor está comparando metadatos de adjuntos para que solo se suban los objetos adjuntos faltantes.",
"txt_backup_remote_run_progress_sync_attachments_skipped_detail": "Esta copia de seguridad no incluye adjuntos, por lo que se omite la sincronización de adjuntos.",
"txt_backup_remote_run_progress_upload_title": "Subiendo archivo de copia de seguridad",
"txt_backup_remote_run_progress_upload_detail": "El servidor está subiendo el ZIP de copia de seguridad al destino remoto.",
"txt_backup_remote_run_progress_verify_title": "Verificando archivo subido",
"txt_backup_remote_run_progress_verify_detail": "El servidor está descargando el ZIP subido de vuelta y verificando su suma de verificación y tamaño.",
"txt_backup_remote_run_progress_cleanup_title": "Limpiando copias de seguridad antiguas",
"txt_backup_remote_run_progress_cleanup_detail": "El servidor está eliminando archivos de copia de seguridad antiguos según la política de retención.",
"txt_backup_remote_run_progress_complete_title": "Copia remota completada",
"txt_backup_remote_run_progress_complete_detail": "La copia de seguridad remota se ha subido y verificado correctamente.",
"txt_backup_remote_run_progress_failed_title": "Error en la copia remota",
"txt_backup_remote_run_progress_failed_detail": "La copia de seguridad remota no pudo completarse.",
"txt_backup_restore_progress_local_upload_title": "Subiendo archivo de copia de seguridad",
"txt_backup_restore_progress_local_upload_detail": "El ZIP seleccionado se está enviando al servidor para su procesamiento.",
"txt_backup_restore_progress_local_shadow_title": "Creando espacio de restauración aislado",
"txt_backup_restore_progress_local_shadow_detail": "El servidor está preparando un área de restauración aislada para que los datos reales permanezcan intactos hasta que la validación sea exitosa.",
"txt_backup_restore_progress_local_data_title": "Escribiendo datos de la bóveda",
"txt_backup_restore_progress_local_data_detail": "El servidor está importando usuarios, carpetas, elementos de la bóveda y metadatos relacionados en tablas sombra.",
"txt_backup_restore_progress_local_files_title": "Restaurando archivos adjuntos",
"txt_backup_restore_progress_local_files_detail": "El servidor está escribiendo objetos adjuntos de vuelta al almacenamiento y eliminando cualquier fila de adjuntos que no pueda ser restaurada.",
"txt_backup_restore_progress_local_finalize_title": "Validando y aplicando datos",
"txt_backup_restore_progress_local_finalize_detail": "El servidor está realizando la validación final y luego intercambiando los datos de restauración verificados en las tablas activas.",
"txt_backup_restore_progress_remote_fetch_title": "Leyendo copia de seguridad remota",
"txt_backup_restore_progress_remote_fetch_detail": "El servidor está descargando el paquete de copia de seguridad seleccionado del destino remoto.",
"txt_backup_restore_progress_remote_shadow_title": "Creando espacio de restauración aislado",
"txt_backup_restore_progress_remote_shadow_detail": "El servidor está preparando un área de restauración aislada para que los datos reales permanezcan intactos hasta que la validación sea exitosa.",
"txt_backup_restore_progress_remote_data_title": "Escribiendo datos de la bóveda",
"txt_backup_restore_progress_remote_data_detail": "El servidor está importando usuarios, carpetas, elementos de la bóveda y metadatos relacionados en tablas sombra.",
"txt_backup_restore_progress_remote_files_title": "Restaurando adjuntos remotos",
"txt_backup_restore_progress_remote_files_detail": "El servidor está obteniendo objetos adjuntos necesarios del almacenamiento remoto y escribiéndolos de vuelta en el almacenamiento local.",
"txt_backup_restore_progress_remote_finalize_title": "Validando y aplicando datos",
"txt_backup_restore_progress_remote_finalize_detail": "El servidor está realizando la validación final y luego cambiando los datos de restauración verificados a las tablas activas.",
"txt_backup_remote_loading": "Cargando copias remotas...",
"txt_backup_remote_cached_empty": "Haga clic en Actualizar para cargar este destino.",
"txt_backup_remote_empty": "No se encontraron archivos de copia de seguridad en esta carpeta.",
"txt_backup_remote_folder": "Carpeta",
"txt_backup_remote_unknown_time": "Hora desconocida",
"txt_backup_remote_current_path": "Carpeta actual",
"txt_backup_remote_load_failed": "Error al cargar copias de seguridad remotas",
"txt_backup_remote_invalid_response": "Respuesta de copia de seguridad remota no válida",
"txt_backup_remote_download_failed": "Error al descargar copia de seguridad remota",
"txt_backup_remote_delete_success": "Copia de seguridad remota eliminada",
"txt_backup_remote_delete_failed": "Error al eliminar copia de seguridad remota",
"txt_backup_remote_delete_confirm_message": "¿Eliminar archivo de copia de seguridad \"{name}\"? Esto no se puede deshacer.",
"txt_backup_remote_deleting": "Eliminando...",
"txt_backup_remote_restore_failed": "Error al restaurar copia de seguridad remota",
"txt_backup_restore_checksum_warning_title": "Advertencia de integridad de copia",
"txt_backup_restore_checksum_warning_message": "El archivo de copia de seguridad seleccionado \"{name}\" falló la verificación de integridad del nombre de archivo. Prefijo esperado {expected}, prefijo real {actual}. El archivo puede estar incompleto o dañado. Continuar puede restaurar datos dañados.",
"txt_backup_remote_restore_checksum_warning_message": "El archivo de copia de seguridad remota \"{name}\" falló la verificación de integridad del nombre de archivo. Prefijo esperado {expected}, prefijo real {actual}. El archivo puede haberse dañado durante la subida o el almacenamiento. Continuar puede restaurar datos dañados y causar una pérdida grave de datos.",
"txt_backup_restore_checksum_warning_message_fallback": "El archivo de copia de seguridad seleccionado falló la verificación de integridad. Continuar puede restaurar datos dañados.",
"txt_backup_restore_checksum_warning_confirm": "Continuar restauración",
"txt_backup_remote_restore_invalid_response": "Respuesta de restauración de copia de seguridad remota no válida",
"txt_backup_remote_run_invalid_response": "Respuesta de ejecución de copia de seguridad remota no válida",
"txt_backup_settings_invalid_response": "Respuesta de configuración de copia de seguridad no válida",
"txt_backup_import_invalid_response": "Respuesta de importación de copia de seguridad no válida",
"txt_backup_destination": "Destino de copia",
"txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3",
"txt_backup_recommend_group_webdav": "WebDAV",
"txt_backup_recommend_group_s3": "S3",
"txt_backup_destination_name_default_webdav": "WebDAV {index}",
"txt_backup_destination_name_default_s3": "S3 {index}",
"txt_backup_type": "Tipo de copia",
"txt_backup_destination_reserved": "Espacio reservado",
"txt_backup_time": "Hora de copia",
"txt_backup_start_time": "Hora de inicio",
"txt_backup_timezone": "Zona horaria",
"txt_backup_interval_hours": "Cada",
"txt_backup_interval_hours_suffix": "horas",
"txt_backup_interval_hours_presets": "Intervalos rápidos",
"txt_backup_frequency": "Frecuencia",
"txt_backup_frequency_daily": "Diaria",
"txt_backup_frequency_weekly": "Semanal",
"txt_backup_frequency_monthly": "Mensual",
"txt_backup_day_of_week": "Día de la semana",
"txt_backup_day_of_month": "Día del mes",
"txt_backup_weekday_monday": "Lunes",
"txt_backup_weekday_tuesday": "Martes",
"txt_backup_weekday_wednesday": "Miércoles",
"txt_backup_weekday_thursday": "Jueves",
"txt_backup_weekday_friday": "Viernes",
"txt_backup_weekday_saturday": "Sábado",
"txt_backup_weekday_sunday": "Domingo",
"txt_backup_retention_count": "Conservar",
"txt_backup_retention_count_suffix": "elementos",
"txt_backup_retention_count_hint": "Dejar vacío para conservar todos los archivos de copia de seguridad. Los destinos nuevos predeterminan 30.",
"txt_backup_destination_include_attachments": "Incluir archivos adjuntos",
"txt_backup_include_attachments_help_button": "Ayuda de copia de seguridad de adjuntos",
"txt_backup_include_attachments_help": "Los adjuntos se almacenan de forma incremental en la carpeta de adjuntos remotos, por lo que las copias de seguridad posteriores solo suben archivos nuevos. Eliminar un adjunto localmente no elimina las copias remotas anteriores. Durante la restauración, NodeWarden lee los archivos necesarios de la carpeta de adjuntos y omite cualquier adjunto que ya no esté disponible.",
"txt_backup_enable_schedule": "Activar copia de seguridad automática diaria",
"txt_backup_schedule_note": "El worker verifica la programación cada 5 minutos. Comienza a la hora seleccionada en la zona horaria seleccionada, luego se repite según el intervalo de horas elegido y se reinicia desde esa hora de inicio cada día.",
"txt_backup_schedule_disabled": "Desactivado",
"txt_backup_schedule_status": "Programación",
"txt_backup_schedule_summary": "Comienza a las {time}, cada {interval} horas ({timezone})",
"txt_backup_schedule_empty": "Aún no hay planes de copia de seguridad automática activados.",
"txt_backup_last_success": "Último éxito",
"txt_backup_last_target": "Último destino",
"txt_backup_last_file": "Último archivo",
"txt_backup_last_error_prefix": "Último error",
"txt_backup_none_yet": "Aún no se ha completado ninguna copia de seguridad remota",
"txt_backup_not_configured": "No configurado",
"txt_backup_never": "Nunca",
"txt_backup_unknown_size": "Tamaño desconocido",
"txt_backup_webdav_url": "URL del servidor WebDAV",
"txt_backup_webdav_username": "Usuario WebDAV",
"txt_backup_webdav_password": "Contraseña WebDAV",
"txt_backup_webdav_path": "Carpeta remota",
"txt_backup_s3_endpoint": "Endpoint S3",
"txt_backup_s3_bucket": "Bucket S3",
"txt_backup_s3_region": "Región",
"txt_backup_s3_access_key": "Clave de acceso",
"txt_backup_s3_secret_key": "Clave secreta",
"txt_backup_s3_path": "Ruta remota",
"txt_backup_reserved_name": "Nombre del proveedor reservado",
"txt_backup_reserved_notes": "Notas reservadas",
"txt_backup_reserved_notes_placeholder": "Deje una nota para el siguiente tipo de destino",
"txt_backup_reserved_hint": "Este espacio está reservado para un futuro destino. Puede guardar notas ahora, pero las subidas automáticas permanecen desactivadas.",
"txt_backup_file": "Archivo de copia de seguridad",
"txt_backup_file_required": "Seleccione un archivo de copia de seguridad",
"txt_backup_no_file_selected": "Ningún archivo de copia de seguridad seleccionado",
"txt_backup_selected_file_name": "Archivo seleccionado: {name}",
"txt_backup_replace_confirm_title": "Reemplazar datos de la instancia actual",
"txt_backup_replace_confirm_message": "La instancia actual ya contiene datos. ¿Continuar con la restauración y reemplazar los datos de la instancia actual con la copia de seguridad seleccionada después de que la verificación sea exitosa?",
"txt_backup_clear_and_import": "Reemplazar e importar",
"txt_backup_clear_and_restore": "Reemplazar y restaurar",
"txt_access_count": "Número de accesos",
"txt_accessed_count_times": "Accedido {count} veces",
"txt_actions": "Acciones",
"txt_add": "Añadir",
"txt_add_field": "Añadir campo",
"txt_add_website": "Añadir sitio web",
"txt_added": "Añadido",
"txt_additional_options": "Opciones adicionales",
"txt_address": "Dirección",
"txt_address_1": "Dirección 1",
"txt_address_2": "Dirección 2",
"txt_address_3": "Dirección 3",
"txt_all_device_authorizations_revoked": "Confianza de todos los dispositivos revocada",
"txt_all_invites_deleted": "Todas las invitaciones eliminadas",
"txt_all_items": "Todos los elementos",
"txt_all_sends": "Todos los envíos",
"txt_android": "Android",
"txt_are_you_sure_you_want_to_delete_count_selected_items": "¿Está seguro de que quiere eliminar {count} elementos seleccionados?",
"txt_are_you_sure_you_want_to_delete_count_selected_items_permanently": "¿Está seguro de que quiere eliminar permanentemente {count} elementos seleccionados?",
"txt_are_you_sure_you_want_to_delete_this_item": "¿Está seguro de que quiere eliminar este elemento?",
"txt_are_you_sure_you_want_to_delete_this_passkey": "¿Está seguro de que quiere eliminar esta clave de acceso?",
"txt_are_you_sure_you_want_to_log_out": "¿Seguro que quiere cerrar sesión?",
"txt_authenticator_key": "Clave de autenticador",
"txt_authorized_devices": "Dispositivos autorizados",
"txt_auto_copy_link_after_save": "Copiar enlace automáticamente después de guardar",
"txt_autofill_options": "Opciones de autocompletado",
"txt_back_to_login": "Volver al inicio de sesión",
"txt_ban": "Bloquear",
"txt_boolean": "Booleano",
"txt_brand": "Marca",
"txt_bulk_delete_failed": "Error al eliminar en lote",
"txt_bulk_permanent_delete_failed": "Error al eliminar permanentemente en lote",
"txt_bulk_restore_failed": "Error al restaurar en lote",
"txt_bulk_delete_sends_failed": "Error al eliminar envíos en lote",
"txt_bulk_move_failed": "Error al mover en lote",
"txt_cancel": "Cancelar",
"txt_continue": "Continuar",
"txt_card": "Tarjeta",
"txt_card_details": "Detalles de la tarjeta",
"txt_cardholder_name": "Nombre del titular",
"txt_change_master_password": "Cambiar contraseña maestra",
"txt_change_password": "Cambiar contraseña",
"txt_change_password_failed": "Error al cambiar la contraseña",
"txt_change_password_confirm_and_sign_out_all_devices": "Cambiar la contraseña maestra cerrará la sesión en todos los dispositivos, incluyendo esta sesión web. ¿Continuar?",
"txt_copy_failed": "Error al copiar",
"txt_checked": "Marcado",
"txt_choose_destination_folder": "Elija la carpeta de destino.",
"txt_chrome_browser": "Navegador Chrome",
"txt_chrome_extension": "Extensión de Chrome",
"txt_city_town": "Ciudad",
"txt_code": "Código",
"txt_company": "Empresa",
"txt_configure_custom_field_values": "Configure los valores de los campos personalizados.",
"txt_confirm": "Confirmar",
"txt_confirm_master_password": "Confirmar contraseña maestra",
"txt_confirm_password": "Confirmar contraseña",
"txt_copy": "Copiar",
"txt_code_copied": "Código copiado",
"txt_copy_code": "Copiar código",
"txt_copy_link": "Copiar enlace",
"txt_copy_secret": "Copiar secreto",
"txt_country": "País",
"txt_create": "Crear",
"txt_create_account": "Crear cuenta",
"txt_registering": "Creando cuenta...",
"txt_create_folder": "Crear carpeta",
"txt_create_folder_failed": "Error al crear carpeta",
"txt_create_item_failed": "Error al crear elemento",
"txt_create_send_failed": "Error al crear envío",
"txt_create_timed_invite": "Crear invitación temporal",
"txt_created_value": "Creado: {value}",
"txt_current_new_password_is_required": "La contraseña actual/nueva es obligatoria",
"txt_current_password": "Contraseña actual",
"txt_custom_fields": "Campos personalizados",
"txt_decrypt_failed": "(Error al descifrar)",
"txt_decrypt_failed_2": "Error al descifrar",
"txt_delete": "Eliminar",
"txt_delete_all": "Eliminar todo",
"txt_delete_all_invite_codes_active_inactive": "¿Eliminar todos los códigos de invitación (activos/inactivos)?",
"txt_delete_all_invites": "Eliminar todas las invitaciones",
"txt_delete_item": "Eliminar elemento",
"txt_delete_passkey": "Eliminar clave de acceso",
"txt_delete_item_failed": "Error al eliminar elemento",
"txt_delete_permanently": "Eliminar permanentemente",
"txt_archive": "Archivar",
"txt_archive_item": "Archivar elemento",
"txt_archive_item_message": "Después de archivar, este elemento será excluido de los resultados generales de búsqueda y sugerencias de autocompletado.",
"txt_archive_selected_items": "Archivar elementos",
"txt_archive_selected_items_message": "Después de archivar, {count} elementos seleccionados serán excluidos de los resultados generales de búsqueda y sugerencias de autocompletado.",
"txt_archived": "Archivado",
"txt_archive_selected": "Archivar",
"txt_item_archived": "Elemento archivado",
"txt_item_unarchived": "Elemento desarchivado",
"txt_archived_selected_items": "Elementos seleccionados archivados",
"txt_unarchived_selected_items": "Elementos seleccionados desarchivados",
"txt_archive_item_failed": "Error al archivar elemento",
"txt_unarchive_item_failed": "Error al desarchivar elemento",
"txt_bulk_archive_failed": "Error al archivar en lote",
"txt_bulk_unarchive_failed": "Error al desarchivar en lote",
"txt_unarchive": "Desarchivar",
"txt_delete_selected": "Eliminar",
"txt_delete_selected_items": "Eliminar elementos seleccionados",
"txt_delete_selected_items_permanently": "Eliminar elementos seleccionados permanentemente",
"txt_delete_send_failed": "Error al eliminar envío",
"txt_delete_this_user_and_all_user_data": "¿Eliminar este usuario y todos sus datos?",
"txt_delete_user": "Eliminar usuario",
"txt_deleted_selected_items": "Elementos seleccionados eliminados",
"txt_deleted_selected_items_permanently": "Elementos seleccionados eliminados permanentemente",
"txt_restored_selected_items": "Elementos seleccionados restaurados",
"txt_deleted_selected_sends": "Envíos seleccionados eliminados",
"txt_deletion_date": "Fecha de eliminación",
"txt_deletion_days": "Días hasta eliminación",
"txt_device": "Dispositivo",
"txt_device_authorization_revoked": "Confianza del dispositivo revocada",
"txt_device_management": "Gestión de dispositivos",
"txt_device_note": "Nota del dispositivo",
"txt_device_note_required": "El nombre del dispositivo es obligatorio",
"txt_device_note_updated": "Nombre del dispositivo actualizado",
"txt_device_removed": "Dispositivo eliminado",
"txt_load_devices_failed": "Error al cargar dispositivos",
"txt_disable_this_send": "Desactivar este envío",
"txt_disable_totp": "Desactivar TOTP",
"txt_disable_totp_failed": "Error al desactivar TOTP",
"txt_download": "Descargar",
"txt_downloading": "Descargando...",
"txt_downloading_percent": "Descargando {percent}%",
"txt_attachment": "Adjunto",
"txt_uploading_attachment_named": "Subiendo {name}...",
"txt_uploading_attachment_named_percent": "Subiendo {name} {percent}%",
"txt_uploading_file_named": "Subiendo {name}...",
"txt_uploading_file_named_percent": "Subiendo {name} {percent}%",
"txt_download_failed": "Error al descargar",
"txt_edge_browser": "Navegador Edge",
"txt_edge_extension": "Extensión de Edge",
"txt_edit": "Editar",
"txt_edit_send": "Editar envío",
"txt_email": "Correo electrónico",
"txt_email_password_and_recovery_code_are_required": "Correo, contraseña y código de recuperación son obligatorios",
"txt_enable_totp": "Activar TOTP",
"txt_enable_totp_failed": "Error al activar TOTP",
"txt_enabled": "Activado",
"txt_encrypted_file": "Archivo cifrado",
"txt_encrypted_file_2": "Archivo cifrado",
"txt_enter_a_folder_name": "Introduzca un nombre de carpeta.",
"txt_enter_master_password_to_disable_two_step_verification": "Introduzca la contraseña maestra para desactivar la verificación en dos pasos.",
"txt_enter_master_password_to_continue": "Introduzca su contraseña maestra para continuar.",
"txt_enter_master_password_to_view_this_item": "Introduzca la contraseña maestra para ver este elemento.",
"txt_expiration_date": "Fecha de expiración",
"txt_expiration_days_0_never": "Días hasta expiración (0 = nunca)",
"txt_expires_at": "Expira el",
"txt_expires_at_value": "Expira el: {value}",
"txt_expiry": "Vencimiento",
"txt_expiry_month": "Mes de vencimiento",
"txt_expiry_year": "Año de vencimiento",
"txt_failed_to_open_send": "Error al abrir envío",
"txt_favorite": "Favorito",
"txt_favorites": "Favoritos",
"txt_duplicates": "Duplicados",
"txt_field": "Campo",
"txt_field_label": "Etiqueta del campo",
"txt_field_label_is_required": "La etiqueta del campo es obligatoria.",
"txt_field_type": "Tipo de campo",
"txt_field_value": "Valor del campo",
"txt_file": "Archivo",
"txt_file_name": "Nombre del archivo",
"txt_file_send": "Envío de archivo",
"txt_file_size": "Tamaño del archivo",
"txt_fingerprint": "Huella",
"txt_firefox_browser": "Navegador Firefox",
"txt_firefox_extension": "Extensión de Firefox",
"txt_first_name": "Nombre",
"txt_folder": "Carpeta",
"txt_folder_created": "Carpeta creada",
"txt_folder_name": "Nombre de la carpeta",
"txt_folder_name_is_required": "El nombre de la carpeta es obligatorio",
"txt_folders": "Carpetas",
"txt_hidden": "Oculto",
"txt_hide": "Ocultar",
"txt_identity": "Identidad",
"txt_identity_details": "Detalles de identidad",
"txt_ie_browser": "Navegador Internet Explorer",
"txt_invite_code_optional": "Código de invitación (No obligatorio para la primera cuenta; obligatorio para todas las demás)",
"txt_invite_created": "Invitación creada",
"txt_invite_revoked": "Invitación revocada",
"txt_invite_validity_hours": "Validez de la invitación en horas",
"txt_invites": "Invitaciones",
"txt_ios": "iOS",
"txt_item": "Elemento",
"txt_item_created": "Elemento creado",
"txt_item_deleted": "Elemento eliminado",
"txt_item_history": "Historial del elemento",
"txt_password_history": "Historial de contraseñas",
"txt_password_updated_value": "Contraseña actualizada: {value}",
"txt_item_name_is_required": "El nombre del elemento es obligatorio.",
"txt_item_updated": "Elemento actualizado",
"txt_last_edited_value": "Última edición: {value}",
"txt_last_name": "Apellido",
"txt_last_seen": "Visto por última vez",
"txt_license_number": "Número de licencia",
"txt_link_copied": "Enlace copiado",
"txt_linked": "Vinculado",
"txt_linux_desktop": "Escritorio Linux",
"txt_loading": "Cargando...",
"txt_loading_nodewarden": "Cargando NodeWarden...",
"txt_jwt_warning_title": "Advertencia de seguridad del servidor",
"txt_jwt_warning_subtitle": "El secreto JWT no está configurado de forma segura.",
"txt_jwt_title_missing": "Falta JWT_SECRET",
"txt_jwt_title_too_short": "JWT_SECRET es demasiado corto",
"txt_jwt_title_default": "JWT_SECRET usa el valor predeterminado",
"txt_jwt_reason_missing": "El secreto JWT no está presente.",
"txt_jwt_reason_default": "El secreto JWT todavía tiene el valor predeterminado/de ejemplo.",
"txt_jwt_reason_too_short": "El secreto JWT es demasiado corto. La longitud mínima es {min}.",
"txt_jwt_how_to_fix_add": "Cómo añadir JWT_SECRET",
"txt_jwt_how_to_fix_replace": "Cómo reemplazar JWT_SECRET",
"txt_jwt_add_step_1": "Use el generador de 32 caracteres a continuación y copie una nueva clave.",
"txt_jwt_add_step_2_prefix": "Vaya al panel de Cloudflare -> Workers y Pages -> Su servicio -> ",
"txt_jwt_add_step_2_suffix": " -> Variables y Secretos -> Añadir",
"txt_jwt_add_step_3": "Guarde y espere el redespliegue, luego actualice esta página.",
"txt_jwt_replace_step_1": "Use el generador de 32 caracteres a continuación y cree una clave más fuerte (mínimo {min} caracteres).",
"txt_jwt_replace_step_2_prefix": "Vaya al panel de Cloudflare -> Workers y Pages -> Su servicio -> ",
"txt_jwt_replace_step_2_suffix": " -> Variables y Secretos -> Actualizar JWT_SECRET",
"txt_jwt_replace_step_3": "Guarde y espere el redespliegue, luego actualice esta página.",
"txt_jwt_secret_type_label": "Tipo:",
"txt_jwt_secret_type_value": "Secreto",
"txt_jwt_secret_name_label": "Nombre de la variable:",
"txt_jwt_secret_value_label": "Valor:",
"txt_jwt_secret_value_requirement": "Cadena aleatoria de al menos {min} caracteres",
"txt_jwt_what_is": "Qué es JWT",
"txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente, es demasiado corta o todavía usa el valor de ejemplo, la instancia no es segura para uso normal.",
"txt_how_to_fix": "Cómo corregirlo",
"txt_jwt_fix_step_1": "Abra las variables de entorno de su despliegue.",
"txt_jwt_fix_step_2": "Si su clave actual no es lo suficientemente aleatoria, use el generador de 32 caracteres a continuación.",
"txt_jwt_fix_step_3": "Panel de Cloudflare -> Workers & Pages -> Su servicio -> Configuración -> Variables y Secretos, actualizar JWT_SECRET.",
"txt_jwt_fix_step_4": "Guarde y espere el redespliegue, luego actualice esta página para verificar.",
"txt_random_secret_generator": "Generador de secreto aleatorio",
"txt_copied": "Copiado",
"txt_log_in": "Iniciar sesión",
"txt_logging_in": "Iniciando sesión...",
"txt_log_out": "Cerrar sesión",
"txt_lock": "Bloquear",
"txt_menu": "Menú",
"txt_settings": "Configuración",
"txt_back": "Volver",
"txt_login": "Inicio de sesión",
"txt_login_credentials": "Credenciales de inicio de sesión",
"txt_login_failed": "Error al iniciar sesión",
"txt_login_success": "Inicio de sesión correcto",
"txt_macos_desktop": "Escritorio macOS",
"txt_manage_authorized_devices_and_30_day_totp_trusted_sessions": "Administre los dispositivos autorizados y las sesiones de confianza TOTP de 30 días.",
"txt_manage_device_sessions_and_30_day_totp_trusted_sessions": "Administre las sesiones de dispositivos y las sesiones de confianza TOTP de 30 días.",
"txt_master_password": "Contraseña maestra",
"txt_master_password_changed_please_login_again": "Contraseña maestra cambiada. Por favor, inicie sesión de nuevo.",
"txt_master_password_changed_signing_out_everywhere": "Contraseña maestra cambiada. Cerrando sesión en todos los dispositivos.",
"txt_master_password_is_required": "La contraseña maestra es obligatoria",
"txt_master_password_is_required_2": "La contraseña maestra es obligatoria.",
"txt_master_password_must_be_at_least_12_chars": "La contraseña maestra debe tener al menos 12 caracteres",
"txt_master_password_reprompt": "Solicitar contraseña maestra de nuevo",
"txt_master_password_reprompt_2": "Solicitar contraseña maestra de nuevo",
"txt_max_access_count": "Número máximo de accesos",
"txt_middle_name": "Segundo nombre",
"txt_drag_to_reorder": "Arrastre para reordenar",
"txt_move": "Mover",
"txt_move_selected_items": "Mover elementos seleccionados",
"txt_moved_selected_items": "Elementos seleccionados movidos",
"txt_name": "Nombre",
"txt_name_is_required": "El nombre es obligatorio",
"txt_new_password": "Nueva contraseña",
"txt_nothing_to_copy": "Nada que copiar",
"txt_new_password_must_be_at_least_12_chars": "La nueva contraseña debe tener al menos 12 caracteres",
"txt_new_passwords_do_not_match": "Las nuevas contraseñas no coinciden",
"txt_new_send": "Nuevo envío",
"txt_next": "Siguiente",
"txt_no": "No",
"txt_no_devices_found": "No se encontraron dispositivos.",
"txt_no_folder": "Sin carpeta",
"txt_no_items": "No hay elementos",
"txt_no_username": "(Sin nombre de usuario)",
"txt_no_verification_codes": "Sin códigos de verificación",
"txt_no_name": "(Sin nombre)",
"txt_no_sends": "No hay envíos",
"txt_nodewarden_send": "Envío NodeWarden",
"txt_not_trusted": "No confiable",
"txt_note": "Nota",
"txt_notes": "Notas",
"txt_replace_device_name_with_note": "Establezca un nombre personalizado para este dispositivo sin cambiar su tipo de sistema detectado.",
"txt_number": "Número",
"txt_open": "Abrir",
"txt_opera_browser": "Navegador Opera",
"txt_opera_extension": "Extensión de Opera",
"txt_or": "o",
"txt_options": "Opciones",
"txt_passport_number": "Número de pasaporte",
"txt_password": "Contraseña",
"txt_password_is_already_verified": "La contraseña ya está verificada.",
"txt_passwords_do_not_match": "Las contraseñas no coinciden",
"txt_password_hint": "Pista de contraseña",
"txt_password_hint_optional": "Pista de contraseña (opcional)",
"txt_password_hint_placeholder": "Una pista que solo tú entiendas",
"txt_password_hint_register_placeholder": "Esta pista se puede mostrar directamente en la página de inicio de sesión web.",
"txt_password_hint_register_help": "Esta pista se puede mostrar directamente en la página de inicio de sesión web. No incluya su contraseña maestra, código de recuperación ni nada que pueda revelarla directamente.",
"txt_password_hint_login_help": "¿Olvidó la contraseña maestra? Revele la pista que guardó durante el registro.",
"txt_password_hint_login_note": "Aquí solo se muestra una pista. Debería ayudarle a recordar la contraseña, no exponerla.",
"txt_show_password_hint": "Mostrar pista de contraseña",
"txt_hide_password_hint": "Ocultar pista de contraseña",
"txt_loading_password_hint": "Cargando pista...",
"txt_password_hint_not_set": "No hay pista de contraseña disponible para este correo.",
"txt_password_hint_load_failed": "Error al cargar la pista de contraseña",
"txt_password_hint_too_long": "La pista de contraseña debe tener 120 caracteres o menos",
"txt_passkey": "Clave de acceso",
"txt_passkeys": "Claves de acceso",
"txt_passkey_created_at_value": "Creado el {value}",
"txt_phone": "Teléfono",
"txt_please_input_email_and_password": "Por favor, introduzca correo y contraseña",
"txt_please_input_master_password": "Por favor, introduzca contraseña maestra",
"txt_please_input_totp_code": "Por favor, introduzca el código TOTP",
"txt_please_select_a_file": "Por favor, seleccione un archivo",
"txt_postal_code": "Código postal",
"txt_prev": "Anterior",
"txt_private_key": "Clave privada",
"txt_profile": "Perfil",
"txt_profile_unavailable": "Perfil no disponible",
"txt_profile_updated": "Perfil actualizado",
"txt_public_key": "Clave pública",
"txt_recover_2fa_failed": "Error al recuperar 2FA",
"txt_recover_two_step_login": "Recuperar inicio de sesión en dos pasos",
"txt_recovered_but_auto_login_failed_please_sign_in": "Recuperado pero error al iniciar sesión automáticamente, por favor inicie sesión.",
"txt_recovery_code": "Código de recuperación",
"txt_recovery_code_and_api_key": "Código de recuperación y clave API",
"txt_recovery_code_copied": "Código de recuperación copiado",
"txt_recovery_code_is_empty": "El código de recuperación está vacío",
"txt_recovery_code_loaded": "Código de recuperación cargado",
"txt_api_key": "Clave API",
"txt_view_api_key": "Ver clave API",
"txt_rotate_api_key": "Rotar clave API",
"txt_api_key_copied": "Clave API copiada",
"txt_api_key_loaded": "Clave API cargada",
"txt_api_key_rotated": "Clave API rotada",
"txt_rotate_api_key_confirm": "¿Rotar clave API? La clave actual dejará de funcionar inmediatamente.",
"txt_api_key_is_empty": "La clave API está vacía",
"txt_api_key_dialog_intro": "Su clave API puede usarse para autenticarse con la CLI de Bitwarden.",
"txt_api_key_warning_body": "Su clave API es un mecanismo de autenticación alternativo. Manténgala secreta.",
"txt_oauth_client_credentials": "Credenciales de cliente OAuth 2.0",
"txt_client_id": "ID de cliente",
"txt_client_secret": "Secreto de cliente",
"txt_scope": "Ámbito",
"txt_grant_type": "Tipo de concesión",
"txt_refresh": "Actualizar",
"txt_refresh_in_seconds_s": "Actualizar en {seconds}s",
"txt_regenerate": "Regenerar",
"txt_registration_succeeded_please_sign_in": "Registro completado. Inicie sesión.",
"txt_remove": "Quitar",
"txt_remove_device": "Quitar dispositivo",
"txt_remove_device_2": "Quitar dispositivo",
"txt_remove_all_devices": "Quitar todos los dispositivos",
"txt_remove_all_devices_and_clear_all_2fa_trust": "¿Quitar todos los dispositivos y limpiar toda la confianza 2FA?",
"txt_remove_all_devices_and_sign_out_all_sessions": "¿Quitar todos los dispositivos, limpiar toda la confianza y cerrar sesión en todos los dispositivos?",
"txt_remove_device_name_and_clear_its_2fa_trust": "¿Quitar dispositivo \"{name}\" y limpiar su confianza 2FA?",
"txt_remove_device_and_sign_out_name": "¿Quitar dispositivo \"{name}\", limpiar su confianza y cerrar sesión?",
"txt_reveal": "Mostrar",
"txt_restore": "Restaurar",
"txt_revoke": "Revocar",
"txt_revoke_30_day_totp_trust_for_name": "¿Revocar la confianza TOTP de 30 días para \"{name}\"?",
"txt_revoke_30_day_totp_trust_from_all_devices": "¿Revocar la confianza TOTP de 30 días de todos los dispositivos?",
"txt_revoke_all_trusted": "Revocar toda la confianza",
"txt_revoke_all_trusted_devices": "Revocar confianza de todos los dispositivos",
"txt_revoke_device_authorization": "Revocar confianza del dispositivo",
"txt_revoke_device_trust_failed": "Error al revocar la confianza del dispositivo",
"txt_revoke_all_device_trust_failed": "Error al revocar la confianza de todos los dispositivos",
"txt_revoke_trust": "Revocar confianza",
"txt_untrust": "Quitar confianza",
"txt_update_device_note_failed": "Error al actualizar la nota del dispositivo",
"txt_role": "Rol",
"txt_save": "Guardar",
"txt_save_profile": "Guardar perfil",
"txt_save_profile_failed": "Error al guardar perfil",
"txt_search_sends": "Buscar envíos...",
"txt_search_your_secure_vault": "Buscar en su bóveda segura...",
"txt_clear_search": "Limpiar búsqueda",
"txt_clear_search_esc": "Limpiar búsqueda (Esc)",
"txt_sort": "Ordenar",
"txt_sort_last_edited": "Modificado",
"txt_sort_created": "Creado",
"txt_sort_name": "A-Z",
"txt_secret_and_code_are_required": "Secreto y código son obligatorios",
"txt_secret_copied": "Secreto copiado",
"txt_secure_note": "Nota segura",
"txt_security_code": "Código de seguridad",
"txt_security_code_cvv": "Código de seguridad (CVV)",
"txt_select_all": "Seleccionar todo",
"txt_select_duplicate_items": "Seleccionar duplicados",
"txt_select_an_item": "Seleccione un elemento",
"txt_send_created": "Envío creado",
"txt_send_deleted": "Envío eliminado",
"txt_send_details": "Detalles del envío",
"txt_send_file": "envío-archivo",
"txt_send_unavailable": "Envío no disponible.",
"txt_send_updated": "Envío actualizado",
"txt_sign_out": "Cerrar sesión",
"txt_ssh_key": "Clave SSH",
"txt_ssn": "NSS",
"txt_state_province": "Estado / provincia",
"txt_status": "Estado",
"txt_online": "En línea",
"txt_offline": "Sin conexión",
"txt_submit": "Enviar",
"txt_sync": "Sincronizar",
"txt_sync_vault": "Sincronizar bóveda",
"txt_switch_to_dark_mode": "Cambiar a modo oscuro",
"txt_switch_to_light_mode": "Cambiar a modo claro",
"txt_dash": "-",
"txt_text": "Texto",
"txt_text_2fa_recovered": "2FA recuperado",
"txt_text_2fa_recovered_new_recovery_code_code": "2FA recuperado. Nuevo código de recuperación: {code}",
"txt_text_3": "------",
"txt_text_is_required": "El texto es obligatorio",
"txt_text_send": "Envío de texto",
"txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically": "Este es un código de un solo uso. Después de usarlo, se genera un nuevo código automáticamente.",
"txt_this_item_requires_master_password_every_time_before_viewing_details": "Este elemento requiere la contraseña maestra cada vez antes de ver los detalles.",
"txt_this_link_is_missing_decryption_key": "A este enlace le falta la clave de descifrado.",
"txt_this_send_is_password_protected": "Este envío está protegido con contraseña.",
"txt_title": "Título",
"txt_totp": "TOTP",
"txt_totp_code": "Código TOTP",
"txt_totp_disabled": "TOTP desactivado",
"txt_totp_enabled": "TOTP activado",
"txt_totp_is_enabled_for_this_account": "TOTP está activado para esta cuenta.",
"txt_total_items_count": "{count} elementos",
"txt_totp_secret": "Secreto TOTP",
"txt_totp_verify_failed": "Error al verificar TOTP",
"txt_attachments": "Archivos adjuntos",
"txt_upload_attachments": "Subir archivos adjuntos",
"txt_new_attachments": "Nuevos adjuntos",
"txt_marked_for_removal_count": "{count} adjunto(s) se eliminarán al guardar",
"txt_trash": "Papelera",
"txt_trust_this_device_for_30_days": "Confiar en este dispositivo por 30 días",
"txt_trusted_until": "Confiable hasta",
"txt_two_step_verification": "Verificación en dos pasos",
"txt_type": "Tipo",
"txt_type_type": "Tipo {type}",
"txt_unban": "Desbloquear",
"txt_unchecked": "No marcado",
"txt_unknown_device": "Dispositivo desconocido",
"txt_unlock": "Desbloquear",
"txt_unlocking": "Desbloqueando...",
"txt_unlock_details": "Detalles de desbloqueo",
"txt_unlock_failed": "Error al desbloquear",
"txt_unlock_failed_master_password_is_incorrect": "Error al desbloquear. La contraseña maestra es incorrecta.",
"txt_unlock_item": "Desbloquear elemento",
"txt_unlock_send": "Desbloquear envío",
"txt_unlock_vault": "Desbloquear bóveda",
"txt_unlocked": "Desbloqueado",
"txt_all_devices_removed": "Todos los dispositivos eliminados",
"txt_remove_device_failed": "Error al quitar dispositivo",
"txt_remove_all_devices_failed": "Error al quitar todos los dispositivos",
"txt_update_item_failed": "Error al actualizar elemento",
"txt_update_send_failed": "Error al actualizar envío",
"txt_use_recovery_code": "Usar código de recuperación",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "Use su código de recuperación de un solo uso para desactivar la verificación en dos pasos.",
"txt_user_deleted": "Usuario eliminado",
"txt_user_status_updated": "Estado del usuario actualizado",
"txt_username": "Nombre de usuario",
"txt_uri_match_default_base_domain": "Predeterminado (dominio base)",
"txt_uri_match_base_domain": "Dominio base",
"txt_uri_match_host": "Host",
"txt_uri_match_exact": "Exacto",
"txt_uri_match_never": "Nunca",
"txt_uri_match_starts_with": "Empieza con",
"txt_uri_match_regular_expression": "Expresión regular",
"txt_users": "Usuarios",
"txt_vault_synced": "Bóveda sincronizada",
"txt_verification_code": "Código de verificación",
"txt_verify": "Verificar",
"txt_warning": "Advertencia",
"txt_view_recovery_code": "Ver código de recuperación",
"txt_web": "Web",
"txt_website": "Sitio web",
"txt_websites": "Sitios web",
"txt_windows_desktop": "Escritorio Windows",
"txt_yes": "Sí",
"txt_auto_lock": "Bloqueo automático",
"txt_auto_lock_description": "Se bloquea tras inactividad. Cerrar y volver a abrir la página siempre inicia bloqueado.",
"txt_auto_lock_updated": "Bloqueo automático actualizado",
"txt_session_timeout": "Tiempo de espera de sesión",
"txt_session_timeout_updated": "Tiempo de espera de sesión actualizado",
"txt_timeout_time": "Tiempo de espera",
"txt_timeout_action": "Acción al expirar",
"txt_timeout_action_logout": "Cerrar sesión",
"txt_timeout_action_lock": "Bloquear",
"txt_in_planning": "En planificación",
"txt_security_preferences": "Preferencias de seguridad",
"txt_timeout_1_minute": "1 minuto",
"txt_timeout_5_minutes": "5 minutos",
"txt_timeout_15_minutes": "15 minutos",
"txt_timeout_30_minutes": "30 minutos",
"txt_timeout_never": "Nunca",
"txt_lock_after_1_minute": "Después de 1 minuto",
"txt_lock_after_5_minutes": "Después de 5 minutos",
"txt_lock_after_15_minutes": "Después de 15 minutos",
"txt_lock_after_30_minutes": "Después de 30 minutos",
"txt_lock_after_never": "Nunca por inactividad",
"txt_import": "Importar",
"txt_export": "Exportar",
"txt_format": "Formato",
"txt_source_file": "Archivo de origen",
"txt_folder_handling": "Gestión de carpetas",
"txt_import_folder_mode_original": "Ruta original del archivo de importación",
"txt_import_folder_mode_none": "Sin carpeta",
"txt_import_folder_mode_target": "Una carpeta seleccionada",
"txt_target_folder": "Carpeta destino",
"txt_select_folder_placeholder": "-- Seleccionar carpeta --",
"txt_import_vault_data_hint": "Importar datos de la bóveda a su cuenta actual.",
"txt_export_vault_data_hint": "Exportar datos de la bóveda desde su cuenta actual.",
"txt_import_export_title": "Importar y exportar",
"txt_encrypted_mode": "Modo cifrado",
"txt_account_verification": "Verificación de cuenta",
"txt_password_verification": "Verificación de contraseña",
"txt_file_password": "Contraseña del archivo",
"txt_zip_password_optional": "Contraseña ZIP (opcional)",
"txt_zip_password": "Contraseña ZIP",
"txt_close": "Cerrar",
"txt_total": "Total",
"txt_import_success": "Importación correcta",
"txt_import_success_number_of_items": "Importados {count} elemento(s) en total.",
"txt_import_attachment_summary": "Importados {imported} de {total} adjunto(s).",
"txt_import_failed_attachments_title": "{count} adjunto(s) no fueron importados:",
"txt_import_attachment_target_not_found": "Elemento importado correspondiente no encontrado.",
"txt_upload_attachment_failed": "Error al subir adjunto.",
"txt_import_file_password_required": "Por favor, introduzca la contraseña del archivo.",
"txt_import_invalid_zip_password": "Contraseña ZIP no válida.",
"txt_export_completed": "Exportación completada",
"txt_export_failed": "Error de exportación",
"txt_import_invalid_password_protected_file": "Archivo de exportación protegido con contraseña no válido.",
"txt_import_decrypt_failed": "Error al descifrar el archivo de importación.",
"txt_import_empty_zip_archive": "El archivo ZIP está vacío.",
"txt_import_no_json_found_in_zip": "No se encontraron datos JSON importables en el archivo zip.",
"txt_import_data_json_not_found": "No se encontró data.json en el archivo ZIP.",
"txt_import_zip_password_required": "La contraseña ZIP es obligatoria.",
"txt_import_invalid_json_file": "Archivo JSON no válido",
"txt_import_failed": "Error de importación",
"txt_import_encrypted_file_title": "Importar archivo cifrado",
"txt_import_encrypted_file_message": "Esta exportación de Bitwarden está protegida con contraseña. Introduzca la contraseña del archivo de exportación para continuar.",
"txt_import_encrypted_zip_title": "Importar ZIP cifrado",
"txt_import_encrypted_zip_message": "Este archivo ZIP está protegido con contraseña. Introduzca la contraseña ZIP para continuar.",
"txt_new_type_header": "Nuevo {type}",
"txt_edit_type_header": "Editar {type}",
"txt_delete_folder": "Eliminar carpeta",
"txt_delete_folder_message": "¿Eliminar carpeta \"{name}\"? Los elementos en su interior se moverán a Sin carpeta.",
"txt_delete_all_folders": "Eliminar todas las carpetas",
"txt_delete_all_folders_message": "¿Eliminar todas las carpetas? Los elementos en su interior se moverán a Sin carpeta.",
"txt_folder_not_found": "Carpeta no encontrada",
"txt_folder_deleted": "Carpeta eliminada",
"txt_folder_updated": "Carpeta actualizada",
"txt_folders_deleted": "Carpetas eliminadas",
"txt_update_folder_failed": "Error al actualizar carpeta",
"txt_delete_folder_failed": "Error al eliminar carpeta",
"txt_delete_all_folders_failed": "Error al eliminar todas las carpetas",
"txt_other": "Otro",
"txt_vault_key_unavailable": "Clave de bóveda no disponible. Desbloquee la bóveda e intente de nuevo.",
"txt_vault_not_ready": "La bóveda aún no está lista",
"txt_unsupported_export_format": "Formato de exportación no compatible",
"txt_invalid_encrypted_export": "Archivo de exportación cifrado no válido.",
"txt_export_belongs_to_another_account": "Esta exportación cifrada pertenece a otra cuenta.",
"txt_invalid_argon2id_params": "Parámetros Argon2id no válidos en el archivo de exportación.",
"txt_unsupported_kdf_type": "Tipo kdf no soportado: {type}",
"txt_invalid_file_password": "Contraseña de archivo no válida.",
"txt_failed_to_map_attachments": "Error al asignar {count} adjunto(s) a los elementos importados.",
"txt_role_admin": "Administrador",
"txt_role_user": "Usuario",
"txt_status_active": "Activo",
"txt_status_banned": "Bloqueado",
"txt_status_inactive": "Inactivo",
"txt_language": "Idioma",
"txt_display_language": "Idioma de visualización",
"txt_language_saved_locally": "Esta preferencia se guarda en este navegador y se usa antes de que la aplicación cargue la próxima vez."
};
export default es;
+848
View File
@@ -0,0 +1,848 @@
// Complete Russian locale. Keep keys and placeholders unchanged.
const ru: Record<string, string> = {
"txt_backup_destination_detail_note": "",
"nav_account_settings": "Настройки учетной записи",
"nav_admin_panel": "Панель администратора",
"nav_device_management": "Управление устройствами",
"nav_my_vault": "Мое хранилище",
"nav_sends": "Отправляет",
"nav_backup_strategy": "Облачное резервное копирование",
"nav_import_export": "Импорт и экспорт",
"backup_strategy_title": "Облачное резервное копирование",
"backup_strategy_under_construction": "В стадии строительства.",
"import_export_title": "Импорт и экспорт",
"import_export_under_construction": "В стадии строительства.",
"txt_backup_export": "Экспортировать резервную копию",
"txt_backup_import": "Восстановить",
"txt_backup_include_attachments": "Включить вложения",
"txt_backup_export_description": "Загрузите полную резервную копию ZIP-файла экземпляра для хранения вручную.",
"txt_backup_import_description": "Загрузите ранее экспортированный ZIP-архив резервной копии и восстановите его в этот экземпляр.",
"txt_backup_exporting": "Экспорт...",
"txt_backup_importing": "Восстановление...",
"txt_backup_restoring": "Восстановление...",
"txt_backup_export_success": "Резервная копия экспортирована.",
"txt_backup_import_success_relogin": "Резервная копия восстановлена. Пожалуйста, войдите снова.",
"txt_backup_restore_success_relogin": "Резервная копия восстановлена. Пожалуйста, войдите снова.",
"txt_backup_restore_completed_verified": "Проверка целостности файла резервной копии пройдена.",
"txt_backup_restore_completed_without_checksum": "Резервная копия восстановлена. Маркер целостности имени файла не был доступен для проверки.",
"txt_backup_remote_restore_completed_verified": "Проверка целостности удаленной резервной копии пройдена.",
"txt_backup_remote_restore_completed_without_checksum": "Удаленная резервная копия восстановлена. Маркер целостности имени файла не был доступен для проверки.",
"txt_backup_restore_skipped_summary": "{reason}. Пропущено вложение(я) {attachments}.",
"txt_backup_restore_skipped_reason_default": "Некоторые файлы не удалось восстановить",
"txt_backup_export_failed": "Не удалось экспортировать резервную копию",
"txt_backup_import_failed": "Восстановление резервной копии не удалось",
"txt_backup_restore_failed": "Восстановление резервной копии не удалось",
"txt_backup_integrity_check_failed": "Проверка целостности резервной копии не удалась",
"txt_backup_center_title": "Резервное копирование экземпляра",
"txt_backup_center_description": "Сохраняйте локальный экспорт для ручного восстановления и настройте один ежедневный целевой объект удаленного резервного копирования для автоматической защиты.",
"txt_backup_restore_note": "При восстановлении текущий экземпляр будет перезаписан, если вы выберете поток замены.",
"txt_backup_manual": "Ручное резервное копирование",
"txt_backup_manual_description": "Экспортируйте ZIP-файл прямо сейчас или импортируйте его обратно в этот экземпляр.",
"txt_backup_destinations_title": "Назначения резервного копирования",
"txt_backup_destinations_description": "Оставьте здесь несколько целей WebDAV и S3. Выберите один слева, чтобы отредактировать или просмотреть его.",
"txt_backup_recommend_title": "Рекомендуемое хранилище",
"txt_backup_recommend_open_signup": "Открыть регистрацию",
"txt_backup_recommend_open_signup_aff": "Открытая регистрация (AFF)",
"txt_backup_recommend_open_guide": "Открыть руководство",
"txt_backup_recommend_empty": "Пока нет рекомендаций.",
"txt_backup_recommend_referral_label": "Реферальный код",
"txt_backup_recommend_referral_note": "Используйте его при регистрации, чтобы получить дополнительно 5 ГБ. Автор получает 2 ГБ.",
"txt_backup_recommend_infinicloud_summary": "Нужен только адрес электронной почты. 20 ГБ бесплатно, всего 25 ГБ с реферальным кодом.",
"txt_backup_recommend_infinicloud_step_1": "Зарегистрируйте учетную запись InfiniCLOUD, используя только свой адрес электронной почты.",
"txt_backup_recommend_infinicloud_step_2_prefix": "Открыть",
"txt_backup_recommend_infinicloud_step_2_suffix": "и включите подключение приложений.",
"txt_backup_recommend_infinicloud_step_3": "Используйте идентификатор подключения в качестве имени пользователя WebDAV и пароль приложения в качестве пароля WebDAV.",
"txt_backup_recommend_infinicloud_step_4": "Введите реферальный код 2HC5E в разделе «Реферальный бонус» внизу моей страницы, чтобы получить дополнительно 5 ГБ.",
"txt_backup_recommend_open_password": "Настройки пароля",
"txt_backup_recommend_open_storage": "Открытое хранилище",
"txt_backup_recommend_koofr_summary": "Нужен только адрес электронной почты. 10 ГБ бесплатно, и он может соединить Google Drive, OneDrive и Dropbox через WebDAV.",
"txt_backup_recommend_koofr_password_link": "Настройки пароля",
"txt_backup_recommend_koofr_storage_link": "Хранение",
"txt_backup_recommend_koofr_step_1": "Зарегистрируйте учетную запись Koofr, используя только свой адрес электронной почты.",
"txt_backup_recommend_koofr_step_2_prefix": "Открыть",
"txt_backup_recommend_koofr_step_2_suffix": ", создайте новый пароль приложения, используйте свой адрес электронной почты в качестве имени пользователя WebDAV и используйте пароль приложения в качестве пароля WebDAV.",
"txt_backup_recommend_koofr_step_3": "Собственный адрес Куфра в WebDAV — https://app.koofr.net/dav/Koofr.",
"txt_backup_recommend_koofr_step_4": "Куфр также может подключать Google Drive, OneDrive и Dropbox. Бесплатные пользователи могут подключить до двух учетных записей хранения.",
"txt_backup_recommend_koofr_step_5_prefix": "Открыть",
"txt_backup_recommend_koofr_step_5_suffix": ", нажмите «Подключиться» на левой боковой панели и выберите облачное хранилище, которое хотите подключить.",
"txt_backup_recommend_koofr_dav_intro": "После подключения учетной записи хранения сохраните тот же адрес электронной почты и пароль приложения и переключите только адрес WebDAV:",
"txt_backup_recommend_koofr_dav_self": "Куфр",
"txt_backup_recommend_pcloud_summary": "Нужен только адрес электронной почты. До 10 ГБ бесплатно со стандартным доступом WebDAV.",
"txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.",
"txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.",
"txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля WebDAV.",
"txt_backup_add_destination": "Добавить пункт назначения",
"txt_backup_schedule_panel_title": "Автоматическое расписание",
"txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.",
"txt_backup_scheduled_target": "Запланированная цель",
"txt_backup_destination_active_badge": "Автоматическое включение",
"txt_backup_destination_idle_badge": "Автовыключение",
"txt_backup_destination_last_success": "Последний успех: {time}",
"txt_backup_destination_never_run": "Пока ни одного успешного запуска",
"txt_backup_destination_detail_title": "Детали пункта назначения",
"txt_backup_destination_name": "Имя места назначения",
"txt_backup_set_scheduled_target": "Используйте для ежедневного резервного копирования",
"txt_backup_delete_destination": "Удалить",
"txt_backup_destination_deleted": "Место назначения резервного копирования удалено.",
"txt_backup_delete_destination_confirm_message": "Удалить место назначения резервного копирования «{name}»? Это невозможно отменить.",
"txt_backup_select_destination": "Сначала выберите место назначения резервного копирования из списка.",
"txt_backup_remote_save_first": "Сначала сохраните это место назначения, прежде чем просматривать файлы удаленных резервных копий.",
"txt_backup_automation": "Автоматическое резервное копирование",
"txt_backup_automation_description": "Выберите пункт назначения, сохраните учетные данные и позвольте работнику загружать одну резервную копию каждый день.",
"txt_backup_settings_saved": "Настройки резервной копии сохранены.",
"txt_backup_settings_save_failed": "Не удалось сохранить настройки резервного копирования.",
"txt_backup_settings_load_failed": "Не удалось загрузить настройки резервного копирования.",
"txt_backup_save_settings": "Сохранить настройки",
"txt_backup_saving": "Сохранение...",
"txt_backup_enable_action": "Включить",
"txt_backup_disable_action": "Отключить",
"txt_backup_run_now": "Запустите удаленное резервное копирование сейчас",
"txt_backup_run_manual": "Запустить вручную",
"txt_backup_running_now": "Бег...",
"txt_backup_remote_run_success": "Удаленное резервное копирование завершено",
"txt_backup_remote_run_success_verified": "Удаленное резервное копирование завершено, проверка целостности пройдена.",
"txt_backup_remote_run_failed": "Удаленное резервное копирование не удалось",
"txt_backup_remote_title": "Удаленное резервное копирование",
"txt_backup_remote_note": "Просмотрите сохраненное место назначения и выберите резервную копию ZIP для загрузки или восстановления.",
"txt_backup_remote_saved_basis": "При удаленном просмотре используются последние сохраненные настройки места назначения, а не несохраненные изменения формы.",
"txt_backup_remote_refresh": "Обновить",
"txt_backup_remote_root": "Корень",
"txt_backup_remote_up": "Вверх",
"txt_backup_remote_open": "Открыть",
"txt_backup_remote_download": "Скачать",
"txt_backup_remote_downloading": "Загрузка...",
"txt_backup_remote_restore": "Восстановить",
"txt_backup_remote_restore_stage_prepare": "Подготовка удаленного восстановления из резервной копии...",
"txt_backup_remote_restore_stage_replace": "Очистка текущих данных и восстановление удаленной резервной копии...",
"txt_backup_progress_kicker": "Задача резервного копирования",
"txt_backup_progress_subject": "Текущий элемент: {name}",
"txt_backup_restore_progress_kicker": "Восстановить прогресс",
"txt_backup_restore_progress_local_title": "Восстановление локальной резервной копии",
"txt_backup_restore_progress_remote_title": "Восстановление удаленной резервной копии",
"txt_backup_export_progress_title": "Экспорт резервной копии",
"txt_backup_remote_run_progress_title": "Запуск удаленного резервного копирования",
"txt_backup_restore_progress_file": "Текущий файл: {name}",
"txt_backup_restore_progress_elapsed": "Прошло {seconds} с.",
"txt_backup_archive_progress_collect_title": "Сбор данных хранилища",
"txt_backup_archive_progress_collect_detail": "Сервер читает таблицы базы данных и собирает полезные данные для резервного копирования.",
"txt_backup_archive_progress_collect_with_attachments_detail": "Сервер читает таблицы базы данных и собирает метаданные вложений для полезной нагрузки резервного копирования.",
"txt_backup_archive_progress_package_title": "Упаковка резервного архива",
"txt_backup_archive_progress_package_detail": "Сервер генерирует резервный ZIP-файл и вычисляет префикс его контрольной суммы.",
"txt_backup_archive_progress_package_with_attachments_detail": "Сервер генерирует резервные метаданные ZIP и вычисляет префикс контрольной суммы для экспорта с учетом вложений.",
"txt_backup_archive_progress_ready_title": "Подготовка загрузки",
"txt_backup_archive_progress_ready_detail": "Резервный архив готов и возвращается в браузер.",
"txt_backup_export_progress_fetch_attachments_title": "Загрузка вложенных файлов",
"txt_backup_export_progress_fetch_attachments_detail": "Браузер извлекает объекты вложений и добавляет их в пакет экспорта.",
"txt_backup_export_progress_rebuild_title": "Восстановление экспортного архива",
"txt_backup_export_progress_rebuild_detail": "Браузер восстанавливает окончательный ZIP-архив и обновляет суффикс контрольной суммы.",
"txt_backup_export_progress_save_title": "Сохранение файла экспорта",
"txt_backup_export_progress_save_detail": "Браузер подготавливает окончательный файл резервной копии для загрузки.",
"txt_backup_export_progress_complete_title": "Экспорт завершен",
"txt_backup_export_progress_complete_detail": "Резервный экспорт готов.",
"txt_backup_export_progress_failed_title": "Экспорт не удался",
"txt_backup_export_progress_failed_detail": "Экспорт резервной копии не удалось завершить.",
"txt_backup_remote_run_progress_prepare_title": "Подготовка удаленного резервного копирования",
"txt_backup_remote_run_progress_prepare_detail": "Сервер загружает выбранное место назначения и готовит этот запуск резервного копирования.",
"txt_backup_remote_run_progress_sync_attachments_title": "Проверка индекса вложений",
"txt_backup_remote_run_progress_sync_attachments_detail": "Сервер сравнивает метаданные вложений, поэтому загружаются только отсутствующие объекты вложений.",
"txt_backup_remote_run_progress_sync_attachments_skipped_detail": "Эта резервная копия не включает вложения, поэтому синхронизация вложений пропускается.",
"txt_backup_remote_run_progress_upload_title": "Загрузка резервного архива",
"txt_backup_remote_run_progress_upload_detail": "Сервер загружает резервную копию ZIP в удаленное место назначения.",
"txt_backup_remote_run_progress_verify_title": "Проверка загруженного архива",
"txt_backup_remote_run_progress_verify_detail": "Сервер загружает загруженный ZIP-архив обратно и проверяет его контрольную сумму и размер.",
"txt_backup_remote_run_progress_cleanup_title": "Очистка старых резервных копий",
"txt_backup_remote_run_progress_cleanup_detail": "Сервер удаляет старые файлы резервных копий в соответствии с политикой хранения.",
"txt_backup_remote_run_progress_complete_title": "Удаленное резервное копирование завершено",
"txt_backup_remote_run_progress_complete_detail": "Удаленная резервная копия успешно загружена и проверена.",
"txt_backup_remote_run_progress_failed_title": "Удаленное резервное копирование не удалось",
"txt_backup_remote_run_progress_failed_detail": "Удаленное резервное копирование не удалось завершить.",
"txt_backup_restore_progress_local_upload_title": "Загрузка резервного архива",
"txt_backup_restore_progress_local_upload_detail": "Выбранный ZIP-файл отправляется на обработку на сервер.",
"txt_backup_restore_progress_local_shadow_title": "Создание теневого рабочего пространства",
"txt_backup_restore_progress_local_shadow_detail": "Сервер подготавливает изолированную область восстановления, поэтому текущие данные остаются нетронутыми до прохождения проверки.",
"txt_backup_restore_progress_local_data_title": "Запись данных хранилища",
"txt_backup_restore_progress_local_data_detail": "Сервер импортирует пользователей, папки, элементы хранилища и связанные метаданные в теневые таблицы.",
"txt_backup_restore_progress_local_files_title": "Восстановление вложенных файлов",
"txt_backup_restore_progress_local_files_detail": "Сервер записывает объекты вложений обратно в хранилище и удаляет все строки вложений, которые невозможно восстановить.",
"txt_backup_restore_progress_local_finalize_title": "Проверка и переключение данных",
"txt_backup_restore_progress_local_finalize_detail": "Сервер выполняет окончательную проверку, а затем заменяет проверенные данные восстановления в действующие таблицы.",
"txt_backup_restore_progress_remote_fetch_title": "Чтение удаленной резервной копии",
"txt_backup_restore_progress_remote_fetch_detail": "Сервер загружает выбранный пакет резервной копии из удаленного места назначения.",
"txt_backup_restore_progress_remote_shadow_title": "Создание теневого рабочего пространства",
"txt_backup_restore_progress_remote_shadow_detail": "Сервер подготавливает изолированную область восстановления, поэтому текущие данные остаются нетронутыми до прохождения проверки.",
"txt_backup_restore_progress_remote_data_title": "Запись данных хранилища",
"txt_backup_restore_progress_remote_data_detail": "Сервер импортирует пользователей, папки, элементы хранилища и связанные метаданные в теневые таблицы.",
"txt_backup_restore_progress_remote_files_title": "Восстановление удаленных вложений",
"txt_backup_restore_progress_remote_files_detail": "Сервер извлекает необходимые объекты вложений из удаленного хранилища и записывает их обратно в локальное хранилище.",
"txt_backup_restore_progress_remote_finalize_title": "Проверка и переключение данных",
"txt_backup_restore_progress_remote_finalize_detail": "Сервер выполняет окончательную проверку, а затем переключает проверенные данные восстановления в живые таблицы.",
"txt_backup_remote_loading": "Загрузка удаленных резервных копий...",
"txt_backup_remote_cached_empty": "Нажмите «Обновить», чтобы загрузить это место назначения.",
"txt_backup_remote_empty": "В этой папке не найдено файлов резервных копий.",
"txt_backup_remote_folder": "Папка",
"txt_backup_remote_unknown_time": "Неизвестное время",
"txt_backup_remote_current_path": "Текущая папка",
"txt_backup_remote_load_failed": "Не удалось загрузить удаленные резервные копии.",
"txt_backup_remote_invalid_response": "Неверный ответ удаленного резервного копирования",
"txt_backup_remote_download_failed": "Не удалось загрузить удаленную резервную копию.",
"txt_backup_remote_delete_success": "Удаленная резервная копия удалена.",
"txt_backup_remote_delete_failed": "Не удалось удалить удаленную резервную копию.",
"txt_backup_remote_delete_confirm_message": "Удалить файл резервной копии «{name}»? Это невозможно отменить.",
"txt_backup_remote_deleting": "Удаление...",
"txt_backup_remote_restore_failed": "Не удалось восстановить удаленную резервную копию.",
"txt_backup_restore_checksum_warning_title": "Предупреждение о целостности резервной копии",
"txt_backup_restore_checksum_warning_message": "Выбранный файл резервной копии «{name}» не прошел проверку целостности имени файла. Ожидаемый префикс {expected}, фактический префикс {actual}. Возможно, файл неполный или поврежден. Продолжение может привести к восстановлению поврежденных данных.",
"txt_backup_remote_restore_checksum_warning_message": "Файл удаленной резервной копии «{name}» не прошел проверку целостности имени файла. Ожидаемый префикс {expected}, фактический префикс {actual}. Файл может быть поврежден во время загрузки или хранения. Продолжение может привести к восстановлению поврежденных данных и может привести к серьезной потере данных.",
"txt_backup_restore_checksum_warning_message_fallback": "Выбранный файл резервной копии не прошел проверку целостности. Продолжение может привести к восстановлению поврежденных данных.",
"txt_backup_restore_checksum_warning_confirm": "Продолжить восстановление",
"txt_backup_remote_restore_invalid_response": "Неверный ответ на удаленное восстановление из резервной копии",
"txt_backup_remote_run_invalid_response": "Неверный ответ на удаленное резервное копирование.",
"txt_backup_settings_invalid_response": "Неверный ответ на настройки резервного копирования",
"txt_backup_import_invalid_response": "Неверный ответ на импорт резервной копии",
"txt_backup_destination": "Место назначения резервного копирования",
"txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3",
"txt_backup_recommend_group_webdav": "WebDAV",
"txt_backup_recommend_group_s3": "S3",
"txt_backup_destination_name_default_webdav": "ВебДАВ {index}",
"txt_backup_destination_name_default_s3": "S3 {index}",
"txt_backup_type": "Тип резервной копии",
"txt_backup_destination_reserved": "Зарезервированный слот",
"txt_backup_time": "Время резервного копирования",
"txt_backup_start_time": "Время начала",
"txt_backup_timezone": "Часовой пояс",
"txt_backup_interval_hours": "Каждый",
"txt_backup_interval_hours_suffix": "часы",
"txt_backup_interval_hours_presets": "Предварительные настройки быстрых интервалов",
"txt_backup_frequency": "Частота",
"txt_backup_frequency_daily": "Ежедневно",
"txt_backup_frequency_weekly": "Еженедельно",
"txt_backup_frequency_monthly": "Ежемесячно",
"txt_backup_day_of_week": "День недели",
"txt_backup_day_of_month": "День месяца",
"txt_backup_weekday_monday": "понедельник",
"txt_backup_weekday_tuesday": "вторник",
"txt_backup_weekday_wednesday": "среда",
"txt_backup_weekday_thursday": "Четверг",
"txt_backup_weekday_friday": "пятница",
"txt_backup_weekday_saturday": "Суббота",
"txt_backup_weekday_sunday": "воскресенье",
"txt_backup_retention_count": "Держите",
"txt_backup_retention_count_suffix": "предметы",
"txt_backup_retention_count_hint": "Оставьте пустым, чтобы сохранить все файлы резервных копий. Новые пункты назначения по умолчанию равны 30.",
"txt_backup_destination_include_attachments": "Включить вложения",
"txt_backup_include_attachments_help_button": "Помощь по резервному копированию вложений",
"txt_backup_include_attachments_help": "Вложения сохраняются постепенно в папке удаленных вложений, поэтому при более поздних резервных копиях обычно загружаются только новые файлы. Локальное удаление вложения не приводит к удалению более ранних удаленных копий. Во время восстановления NodeWarden считывает необходимые файлы из папки вложений и пропускает все вложения, которые больше не доступны.",
"txt_backup_enable_schedule": "Включить автоматическое ежедневное резервное копирование",
"txt_backup_schedule_note": "Работник проверяет расписание каждые 5 минут. Он начинается в выбранное время в выбранном часовом поясе, затем повторяется с выбранным часовым интервалом и сбрасывается с этого времени каждый день.",
"txt_backup_schedule_disabled": "Отключено",
"txt_backup_schedule_status": "Расписание",
"txt_backup_schedule_summary": "Начало в {time} каждые {interval} часов ({timezone}).",
"txt_backup_schedule_empty": "Планы автоматического резервного копирования пока не включены.",
"txt_backup_last_success": "Последний успех",
"txt_backup_last_target": "Последняя цель",
"txt_backup_last_file": "Последний файл",
"txt_backup_last_error_prefix": "Последняя ошибка",
"txt_backup_none_yet": "Удаленное резервное копирование еще не завершено",
"txt_backup_not_configured": "Не настроено",
"txt_backup_never": "Никогда",
"txt_backup_unknown_size": "Неизвестный размер",
"txt_backup_webdav_url": "URL-адрес сервера WebDAV",
"txt_backup_webdav_username": "Имя пользователя WebDAV",
"txt_backup_webdav_password": "Пароль WebDAV",
"txt_backup_webdav_path": "Удаленная папка",
"txt_backup_s3_endpoint": "S3 endpoint",
"txt_backup_s3_bucket": "Бакет",
"txt_backup_s3_region": "Регион",
"txt_backup_s3_access_key": "Ключ доступа",
"txt_backup_s3_secret_key": "Секретный ключ",
"txt_backup_s3_path": "Удаленный путь",
"txt_backup_reserved_name": "Зарезервированное имя поставщика",
"txt_backup_reserved_notes": "Зарезервированные заметки",
"txt_backup_reserved_notes_placeholder": "Оставьте заметку для следующего типа пункта назначения",
"txt_backup_reserved_hint": "Этот слот зарезервирован для будущего пункта назначения. Теперь вы можете сохранять заметки, но автоматическая загрузка остается отключенной.",
"txt_backup_file": "Резервный файл",
"txt_backup_file_required": "Пожалуйста, выберите файл резервной копии",
"txt_backup_no_file_selected": "Файл резервной копии не выбран",
"txt_backup_selected_file_name": "Выбранный файл: {name}",
"txt_backup_replace_confirm_title": "Заменить текущие данные экземпляра",
"txt_backup_replace_confirm_message": "Текущий экземпляр уже содержит данные. Продолжить восстановление и заменить текущие данные экземпляра выбранной резервной копией после успешной проверки?",
"txt_backup_clear_and_import": "Заменить и импортировать",
"txt_backup_clear_and_restore": "Заменить и восстановить",
"txt_access_count": "Количество доступов",
"txt_accessed_count_times": "Доступ {count} раз",
"txt_actions": "Действия",
"txt_add": "Добавить",
"txt_add_field": "Добавить поле",
"txt_add_website": "Добавить веб-сайт",
"txt_added": "Добавлено",
"txt_additional_options": "Дополнительные опции",
"txt_address": "Адрес",
"txt_address_1": "Адрес 1",
"txt_address_2": "Адрес 2",
"txt_address_3": "Адрес 3",
"txt_all_device_authorizations_revoked": "Все доверие к устройствам отозвано",
"txt_all_invites_deleted": "Все приглашения удалены",
"txt_all_items": "Все предметы",
"txt_all_sends": "Все отправки",
"txt_android": "Андроид",
"txt_are_you_sure_you_want_to_delete_count_selected_items": "Вы уверены, что хотите удалить выбранные элементы {count}?",
"txt_are_you_sure_you_want_to_delete_count_selected_items_permanently": "Вы уверены, что хотите навсегда удалить выбранные элементы {count}?",
"txt_are_you_sure_you_want_to_delete_this_item": "Вы уверены, что хотите удалить этот элемент?",
"txt_are_you_sure_you_want_to_delete_this_passkey": "Вы уверены, что хотите удалить этот ключ доступа?",
"txt_are_you_sure_you_want_to_log_out": "Вы уверены, что хотите выйти?",
"txt_authenticator_key": "Ключ аутентификации",
"txt_authorized_devices": "Авторизованные устройства",
"txt_auto_copy_link_after_save": "Автоматическое копирование ссылки после сохранения",
"txt_autofill_options": "Параметры автозаполнения",
"txt_back_to_login": "Вернуться к входу",
"txt_ban": "Запретить",
"txt_boolean": "логическое значение",
"txt_brand": "Бренд",
"txt_bulk_delete_failed": "Массовое удаление не удалось",
"txt_bulk_permanent_delete_failed": "Не удалось выполнить массовое окончательное удаление.",
"txt_bulk_restore_failed": "Массовое восстановление не удалось",
"txt_bulk_delete_sends_failed": "Массовое удаление не удалось отправить",
"txt_bulk_move_failed": "Массовое перемещение не удалось",
"txt_cancel": "Отмена",
"txt_continue": "Продолжить",
"txt_card": "Карта",
"txt_card_details": "Детали карты",
"txt_cardholder_name": "Имя владельца карты",
"txt_change_master_password": "Изменить главный пароль",
"txt_change_password": "Изменить пароль",
"txt_change_password_failed": "Сменить пароль не удалось",
"txt_change_password_confirm_and_sign_out_all_devices": "Изменение главного пароля приведет к выходу из системы всех устройств, включая этот веб-сеанс. Продолжать?",
"txt_copy_failed": "Не удалось скопировать",
"txt_checked": "Проверено",
"txt_choose_destination_folder": "Выберите папку назначения.",
"txt_chrome_browser": "Браузер Chrome",
"txt_chrome_extension": "Расширение Chrome",
"txt_city_town": "Город / Город",
"txt_code": "Код",
"txt_company": "Компания",
"txt_configure_custom_field_values": "Настройте значения настраиваемых полей.",
"txt_confirm": "Подтвердить",
"txt_confirm_master_password": "Подтвердите мастер-пароль",
"txt_confirm_password": "Подтвердите пароль",
"txt_copy": "Копировать",
"txt_code_copied": "Код скопирован.",
"txt_copy_code": "Копировать код",
"txt_copy_link": "Копировать ссылку",
"txt_copy_secret": "Копировать секрет",
"txt_country": "Страна",
"txt_create": "Создать",
"txt_create_account": "Создать учетную запись",
"txt_registering": "Создание учетной записи...",
"txt_create_folder": "Создать папку",
"txt_create_folder_failed": "Создать папку не удалось",
"txt_create_item_failed": "Создать элемент не удалось",
"txt_create_send_failed": "Создать отправить не удалось",
"txt_create_timed_invite": "Создать приглашение на время",
"txt_created_value": "Создано: {value}",
"txt_current_new_password_is_required": "Требуется текущий/новый пароль",
"txt_current_password": "Текущий пароль",
"txt_custom_fields": "Пользовательские поля",
"txt_decrypt_failed": "(Расшифровать не удалось)",
"txt_decrypt_failed_2": "Расшифровать не удалось",
"txt_delete": "Удалить",
"txt_delete_all": "Удалить все",
"txt_delete_all_invite_codes_active_inactive": "Удалить все пригласительные коды (активные/неактивные)?",
"txt_delete_all_invites": "Удалить все приглашения",
"txt_delete_item": "Удалить элемент",
"txt_delete_passkey": "Удалить пароль",
"txt_delete_item_failed": "Удалить элемент не удалось",
"txt_delete_permanently": "Удалить навсегда",
"txt_archive": "Архив",
"txt_archive_item": "Архивный элемент",
"txt_archive_item_message": "После архивирования этот элемент будет исключен из общих результатов поиска и предложений автозаполнения.",
"txt_archive_selected_items": "Архивные элементы",
"txt_archive_selected_items_message": "После архивирования выбранные элементы {count} будут исключены из общих результатов поиска и предложений автозаполнения.",
"txt_archived": "В архиве",
"txt_archive_selected": "Архив",
"txt_item_archived": "Объект заархивирован",
"txt_item_unarchived": "Объект разархивирован",
"txt_archived_selected_items": "Выбранные элементы заархивированы",
"txt_unarchived_selected_items": "Разархивированы выбранные элементы",
"txt_archive_item_failed": "Архивировать элемент не удалось",
"txt_unarchive_item_failed": "Разархивировать элемент не удалось",
"txt_bulk_archive_failed": "Массовое архивирование не удалось",
"txt_bulk_unarchive_failed": "Массовое разархивирование не удалось",
"txt_unarchive": "Разархивировать",
"txt_delete_selected": "Удалить",
"txt_delete_selected_items": "Удалить выбранные элементы",
"txt_delete_selected_items_permanently": "Удалить выбранные элементы навсегда",
"txt_delete_send_failed": "Удаление, отправка не удалась",
"txt_delete_this_user_and_all_user_data": "Удалить этого пользователя и все пользовательские данные?",
"txt_delete_user": "Удалить пользователя",
"txt_deleted_selected_items": "Удалены выбранные элементы",
"txt_deleted_selected_items_permanently": "Безвозвратно удалены выбранные элементы",
"txt_restored_selected_items": "Восстановлены выбранные элементы",
"txt_deleted_selected_sends": "Удалены выбранные отправки",
"txt_deletion_date": "Дата удаления",
"txt_deletion_days": "Дни удаления",
"txt_device": "Устройство",
"txt_device_authorization_revoked": "Доверие к устройству отозвано",
"txt_device_management": "Управление устройствами",
"txt_device_note": "Примечание устройства",
"txt_device_note_required": "Укажите имя устройства.",
"txt_device_note_updated": "Имя устройства обновлено.",
"txt_device_removed": "Устройство удалено",
"txt_load_devices_failed": "Не удалось загрузить устройства.",
"txt_disable_this_send": "Отключить эту отправку",
"txt_disable_totp": "Отключить TOTP",
"txt_disable_totp_failed": "Отключить TOTP не удалось",
"txt_download": "Скачать",
"txt_downloading": "Загрузка...",
"txt_downloading_percent": "Загрузка {percent}%",
"txt_attachment": "Приложение",
"txt_uploading_attachment_named": "Загрузка {name}...",
"txt_uploading_attachment_named_percent": "Загрузка {name} {percent}%",
"txt_uploading_file_named": "Загрузка {name}...",
"txt_uploading_file_named_percent": "Загрузка {name} {percent}%",
"txt_download_failed": "Загрузка не удалась",
"txt_edge_browser": "Крайний браузер",
"txt_edge_extension": "Расширение края",
"txt_edit": "Редактировать",
"txt_edit_send": "Редактировать Отправить",
"txt_email": "электронная почта",
"txt_email_password_and_recovery_code_are_required": "Требуется адрес электронной почты, пароль и код восстановления.",
"txt_enable_totp": "Включить TOTP",
"txt_enable_totp_failed": "Включить TOTP не удалось",
"txt_enabled": "Включено",
"txt_encrypted_file": "Зашифрованный файл",
"txt_encrypted_file_2": "Зашифрованный файл",
"txt_enter_a_folder_name": "Введите имя папки.",
"txt_enter_master_password_to_disable_two_step_verification": "Введите мастер-пароль, чтобы отключить двухэтапную проверку.",
"txt_enter_master_password_to_continue": "Введите свой мастер-пароль, чтобы продолжить.",
"txt_enter_master_password_to_view_this_item": "Введите мастер-пароль, чтобы просмотреть этот элемент.",
"txt_expiration_date": "Срок годности",
"txt_expiration_days_0_never": "Дни истечения срока действия (0 = никогда)",
"txt_expires_at": "Срок действия истекает в",
"txt_expires_at_value": "Срок действия истекает: {value}",
"txt_expiry": "Срок действия",
"txt_expiry_month": "Месяц истечения срока действия",
"txt_expiry_year": "Год истечения срока действия",
"txt_failed_to_open_send": "Не удалось открыть отправку",
"txt_favorite": "Любимый",
"txt_favorites": "Избранное",
"txt_duplicates": "Дубликаты",
"txt_field": "Поле",
"txt_field_label": "Метка поля",
"txt_field_label_is_required": "Метка поля обязательна.",
"txt_field_type": "Тип поля",
"txt_field_value": "Значение поля",
"txt_file": "Файл",
"txt_file_name": "Имя файла",
"txt_file_send": "Отправить файл",
"txt_file_size": "Размер файла",
"txt_fingerprint": "Отпечаток пальца",
"txt_firefox_browser": "Браузер Firefox",
"txt_firefox_extension": "Расширение Firefox",
"txt_first_name": "Имя",
"txt_folder": "Папка",
"txt_folder_created": "Папка создана",
"txt_folder_name": "Имя папки",
"txt_folder_name_is_required": "Укажите название папки.",
"txt_folders": "Папки",
"txt_hidden": "Скрытый",
"txt_hide": "Скрыть",
"txt_identity": "идентичность",
"txt_identity_details": "Данные личности",
"txt_ie_browser": "IE-браузер",
"txt_invite_code_optional": "Пригласительный код (не требуется для первой учетной записи; требуется для всех остальных)",
"txt_invite_created": "Приглашение создано",
"txt_invite_revoked": "Приглашение отозвано",
"txt_invite_validity_hours": "Срок действия приглашения (часы)",
"txt_invites": "Приглашает",
"txt_ios": "iOS",
"txt_item": "Товар",
"txt_item_created": "Объект создан",
"txt_item_deleted": "Объект удален.",
"txt_item_history": "История предмета",
"txt_password_history": "История паролей",
"txt_password_updated_value": "Пароль обновлен: {value}",
"txt_item_name_is_required": "Укажите название элемента.",
"txt_item_updated": "Товар обновлен",
"txt_last_edited_value": "Последнее редактирование: {value}",
"txt_last_name": "Фамилия",
"txt_last_seen": "Последний визит",
"txt_license_number": "Номер лицензии",
"txt_link_copied": "Ссылка скопирована",
"txt_linked": "Связано",
"txt_linux_desktop": "Рабочий стол Linux",
"txt_loading": "Загрузка...",
"txt_loading_nodewarden": "Загрузка NodeWarden...",
"txt_jwt_warning_title": "Предупреждение безопасности сервера",
"txt_jwt_warning_subtitle": "Секрет JWT настроен неправильно.",
"txt_jwt_title_missing": "JWT_SECRET отсутствует.",
"txt_jwt_title_too_short": "JWT_SECRET слишком короткий",
"txt_jwt_title_default": "JWT_SECRET использует значение по умолчанию.",
"txt_jwt_reason_missing": "Секрет JWT отсутствует.",
"txt_jwt_reason_default": "Секрет JWT по-прежнему является значением по умолчанию/образцом.",
"txt_jwt_reason_too_short": "Секрет JWT слишком короткий. Минимальная длина — {min}.",
"txt_jwt_how_to_fix_add": "Как добавить JWT_SECRET",
"txt_jwt_how_to_fix_replace": "Как заменить JWT_SECRET",
"txt_jwt_add_step_1": "Используйте 32-значный генератор ниже и скопируйте новый ключ.",
"txt_jwt_add_step_2_prefix": "Перейдите на панель управления Cloudflare -> Рабочие и страницы -> Ваш сервис ->.",
"txt_jwt_add_step_2_suffix": "-> Переменные и секреты -> Добавить",
"txt_jwt_add_step_3": "Сохраните и дождитесь повторного развертывания, затем обновите эту страницу.",
"txt_jwt_replace_step_1": "Используйте приведенный ниже 32-символьный генератор и создайте более надежный ключ (минимум {min} символов).",
"txt_jwt_replace_step_2_prefix": "Перейдите на панель управления Cloudflare -> Рабочие и страницы -> Ваш сервис ->.",
"txt_jwt_replace_step_2_suffix": "-> Переменные и секреты -> Обновить JWT_SECRET",
"txt_jwt_replace_step_3": "Сохраните и дождитесь повторного развертывания, затем обновите эту страницу.",
"txt_jwt_secret_type_label": "Тип:",
"txt_jwt_secret_type_value": "Секрет",
"txt_jwt_secret_name_label": "Имя переменной:",
"txt_jwt_secret_value_label": "Значение:",
"txt_jwt_secret_value_requirement": "Случайная строка, содержащая не менее {min} символов.",
"txt_jwt_what_is": "Что такое JWT?",
"txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует, слишком короткий или все еще использует образец значения, обычное использование экземпляра небезопасно.",
"txt_how_to_fix": "Как исправить",
"txt_jwt_fix_step_1": "Откройте переменные среды развертывания.",
"txt_jwt_fix_step_2": "Если ваш текущий ключ недостаточно случайный, используйте 32-значный генератор ниже.",
"txt_jwt_fix_step_3": "Панель управления Cloudflare -> Рабочие и страницы -> Ваш сервис -> Настройки -> Переменные и секреты, обновите JWT_SECRET.",
"txt_jwt_fix_step_4": "Сохраните и дождитесь повторного развертывания, затем обновите эту страницу для проверки.",
"txt_random_secret_generator": "Генератор случайных секретов",
"txt_copied": "Скопировано",
"txt_log_in": "Войти",
"txt_logging_in": "Вход в систему...",
"txt_log_out": "Выйти",
"txt_lock": "Блокировка",
"txt_menu": "Меню",
"txt_settings": "Настройки",
"txt_back": "Назад",
"txt_login": "Войти",
"txt_login_credentials": "Учетные данные для входа",
"txt_login_failed": "Не удалось войти",
"txt_login_success": "Вход успешный",
"txt_macos_desktop": "macOS Рабочий стол",
"txt_manage_authorized_devices_and_30_day_totp_trusted_sessions": "Управляйте авторизованными устройствами и 30-дневными доверенными сеансами TOTP.",
"txt_manage_device_sessions_and_30_day_totp_trusted_sessions": "Управляйте сеансами устройств и 30-дневными доверенными сеансами TOTP.",
"txt_master_password": "Мастер-пароль",
"txt_master_password_changed_please_login_again": "Мастер-пароль изменен. Пожалуйста, войдите снова.",
"txt_master_password_changed_signing_out_everywhere": "Мастер-пароль изменен. Выходим из всех устройств.",
"txt_master_password_is_required": "Требуется мастер-пароль",
"txt_master_password_is_required_2": "Требуется мастер-пароль.",
"txt_master_password_must_be_at_least_12_chars": "Мастер-пароль должен содержать не менее 12 символов.",
"txt_master_password_reprompt": "Повторный запрос мастер-пароля",
"txt_master_password_reprompt_2": "Повторный запрос мастер-пароля",
"txt_max_access_count": "Максимальное количество доступов",
"txt_middle_name": "Второе имя",
"txt_drag_to_reorder": "Перетащите, чтобы изменить порядок",
"txt_move": "Переместить",
"txt_move_selected_items": "Переместить выбранные элементы",
"txt_moved_selected_items": "Перемещены выбранные элементы",
"txt_name": "Имя",
"txt_name_is_required": "Требуется имя",
"txt_new_password": "Новый пароль",
"txt_nothing_to_copy": "Нечего копировать",
"txt_new_password_must_be_at_least_12_chars": "Новый пароль должен содержать не менее 12 символов.",
"txt_new_passwords_do_not_match": "Новые пароли не совпадают",
"txt_new_send": "Новая отправка",
"txt_next": "Далее",
"txt_no": "Нет",
"txt_no_devices_found": "Устройства не найдены.",
"txt_no_folder": "Нет папки",
"txt_no_items": "Нет товаров",
"txt_no_username": "(Нет имени пользователя)",
"txt_no_verification_codes": "Нет кодов подтверждения",
"txt_no_name": "(Без имени)",
"txt_no_sends": "Нет отправок",
"txt_nodewarden_send": "NodeWarden Отправить",
"txt_not_trusted": "Не доверяю",
"txt_note": "Примечание",
"txt_notes": "Примечания",
"txt_replace_device_name_with_note": "Задайте собственное имя для этого устройства, не меняя тип обнаруженной системы.",
"txt_number": "Номер",
"txt_open": "Открыть",
"txt_opera_browser": "Браузер Опера",
"txt_opera_extension": "Расширение Оперы",
"txt_or": "или",
"txt_options": "Опции",
"txt_passport_number": "Номер паспорта",
"txt_password": "Пароль",
"txt_password_is_already_verified": "Пароль уже подтвержден.",
"txt_passwords_do_not_match": "Пароли не совпадают",
"txt_password_hint": "Подсказка к паролю",
"txt_password_hint_optional": "Подсказка к паролю (необязательно)",
"txt_password_hint_placeholder": "Подсказка, которую поймешь только ты",
"txt_password_hint_register_placeholder": "Эту подсказку можно отобразить непосредственно на странице входа в Интернет.",
"txt_password_hint_register_help": "Эту подсказку можно отобразить непосредственно на странице входа в Интернет. Не указывайте свой главный пароль, код восстановления или что-либо, что может его раскрыть.",
"txt_password_hint_login_help": "Забыли мастер-пароль? Покажите подсказку, которую вы сохранили при регистрации.",
"txt_password_hint_login_note": "Здесь показана только подсказка. Это должно помочь вам запомнить пароль, а не раскрыть его.",
"txt_show_password_hint": "Показать подсказку к паролю",
"txt_hide_password_hint": "Скрыть подсказку к паролю",
"txt_loading_password_hint": "Загрузка подсказки...",
"txt_password_hint_not_set": "Для этого адреса электронной почты подсказка к паролю недоступна.",
"txt_password_hint_load_failed": "Не удалось загрузить подсказку к паролю.",
"txt_password_hint_too_long": "Подсказка к паролю должна содержать не более 120 символов.",
"txt_passkey": "Ключ доступа",
"txt_passkeys": "Ключи доступа",
"txt_passkey_created_at_value": "Создано {value}",
"txt_phone": "Телефон",
"txt_please_input_email_and_password": "Пожалуйста, введите адрес электронной почты и пароль",
"txt_please_input_master_password": "Пожалуйста, введите мастер-пароль",
"txt_please_input_totp_code": "Пожалуйста, введите код TOTP",
"txt_please_select_a_file": "Пожалуйста, выберите файл",
"txt_postal_code": "Почтовый индекс",
"txt_prev": "Предыдущий",
"txt_private_key": "Закрытый ключ",
"txt_profile": "Профиль",
"txt_profile_unavailable": "Профиль недоступен",
"txt_profile_updated": "Профиль обновлен",
"txt_public_key": "Открытый ключ",
"txt_recover_2fa_failed": "Восстановить 2FA не удалось",
"txt_recover_two_step_login": "Восстановить двухэтапный вход",
"txt_recovered_but_auto_login_failed_please_sign_in": "Восстановлено, но не удалось выполнить автоматический вход. Войдите в систему.",
"txt_recovery_code": "Код восстановления",
"txt_recovery_code_and_api_key": "Код восстановления и ключ API",
"txt_recovery_code_copied": "Код восстановления скопирован.",
"txt_recovery_code_is_empty": "Код восстановления пуст",
"txt_recovery_code_loaded": "Код восстановления загружен.",
"txt_api_key": "API-ключ",
"txt_view_api_key": "Посмотреть ключ API",
"txt_rotate_api_key": "Поворот API-ключа",
"txt_api_key_copied": "Ключ API скопирован.",
"txt_api_key_loaded": "Ключ API загружен",
"txt_api_key_rotated": "Ключ API поменян",
"txt_rotate_api_key_confirm": "Поменять ключ API? Текущий ключ немедленно перестанет работать.",
"txt_api_key_is_empty": "Ключ API пуст",
"txt_api_key_dialog_intro": "Ваш ключ API можно использовать для аутентификации с помощью Bitwarden CLI.",
"txt_api_key_warning_body": "Ваш ключ API — это альтернативный механизм аутентификации. Держите это в секрете.",
"txt_oauth_client_credentials": "Учетные данные клиента OAuth 2.0",
"txt_client_id": "Идентификатор клиента",
"txt_client_secret": "Секрет клиента",
"txt_scope": "Область доступа",
"txt_grant_type": "Тип авторизации",
"txt_refresh": "Обновить",
"txt_refresh_in_seconds_s": "Обновить через {seconds} с.",
"txt_regenerate": "Регенерировать",
"txt_registration_succeeded_please_sign_in": "Регистрация прошла успешно. Пожалуйста, войдите в систему.",
"txt_remove": "Удалить",
"txt_remove_device": "Удалить устройство",
"txt_remove_device_2": "Удалить устройство",
"txt_remove_all_devices": "Удалить все устройства",
"txt_remove_all_devices_and_clear_all_2fa_trust": "Удалить все устройства и очистить все доверие 2FA?",
"txt_remove_all_devices_and_sign_out_all_sessions": "Удалить все устройства, отменить все доверительные отношения и выйти из системы на каждом устройстве?",
"txt_remove_device_name_and_clear_its_2fa_trust": "Удалить устройство «{name}» и очистить его доверие 2FA?",
"txt_remove_device_and_sign_out_name": "Удалить устройство «{name}», очистить его доверие и выйти из системы?",
"txt_reveal": "Раскрыть",
"txt_restore": "Восстановить",
"txt_revoke": "Отозвать",
"txt_revoke_30_day_totp_trust_for_name": "Отозвать 30-дневное доверие TOTP для «{name}»?",
"txt_revoke_30_day_totp_trust_from_all_devices": "Отозвать 30-дневное доверие TOTP со всех устройств?",
"txt_revoke_all_trusted": "Отозвать все доверенные",
"txt_revoke_all_trusted_devices": "Отозвать все доверие к устройствам",
"txt_revoke_device_authorization": "Отозвать доверие устройства",
"txt_revoke_device_trust_failed": "Не удалось отозвать доверие устройства.",
"txt_revoke_all_device_trust_failed": "Не удалось отозвать все доверие устройств.",
"txt_revoke_trust": "Отозвать доверие",
"txt_untrust": "Не доверять",
"txt_update_device_note_failed": "Не удалось обновить примечание об устройстве.",
"txt_role": "Роль",
"txt_save": "Сохранить",
"txt_save_profile": "Сохранить профиль",
"txt_save_profile_failed": "Сохранить профиль не удалось",
"txt_search_sends": "Поиск отправляет...",
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
"txt_clear_search": "Очистить поиск",
"txt_clear_search_esc": "Очистить поиск (Esc)",
"txt_sort": "Сортировать",
"txt_sort_last_edited": "Модифицированный",
"txt_sort_created": "Создано",
"txt_sort_name": "А-Я",
"txt_secret_and_code_are_required": "Требуется секрет и код",
"txt_secret_copied": "Секрет скопирован.",
"txt_secure_note": "Безопасная заметка",
"txt_security_code": "Код безопасности",
"txt_security_code_cvv": "Код безопасности (CVV)",
"txt_select_all": "Выбрать все",
"txt_select_duplicate_items": "Выберите дубликаты",
"txt_select_an_item": "Выберите элемент",
"txt_send_created": "Отправить создано",
"txt_send_deleted": "Отправить удалено",
"txt_send_details": "Отправить детали",
"txt_send_file": "отправить файл",
"txt_send_unavailable": "Send недоступна.",
"txt_send_updated": "Отправить обновленное",
"txt_sign_out": "Выйти",
"txt_ssh_key": "SSH-ключ",
"txt_ssn": "ССН",
"txt_state_province": "Штат/Провинция",
"txt_status": "Статус",
"txt_online": "Онлайн",
"txt_offline": "Офлайн",
"txt_submit": "Отправить",
"txt_sync": "Синхронизировать",
"txt_sync_vault": "Синхронизировать хранилище",
"txt_switch_to_dark_mode": "Переключиться в темный режим",
"txt_switch_to_light_mode": "Переключиться в светлый режим",
"txt_dash": "-",
"txt_text": "Текст",
"txt_text_2fa_recovered": "2FA восстановлена",
"txt_text_2fa_recovered_new_recovery_code_code": "2FA восстановлена. Новый код восстановления: {code}.",
"txt_text_3": "------",
"txt_text_is_required": "Требуется текст",
"txt_text_send": "Отправить текст",
"txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically": "Это одноразовый код. После его использования автоматически генерируется новый код.",
"txt_this_item_requires_master_password_every_time_before_viewing_details": "Этот элемент требует мастер-пароль каждый раз перед просмотром деталей.",
"txt_this_link_is_missing_decryption_key": "В этой ссылке отсутствует ключ дешифрования.",
"txt_this_send_is_password_protected": "Эта отправка защищена паролем.",
"txt_title": "Название",
"txt_totp": "ТОТП",
"txt_totp_code": "TOTP-код",
"txt_totp_disabled": "TOTP отключен",
"txt_totp_enabled": "TOTP включен",
"txt_totp_is_enabled_for_this_account": "TOTP включен для этой учетной записи.",
"txt_total_items_count": "{count} товаров",
"txt_totp_secret": "Секрет TOTP",
"txt_totp_verify_failed": "Проверка TOTP не удалась",
"txt_attachments": "Вложения",
"txt_upload_attachments": "Загрузить вложения",
"txt_new_attachments": "Новые вложения",
"txt_marked_for_removal_count": "Вложения {count} будут удалены при сохранении.",
"txt_trash": "мусор",
"txt_trust_this_device_for_30_days": "Доверяйте этому устройству в течение 30 дней.",
"txt_trusted_until": "Доверено до тех пор, пока",
"txt_two_step_verification": "Двухэтапная проверка",
"txt_type": "Тип",
"txt_type_type": "Введите {type}",
"txt_unban": "Разбанить",
"txt_unchecked": "Не отмечено",
"txt_unknown_device": "Неизвестное устройство",
"txt_unlock": "Разблокировать",
"txt_unlocking": "Разблокировка...",
"txt_unlock_details": "Разблокировать детали",
"txt_unlock_failed": "Разблокировать не удалось",
"txt_unlock_failed_master_password_is_incorrect": "Разблокировать не удалось. Мастер-пароль неверен.",
"txt_unlock_item": "Разблокировать предмет",
"txt_unlock_send": "Разблокировать Отправить",
"txt_unlock_vault": "Разблокировать хранилище",
"txt_unlocked": "Разблокировано",
"txt_all_devices_removed": "Все устройства удалены",
"txt_remove_device_failed": "Не удалось удалить устройство.",
"txt_remove_all_devices_failed": "Не удалось удалить все устройства.",
"txt_update_item_failed": "Обновить элемент не удалось",
"txt_update_send_failed": "Send обновления не удалась",
"txt_use_recovery_code": "Использовать код восстановления",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "Используйте одноразовый код восстановления, чтобы отключить двухэтапную проверку.",
"txt_user_deleted": "Пользователь удален",
"txt_user_status_updated": "Статус пользователя обновлен",
"txt_username": "Имя пользователя",
"txt_uri_match_default_base_domain": "По умолчанию (базовый домен)",
"txt_uri_match_base_domain": "Базовый домен",
"txt_uri_match_host": "Хост",
"txt_uri_match_exact": "Точный",
"txt_uri_match_never": "Никогда",
"txt_uri_match_starts_with": "Начинается с",
"txt_uri_match_regular_expression": "Регулярное выражение",
"txt_users": "Пользователи",
"txt_vault_synced": "Сейф синхронизирован",
"txt_verification_code": "Код подтверждения",
"txt_verify": "Проверить",
"txt_warning": "Предупреждение",
"txt_view_recovery_code": "Посмотреть код восстановления",
"txt_web": "Интернет",
"txt_website": "Веб-сайт",
"txt_websites": "Веб-сайты",
"txt_windows_desktop": "Рабочий стол Windows",
"txt_yes": "Да",
"txt_auto_lock": "Автоблокировка",
"txt_auto_lock_description": "Блокируется после бездействия. Closing and reopening the page always starts locked.",
"txt_auto_lock_updated": "Автоблокировка обновлена",
"txt_session_timeout": "Тайм-аут сеанса",
"txt_session_timeout_updated": "Тайм-аут сеанса обновлен.",
"txt_timeout_time": "Время ожидания",
"txt_timeout_action": "Действие по тайм-ауту",
"txt_timeout_action_logout": "Выйти",
"txt_timeout_action_lock": "Блокировка",
"txt_in_planning": "В планировании",
"txt_security_preferences": "Настройки безопасности",
"txt_timeout_1_minute": "1 минута",
"txt_timeout_5_minutes": "5 минут",
"txt_timeout_15_minutes": "15 минут",
"txt_timeout_30_minutes": "30 минут",
"txt_timeout_never": "Никогда",
"txt_lock_after_1_minute": "Через 1 минуту",
"txt_lock_after_5_minutes": "Через 5 минут",
"txt_lock_after_15_minutes": "Через 15 минут",
"txt_lock_after_30_minutes": "Через 30 минут",
"txt_lock_after_never": "Никогда за бездействие",
"txt_import": "Импорт",
"txt_export": "Экспорт",
"txt_format": "Формат",
"txt_source_file": "Исходный файл",
"txt_folder_handling": "Обработка папок",
"txt_import_folder_mode_original": "Исходный путь из файла импорта",
"txt_import_folder_mode_none": "Нет папки",
"txt_import_folder_mode_target": "Одна выбранная папка",
"txt_target_folder": "Целевая папка",
"txt_select_folder_placeholder": "-- Выберите папку --",
"txt_import_vault_data_hint": "Импортируйте данные хранилища в свою текущую учетную запись.",
"txt_export_vault_data_hint": "Экспортируйте данные хранилища из вашей текущей учетной записи.",
"txt_import_export_title": "Импорт и экспорт",
"txt_encrypted_mode": "Зашифрованный режим",
"txt_account_verification": "Проверка аккаунта",
"txt_password_verification": "Проверка пароля",
"txt_file_password": "Пароль файла",
"txt_zip_password_optional": "ZIP-пароль (необязательно)",
"txt_zip_password": "ZIP-пароль",
"txt_close": "Закрыть",
"txt_total": "Итого",
"txt_import_success": "Импорт выполнен успешно.",
"txt_import_success_number_of_items": "Всего импортировано {count} элементов.",
"txt_import_attachment_summary": "Импортировано {imported} из {total} вложений.",
"txt_import_failed_attachments_title": "Вложения {count} не были импортированы:",
"txt_import_attachment_target_not_found": "Соответствующий импортированный элемент не найден.",
"txt_upload_attachment_failed": "Не удалось загрузить вложение.",
"txt_import_file_password_required": "Пожалуйста, введите пароль файла.",
"txt_import_invalid_zip_password": "Неверный пароль ZIP.",
"txt_export_completed": "Экспорт завершен",
"txt_export_failed": "Экспорт не удался",
"txt_import_invalid_password_protected_file": "Неверный файл экспорта, защищенный паролем.",
"txt_import_decrypt_failed": "Не удалось расшифровать файл импорта.",
"txt_import_empty_zip_archive": "Пустой zip-архив.",
"txt_import_no_json_found_in_zip": "В zip-архиве не найдены импортируемые данные JSON.",
"txt_import_data_json_not_found": "data.json не найден в zip-архиве.",
"txt_import_zip_password_required": "Требуется пароль ZIP.",
"txt_import_invalid_json_file": "Неверный файл JSON",
"txt_import_failed": "Импорт не удался",
"txt_import_encrypted_file_title": "Импортировать зашифрованный файл",
"txt_import_encrypted_file_message": "Этот экспорт Bitwarden защищен паролем. Введите пароль файла экспорта, чтобы продолжить.",
"txt_import_encrypted_zip_title": "Импортировать зашифрованный ZIP-файл",
"txt_import_encrypted_zip_message": "Этот ZIP-архив защищен паролем. Введите пароль ZIP, чтобы продолжить.",
"txt_new_type_header": "Новый {type}",
"txt_edit_type_header": "Изменить {type}",
"txt_delete_folder": "Удалить папку",
"txt_delete_folder_message": "Удалить папку «{name}»? Элементы внутри переместятся в папку «Без папки».",
"txt_delete_all_folders": "Удалить все папки",
"txt_delete_all_folders_message": "Удалить все папки? Элементы внутри переместятся в папку «Без папки».",
"txt_folder_not_found": "Папка не найдена",
"txt_folder_deleted": "Папка удалена",
"txt_folder_updated": "Папка обновлена",
"txt_folders_deleted": "Папки удалены",
"txt_update_folder_failed": "Обновить папку не удалось",
"txt_delete_folder_failed": "Удалить папку не удалось",
"txt_delete_all_folders_failed": "Удалить все папки не удалось",
"txt_other": "Другое",
"txt_vault_key_unavailable": "Ключ хранилища недоступен. Пожалуйста, разблокируйте хранилище и повторите попытку.",
"txt_vault_not_ready": "Хранилище еще не готово",
"txt_unsupported_export_format": "Неподдерживаемый формат экспорта",
"txt_invalid_encrypted_export": "Неверный зашифрованный файл экспорта.",
"txt_export_belongs_to_another_account": "Этот зашифрованный экспорт принадлежит другому аккаунту.",
"txt_invalid_argon2id_params": "Неверные параметры Argon2id в файле экспорта.",
"txt_unsupported_kdf_type": "Неподдерживаемый тип kdf: {type}",
"txt_invalid_file_password": "Неверный пароль файла.",
"txt_failed_to_map_attachments": "Не удалось сопоставить {count} вложений с импортированными элементами.",
"txt_role_admin": "Админ",
"txt_role_user": "Пользователь",
"txt_status_active": "Активный",
"txt_status_banned": "Запрещено",
"txt_status_inactive": "Неактивный",
"txt_language": "Язык",
"txt_display_language": "Язык дисплея",
"txt_language_saved_locally": "Этот выбор сохраняется в текущем браузере и применяется при следующей загрузке приложения."
};
export default ru;
+848
View File
@@ -0,0 +1,848 @@
// Complete Simplified Chinese locale. Keep keys and placeholders unchanged.
const zhCN: Record<string, string> = {
"nav_account_settings": "账户设置",
"nav_admin_panel": "用户管理",
"nav_device_management": "设备管理",
"nav_my_vault": "我的密码库",
"nav_sends": "Send",
"nav_backup_strategy": "云端备份",
"nav_import_export": "导入导出",
"backup_strategy_title": "云端备份",
"backup_strategy_under_construction": "正在搭建中",
"import_export_title": "导入导出",
"import_export_under_construction": "正在搭建中",
"txt_backup_export": "导出备份",
"txt_backup_import": "还原",
"txt_backup_include_attachments": "包含附件",
"txt_backup_export_description": "下载一个完整的实例备份 ZIP,手动保管即可。",
"txt_backup_import_description": "上传之前导出的备份 ZIP,并还原到当前实例。",
"txt_backup_exporting": "正在导出...",
"txt_backup_importing": "正在还原...",
"txt_backup_restoring": "正在还原...",
"txt_backup_export_success": "备份已导出",
"txt_backup_import_success_relogin": "备份已还原,请重新登录",
"txt_backup_restore_success_relogin": "备份已还原,请重新登录",
"txt_backup_restore_completed_verified": "备份文件完整性校验已通过。",
"txt_backup_restore_completed_without_checksum": "备份已还原,但文件名中未提供可校验的完整性标记。",
"txt_backup_remote_restore_completed_verified": "远程备份完整性校验已通过。",
"txt_backup_remote_restore_completed_without_checksum": "远程备份已还原,但文件名中未提供可校验的完整性标记。",
"txt_backup_restore_skipped_summary": "{reason},已跳过 {attachments} 个附件",
"txt_backup_restore_skipped_reason_default": "部分文件无法还原",
"txt_backup_export_failed": "备份导出失败",
"txt_backup_import_failed": "备份还原失败",
"txt_backup_restore_failed": "备份还原失败",
"txt_backup_integrity_check_failed": "备份完整性校验失败",
"txt_backup_center_title": "实例备份",
"txt_backup_center_description": "把本地导出和远程自动备份放在一起管理,既方便手动恢复,也能每天自动留一份。",
"txt_backup_restore_note": "还原会覆盖当前实例;如果当前已有数据,系统会要求你确认“清空后还原”。",
"txt_backup_manual": "手动备份",
"txt_backup_manual_description": "现在就导出 ZIP,或者把之前导出的 ZIP 恢复到当前实例。",
"txt_backup_destinations_title": "备份地点",
"txt_backup_destinations_description": "把多个 WebDAV、S3 地点统一放在这里。左侧选一个,右侧编辑和浏览它。",
"txt_backup_recommend_title": "推荐储存库",
"txt_backup_recommend_open_signup": "前往注册",
"txt_backup_recommend_open_signup_aff": "前往注册(含 AFF",
"txt_backup_recommend_open_guide": "查看教程",
"txt_backup_recommend_empty": "暂时没有推荐",
"txt_backup_recommend_referral_label": "推荐码",
"txt_backup_recommend_referral_note": "注册时填写可额外获得 5 GB,作者会收到 2 GB。",
"txt_backup_recommend_infinicloud_summary": "只需邮箱即可注册。免费 20 GB;填写推荐码后总计 25 GB。",
"txt_backup_recommend_infinicloud_step_1": "先用邮箱注册一个 InfiniCLOUD 账号。",
"txt_backup_recommend_infinicloud_step_2_prefix": "进入",
"txt_backup_recommend_infinicloud_step_2_suffix": ",然后开启 Turn on Apps Connection。",
"txt_backup_recommend_infinicloud_step_3": "Connection ID 用作 WebDAV 用户名,Apps Password 用作 WebDAV 密码。",
"txt_backup_recommend_infinicloud_step_4": "在 My Page 最下面的 Referral Bonus 填入推荐码 2HC5E,可额外获得 5 GB。",
"txt_backup_recommend_open_password": "密码设置",
"txt_backup_recommend_open_storage": "打开储存连接",
"txt_backup_recommend_koofr_summary": "只需邮箱即可注册使用。免费 10 GB,并且可以通过 WebDAV 接到 Google Drive、OneDrive、Dropbox。",
"txt_backup_recommend_koofr_password_link": "密码设置",
"txt_backup_recommend_koofr_storage_link": "Storage",
"txt_backup_recommend_koofr_step_1": "先用邮箱注册一个 Koofr 账号。",
"txt_backup_recommend_koofr_step_2_prefix": "打开",
"txt_backup_recommend_koofr_step_2_suffix": ",生成新的应用密码。注册邮箱用作 WebDAV 用户名,应用密码用作 WebDAV 密码。",
"txt_backup_recommend_koofr_step_3": "Koofr 自己的 WebDAV 地址是 https://app.koofr.net/dav/Koofr。",
"txt_backup_recommend_koofr_step_4": "Koofr 最方便的地方,是还能接 Google Drive、OneDrive、Dropbox 这三大云盘;免费用户最多能连接两个。",
"txt_backup_recommend_koofr_step_5_prefix": "打开",
"txt_backup_recommend_koofr_step_5_suffix": ",在左侧栏点击“连接”,选择你要连接的储存即可。",
"txt_backup_recommend_koofr_dav_intro": "连接好储存后,账号和应用密码都不变,只需要切换 WebDAV 地址:",
"txt_backup_recommend_koofr_dav_self": "Koofr",
"txt_backup_recommend_pcloud_summary": "只需邮箱即可注册。免费最高 10 GB,并且自带标准 WebDAV 访问。",
"txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。",
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。",
"txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。",
"txt_backup_add_destination": "新增地点",
"txt_backup_schedule_panel_title": "自动备份计划",
"txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。",
"txt_backup_scheduled_target": "当前计划目标",
"txt_backup_destination_active_badge": "已启用计划",
"txt_backup_destination_idle_badge": "未启用计划",
"txt_backup_destination_last_success": "上次成功:{time}",
"txt_backup_destination_never_run": "还没有成功执行过",
"txt_backup_destination_detail_title": "地点详情",
"txt_backup_destination_detail_note": "",
"txt_backup_destination_name": "地点名称",
"txt_backup_set_scheduled_target": "设为每日备份目标",
"txt_backup_delete_destination": "删除",
"txt_backup_destination_deleted": "备份地点已删除",
"txt_backup_delete_destination_confirm_message": "删除备份地点“{name}”?此操作不可撤销。",
"txt_backup_select_destination": "请先从左侧列表选择一个备份地点",
"txt_backup_remote_save_first": "请先保存这个备份地点,再浏览它的远端备份文件",
"txt_backup_automation": "自动备份",
"txt_backup_automation_description": "选择备份地点,保存连接信息后,系统会按设定时间每天自动上传一份备份。",
"txt_backup_settings_saved": "备份设置已保存",
"txt_backup_settings_save_failed": "备份设置保存失败",
"txt_backup_settings_load_failed": "备份设置加载失败",
"txt_backup_save_settings": "保存设置",
"txt_backup_saving": "正在保存...",
"txt_backup_enable_action": "启用",
"txt_backup_disable_action": "停用",
"txt_backup_run_now": "立即执行远程备份",
"txt_backup_run_manual": "手动执行",
"txt_backup_running_now": "执行中...",
"txt_backup_remote_run_success": "远程备份已完成",
"txt_backup_remote_run_success_verified": "远程备份已完成,且完整性校验已通过。",
"txt_backup_remote_run_failed": "远程备份失败",
"txt_backup_remote_title": "远端备份",
"txt_backup_remote_note": "浏览已保存的备份地点,选择某个备份 ZIP 后可以下载,也可以直接还原。",
"txt_backup_remote_saved_basis": "远端浏览使用的是“已保存”的备份地点配置,不会读取你当前未保存的表单内容。",
"txt_backup_remote_refresh": "刷新",
"txt_backup_remote_root": "根目录",
"txt_backup_remote_up": "上一级",
"txt_backup_remote_open": "打开",
"txt_backup_remote_download": "下载",
"txt_backup_remote_downloading": "下载中...",
"txt_backup_remote_restore": "还原",
"txt_backup_remote_restore_stage_prepare": "正在读取远端备份并检查可恢复内容...",
"txt_backup_remote_restore_stage_replace": "正在清空当前数据并还原远端备份,请稍候...",
"txt_backup_progress_kicker": "备份任务",
"txt_backup_progress_subject": "当前对象:{name}",
"txt_backup_restore_progress_kicker": "还原进度",
"txt_backup_restore_progress_local_title": "正在还原本地备份",
"txt_backup_restore_progress_remote_title": "正在还原远端备份",
"txt_backup_export_progress_title": "正在导出备份",
"txt_backup_remote_run_progress_title": "正在执行远程备份",
"txt_backup_restore_progress_file": "当前文件:{name}",
"txt_backup_restore_progress_elapsed": "已耗时 {seconds} 秒",
"txt_backup_archive_progress_collect_title": "正在收集密码库数据",
"txt_backup_archive_progress_collect_detail": "服务器正在读取数据库表,并整理备份所需的数据内容。",
"txt_backup_archive_progress_collect_with_attachments_detail": "服务器正在读取数据库表,并整理附件元数据与备份内容。",
"txt_backup_archive_progress_package_title": "正在打包备份压缩包",
"txt_backup_archive_progress_package_detail": "服务器正在生成备份 ZIP,并计算文件名校验前缀。",
"txt_backup_archive_progress_package_with_attachments_detail": "服务器正在生成带附件信息的备份 ZIP 元数据,并计算文件名校验前缀。",
"txt_backup_archive_progress_ready_title": "正在准备下载",
"txt_backup_archive_progress_ready_detail": "备份压缩包已经生成,服务器正在把它返回给浏览器。",
"txt_backup_export_progress_fetch_attachments_title": "正在下载附件文件",
"txt_backup_export_progress_fetch_attachments_detail": "浏览器正在读取附件对象,并把它们补入导出备份包。",
"txt_backup_export_progress_rebuild_title": "正在重建导出压缩包",
"txt_backup_export_progress_rebuild_detail": "浏览器正在重建最终 ZIP,并刷新文件名里的校验后缀。",
"txt_backup_export_progress_save_title": "正在保存导出文件",
"txt_backup_export_progress_save_detail": "浏览器正在准备最终的备份文件下载。",
"txt_backup_export_progress_complete_title": "备份导出已完成",
"txt_backup_export_progress_complete_detail": "导出备份已经准备完成。",
"txt_backup_export_progress_failed_title": "备份导出失败",
"txt_backup_export_progress_failed_detail": "导出备份未能完成。",
"txt_backup_remote_run_progress_prepare_title": "正在准备远程备份",
"txt_backup_remote_run_progress_prepare_detail": "服务器正在读取当前备份目标,并准备执行这次远程备份。",
"txt_backup_remote_run_progress_sync_attachments_title": "正在检查附件索引",
"txt_backup_remote_run_progress_sync_attachments_detail": "服务器正在比对附件索引,只会上传缺失或不一致的附件对象。",
"txt_backup_remote_run_progress_sync_attachments_skipped_detail": "当前备份未包含附件,因此跳过附件同步。",
"txt_backup_remote_run_progress_upload_title": "正在上传备份压缩包",
"txt_backup_remote_run_progress_upload_detail": "服务器正在把备份 ZIP 上传到远程备份目标。",
"txt_backup_remote_run_progress_verify_title": "正在校验已上传压缩包",
"txt_backup_remote_run_progress_verify_detail": "服务器正在回读刚上传的 ZIP,并校验它的哈希和大小。",
"txt_backup_remote_run_progress_cleanup_title": "正在清理旧备份",
"txt_backup_remote_run_progress_cleanup_detail": "服务器正在按保留策略清理旧备份文件。",
"txt_backup_remote_run_progress_complete_title": "远程备份已完成",
"txt_backup_remote_run_progress_complete_detail": "远程备份已上传完成,并通过完整性校验。",
"txt_backup_remote_run_progress_failed_title": "远程备份失败",
"txt_backup_remote_run_progress_failed_detail": "远程备份未能完成。",
"txt_backup_restore_progress_local_upload_title": "正在上传备份包",
"txt_backup_restore_progress_local_upload_detail": "已选 ZIP 正在发送到服务器,服务器收到后会开始执行还原。",
"txt_backup_restore_progress_local_shadow_title": "正在创建影子恢复区",
"txt_backup_restore_progress_local_shadow_detail": "服务器正在准备独立的影子数据区,只有校验通过后才会替换正式数据。",
"txt_backup_restore_progress_local_data_title": "正在写入密码库数据",
"txt_backup_restore_progress_local_data_detail": "服务器正在把用户、文件夹、密码条目和相关元数据写入影子表。",
"txt_backup_restore_progress_local_files_title": "正在恢复附件文件",
"txt_backup_restore_progress_local_files_detail": "服务器正在把附件对象写回存储,并剔除无法恢复的附件记录。",
"txt_backup_restore_progress_local_finalize_title": "正在校验并完成切换",
"txt_backup_restore_progress_local_finalize_detail": "服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。",
"txt_backup_restore_progress_remote_fetch_title": "正在读取远端备份包",
"txt_backup_restore_progress_remote_fetch_detail": "服务器正在从远端备份目标下载你选中的备份包。",
"txt_backup_restore_progress_remote_shadow_title": "正在创建影子恢复区",
"txt_backup_restore_progress_remote_shadow_detail": "服务器正在准备独立的影子数据区,只有校验通过后才会替换正式数据。",
"txt_backup_restore_progress_remote_data_title": "正在写入密码库数据",
"txt_backup_restore_progress_remote_data_detail": "服务器正在把用户、文件夹、密码条目和相关元数据写入影子表。",
"txt_backup_restore_progress_remote_files_title": "正在恢复远端附件",
"txt_backup_restore_progress_remote_files_detail": "服务器正在从远端存储读取所需附件,并写回到当前实例的附件存储。",
"txt_backup_restore_progress_remote_finalize_title": "正在校验并完成切换",
"txt_backup_restore_progress_remote_finalize_detail": "服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。",
"txt_backup_remote_loading": "正在读取远端备份...",
"txt_backup_remote_cached_empty": "点击“刷新”后读取",
"txt_backup_remote_empty": "这个目录下还没有备份文件",
"txt_backup_remote_folder": "文件夹",
"txt_backup_remote_unknown_time": "未知时间",
"txt_backup_remote_current_path": "当前目录",
"txt_backup_remote_load_failed": "读取远端备份失败",
"txt_backup_remote_invalid_response": "远端备份响应无效",
"txt_backup_remote_download_failed": "下载远端备份失败",
"txt_backup_remote_delete_success": "远端备份已删除",
"txt_backup_remote_delete_failed": "删除远端备份失败",
"txt_backup_remote_delete_confirm_message": "删除备份文件“{name}”?此操作不可撤销。",
"txt_backup_remote_deleting": "删除中...",
"txt_backup_remote_restore_failed": "还原远端备份失败",
"txt_backup_restore_checksum_warning_title": "备份完整性警告",
"txt_backup_restore_checksum_warning_message": "所选备份文件“{name}”未通过文件名完整性校验。期望前缀为 {expected},实际计算结果为 {actual}。该文件可能不完整或已经损坏。继续还原可能会导入受损数据。",
"txt_backup_remote_restore_checksum_warning_message": "远程备份文件“{name}”未通过文件名完整性校验。期望前缀为 {expected},实际计算结果为 {actual}。该文件可能在上传或存储过程中损坏。继续还原可能会导入受损数据,并可能造成严重后果。",
"txt_backup_restore_checksum_warning_message_fallback": "所选备份文件未通过完整性校验。继续还原可能会导入受损数据。",
"txt_backup_restore_checksum_warning_confirm": "继续还原",
"txt_backup_remote_restore_invalid_response": "远端备份还原响应无效",
"txt_backup_remote_run_invalid_response": "远端备份执行响应无效",
"txt_backup_settings_invalid_response": "备份设置响应无效",
"txt_backup_import_invalid_response": "备份还原响应无效",
"txt_backup_destination": "备份地点",
"txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3",
"txt_backup_recommend_group_webdav": "WebDAV",
"txt_backup_recommend_group_s3": "S3",
"txt_backup_destination_name_default_webdav": "WebDAV {index}",
"txt_backup_destination_name_default_s3": "S3 {index}",
"txt_backup_type": "备份类型",
"txt_backup_destination_reserved": "预留位置",
"txt_backup_time": "备份时间",
"txt_backup_start_time": "开始时间",
"txt_backup_timezone": "时区",
"txt_backup_interval_hours": "每隔",
"txt_backup_interval_hours_suffix": "小时",
"txt_backup_interval_hours_presets": "快捷时间预设",
"txt_backup_frequency": "备份频率",
"txt_backup_frequency_daily": "每天",
"txt_backup_frequency_weekly": "每周",
"txt_backup_frequency_monthly": "每月",
"txt_backup_day_of_week": "星期",
"txt_backup_day_of_month": "日期",
"txt_backup_weekday_monday": "周一",
"txt_backup_weekday_tuesday": "周二",
"txt_backup_weekday_wednesday": "周三",
"txt_backup_weekday_thursday": "周四",
"txt_backup_weekday_friday": "周五",
"txt_backup_weekday_saturday": "周六",
"txt_backup_weekday_sunday": "周日",
"txt_backup_retention_count": "只保留",
"txt_backup_retention_count_suffix": "个",
"txt_backup_retention_count_hint": "留空表示不限,新建备份地点默认保留 30 个",
"txt_backup_destination_include_attachments": "包含附件",
"txt_backup_include_attachments_help_button": "附件备份说明",
"txt_backup_include_attachments_help": "附件会以增量方式保存在远端的 attachments 文件夹中,后续备份通常只上传新增文件。你在本地删除附件时,已经备份到远端的旧文件不会自动删除。恢复时会按需从 attachments 文件夹读取对应附件,找不到的附件会自动跳过。",
"txt_backup_enable_schedule": "启用每日自动备份",
"txt_backup_schedule_note": "Worker 每 5 分钟检查一次计划。会先按你选择的时区和开始时间起跑,再按小时间隔继续执行;到了下一天,会重新从开始时间开始。",
"txt_backup_schedule_disabled": "未启用",
"txt_backup_schedule_status": "计划状态",
"txt_backup_schedule_summary": "从 {time} 开始,每隔 {interval} 小时({timezone}",
"txt_backup_schedule_empty": "还没有启用任何自动备份计划",
"txt_backup_last_success": "上次成功时间",
"txt_backup_last_target": "上次备份位置",
"txt_backup_last_file": "上次备份文件",
"txt_backup_last_error_prefix": "上次错误",
"txt_backup_none_yet": "还没有成功完成过远程备份",
"txt_backup_not_configured": "尚未配置",
"txt_backup_never": "从未",
"txt_backup_unknown_size": "大小未知",
"txt_backup_webdav_url": "WebDAV 服务地址",
"txt_backup_webdav_username": "WebDAV 用户名",
"txt_backup_webdav_password": "WebDAV 密码",
"txt_backup_webdav_path": "远程目录",
"txt_backup_s3_endpoint": "S3 端点",
"txt_backup_s3_bucket": "存储桶",
"txt_backup_s3_region": "区域",
"txt_backup_s3_access_key": "访问密钥",
"txt_backup_s3_secret_key": "秘密密钥",
"txt_backup_s3_path": "远程路径",
"txt_backup_reserved_name": "预留类型名称",
"txt_backup_reserved_notes": "预留备注",
"txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明",
"txt_backup_reserved_hint": "这个位置先预留给后续备份地点。你现在可以先保存备注,但自动上传不会启用。",
"txt_backup_file": "备份文件",
"txt_backup_file_required": "请选择备份文件",
"txt_backup_no_file_selected": "尚未选择备份文件",
"txt_backup_selected_file_name": "已选择文件:{name}",
"txt_backup_replace_confirm_title": "替换当前实例数据",
"txt_backup_replace_confirm_message": "当前实例里已经有数据。确认后,系统会先完成校验与恢复准备,只有在恢复成功后才会用所选备份替换当前实例数据。是否继续?",
"txt_backup_clear_and_import": "替换并导入",
"txt_backup_clear_and_restore": "替换并还原",
"txt_access_count": "访问次数",
"txt_accessed_count_times": "已访问 {count} 次",
"txt_actions": "操作",
"txt_add": "新增",
"txt_add_field": "添加字段",
"txt_add_website": "添加网站",
"txt_added": "已添加",
"txt_additional_options": "附加选项",
"txt_address": "地址",
"txt_address_1": "地址 1",
"txt_address_2": "地址 2",
"txt_address_3": "地址 3",
"txt_all_device_authorizations_revoked": "已撤销所有设备信任",
"txt_all_invites_deleted": "已删除所有邀请码",
"txt_all_items": "所有项目",
"txt_all_sends": "所有发送",
"txt_android": "安卓",
"txt_are_you_sure_you_want_to_delete_count_selected_items": "确认删除所选的 {count} 个项目?",
"txt_are_you_sure_you_want_to_delete_count_selected_items_permanently": "确认永久删除所选的 {count} 个项目?",
"txt_are_you_sure_you_want_to_delete_this_item": "确认删除此项目?",
"txt_are_you_sure_you_want_to_delete_this_passkey": "确认删除这个通行密钥?",
"txt_are_you_sure_you_want_to_log_out": "确认要退出登录吗?",
"txt_authenticator_key": "验证器密钥",
"txt_authorized_devices": "已授权设备",
"txt_auto_copy_link_after_save": "保存后自动复制链接",
"txt_autofill_options": "自动填充选项",
"txt_back_to_login": "返回登录",
"txt_ban": "封禁",
"txt_boolean": "布尔",
"txt_brand": "品牌",
"txt_bulk_delete_failed": "批量删除失败",
"txt_bulk_permanent_delete_failed": "批量永久删除失败",
"txt_bulk_restore_failed": "批量恢复失败",
"txt_bulk_delete_sends_failed": "批量删除发送失败",
"txt_bulk_move_failed": "批量移动失败",
"txt_cancel": "取消",
"txt_continue": "继续",
"txt_card": "银行卡",
"txt_card_details": "银行卡详情",
"txt_cardholder_name": "持卡人姓名",
"txt_change_master_password": "修改主密码",
"txt_change_password": "修改密码",
"txt_change_password_failed": "修改密码失败",
"txt_change_password_confirm_and_sign_out_all_devices": "修改主密码后会强制退出所有设备,包括当前网页端。确认继续吗",
"txt_copy_failed": "复制失败",
"txt_checked": "已勾选",
"txt_choose_destination_folder": "选择目标文件夹。",
"txt_chrome_browser": "Chrome 浏览器",
"txt_chrome_extension": "Chrome 扩展",
"txt_city_town": "城市 / 城镇",
"txt_code": "代码",
"txt_company": "公司",
"txt_configure_custom_field_values": "配置自定义字段值。",
"txt_confirm": "确认",
"txt_confirm_master_password": "确认主密码",
"txt_confirm_password": "确认密码",
"txt_copy": "复制",
"txt_code_copied": "验证码已复制",
"txt_copy_code": "复制代码",
"txt_copy_link": "复制链接",
"txt_copy_secret": "复制密钥",
"txt_country": "国家",
"txt_create": "创建",
"txt_create_account": "创建账户",
"txt_registering": "正在注册...",
"txt_create_folder": "创建文件夹",
"txt_create_folder_failed": "创建文件夹失败",
"txt_create_item_failed": "创建项目失败",
"txt_create_send_failed": "创建发送失败",
"txt_create_timed_invite": "创建时效邀请码",
"txt_created_value": "创建于:{value}",
"txt_current_new_password_is_required": "需要输入当前密码和新密码",
"txt_current_password": "当前密码",
"txt_custom_fields": "自定义字段",
"txt_decrypt_failed": "(解密失败)",
"txt_decrypt_failed_2": "解密失败",
"txt_delete": "删除",
"txt_delete_all": "全部删除",
"txt_delete_all_invite_codes_active_inactive": "删除所有邀请码(有效/无效)?",
"txt_delete_all_invites": "删除所有邀请码",
"txt_delete_item": "删除项目",
"txt_delete_passkey": "删除通行密钥",
"txt_delete_item_failed": "删除项目失败",
"txt_delete_permanently": "永久删除",
"txt_archive": "归档",
"txt_archive_item": "归档项目",
"txt_archive_item_message": "归档后,此项目将被排除在一般搜索结果和自动填充建议之外。",
"txt_archive_selected_items": "归档项目",
"txt_archive_selected_items_message": "归档后,所选的 {count} 个项目将被排除在一般搜索结果和自动填充建议之外。",
"txt_archived": "已归档",
"txt_archive_selected": "归档",
"txt_item_archived": "项目已归档",
"txt_item_unarchived": "项目已取消归档",
"txt_archived_selected_items": "已归档所选项目",
"txt_unarchived_selected_items": "已取消归档所选项目",
"txt_archive_item_failed": "归档项目失败",
"txt_unarchive_item_failed": "取消归档项目失败",
"txt_bulk_archive_failed": "批量归档失败",
"txt_bulk_unarchive_failed": "批量取消归档失败",
"txt_unarchive": "取消归档",
"txt_delete_selected": "删除",
"txt_delete_selected_items": "删除所选项目",
"txt_delete_selected_items_permanently": "Delete Selected Items Permanently",
"txt_delete_send_failed": "删除发送失败",
"txt_delete_this_user_and_all_user_data": "删除此用户及其所有数据?",
"txt_delete_user": "删除用户",
"txt_deleted_selected_items": "已删除所选项目",
"txt_deleted_selected_items_permanently": "已永久删除所选项目",
"txt_restored_selected_items": "已恢复所选项目",
"txt_deleted_selected_sends": "已删除所选发送",
"txt_deletion_date": "删除日期",
"txt_deletion_days": "删除天数",
"txt_device": "设备",
"txt_device_authorization_revoked": "设备信任已撤销",
"txt_device_management": "设备管理",
"txt_device_note": "备注",
"txt_device_note_required": "设备名称不能为空",
"txt_device_note_updated": "设备名称已更新",
"txt_device_removed": "设备已移除",
"txt_load_devices_failed": "加载设备失败",
"txt_disable_this_send": "禁用此发送",
"txt_disable_totp": "停用 TOTP",
"txt_disable_totp_failed": "禁用 TOTP 失败",
"txt_download": "下载",
"txt_downloading": "下载中...",
"txt_downloading_percent": "下载中 {percent}%",
"txt_attachment": "附件",
"txt_uploading_attachment_named": "正在上传 {name}...",
"txt_uploading_attachment_named_percent": "正在上传 {name} {percent}%",
"txt_uploading_file_named": "正在上传 {name}...",
"txt_uploading_file_named_percent": "正在上传 {name} {percent}%",
"txt_download_failed": "下载失败",
"txt_edge_browser": "Edge 浏览器",
"txt_edge_extension": "Edge 扩展",
"txt_edit": "编辑",
"txt_edit_send": "编辑发送",
"txt_email": "邮箱",
"txt_email_password_and_recovery_code_are_required": "需要输入邮箱、密码和恢复代码",
"txt_enable_totp": "启用 TOTP",
"txt_enable_totp_failed": "启用 TOTP 失败",
"txt_enabled": "已启用",
"txt_encrypted_file": "加密文件",
"txt_encrypted_file_2": "加密文件",
"txt_enter_a_folder_name": "请输入文件夹名称",
"txt_enter_master_password_to_disable_two_step_verification": "输入主密码以禁用两步验证",
"txt_enter_master_password_to_continue": "输入主密码以继续",
"txt_enter_master_password_to_view_this_item": "输入主密码以查看此项目",
"txt_expiration_date": "过期日期",
"txt_expiration_days_0_never": "过期天数(0 表示不过期)",
"txt_expires_at": "过期时间",
"txt_expires_at_value": "过期于:{value}",
"txt_expiry": "有效期",
"txt_expiry_month": "有效期月",
"txt_expiry_year": "有效期年",
"txt_failed_to_open_send": "打开发送失败",
"txt_favorite": "收藏",
"txt_favorites": "收藏",
"txt_duplicates": "重复项",
"txt_field": "字段",
"txt_field_label": "字段标签",
"txt_field_label_is_required": "字段标签不能为空",
"txt_field_type": "字段类型",
"txt_field_value": "字段值",
"txt_file": "文件",
"txt_file_name": "文件名",
"txt_file_send": "文件发送",
"txt_file_size": "文件大小",
"txt_fingerprint": "指纹",
"txt_firefox_browser": "Firefox 浏览器",
"txt_firefox_extension": "Firefox 扩展",
"txt_first_name": "名",
"txt_folder": "文件夹",
"txt_folder_created": "文件夹已创建",
"txt_folder_name": "文件夹名称",
"txt_folder_name_is_required": "文件夹名称不能为空",
"txt_folders": "文件夹",
"txt_hidden": "隐藏",
"txt_hide": "隐藏",
"txt_identity": "身份",
"txt_identity_details": "身份详情",
"txt_ie_browser": "IE 浏览器",
"txt_invite_code_optional": "邀请码(首位注册者无需填写,其他人必填)",
"txt_invite_created": "邀请码已创建",
"txt_invite_revoked": "邀请码已撤销",
"txt_invite_validity_hours": "邀请码有效期(小时)",
"txt_invites": "邀请码",
"txt_ios": "iOS",
"txt_item": "项目",
"txt_item_created": "项目已创建",
"txt_item_deleted": "项目已删除",
"txt_item_history": "项目历史",
"txt_password_history": "密码历史记录",
"txt_password_updated_value": "密码更新于: {value}",
"txt_item_name_is_required": "项目名称不能为空",
"txt_item_updated": "项目已更新",
"txt_last_edited_value": "最后编辑:{value}",
"txt_last_name": "姓",
"txt_last_seen": "最后在线",
"txt_license_number": "证件号",
"txt_link_copied": "链接已复制",
"txt_linked": "已关联",
"txt_linux_desktop": "Linux 桌面端",
"txt_loading": "加载中...",
"txt_loading_nodewarden": "正在加载 NodeWarden...",
"txt_jwt_warning_title": "JWT_SECRET 配置警告",
"txt_jwt_warning_subtitle": "JWT 密钥当前不安全,请先修复后再继续。",
"txt_jwt_title_missing": "未检测到 JWT_SECRET",
"txt_jwt_title_too_short": "JWT_SECRET 长度过短",
"txt_jwt_title_default": "JWT_SECRET使用默认值",
"txt_jwt_reason_missing": "未检测到 JWT_SECRET。",
"txt_jwt_reason_default": "JWT_SECRET 仍在使用默认示例值。",
"txt_jwt_reason_too_short": "JWT_SECRET 长度过短,至少需要 {min} 位。",
"txt_jwt_how_to_fix_add": "处理步骤(添加 JWT_SECRET",
"txt_jwt_how_to_fix_replace": "处理步骤(更换 JWT_SECRET",
"txt_jwt_add_step_1": "使用下方 32 位随机生成器,复制一个新密钥。",
"txt_jwt_add_step_2_prefix": "到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> ",
"txt_jwt_add_step_2_suffix": " -> 变量和机密 -> 新增",
"txt_jwt_add_step_3": "保存并等待重新部署完成,然后刷新本页确认。",
"txt_jwt_replace_step_1": "使用下方 32 位随机生成器,生成更强的密钥(至少 {min} 位)。",
"txt_jwt_replace_step_2_prefix": "到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> ",
"txt_jwt_replace_step_2_suffix": " -> 变量和机密 -> 更新 JWT_SECRET",
"txt_jwt_replace_step_3": "保存并等待重新部署完成,然后刷新本页确认。",
"txt_jwt_secret_type_label": "类型:",
"txt_jwt_secret_type_value": "密钥",
"txt_jwt_secret_name_label": "变量名称:",
"txt_jwt_secret_value_label": "值:",
"txt_jwt_secret_value_requirement": "最低 {min} 位随机字符",
"txt_jwt_what_is": "JWT 是什么",
"txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失、过短,或者仍然使用示例值,实例就不能安全地正常使用。",
"txt_how_to_fix": "处理步骤(添加 / 更换)",
"txt_jwt_fix_step_1": "你可以继续下一步,不影响使用。",
"txt_jwt_fix_step_2": "如果当前密钥不是强随机值,建议使用下方 32 位生成器。",
"txt_jwt_fix_step_3": "到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> 设置 -> 变量和机密,更新 JWT_SECRET。",
"txt_jwt_fix_step_4": "保存并等待重新部署完成,然后刷新本页确认。",
"txt_random_secret_generator": "随机密钥生成器",
"txt_copied": "已复制",
"txt_log_in": "登录",
"txt_logging_in": "正在登录...",
"txt_log_out": "退出",
"txt_lock": "锁定",
"txt_menu": "菜单",
"txt_settings": "设置",
"txt_back": "返回",
"txt_login": "登录",
"txt_login_credentials": "登录信息",
"txt_login_failed": "登录失败",
"txt_login_success": "登录成功",
"txt_macos_desktop": "macOS 桌面端",
"txt_manage_authorized_devices_and_30_day_totp_trusted_sessions": "管理已授权设备和 30 天 TOTP 受信会话。",
"txt_manage_device_sessions_and_30_day_totp_trusted_sessions": "管理设备会话和 30 天 TOTP 受信状态。",
"txt_master_password": "主密码",
"txt_master_password_changed_please_login_again": "主密码已修改,请重新登录",
"txt_master_password_changed_signing_out_everywhere": "主密码已修改,正在退出所有设备",
"txt_master_password_is_required": "主密码不能为空",
"txt_master_password_is_required_2": "请输入主密码",
"txt_master_password_must_be_at_least_12_chars": "主密码至少需要 12 个字符",
"txt_master_password_reprompt": "主密码二次确认",
"txt_master_password_reprompt_2": "主密码二次确认",
"txt_max_access_count": "最大访问次数",
"txt_middle_name": "中间名",
"txt_drag_to_reorder": "拖动调整顺序",
"txt_move": "移动",
"txt_move_selected_items": "移动所选项目",
"txt_moved_selected_items": "已移动所选项目",
"txt_name": "名称",
"txt_name_is_required": "名称不能为空",
"txt_new_password": "新密码",
"txt_nothing_to_copy": "没有可复制的内容",
"txt_new_password_must_be_at_least_12_chars": "新密码至少需要 12 个字符",
"txt_new_passwords_do_not_match": "两次输入的新密码不一致",
"txt_new_send": "新建发送",
"txt_next": "下一页",
"txt_no": "否",
"txt_no_devices_found": "未找到设备",
"txt_no_folder": "无文件夹",
"txt_no_items": "没有项目",
"txt_no_username": "无用户名",
"txt_no_verification_codes": "没有验证码",
"txt_no_name": "(无名称)",
"txt_no_sends": "没有发送",
"txt_nodewarden_send": "NodeWarden 发送",
"txt_not_trusted": "未信任",
"txt_note": "笔记",
"txt_notes": "备注",
"txt_replace_device_name_with_note": "为这台设备设置自定义名称,不会改变系统识别到的设备类型。",
"txt_number": "数字",
"txt_open": "打开",
"txt_opera_browser": "Opera 浏览器",
"txt_opera_extension": "Opera 扩展",
"txt_or": "或",
"txt_options": "选项",
"txt_passport_number": "护照号",
"txt_password": "密码",
"txt_password_is_already_verified": "密码已验证",
"txt_passwords_do_not_match": "两次输入的密码不一致",
"txt_password_hint": "密码提示",
"txt_password_hint_optional": "密码提示(可选)",
"txt_password_hint_placeholder": "写一句只有你自己看得懂的线索",
"txt_password_hint_register_placeholder": "这个提示可以在网页登录页直接显示。",
"txt_password_hint_register_help": "这个提示可以在网页登录页直接显示。不要填写主密码、恢复代码,或任何能直接暴露密码的信息。",
"txt_password_hint_login_help": "忘记主密码时,可以查看注册时保存的提示。",
"txt_password_hint_login_note": "这里只会显示提示语,不会显示你的主密码本身。",
"txt_show_password_hint": "查看密码提示",
"txt_hide_password_hint": "隐藏密码提示",
"txt_loading_password_hint": "正在加载提示...",
"txt_password_hint_not_set": "这个邮箱没有可显示的密码提示。",
"txt_password_hint_load_failed": "加载密码提示失败",
"txt_password_hint_too_long": "密码提示最多只能输入 120 个字符",
"txt_passkey": "通行密钥",
"txt_passkeys": "通行密钥",
"txt_passkey_created_at_value": "创建于 {value}",
"txt_phone": "电话",
"txt_please_input_email_and_password": "请输入邮箱和密码",
"txt_please_input_master_password": "请输入主密码",
"txt_please_input_totp_code": "请输入 TOTP 验证码",
"txt_please_select_a_file": "请选择文件",
"txt_postal_code": "邮政编码",
"txt_prev": "上一页",
"txt_private_key": "私钥",
"txt_profile": "资料",
"txt_profile_unavailable": "资料不可用",
"txt_profile_updated": "资料已更新",
"txt_public_key": "公钥",
"txt_recover_2fa_failed": "恢复 2FA 失败",
"txt_recover_two_step_login": "恢复两步登录",
"txt_recovered_but_auto_login_failed_please_sign_in": "已恢复,但自动登录失败,请手动登录",
"txt_recovery_code": "恢复代码",
"txt_recovery_code_and_api_key": "恢复代码和 API 密钥",
"txt_recovery_code_copied": "恢复代码已复制",
"txt_recovery_code_is_empty": "恢复代码为空",
"txt_recovery_code_loaded": "恢复代码已加载",
"txt_api_key": "API 密钥",
"txt_view_api_key": "查看 API 密钥",
"txt_rotate_api_key": "轮换 API 密钥",
"txt_api_key_copied": "API 密钥已复制",
"txt_api_key_loaded": "API 密钥已加载",
"txt_api_key_rotated": "API 密钥已轮换",
"txt_rotate_api_key_confirm": "轮换 API 密钥?当前密钥将立即失效。",
"txt_api_key_is_empty": "API 密钥为空",
"txt_api_key_dialog_intro": "您的 API 密钥可用于在 Bitwarden CLI 中进行身份验证。",
"txt_api_key_warning_body": "您的 API 密钥是一种替代身份验证机制。请严格保密。",
"txt_oauth_client_credentials": "OAuth 2.0 客户端凭据",
"txt_client_id": "客户端 ID",
"txt_client_secret": "客户端密钥",
"txt_scope": "权限范围",
"txt_grant_type": "授权类型",
"txt_refresh": "刷新",
"txt_refresh_in_seconds_s": "{seconds} 秒后刷新",
"txt_regenerate": "重新生成",
"txt_registration_succeeded_please_sign_in": "注册成功,请登录",
"txt_remove": "移除",
"txt_remove_device": "移除设备",
"txt_remove_device_2": "移除设备",
"txt_remove_all_devices": "移除所有设备",
"txt_remove_all_devices_and_clear_all_2fa_trust": "确认移除所有设备并清除全部 2FA 信任吗?",
"txt_remove_all_devices_and_sign_out_all_sessions": "确认移除所有设备、清除全部信任,并让所有设备重新登录吗?",
"txt_remove_device_name_and_clear_its_2fa_trust": "确认移除设备“{name}”并清除其 2FA 信任吗?",
"txt_remove_device_and_sign_out_name": "确认移除设备“{name}”,清除其信任,并让它重新登录吗?",
"txt_reveal": "显示",
"txt_restore": "恢复",
"txt_revoke": "撤销",
"txt_revoke_30_day_totp_trust_for_name": "确认撤销“{name}”的 30 天 TOTP 信任吗?",
"txt_revoke_30_day_totp_trust_from_all_devices": "确认撤销所有设备的 30 天 TOTP 信任吗?",
"txt_revoke_all_trusted": "撤销全部受信任设备",
"txt_revoke_all_trusted_devices": "撤销所有设备信任",
"txt_revoke_device_authorization": "撤销设备信任",
"txt_revoke_device_trust_failed": "撤销设备信任失败",
"txt_revoke_all_device_trust_failed": "撤销所有设备信任失败",
"txt_revoke_trust": "撤销信任",
"txt_untrust": "不信任",
"txt_update_device_note_failed": "更新设备备注失败",
"txt_role": "角色",
"txt_save": "保存",
"txt_save_profile": "保存资料",
"txt_save_profile_failed": "保存资料失败",
"txt_search_sends": "搜索发送...",
"txt_search_your_secure_vault": "搜索你的密码库...",
"txt_clear_search": "清空搜索",
"txt_clear_search_esc": "清空搜索(Esc",
"txt_sort": "排序",
"txt_sort_last_edited": "最近修改",
"txt_sort_created": "最近创建",
"txt_sort_name": "A-Z",
"txt_secret_and_code_are_required": "密钥和代码不能为空",
"txt_secret_copied": "密钥已复制",
"txt_secure_note": "安全笔记",
"txt_security_code": "安全码",
"txt_security_code_cvv": "安全码 (CVV)",
"txt_select_all": "全选",
"txt_select_duplicate_items": "选择重复项",
"txt_select_an_item": "请选择一个项目",
"txt_send_created": "发送已创建",
"txt_send_deleted": "发送已删除",
"txt_send_details": "发送详情",
"txt_send_file": "发送文件",
"txt_send_unavailable": "发送不可用。",
"txt_send_updated": "发送已更新",
"txt_sign_out": "退出登录",
"txt_ssh_key": "SSH 密钥",
"txt_ssn": "社保号",
"txt_state_province": "省 / 州",
"txt_status": "状态",
"txt_online": "在线",
"txt_offline": "离线",
"txt_submit": "提交",
"txt_sync": "同步",
"txt_sync_vault": "同步",
"txt_switch_to_dark_mode": "切换到暗黑模式",
"txt_switch_to_light_mode": "切换到明亮模式",
"txt_dash": "-",
"txt_text": "文本",
"txt_text_2fa_recovered": "2FA 已恢复",
"txt_text_2fa_recovered_new_recovery_code_code": "2FA 已恢复,新的恢复代码:{code}",
"txt_text_3": "------",
"txt_text_is_required": "文本不能为空",
"txt_text_send": "文本发送",
"txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically": "这是一次性恢复代码,使用后将自动生成新的恢复代码。",
"txt_this_item_requires_master_password_every_time_before_viewing_details": "每次查看详情前均需输入主密码",
"txt_this_link_is_missing_decryption_key": "此链接缺少解密密钥",
"txt_this_send_is_password_protected": "此发送受密码保护",
"txt_title": "称谓",
"txt_totp": "TOTP",
"txt_totp_code": "TOTP 验证码",
"txt_totp_disabled": "TOTP 已禁用",
"txt_totp_enabled": "TOTP 已启用",
"txt_totp_is_enabled_for_this_account": "此账户已启用 TOTP。",
"txt_total_items_count": "共 {count} 项",
"txt_totp_secret": "TOTP 密钥",
"txt_totp_verify_failed": "TOTP 验证失败",
"txt_attachments": "附件",
"txt_upload_attachments": "上传附件",
"txt_new_attachments": "待上传附件",
"txt_marked_for_removal_count": "保存后将删除 {count} 个附件",
"txt_trash": "回收站",
"txt_trust_this_device_for_30_days": "信任此设备 30 天",
"txt_trusted_until": "信任至",
"txt_two_step_verification": "两步验证",
"txt_type": "类型",
"txt_type_type": "类型 {type}",
"txt_unban": "解封",
"txt_unchecked": "未勾选",
"txt_unknown_device": "未知设备",
"txt_unlock": "解锁",
"txt_unlocking": "正在解锁...",
"txt_unlock_details": "解锁详情",
"txt_unlock_failed": "解锁失败",
"txt_unlock_failed_master_password_is_incorrect": "解锁失败,主密码不正确。",
"txt_unlock_item": "解锁项目",
"txt_unlock_send": "解锁发送",
"txt_unlock_vault": "解锁密码库",
"txt_unlocked": "已解锁",
"txt_all_devices_removed": "已移除所有设备",
"txt_remove_device_failed": "移除设备失败",
"txt_remove_all_devices_failed": "移除所有设备失败",
"txt_update_item_failed": "更新项目失败",
"txt_update_send_failed": "更新发送失败",
"txt_use_recovery_code": "使用恢复代码",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "使用一次性恢复代码禁用两步验证。",
"txt_user_deleted": "用户已删除",
"txt_user_status_updated": "用户状态已更新",
"txt_username": "用户名",
"txt_uri_match_default_base_domain": "默认(基础域名)",
"txt_uri_match_base_domain": "基础域名",
"txt_uri_match_host": "主机",
"txt_uri_match_exact": "精确",
"txt_uri_match_never": "从不",
"txt_uri_match_starts_with": "开始于",
"txt_uri_match_regular_expression": "正则表达式",
"txt_users": "用户",
"txt_vault_synced": "密码库已同步",
"txt_verification_code": "验证码",
"txt_verify": "验证",
"txt_warning": "警告",
"txt_view_recovery_code": "查看恢复代码",
"txt_web": "网页",
"txt_website": "网站",
"txt_websites": "网站",
"txt_windows_desktop": "Windows 桌面端",
"txt_yes": "是",
"txt_auto_lock": "会话超时",
"txt_auto_lock_description": "页面闲置后执行会话超时动作;关闭页面或浏览器后再次打开始终进入锁定页。",
"txt_auto_lock_updated": "会话超时已更新",
"txt_session_timeout": "会话超时",
"txt_session_timeout_updated": "会话超时已更新",
"txt_timeout_time": "超时时间",
"txt_timeout_action": "超时动作",
"txt_timeout_action_logout": "注销",
"txt_timeout_action_lock": "锁定",
"txt_in_planning": "构思中",
"txt_security_preferences": "安全偏好",
"txt_timeout_1_minute": "1 分钟",
"txt_timeout_5_minutes": "5 分钟",
"txt_timeout_15_minutes": "15 分钟",
"txt_timeout_30_minutes": "30 分钟",
"txt_timeout_never": "从不",
"txt_lock_after_1_minute": "闲置 1 分钟后",
"txt_lock_after_5_minutes": "闲置 5 分钟后",
"txt_lock_after_15_minutes": "闲置 15 分钟后",
"txt_lock_after_30_minutes": "闲置 30 分钟后",
"txt_lock_after_never": "不因闲置锁定",
"txt_import": "导入",
"txt_export": "导出",
"txt_format": "格式",
"txt_source_file": "源文件",
"txt_folder_handling": "文件夹处理",
"txt_import_folder_mode_original": "保留导入文件中的原始路径",
"txt_import_folder_mode_none": "不使用文件夹",
"txt_import_folder_mode_target": "导入到指定文件夹",
"txt_target_folder": "目标文件夹",
"txt_select_folder_placeholder": "-- 选择文件夹 --",
"txt_import_vault_data_hint": "将数据导入到当前账号。",
"txt_export_vault_data_hint": "从当前账号导出数据。",
"txt_import_export_title": "导入导出",
"txt_encrypted_mode": "加密方式",
"txt_account_verification": "账号验证",
"txt_password_verification": "密码验证",
"txt_file_password": "文件密码",
"txt_zip_password_optional": "ZIP 密码(可选)",
"txt_zip_password": "ZIP 密码",
"txt_close": "关闭",
"txt_total": "总计",
"txt_import_success": "数据导入成功",
"txt_import_success_number_of_items": "一共导入了 {count} 个项目。",
"txt_import_attachment_summary": "附件已导入 {imported}/{total} 个。",
"txt_import_failed_attachments_title": "以下 {count} 个附件未导入:",
"txt_import_attachment_target_not_found": "没有找到对应的导入项目。",
"txt_upload_attachment_failed": "附件上传失败。",
"txt_import_file_password_required": "请输入文件密码。",
"txt_import_invalid_zip_password": "ZIP 密码错误。",
"txt_export_completed": "导出完成",
"txt_export_failed": "导出失败",
"txt_import_invalid_password_protected_file": "密码保护导出文件格式无效。",
"txt_import_decrypt_failed": "导入文件解密失败。",
"txt_import_empty_zip_archive": "ZIP 压缩包为空。",
"txt_import_no_json_found_in_zip": "ZIP 内未找到可导入的 JSON 数据。",
"txt_import_data_json_not_found": "ZIP 内未找到 data.json。",
"txt_import_zip_password_required": "该 ZIP 需要密码。",
"txt_import_invalid_json_file": "JSON 文件无效",
"txt_import_failed": "导入失败",
"txt_import_encrypted_file_title": "导入加密文件",
"txt_import_encrypted_file_message": "该 Bitwarden 导出文件已加密,请输入文件密码继续。",
"txt_import_encrypted_zip_title": "导入加密 ZIP",
"txt_import_encrypted_zip_message": "该 ZIP 压缩包已加密,请输入 ZIP 密码继续。",
"txt_new_type_header": "新建{type}",
"txt_edit_type_header": "编辑{type}",
"txt_delete_folder": "删除文件夹",
"txt_delete_folder_message": "删除文件夹「{name}」?其中的项目将移至无文件夹。",
"txt_delete_all_folders": "删除全部文件夹",
"txt_delete_all_folders_message": "确认删除全部文件夹吗?其中的项目将移至无文件夹。",
"txt_folder_not_found": "文件夹不存在",
"txt_folder_deleted": "文件夹已删除",
"txt_folder_updated": "文件夹已重命名",
"txt_folders_deleted": "文件夹已删除",
"txt_update_folder_failed": "重命名文件夹失败",
"txt_delete_folder_failed": "删除文件夹失败",
"txt_delete_all_folders_failed": "删除全部文件夹失败",
"txt_other": "其他",
"txt_vault_key_unavailable": "账户密钥不可用,请先解锁密码库后重试。",
"txt_vault_not_ready": "密码库数据尚未就绪",
"txt_unsupported_export_format": "不支持的导出格式",
"txt_invalid_encrypted_export": "加密导出文件无效。",
"txt_export_belongs_to_another_account": "此加密导出文件属于另一个账号。",
"txt_invalid_argon2id_params": "导出文件中的 Argon2id 参数无效。",
"txt_unsupported_kdf_type": "不支持的 KDF 类型:{type}",
"txt_invalid_file_password": "文件密码错误。",
"txt_failed_to_map_attachments": "无法将 {count} 个附件匹配到导入项目。",
"txt_role_admin": "管理员",
"txt_role_user": "用户",
"txt_status_active": "正常",
"txt_status_banned": "已封禁",
"txt_status_inactive": "未激活",
"txt_language": "语言",
"txt_display_language": "显示语言",
"txt_language_saved_locally": "此偏好会保存在当前浏览器中,下次打开应用前就会生效。"
};
export default zhCN;
+848
View File
@@ -0,0 +1,848 @@
// Complete Traditional Chinese locale generated from zh-CN with OpenCC. Keep keys and placeholders unchanged.
const zhTW: Record<string, string> = {
"nav_account_settings": "賬戶設置",
"nav_admin_panel": "用戶管理",
"nav_device_management": "設備管理",
"nav_my_vault": "我的密碼庫",
"nav_sends": "Send",
"nav_backup_strategy": "雲端備份",
"nav_import_export": "導入導出",
"backup_strategy_title": "雲端備份",
"backup_strategy_under_construction": "正在搭建中",
"import_export_title": "導入導出",
"import_export_under_construction": "正在搭建中",
"txt_backup_export": "導出備份",
"txt_backup_import": "還原",
"txt_backup_include_attachments": "包含附件",
"txt_backup_export_description": "下載一個完整的實例備份 ZIP,手動保管即可。",
"txt_backup_import_description": "上傳之前導出的備份 ZIP,並還原到當前實例。",
"txt_backup_exporting": "正在導出...",
"txt_backup_importing": "正在還原...",
"txt_backup_restoring": "正在還原...",
"txt_backup_export_success": "備份已導出",
"txt_backup_import_success_relogin": "備份已還原,請重新登錄",
"txt_backup_restore_success_relogin": "備份已還原,請重新登錄",
"txt_backup_restore_completed_verified": "備份文件完整性校驗已通過。",
"txt_backup_restore_completed_without_checksum": "備份已還原,但文件名中未提供可校驗的完整性標記。",
"txt_backup_remote_restore_completed_verified": "遠程備份完整性校驗已通過。",
"txt_backup_remote_restore_completed_without_checksum": "遠程備份已還原,但文件名中未提供可校驗的完整性標記。",
"txt_backup_restore_skipped_summary": "{reason},已跳過 {attachments} 個附件",
"txt_backup_restore_skipped_reason_default": "部分文件無法還原",
"txt_backup_export_failed": "備份導出失敗",
"txt_backup_import_failed": "備份還原失敗",
"txt_backup_restore_failed": "備份還原失敗",
"txt_backup_integrity_check_failed": "備份完整性校驗失敗",
"txt_backup_center_title": "實例備份",
"txt_backup_center_description": "把本地導出和遠程自動備份放在一起管理,既方便手動恢復,也能每天自動留一份。",
"txt_backup_restore_note": "還原會覆蓋當前實例;如果當前已有數據,系統會要求你確認“清空後還原”。",
"txt_backup_manual": "手動備份",
"txt_backup_manual_description": "現在就導出 ZIP,或者把之前導出的 ZIP 恢復到當前實例。",
"txt_backup_destinations_title": "備份地點",
"txt_backup_destinations_description": "把多個 WebDAV、S3 地點統一放在這裡。左側選一個,右側編輯和瀏覽它。",
"txt_backup_recommend_title": "推薦儲存庫",
"txt_backup_recommend_open_signup": "前往註冊",
"txt_backup_recommend_open_signup_aff": "前往註冊(含 AFF",
"txt_backup_recommend_open_guide": "查看教程",
"txt_backup_recommend_empty": "暫時沒有推薦",
"txt_backup_recommend_referral_label": "推薦碼",
"txt_backup_recommend_referral_note": "註冊時填寫可額外獲得 5 GB,作者會收到 2 GB。",
"txt_backup_recommend_infinicloud_summary": "只需郵箱即可註冊。免費 20 GB;填寫推薦碼後總計 25 GB。",
"txt_backup_recommend_infinicloud_step_1": "先用郵箱註冊一個 InfiniCLOUD 賬號。",
"txt_backup_recommend_infinicloud_step_2_prefix": "進入",
"txt_backup_recommend_infinicloud_step_2_suffix": ",然後開啟 Turn on Apps Connection。",
"txt_backup_recommend_infinicloud_step_3": "Connection ID 用作 WebDAV 用戶名,Apps Password 用作 WebDAV 密碼。",
"txt_backup_recommend_infinicloud_step_4": "在 My Page 最下面的 Referral Bonus 填入推薦碼 2HC5E,可額外獲得 5 GB。",
"txt_backup_recommend_open_password": "密碼設置",
"txt_backup_recommend_open_storage": "打開儲存連接",
"txt_backup_recommend_koofr_summary": "只需郵箱即可註冊使用。免費 10 GB,並且可以通過 WebDAV 接到 Google Drive、OneDrive、Dropbox。",
"txt_backup_recommend_koofr_password_link": "密碼設置",
"txt_backup_recommend_koofr_storage_link": "Storage",
"txt_backup_recommend_koofr_step_1": "先用郵箱註冊一個 Koofr 賬號。",
"txt_backup_recommend_koofr_step_2_prefix": "打開",
"txt_backup_recommend_koofr_step_2_suffix": ",生成新的應用密碼。註冊郵箱用作 WebDAV 用戶名,應用密碼用作 WebDAV 密碼。",
"txt_backup_recommend_koofr_step_3": "Koofr 自己的 WebDAV 地址是 https://app.koofr.net/dav/Koofr。",
"txt_backup_recommend_koofr_step_4": "Koofr 最方便的地方,是還能接 Google Drive、OneDrive、Dropbox 這三大雲盤;免費用戶最多能連接兩個。",
"txt_backup_recommend_koofr_step_5_prefix": "打開",
"txt_backup_recommend_koofr_step_5_suffix": ",在左側欄點擊“連接”,選擇你要連接的儲存即可。",
"txt_backup_recommend_koofr_dav_intro": "連接好儲存後,賬號和應用密碼都不變,只需要切換 WebDAV 地址:",
"txt_backup_recommend_koofr_dav_self": "Koofr",
"txt_backup_recommend_pcloud_summary": "只需郵箱即可註冊。免費最高 10 GB,並且自帶標準 WebDAV 訪問。",
"txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。",
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。",
"txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。",
"txt_backup_add_destination": "新增地點",
"txt_backup_schedule_panel_title": "自動備份計劃",
"txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。",
"txt_backup_scheduled_target": "當前計劃目標",
"txt_backup_destination_active_badge": "已啟用計劃",
"txt_backup_destination_idle_badge": "未啟用計劃",
"txt_backup_destination_last_success": "上次成功:{time}",
"txt_backup_destination_never_run": "還沒有成功執行過",
"txt_backup_destination_detail_title": "地點詳情",
"txt_backup_destination_detail_note": "",
"txt_backup_destination_name": "地點名稱",
"txt_backup_set_scheduled_target": "設為每日備份目標",
"txt_backup_delete_destination": "刪除",
"txt_backup_destination_deleted": "備份地點已刪除",
"txt_backup_delete_destination_confirm_message": "刪除備份地點“{name}”?此操作不可撤銷。",
"txt_backup_select_destination": "請先從左側列表選擇一個備份地點",
"txt_backup_remote_save_first": "請先保存這個備份地點,再瀏覽它的遠端備份文件",
"txt_backup_automation": "自動備份",
"txt_backup_automation_description": "選擇備份地點,保存連接信息後,系統會按設定時間每天自動上傳一份備份。",
"txt_backup_settings_saved": "備份設置已保存",
"txt_backup_settings_save_failed": "備份設置保存失敗",
"txt_backup_settings_load_failed": "備份設置加載失敗",
"txt_backup_save_settings": "保存設置",
"txt_backup_saving": "正在保存...",
"txt_backup_enable_action": "啟用",
"txt_backup_disable_action": "停用",
"txt_backup_run_now": "立即執行遠程備份",
"txt_backup_run_manual": "手動執行",
"txt_backup_running_now": "執行中...",
"txt_backup_remote_run_success": "遠程備份已完成",
"txt_backup_remote_run_success_verified": "遠程備份已完成,且完整性校驗已通過。",
"txt_backup_remote_run_failed": "遠程備份失敗",
"txt_backup_remote_title": "遠端備份",
"txt_backup_remote_note": "瀏覽已保存的備份地點,選擇某個備份 ZIP 後可以下載,也可以直接還原。",
"txt_backup_remote_saved_basis": "遠端瀏覽使用的是“已保存”的備份地點配置,不會讀取你當前未保存的表單內容。",
"txt_backup_remote_refresh": "刷新",
"txt_backup_remote_root": "根目錄",
"txt_backup_remote_up": "上一級",
"txt_backup_remote_open": "打開",
"txt_backup_remote_download": "下載",
"txt_backup_remote_downloading": "下載中...",
"txt_backup_remote_restore": "還原",
"txt_backup_remote_restore_stage_prepare": "正在讀取遠端備份並檢查可恢復內容...",
"txt_backup_remote_restore_stage_replace": "正在清空當前數據並還原遠端備份,請稍候...",
"txt_backup_progress_kicker": "備份任務",
"txt_backup_progress_subject": "當前對象:{name}",
"txt_backup_restore_progress_kicker": "還原進度",
"txt_backup_restore_progress_local_title": "正在還原本地備份",
"txt_backup_restore_progress_remote_title": "正在還原遠端備份",
"txt_backup_export_progress_title": "正在導出備份",
"txt_backup_remote_run_progress_title": "正在執行遠程備份",
"txt_backup_restore_progress_file": "當前文件:{name}",
"txt_backup_restore_progress_elapsed": "已耗時 {seconds} 秒",
"txt_backup_archive_progress_collect_title": "正在收集密碼庫數據",
"txt_backup_archive_progress_collect_detail": "服務器正在讀取數據庫表,並整理備份所需的數據內容。",
"txt_backup_archive_progress_collect_with_attachments_detail": "服務器正在讀取數據庫表,並整理附件元數據與備份內容。",
"txt_backup_archive_progress_package_title": "正在打包備份壓縮包",
"txt_backup_archive_progress_package_detail": "服務器正在生成備份 ZIP,並計算文件名校驗前綴。",
"txt_backup_archive_progress_package_with_attachments_detail": "服務器正在生成帶附件信息的備份 ZIP 元數據,並計算文件名校驗前綴。",
"txt_backup_archive_progress_ready_title": "正在準備下載",
"txt_backup_archive_progress_ready_detail": "備份壓縮包已經生成,服務器正在把它返回給瀏覽器。",
"txt_backup_export_progress_fetch_attachments_title": "正在下載附件文件",
"txt_backup_export_progress_fetch_attachments_detail": "瀏覽器正在讀取附件對象,並把它們補入導出備份包。",
"txt_backup_export_progress_rebuild_title": "正在重建導出壓縮包",
"txt_backup_export_progress_rebuild_detail": "瀏覽器正在重建最終 ZIP,並刷新文件名裡的校驗後綴。",
"txt_backup_export_progress_save_title": "正在保存導出文件",
"txt_backup_export_progress_save_detail": "瀏覽器正在準備最終的備份文件下載。",
"txt_backup_export_progress_complete_title": "備份導出已完成",
"txt_backup_export_progress_complete_detail": "導出備份已經準備完成。",
"txt_backup_export_progress_failed_title": "備份導出失敗",
"txt_backup_export_progress_failed_detail": "導出備份未能完成。",
"txt_backup_remote_run_progress_prepare_title": "正在準備遠程備份",
"txt_backup_remote_run_progress_prepare_detail": "服務器正在讀取當前備份目標,並準備執行這次遠程備份。",
"txt_backup_remote_run_progress_sync_attachments_title": "正在檢查附件索引",
"txt_backup_remote_run_progress_sync_attachments_detail": "服務器正在比對附件索引,只會上傳缺失或不一致的附件對象。",
"txt_backup_remote_run_progress_sync_attachments_skipped_detail": "當前備份未包含附件,因此跳過附件同步。",
"txt_backup_remote_run_progress_upload_title": "正在上傳備份壓縮包",
"txt_backup_remote_run_progress_upload_detail": "服務器正在把備份 ZIP 上傳到遠程備份目標。",
"txt_backup_remote_run_progress_verify_title": "正在校驗已上傳壓縮包",
"txt_backup_remote_run_progress_verify_detail": "服務器正在回讀剛上傳的 ZIP,並校驗它的哈希和大小。",
"txt_backup_remote_run_progress_cleanup_title": "正在清理舊備份",
"txt_backup_remote_run_progress_cleanup_detail": "服務器正在按保留策略清理舊備份文件。",
"txt_backup_remote_run_progress_complete_title": "遠程備份已完成",
"txt_backup_remote_run_progress_complete_detail": "遠程備份已上傳完成,並通過完整性校驗。",
"txt_backup_remote_run_progress_failed_title": "遠程備份失敗",
"txt_backup_remote_run_progress_failed_detail": "遠程備份未能完成。",
"txt_backup_restore_progress_local_upload_title": "正在上傳備份包",
"txt_backup_restore_progress_local_upload_detail": "已選 ZIP 正在發送到服務器,服務器收到後會開始執行還原。",
"txt_backup_restore_progress_local_shadow_title": "正在創建影子恢復區",
"txt_backup_restore_progress_local_shadow_detail": "服務器正在準備獨立的影子數據區,只有校驗通過後才會替換正式數據。",
"txt_backup_restore_progress_local_data_title": "正在寫入密碼庫數據",
"txt_backup_restore_progress_local_data_detail": "服務器正在把用戶、文件夾、密碼條目和相關元數據寫入影子表。",
"txt_backup_restore_progress_local_files_title": "正在恢復附件文件",
"txt_backup_restore_progress_local_files_detail": "服務器正在把附件對象寫回存儲,並剔除無法恢復的附件記錄。",
"txt_backup_restore_progress_local_finalize_title": "正在校驗並完成切換",
"txt_backup_restore_progress_local_finalize_detail": "服務器正在執行最終校驗,校驗通過後會把已驗證的數據切換為正式數據。",
"txt_backup_restore_progress_remote_fetch_title": "正在讀取遠端備份包",
"txt_backup_restore_progress_remote_fetch_detail": "服務器正在從遠端備份目標下載你選中的備份包。",
"txt_backup_restore_progress_remote_shadow_title": "正在創建影子恢復區",
"txt_backup_restore_progress_remote_shadow_detail": "服務器正在準備獨立的影子數據區,只有校驗通過後才會替換正式數據。",
"txt_backup_restore_progress_remote_data_title": "正在寫入密碼庫數據",
"txt_backup_restore_progress_remote_data_detail": "服務器正在把用戶、文件夾、密碼條目和相關元數據寫入影子表。",
"txt_backup_restore_progress_remote_files_title": "正在恢復遠端附件",
"txt_backup_restore_progress_remote_files_detail": "服務器正在從遠端存儲讀取所需附件,並寫回到當前實例的附件存儲。",
"txt_backup_restore_progress_remote_finalize_title": "正在校驗並完成切換",
"txt_backup_restore_progress_remote_finalize_detail": "服務器正在執行最終校驗,校驗通過後會把已驗證的數據切換為正式數據。",
"txt_backup_remote_loading": "正在讀取遠端備份...",
"txt_backup_remote_cached_empty": "點擊“刷新”後讀取",
"txt_backup_remote_empty": "這個目錄下還沒有備份文件",
"txt_backup_remote_folder": "文件夾",
"txt_backup_remote_unknown_time": "未知時間",
"txt_backup_remote_current_path": "當前目錄",
"txt_backup_remote_load_failed": "讀取遠端備份失敗",
"txt_backup_remote_invalid_response": "遠端備份響應無效",
"txt_backup_remote_download_failed": "下載遠端備份失敗",
"txt_backup_remote_delete_success": "遠端備份已刪除",
"txt_backup_remote_delete_failed": "刪除遠端備份失敗",
"txt_backup_remote_delete_confirm_message": "刪除備份文件“{name}”?此操作不可撤銷。",
"txt_backup_remote_deleting": "刪除中...",
"txt_backup_remote_restore_failed": "還原遠端備份失敗",
"txt_backup_restore_checksum_warning_title": "備份完整性警告",
"txt_backup_restore_checksum_warning_message": "所選備份文件“{name}”未通過文件名完整性校驗。期望前綴為 {expected},實際計算結果為 {actual}。該文件可能不完整或已經損壞。繼續還原可能會導入受損數據。",
"txt_backup_remote_restore_checksum_warning_message": "遠程備份文件“{name}”未通過文件名完整性校驗。期望前綴為 {expected},實際計算結果為 {actual}。該文件可能在上傳或存儲過程中損壞。繼續還原可能會導入受損數據,並可能造成嚴重後果。",
"txt_backup_restore_checksum_warning_message_fallback": "所選備份文件未通過完整性校驗。繼續還原可能會導入受損數據。",
"txt_backup_restore_checksum_warning_confirm": "繼續還原",
"txt_backup_remote_restore_invalid_response": "遠端備份還原響應無效",
"txt_backup_remote_run_invalid_response": "遠端備份執行響應無效",
"txt_backup_settings_invalid_response": "備份設置響應無效",
"txt_backup_import_invalid_response": "備份還原響應無效",
"txt_backup_destination": "備份地點",
"txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3",
"txt_backup_recommend_group_webdav": "WebDAV",
"txt_backup_recommend_group_s3": "S3",
"txt_backup_destination_name_default_webdav": "WebDAV {index}",
"txt_backup_destination_name_default_s3": "S3 {index}",
"txt_backup_type": "備份類型",
"txt_backup_destination_reserved": "預留位置",
"txt_backup_time": "備份時間",
"txt_backup_start_time": "開始時間",
"txt_backup_timezone": "時區",
"txt_backup_interval_hours": "每隔",
"txt_backup_interval_hours_suffix": "小時",
"txt_backup_interval_hours_presets": "快捷時間預設",
"txt_backup_frequency": "備份頻率",
"txt_backup_frequency_daily": "每天",
"txt_backup_frequency_weekly": "每週",
"txt_backup_frequency_monthly": "每月",
"txt_backup_day_of_week": "星期",
"txt_backup_day_of_month": "日期",
"txt_backup_weekday_monday": "週一",
"txt_backup_weekday_tuesday": "週二",
"txt_backup_weekday_wednesday": "週三",
"txt_backup_weekday_thursday": "週四",
"txt_backup_weekday_friday": "週五",
"txt_backup_weekday_saturday": "週六",
"txt_backup_weekday_sunday": "週日",
"txt_backup_retention_count": "只保留",
"txt_backup_retention_count_suffix": "個",
"txt_backup_retention_count_hint": "留空表示不限,新建備份地點默認保留 30 個",
"txt_backup_destination_include_attachments": "包含附件",
"txt_backup_include_attachments_help_button": "附件備份說明",
"txt_backup_include_attachments_help": "附件會以增量方式保存在遠端的 attachments 文件夾中,後續備份通常只上傳新增文件。你在本地刪除附件時,已經備份到遠端的舊文件不會自動刪除。恢復時會按需從 attachments 文件夾讀取對應附件,找不到的附件會自動跳過。",
"txt_backup_enable_schedule": "啟用每日自動備份",
"txt_backup_schedule_note": "Worker 每 5 分鐘檢查一次計劃。會先按你選擇的時區和開始時間起跑,再按小時間隔繼續執行;到了下一天,會重新從開始時間開始。",
"txt_backup_schedule_disabled": "未啟用",
"txt_backup_schedule_status": "計劃狀態",
"txt_backup_schedule_summary": "從 {time} 開始,每隔 {interval} 小時({timezone}",
"txt_backup_schedule_empty": "還沒有啟用任何自動備份計劃",
"txt_backup_last_success": "上次成功時間",
"txt_backup_last_target": "上次備份位置",
"txt_backup_last_file": "上次備份文件",
"txt_backup_last_error_prefix": "上次錯誤",
"txt_backup_none_yet": "還沒有成功完成過遠程備份",
"txt_backup_not_configured": "尚未配置",
"txt_backup_never": "從未",
"txt_backup_unknown_size": "大小未知",
"txt_backup_webdav_url": "WebDAV 服務地址",
"txt_backup_webdav_username": "WebDAV 用戶名",
"txt_backup_webdav_password": "WebDAV 密碼",
"txt_backup_webdav_path": "遠程目錄",
"txt_backup_s3_endpoint": "S3 端點",
"txt_backup_s3_bucket": "儲存桶",
"txt_backup_s3_region": "區域",
"txt_backup_s3_access_key": "存取金鑰",
"txt_backup_s3_secret_key": "秘密金鑰",
"txt_backup_s3_path": "遠程路徑",
"txt_backup_reserved_name": "預留類型名稱",
"txt_backup_reserved_notes": "預留備註",
"txt_backup_reserved_notes_placeholder": "給下一個備份地點先留個說明",
"txt_backup_reserved_hint": "這個位置先預留給後續備份地點。你現在可以先保存備註,但自動上傳不會啟用。",
"txt_backup_file": "備份文件",
"txt_backup_file_required": "請選擇備份文件",
"txt_backup_no_file_selected": "尚未選擇備份文件",
"txt_backup_selected_file_name": "已選擇文件:{name}",
"txt_backup_replace_confirm_title": "替換當前實例數據",
"txt_backup_replace_confirm_message": "當前實例裡已經有數據。確認後,系統會先完成校驗與恢復準備,只有在恢復成功後才會用所選備份替換當前實例數據。是否繼續?",
"txt_backup_clear_and_import": "替換並導入",
"txt_backup_clear_and_restore": "替換並還原",
"txt_access_count": "訪問次數",
"txt_accessed_count_times": "已訪問 {count} 次",
"txt_actions": "操作",
"txt_add": "新增",
"txt_add_field": "添加字段",
"txt_add_website": "添加網站",
"txt_added": "已添加",
"txt_additional_options": "附加選項",
"txt_address": "地址",
"txt_address_1": "地址 1",
"txt_address_2": "地址 2",
"txt_address_3": "地址 3",
"txt_all_device_authorizations_revoked": "已撤銷所有設備信任",
"txt_all_invites_deleted": "已刪除所有邀請碼",
"txt_all_items": "所有項目",
"txt_all_sends": "所有發送",
"txt_android": "安卓",
"txt_are_you_sure_you_want_to_delete_count_selected_items": "確認刪除所選的 {count} 個項目?",
"txt_are_you_sure_you_want_to_delete_count_selected_items_permanently": "確認永久刪除所選的 {count} 個項目?",
"txt_are_you_sure_you_want_to_delete_this_item": "確認刪除此項目?",
"txt_are_you_sure_you_want_to_delete_this_passkey": "確認刪除這個通行密鑰?",
"txt_are_you_sure_you_want_to_log_out": "確認要退出登錄嗎?",
"txt_authenticator_key": "驗證器密鑰",
"txt_authorized_devices": "已授權設備",
"txt_auto_copy_link_after_save": "保存後自動複製鏈接",
"txt_autofill_options": "自動填充選項",
"txt_back_to_login": "返回登錄",
"txt_ban": "封禁",
"txt_boolean": "布爾",
"txt_brand": "品牌",
"txt_bulk_delete_failed": "批量刪除失敗",
"txt_bulk_permanent_delete_failed": "批量永久刪除失敗",
"txt_bulk_restore_failed": "批量恢復失敗",
"txt_bulk_delete_sends_failed": "批量刪除發送失敗",
"txt_bulk_move_failed": "批量移動失敗",
"txt_cancel": "取消",
"txt_continue": "繼續",
"txt_card": "銀行卡",
"txt_card_details": "銀行卡詳情",
"txt_cardholder_name": "持卡人姓名",
"txt_change_master_password": "修改主密碼",
"txt_change_password": "修改密碼",
"txt_change_password_failed": "修改密碼失敗",
"txt_change_password_confirm_and_sign_out_all_devices": "修改主密碼後會強制退出所有設備,包括當前網頁端。確認繼續嗎",
"txt_copy_failed": "複製失敗",
"txt_checked": "已勾選",
"txt_choose_destination_folder": "選擇目標文件夾。",
"txt_chrome_browser": "Chrome 瀏覽器",
"txt_chrome_extension": "Chrome 擴展",
"txt_city_town": "城市 / 城鎮",
"txt_code": "代碼",
"txt_company": "公司",
"txt_configure_custom_field_values": "配置自定義字段值。",
"txt_confirm": "確認",
"txt_confirm_master_password": "確認主密碼",
"txt_confirm_password": "確認密碼",
"txt_copy": "複製",
"txt_code_copied": "驗證碼已複製",
"txt_copy_code": "複製代碼",
"txt_copy_link": "複製鏈接",
"txt_copy_secret": "複製密鑰",
"txt_country": "國家",
"txt_create": "創建",
"txt_create_account": "創建賬戶",
"txt_registering": "正在註冊...",
"txt_create_folder": "創建文件夾",
"txt_create_folder_failed": "創建文件夾失敗",
"txt_create_item_failed": "創建項目失敗",
"txt_create_send_failed": "創建發送失敗",
"txt_create_timed_invite": "創建時效邀請碼",
"txt_created_value": "創建於:{value}",
"txt_current_new_password_is_required": "需要輸入當前密碼和新密碼",
"txt_current_password": "當前密碼",
"txt_custom_fields": "自定義字段",
"txt_decrypt_failed": "(解密失敗)",
"txt_decrypt_failed_2": "解密失敗",
"txt_delete": "刪除",
"txt_delete_all": "全部刪除",
"txt_delete_all_invite_codes_active_inactive": "刪除所有邀請碼(有效/無效)?",
"txt_delete_all_invites": "刪除所有邀請碼",
"txt_delete_item": "刪除項目",
"txt_delete_passkey": "刪除通行密鑰",
"txt_delete_item_failed": "刪除項目失敗",
"txt_delete_permanently": "永久刪除",
"txt_archive": "歸檔",
"txt_archive_item": "歸檔項目",
"txt_archive_item_message": "歸檔後,此項目將被排除在一般搜索結果和自動填充建議之外。",
"txt_archive_selected_items": "歸檔項目",
"txt_archive_selected_items_message": "歸檔後,所選的 {count} 個項目將被排除在一般搜索結果和自動填充建議之外。",
"txt_archived": "已歸檔",
"txt_archive_selected": "歸檔",
"txt_item_archived": "項目已歸檔",
"txt_item_unarchived": "項目已取消歸檔",
"txt_archived_selected_items": "已歸檔所選項目",
"txt_unarchived_selected_items": "已取消歸檔所選項目",
"txt_archive_item_failed": "歸檔項目失敗",
"txt_unarchive_item_failed": "取消歸檔項目失敗",
"txt_bulk_archive_failed": "批量歸檔失敗",
"txt_bulk_unarchive_failed": "批量取消歸檔失敗",
"txt_unarchive": "取消歸檔",
"txt_delete_selected": "刪除",
"txt_delete_selected_items": "刪除所選項目",
"txt_delete_selected_items_permanently": "Delete Selected Items Permanently",
"txt_delete_send_failed": "刪除發送失敗",
"txt_delete_this_user_and_all_user_data": "刪除此用戶及其所有數據?",
"txt_delete_user": "刪除用戶",
"txt_deleted_selected_items": "已刪除所選項目",
"txt_deleted_selected_items_permanently": "已永久刪除所選項目",
"txt_restored_selected_items": "已恢復所選項目",
"txt_deleted_selected_sends": "已刪除所選發送",
"txt_deletion_date": "刪除日期",
"txt_deletion_days": "刪除天數",
"txt_device": "設備",
"txt_device_authorization_revoked": "設備信任已撤銷",
"txt_device_management": "設備管理",
"txt_device_note": "備註",
"txt_device_note_required": "設備名稱不能為空",
"txt_device_note_updated": "設備名稱已更新",
"txt_device_removed": "設備已移除",
"txt_load_devices_failed": "加載設備失敗",
"txt_disable_this_send": "禁用此發送",
"txt_disable_totp": "停用 TOTP",
"txt_disable_totp_failed": "禁用 TOTP 失敗",
"txt_download": "下載",
"txt_downloading": "下載中...",
"txt_downloading_percent": "下載中 {percent}%",
"txt_attachment": "附件",
"txt_uploading_attachment_named": "正在上傳 {name}...",
"txt_uploading_attachment_named_percent": "正在上傳 {name} {percent}%",
"txt_uploading_file_named": "正在上傳 {name}...",
"txt_uploading_file_named_percent": "正在上傳 {name} {percent}%",
"txt_download_failed": "下載失敗",
"txt_edge_browser": "Edge 瀏覽器",
"txt_edge_extension": "Edge 擴展",
"txt_edit": "編輯",
"txt_edit_send": "編輯發送",
"txt_email": "郵箱",
"txt_email_password_and_recovery_code_are_required": "需要輸入郵箱、密碼和恢復代碼",
"txt_enable_totp": "啟用 TOTP",
"txt_enable_totp_failed": "啟用 TOTP 失敗",
"txt_enabled": "已啟用",
"txt_encrypted_file": "加密文件",
"txt_encrypted_file_2": "加密文件",
"txt_enter_a_folder_name": "請輸入文件夾名稱",
"txt_enter_master_password_to_disable_two_step_verification": "輸入主密碼以禁用兩步驗證",
"txt_enter_master_password_to_continue": "輸入主密碼以繼續",
"txt_enter_master_password_to_view_this_item": "輸入主密碼以查看此項目",
"txt_expiration_date": "過期日期",
"txt_expiration_days_0_never": "過期天數(0 表示不過期)",
"txt_expires_at": "過期時間",
"txt_expires_at_value": "過期於:{value}",
"txt_expiry": "有效期",
"txt_expiry_month": "有效期月",
"txt_expiry_year": "有效期年",
"txt_failed_to_open_send": "打開發送失敗",
"txt_favorite": "收藏",
"txt_favorites": "收藏",
"txt_duplicates": "重複項",
"txt_field": "字段",
"txt_field_label": "字段標籤",
"txt_field_label_is_required": "字段標籤不能為空",
"txt_field_type": "字段類型",
"txt_field_value": "字段值",
"txt_file": "文件",
"txt_file_name": "文件名",
"txt_file_send": "文件發送",
"txt_file_size": "文件大小",
"txt_fingerprint": "指紋",
"txt_firefox_browser": "Firefox 瀏覽器",
"txt_firefox_extension": "Firefox 擴展",
"txt_first_name": "名",
"txt_folder": "文件夾",
"txt_folder_created": "文件夾已創建",
"txt_folder_name": "文件夾名稱",
"txt_folder_name_is_required": "文件夾名稱不能為空",
"txt_folders": "文件夾",
"txt_hidden": "隱藏",
"txt_hide": "隱藏",
"txt_identity": "身份",
"txt_identity_details": "身份詳情",
"txt_ie_browser": "IE 瀏覽器",
"txt_invite_code_optional": "邀請碼(首位註冊者無需填寫,其他人必填)",
"txt_invite_created": "邀請碼已創建",
"txt_invite_revoked": "邀請碼已撤銷",
"txt_invite_validity_hours": "邀請碼有效期(小時)",
"txt_invites": "邀請碼",
"txt_ios": "iOS",
"txt_item": "項目",
"txt_item_created": "項目已創建",
"txt_item_deleted": "項目已刪除",
"txt_item_history": "項目歷史",
"txt_password_history": "密碼歷史記錄",
"txt_password_updated_value": "密碼更新新於: {value}",
"txt_item_name_is_required": "項目名稱不能為空",
"txt_item_updated": "項目已更新",
"txt_last_edited_value": "最後編輯:{value}",
"txt_last_name": "姓",
"txt_last_seen": "最後在線",
"txt_license_number": "證件號",
"txt_link_copied": "鏈接已複製",
"txt_linked": "已關聯",
"txt_linux_desktop": "Linux 桌面端",
"txt_loading": "加載中...",
"txt_loading_nodewarden": "正在加載 NodeWarden...",
"txt_jwt_warning_title": "JWT_SECRET 配置警告",
"txt_jwt_warning_subtitle": "JWT 密鑰當前不安全,請先修復後再繼續。",
"txt_jwt_title_missing": "未檢測到 JWT_SECRET",
"txt_jwt_title_too_short": "JWT_SECRET 長度過短",
"txt_jwt_title_default": "JWT_SECRET使用默認值",
"txt_jwt_reason_missing": "未檢測到 JWT_SECRET。",
"txt_jwt_reason_default": "JWT_SECRET 仍在使用默認示例值。",
"txt_jwt_reason_too_short": "JWT_SECRET 長度過短,至少需要 {min} 位。",
"txt_jwt_how_to_fix_add": "處理步驟(添加 JWT_SECRET",
"txt_jwt_how_to_fix_replace": "處理步驟(更換 JWT_SECRET",
"txt_jwt_add_step_1": "使用下方 32 位隨機生成器,複製一個新密鑰。",
"txt_jwt_add_step_2_prefix": "到 Cloudflare 控制檯 -> Workers 和 Pages -> 你的服務 -> ",
"txt_jwt_add_step_2_suffix": " -> 變量和機密 -> 新增",
"txt_jwt_add_step_3": "保存並等待重新部署完成,然後刷新本頁確認。",
"txt_jwt_replace_step_1": "使用下方 32 位隨機生成器,生成更強的密鑰(至少 {min} 位)。",
"txt_jwt_replace_step_2_prefix": "到 Cloudflare 控制檯 -> Workers 和 Pages -> 你的服務 -> ",
"txt_jwt_replace_step_2_suffix": " -> 變量和機密 -> 更新 JWT_SECRET",
"txt_jwt_replace_step_3": "保存並等待重新部署完成,然後刷新本頁確認。",
"txt_jwt_secret_type_label": "類型:",
"txt_jwt_secret_type_value": "密鑰",
"txt_jwt_secret_name_label": "變量名稱:",
"txt_jwt_secret_value_label": "值:",
"txt_jwt_secret_value_requirement": "最低 {min} 位隨機字符",
"txt_jwt_what_is": "JWT 是什麼",
"txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失、過短,或者仍然使用示例值,實例就不能安全地正常使用。",
"txt_how_to_fix": "處理步驟(添加 / 更換)",
"txt_jwt_fix_step_1": "你可以繼續下一步,不影響使用。",
"txt_jwt_fix_step_2": "如果當前密鑰不是強隨機值,建議使用下方 32 位生成器。",
"txt_jwt_fix_step_3": "到 Cloudflare 控制檯 -> Workers 和 Pages -> 你的服務 -> 設置 -> 變量和機密,更新 JWT_SECRET。",
"txt_jwt_fix_step_4": "保存並等待重新部署完成,然後刷新本頁確認。",
"txt_random_secret_generator": "隨機密鑰生成器",
"txt_copied": "已複製",
"txt_log_in": "登錄",
"txt_logging_in": "正在登錄...",
"txt_log_out": "退出",
"txt_lock": "鎖定",
"txt_menu": "菜單",
"txt_settings": "設置",
"txt_back": "返回",
"txt_login": "登錄",
"txt_login_credentials": "登錄信息",
"txt_login_failed": "登錄失敗",
"txt_login_success": "登錄成功",
"txt_macos_desktop": "macOS 桌面端",
"txt_manage_authorized_devices_and_30_day_totp_trusted_sessions": "管理已授權設備和 30 天 TOTP 受信會話。",
"txt_manage_device_sessions_and_30_day_totp_trusted_sessions": "管理設備會話和 30 天 TOTP 受信狀態。",
"txt_master_password": "主密碼",
"txt_master_password_changed_please_login_again": "主密碼已修改,請重新登錄",
"txt_master_password_changed_signing_out_everywhere": "主密碼已修改,正在退出所有設備",
"txt_master_password_is_required": "主密碼不能為空",
"txt_master_password_is_required_2": "請輸入主密碼",
"txt_master_password_must_be_at_least_12_chars": "主密碼至少需要 12 個字符",
"txt_master_password_reprompt": "主密碼二次確認",
"txt_master_password_reprompt_2": "主密碼二次確認",
"txt_max_access_count": "最大訪問次數",
"txt_middle_name": "中間名",
"txt_drag_to_reorder": "拖動調整順序",
"txt_move": "移動",
"txt_move_selected_items": "移動所選項目",
"txt_moved_selected_items": "已移動所選項目",
"txt_name": "名稱",
"txt_name_is_required": "名稱不能為空",
"txt_new_password": "新密碼",
"txt_nothing_to_copy": "沒有可複製的內容",
"txt_new_password_must_be_at_least_12_chars": "新密碼至少需要 12 個字符",
"txt_new_passwords_do_not_match": "兩次輸入的新密碼不一致",
"txt_new_send": "新建發送",
"txt_next": "下一頁",
"txt_no": "否",
"txt_no_devices_found": "未找到設備",
"txt_no_folder": "無文件夾",
"txt_no_items": "沒有項目",
"txt_no_username": "無用戶名",
"txt_no_verification_codes": "沒有驗證碼",
"txt_no_name": "(無名稱)",
"txt_no_sends": "沒有發送",
"txt_nodewarden_send": "NodeWarden 發送",
"txt_not_trusted": "未信任",
"txt_note": "筆記",
"txt_notes": "備註",
"txt_replace_device_name_with_note": "為這臺設備設置自定義名稱,不會改變系統識別到的設備類型。",
"txt_number": "數字",
"txt_open": "打開",
"txt_opera_browser": "Opera 瀏覽器",
"txt_opera_extension": "Opera 擴展",
"txt_or": "或",
"txt_options": "選項",
"txt_passport_number": "護照號",
"txt_password": "密碼",
"txt_password_is_already_verified": "密碼已驗證",
"txt_passwords_do_not_match": "兩次輸入的密碼不一致",
"txt_password_hint": "密碼提示",
"txt_password_hint_optional": "密碼提示(可選)",
"txt_password_hint_placeholder": "寫一句只有你自己看得懂的線索",
"txt_password_hint_register_placeholder": "這個提示可以在網頁登錄頁直接顯示。",
"txt_password_hint_register_help": "這個提示可以在網頁登錄頁直接顯示。不要填寫主密碼、恢復代碼,或任何能直接暴露密碼的信息。",
"txt_password_hint_login_help": "忘記主密碼時,可以查看註冊時保存的提示。",
"txt_password_hint_login_note": "這裡只會顯示提示語,不會顯示你的主密碼本身。",
"txt_show_password_hint": "查看密碼提示",
"txt_hide_password_hint": "隱藏密碼提示",
"txt_loading_password_hint": "正在加載提示...",
"txt_password_hint_not_set": "這個郵箱沒有可顯示的密碼提示。",
"txt_password_hint_load_failed": "加載密碼提示失敗",
"txt_password_hint_too_long": "密碼提示最多隻能輸入 120 個字符",
"txt_passkey": "通行密鑰",
"txt_passkeys": "通行密鑰",
"txt_passkey_created_at_value": "創建於 {value}",
"txt_phone": "電話",
"txt_please_input_email_and_password": "請輸入郵箱和密碼",
"txt_please_input_master_password": "請輸入主密碼",
"txt_please_input_totp_code": "請輸入 TOTP 驗證碼",
"txt_please_select_a_file": "請選擇文件",
"txt_postal_code": "郵政編碼",
"txt_prev": "上一頁",
"txt_private_key": "私鑰",
"txt_profile": "資料",
"txt_profile_unavailable": "資料不可用",
"txt_profile_updated": "資料已更新",
"txt_public_key": "公鑰",
"txt_recover_2fa_failed": "恢復 2FA 失敗",
"txt_recover_two_step_login": "恢復兩步登錄",
"txt_recovered_but_auto_login_failed_please_sign_in": "已恢復,但自動登錄失敗,請手動登錄",
"txt_recovery_code": "恢復代碼",
"txt_recovery_code_and_api_key": "恢復代碼和 API 密鑰",
"txt_recovery_code_copied": "恢復代碼已複製",
"txt_recovery_code_is_empty": "恢復代碼為空",
"txt_recovery_code_loaded": "恢復代碼已加載",
"txt_api_key": "API 密鑰",
"txt_view_api_key": "查看 API 密鑰",
"txt_rotate_api_key": "輪換 API 密鑰",
"txt_api_key_copied": "API 密鑰已複製",
"txt_api_key_loaded": "API 密鑰已加載",
"txt_api_key_rotated": "API 密鑰已輪換",
"txt_rotate_api_key_confirm": "輪換 API 密鑰?當前密鑰將立即失效。",
"txt_api_key_is_empty": "API 密鑰為空",
"txt_api_key_dialog_intro": "您的 API 密鑰可用於在 Bitwarden CLI 中進行身份驗證。",
"txt_api_key_warning_body": "您的 API 密鑰是一種替代身份驗證機制。請嚴格保密。",
"txt_oauth_client_credentials": "OAuth 2.0 客戶端憑據",
"txt_client_id": "用戶端 ID",
"txt_client_secret": "用戶端密鑰",
"txt_scope": "權限範圍",
"txt_grant_type": "授權類型",
"txt_refresh": "刷新",
"txt_refresh_in_seconds_s": "{seconds} 秒後刷新",
"txt_regenerate": "重新生成",
"txt_registration_succeeded_please_sign_in": "註冊成功,請登錄",
"txt_remove": "移除",
"txt_remove_device": "移除設備",
"txt_remove_device_2": "移除設備",
"txt_remove_all_devices": "移除所有設備",
"txt_remove_all_devices_and_clear_all_2fa_trust": "確認移除所有設備並清除全部 2FA 信任嗎?",
"txt_remove_all_devices_and_sign_out_all_sessions": "確認移除所有設備、清除全部信任,並讓所有設備重新登錄嗎?",
"txt_remove_device_name_and_clear_its_2fa_trust": "確認移除設備“{name}”並清除其 2FA 信任嗎?",
"txt_remove_device_and_sign_out_name": "確認移除設備“{name}”,清除其信任,並讓它重新登錄嗎?",
"txt_reveal": "顯示",
"txt_restore": "恢復",
"txt_revoke": "撤銷",
"txt_revoke_30_day_totp_trust_for_name": "確認撤銷“{name}”的 30 天 TOTP 信任嗎?",
"txt_revoke_30_day_totp_trust_from_all_devices": "確認撤銷所有設備的 30 天 TOTP 信任嗎?",
"txt_revoke_all_trusted": "撤銷全部受信任設備",
"txt_revoke_all_trusted_devices": "撤銷所有設備信任",
"txt_revoke_device_authorization": "撤銷設備信任",
"txt_revoke_device_trust_failed": "撤銷設備信任失敗",
"txt_revoke_all_device_trust_failed": "撤銷所有設備信任失敗",
"txt_revoke_trust": "撤銷信任",
"txt_untrust": "不信任",
"txt_update_device_note_failed": "更新設備備註失敗",
"txt_role": "角色",
"txt_save": "保存",
"txt_save_profile": "保存資料",
"txt_save_profile_failed": "保存資料失敗",
"txt_search_sends": "搜索發送...",
"txt_search_your_secure_vault": "搜索你的密碼庫...",
"txt_clear_search": "清空搜索",
"txt_clear_search_esc": "清空搜索(Esc",
"txt_sort": "排序",
"txt_sort_last_edited": "最近修改",
"txt_sort_created": "最近創建",
"txt_sort_name": "A-Z",
"txt_secret_and_code_are_required": "密鑰和代碼不能為空",
"txt_secret_copied": "密鑰已複製",
"txt_secure_note": "安全筆記",
"txt_security_code": "安全碼",
"txt_security_code_cvv": "安全碼 (CVV)",
"txt_select_all": "全選",
"txt_select_duplicate_items": "選擇重複項",
"txt_select_an_item": "請選擇一個項目",
"txt_send_created": "發送已創建",
"txt_send_deleted": "發送已刪除",
"txt_send_details": "發送詳情",
"txt_send_file": "發送文件",
"txt_send_unavailable": "發送不可用。",
"txt_send_updated": "發送已更新",
"txt_sign_out": "退出登錄",
"txt_ssh_key": "SSH 密鑰",
"txt_ssn": "社保號",
"txt_state_province": "省 / 州",
"txt_status": "狀態",
"txt_online": "在線",
"txt_offline": "離線",
"txt_submit": "提交",
"txt_sync": "同步",
"txt_sync_vault": "同步",
"txt_switch_to_dark_mode": "切換到暗黑模式",
"txt_switch_to_light_mode": "切換到明亮模式",
"txt_dash": "-",
"txt_text": "文本",
"txt_text_2fa_recovered": "2FA 已恢復",
"txt_text_2fa_recovered_new_recovery_code_code": "2FA 已恢復,新的恢復代碼:{code}",
"txt_text_3": "------",
"txt_text_is_required": "文本不能為空",
"txt_text_send": "文本發送",
"txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically": "這是一次性恢復代碼,使用後將自動生成新的恢復代碼。",
"txt_this_item_requires_master_password_every_time_before_viewing_details": "每次查看詳情前均需輸入主密碼",
"txt_this_link_is_missing_decryption_key": "此鏈接缺少解密密鑰",
"txt_this_send_is_password_protected": "此發送受密碼保護",
"txt_title": "稱謂",
"txt_totp": "TOTP",
"txt_totp_code": "TOTP 驗證碼",
"txt_totp_disabled": "TOTP 已禁用",
"txt_totp_enabled": "TOTP 已啟用",
"txt_totp_is_enabled_for_this_account": "此賬戶已啟用 TOTP。",
"txt_total_items_count": "共 {count} 項",
"txt_totp_secret": "TOTP 密鑰",
"txt_totp_verify_failed": "TOTP 驗證失敗",
"txt_attachments": "附件",
"txt_upload_attachments": "上傳附件",
"txt_new_attachments": "待上傳附件",
"txt_marked_for_removal_count": "保存後將刪除 {count} 個附件",
"txt_trash": "回收站",
"txt_trust_this_device_for_30_days": "信任此設備 30 天",
"txt_trusted_until": "信任至",
"txt_two_step_verification": "兩步驗證",
"txt_type": "類型",
"txt_type_type": "類型 {type}",
"txt_unban": "解封",
"txt_unchecked": "未勾選",
"txt_unknown_device": "未知設備",
"txt_unlock": "解鎖",
"txt_unlocking": "正在解鎖...",
"txt_unlock_details": "解鎖詳情",
"txt_unlock_failed": "解鎖失敗",
"txt_unlock_failed_master_password_is_incorrect": "解鎖失敗,主密碼不正確。",
"txt_unlock_item": "解鎖項目",
"txt_unlock_send": "解鎖發送",
"txt_unlock_vault": "解鎖密碼庫",
"txt_unlocked": "已解鎖",
"txt_all_devices_removed": "已移除所有設備",
"txt_remove_device_failed": "移除設備失敗",
"txt_remove_all_devices_failed": "移除所有設備失敗",
"txt_update_item_failed": "更新項目失敗",
"txt_update_send_failed": "更新發送失敗",
"txt_use_recovery_code": "使用恢復代碼",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "使用一次性恢復代碼禁用兩步驗證。",
"txt_user_deleted": "用戶已刪除",
"txt_user_status_updated": "用戶狀態已更新",
"txt_username": "用戶名",
"txt_uri_match_default_base_domain": "默認(基礎域名)",
"txt_uri_match_base_domain": "基礎域名",
"txt_uri_match_host": "主機",
"txt_uri_match_exact": "精確",
"txt_uri_match_never": "從不",
"txt_uri_match_starts_with": "開始於",
"txt_uri_match_regular_expression": "正則表達式",
"txt_users": "用戶",
"txt_vault_synced": "密碼庫已同步",
"txt_verification_code": "驗證碼",
"txt_verify": "驗證",
"txt_warning": "警告",
"txt_view_recovery_code": "查看恢復代碼",
"txt_web": "網頁",
"txt_website": "網站",
"txt_websites": "網站",
"txt_windows_desktop": "Windows 桌面端",
"txt_yes": "是",
"txt_auto_lock": "會話超時",
"txt_auto_lock_description": "頁面閒置後執行會話超時動作;關閉頁面或瀏覽器後再次打開始終進入鎖定頁。",
"txt_auto_lock_updated": "會話超時已更新",
"txt_session_timeout": "會話超時",
"txt_session_timeout_updated": "會話超時已更新",
"txt_timeout_time": "超時時間",
"txt_timeout_action": "超時動作",
"txt_timeout_action_logout": "註銷",
"txt_timeout_action_lock": "鎖定",
"txt_in_planning": "構思中",
"txt_security_preferences": "安全偏好",
"txt_timeout_1_minute": "1 分鐘",
"txt_timeout_5_minutes": "5 分鐘",
"txt_timeout_15_minutes": "15 分鐘",
"txt_timeout_30_minutes": "30 分鐘",
"txt_timeout_never": "從不",
"txt_lock_after_1_minute": "閒置 1 分鐘後",
"txt_lock_after_5_minutes": "閒置 5 分鐘後",
"txt_lock_after_15_minutes": "閒置 15 分鐘後",
"txt_lock_after_30_minutes": "閒置 30 分鐘後",
"txt_lock_after_never": "不因閒置鎖定",
"txt_import": "導入",
"txt_export": "導出",
"txt_format": "格式",
"txt_source_file": "源文件",
"txt_folder_handling": "文件夾處理",
"txt_import_folder_mode_original": "保留導入文件中的原始路徑",
"txt_import_folder_mode_none": "不使用文件夾",
"txt_import_folder_mode_target": "導入到指定文件夾",
"txt_target_folder": "目標文件夾",
"txt_select_folder_placeholder": "-- 選擇文件夾 --",
"txt_import_vault_data_hint": "將數據導入到當前賬號。",
"txt_export_vault_data_hint": "從當前賬號導出數據。",
"txt_import_export_title": "導入導出",
"txt_encrypted_mode": "加密方式",
"txt_account_verification": "賬號驗證",
"txt_password_verification": "密碼驗證",
"txt_file_password": "文件密碼",
"txt_zip_password_optional": "ZIP 密碼(可選)",
"txt_zip_password": "ZIP 密碼",
"txt_close": "關閉",
"txt_total": "總計",
"txt_import_success": "數據導入成功",
"txt_import_success_number_of_items": "一共導入了 {count} 個項目。",
"txt_import_attachment_summary": "附件已導入 {imported}/{total} 個。",
"txt_import_failed_attachments_title": "以下 {count} 個附件未導入:",
"txt_import_attachment_target_not_found": "沒有找到對應的導入項目。",
"txt_upload_attachment_failed": "附件上傳失敗。",
"txt_import_file_password_required": "請輸入文件密碼。",
"txt_import_invalid_zip_password": "ZIP 密碼錯誤。",
"txt_export_completed": "導出完成",
"txt_export_failed": "導出失敗",
"txt_import_invalid_password_protected_file": "密碼保護導出文件格式無效。",
"txt_import_decrypt_failed": "導入文件解密失敗。",
"txt_import_empty_zip_archive": "ZIP 壓縮包為空。",
"txt_import_no_json_found_in_zip": "ZIP 內未找到可導入的 JSON 數據。",
"txt_import_data_json_not_found": "ZIP 內未找到 data.json。",
"txt_import_zip_password_required": "該 ZIP 需要密碼。",
"txt_import_invalid_json_file": "JSON 文件無效",
"txt_import_failed": "導入失敗",
"txt_import_encrypted_file_title": "導入加密文件",
"txt_import_encrypted_file_message": "該 Bitwarden 導出文件已加密,請輸入文件密碼繼續。",
"txt_import_encrypted_zip_title": "導入加密 ZIP",
"txt_import_encrypted_zip_message": "該 ZIP 壓縮包已加密,請輸入 ZIP 密碼繼續。",
"txt_new_type_header": "新建{type}",
"txt_edit_type_header": "編輯{type}",
"txt_delete_folder": "刪除文件夾",
"txt_delete_folder_message": "刪除文件夾「{name}」?其中的項目將移至無文件夾。",
"txt_delete_all_folders": "刪除全部文件夾",
"txt_delete_all_folders_message": "確認刪除全部文件夾嗎?其中的項目將移至無文件夾。",
"txt_folder_not_found": "文件夾不存在",
"txt_folder_deleted": "文件夾已刪除",
"txt_folder_updated": "文件夾已重命名",
"txt_folders_deleted": "文件夾已刪除",
"txt_update_folder_failed": "重命名文件夾失敗",
"txt_delete_folder_failed": "刪除文件夾失敗",
"txt_delete_all_folders_failed": "刪除全部文件夾失敗",
"txt_other": "其他",
"txt_vault_key_unavailable": "賬戶密鑰不可用,請先解鎖密碼庫後重試。",
"txt_vault_not_ready": "密碼庫數據尚未就緒",
"txt_unsupported_export_format": "不支持的導出格式",
"txt_invalid_encrypted_export": "加密導出文件無效。",
"txt_export_belongs_to_another_account": "此加密導出文件屬於另一個賬號。",
"txt_invalid_argon2id_params": "導出文件中的 Argon2id 參數無效。",
"txt_unsupported_kdf_type": "不支持的 KDF 類型:{type}",
"txt_invalid_file_password": "文件密碼錯誤。",
"txt_failed_to_map_attachments": "無法將 {count} 個附件匹配到導入項目。",
"txt_role_admin": "管理員",
"txt_role_user": "用戶",
"txt_status_active": "正常",
"txt_status_banned": "已封禁",
"txt_status_inactive": "未激活",
"txt_language": "語言",
"txt_display_language": "顯示語言",
"txt_language_saved_locally": "此偏好會保存在當前瀏覽器中,下次打開應用前就會生效。"
};
export default zhTW;
+71 -11
View File
@@ -19,6 +19,71 @@ export function normalizeUri(raw: string): string | null {
return s.slice(0, 1000);
}
export function normalizeUriList(rawUris: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of rawUris) {
const uri = normalizeUri(raw);
if (!uri) continue;
const key = uri.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(uri);
}
return out;
}
export function setLoginUris(login: Record<string, unknown>, rawUris: string[]): void {
const uris = normalizeUriList(rawUris);
login.uris = uris.length ? uris.map((uri) => ({ uri, match: null })) : null;
}
export function addLoginUri(login: Record<string, unknown>, rawUri: string): void {
const existing = Array.isArray(login.uris)
? login.uris.map((entry) => txt((entry as Record<string, unknown>)?.uri)).filter(Boolean)
: [];
setLoginUris(login, [...existing, rawUri]);
}
export function isTotpFieldName(raw: unknown): boolean {
const name = txt(raw).toLowerCase().replace(/[\s_-]+/g, '');
if (!name) return false;
return [
'totp',
'totpuri',
'otp',
'otpuri',
'otpurl',
'otpauth',
'onetimepassword',
'onetimepasscode',
'2fa',
'twofactor',
'twofactorauthentication',
'authenticator',
'verificationcode',
].includes(name);
}
export function extractTotpValue(raw: unknown): string {
if (raw === null || raw === undefined) return '';
if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') return txt(raw);
if (Array.isArray(raw)) {
for (const item of raw) {
const value = extractTotpValue(item);
if (value) return value;
}
return '';
}
if (typeof raw !== 'object') return '';
const obj = raw as Record<string, unknown>;
for (const key of ['totpUri', 'otpAuth', 'otpauth', 'uri', 'url', 'secret', 'totp', 'otp', 'value', 'code']) {
const value = extractTotpValue(obj[key]);
if (value) return value;
}
return '';
}
export function parseSerializedUris(raw: string): string[] {
const source = txt(raw);
if (!source) return [];
@@ -38,15 +103,7 @@ export function parseSerializedUris(raw: string): string[] {
.filter(Boolean)
: [source];
const seen = new Set<string>();
const uris: string[] = [];
for (const part of parts) {
const normalized = normalizeUri(part);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
uris.push(normalized);
}
return uris;
return normalizeUriList(parts);
}
export function nameFromUrl(raw: string): string | null {
@@ -148,7 +205,7 @@ export function parseCsv(raw: string): CsvRow[] {
rows.push(row);
const nonEmpty = rows.filter((r) => r.some((c) => txt(c)));
if (!nonEmpty.length) return [];
const headers = nonEmpty[0].map((h) => txt(h));
const headers = nonEmpty[0].map((h, index) => txt(index === 0 ? h.replace(/^\uFEFF/, '') : h));
const out: CsvRow[] = [];
for (let i = 1; i < nonEmpty.length; i++) {
const values = nonEmpty[i];
@@ -206,9 +263,12 @@ export function processKvp(cipher: Record<string, unknown>, key: string, value:
const v = txt(value);
if (!v) return;
const fields = Array.isArray(cipher.fields) ? (cipher.fields as Array<Record<string, unknown>>) : [];
if (fields.some((field) => txt(field.name) === k && txt(field.value) === v)) return;
if (v.length > 200 || /\r\n|\r|\n/.test(v)) {
const existing = txt(cipher.notes);
cipher.notes = `${existing}${existing ? '\n' : ''}${k ? `${k}: ` : ''}${v}`;
const entry = `${k ? `${k}: ` : ''}${v}`;
if (existing.split('\n').some((line) => line === entry)) return;
cipher.notes = `${existing}${existing ? '\n' : ''}${entry}`;
return;
}
fields.push({ type: hidden ? 1 : 0, name: k, value: v, linkedId: null });
+36 -22
View File
@@ -1,5 +1,5 @@
import type { CiphersImportPayload } from '@/lib/api/vault';
import { addFolder, cardBrand, makeLoginCipher, nameFromUrl, normalizeUri, parseCsv, parseSerializedUris, txt, val } from '@/lib/import-format-shared';
import { addFolder, cardBrand, makeLoginCipher, nameFromUrl, normalizeUri, parseCsv, parseSerializedUris, processKvp, txt, val } from '@/lib/import-format-shared';
export function parseChromeCsv(textRaw: string): CiphersImportPayload {
const rows = parseCsv(textRaw);
@@ -62,25 +62,37 @@ export function parseSafariCsv(textRaw: string): CiphersImportPayload {
export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
const rows = parseCsv(textRaw);
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
const applyBitwardenCustomFields = (cipher: Record<string, unknown>, rawFields: unknown) => {
const lines = String(rawFields || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const delim = line.lastIndexOf(': ');
if (delim < 0) continue;
processKvp(cipher, line.slice(0, delim), line.slice(delim + 2), false);
}
};
for (const row of rows) {
const type = txt(row.type).toLowerCase() || 'login';
if (type === 'note') {
const idx =
result.ciphers.push({
type: 2,
name: val(row.name, '--'),
notes: val(row.notes),
favorite: txt(row.favorite) === '1',
reprompt: 0,
key: null,
login: null,
card: null,
identity: null,
secureNote: { type: 0 },
fields: null,
passwordHistory: null,
sshKey: null,
}) - 1;
if (type === 'note' || type === 'secure note' || type === 'securenote') {
const cipher = {
type: 2,
name: val(row.name, '--'),
notes: val(row.notes),
favorite: txt(row.favorite) === '1',
reprompt: Number(row.reprompt ?? 0) || 0,
key: null,
login: null,
card: null,
identity: null,
secureNote: { type: 0 },
fields: [],
passwordHistory: null,
sshKey: null,
};
applyBitwardenCustomFields(cipher, row.fields);
const idx = result.ciphers.push(cipher) - 1;
addFolder(result, row.folder, idx);
continue;
}
@@ -88,11 +100,13 @@ export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
cipher.name = val(row.name, '--');
cipher.notes = val(row.notes);
cipher.favorite = txt(row.favorite) === '1';
cipher.reprompt = Number(row.reprompt ?? 0) || 0;
applyBitwardenCustomFields(cipher, row.fields);
const login = cipher.login as Record<string, unknown>;
login.username = val(row.login_username);
login.password = val(row.login_password);
login.totp = val(row.login_totp);
const uris = parseSerializedUris(row.login_uri || '');
login.username = val(row.login_username, val(row.username));
login.password = val(row.login_password, val(row.password));
login.totp = val(row.login_totp, val(row.totp));
const uris = parseSerializedUris(row.login_uri || row.uri || '');
login.uris = uris.length ? uris.map((uri) => ({ uri, match: null })) : null;
const idx = result.ciphers.push(cipher) - 1;
addFolder(result, row.folder, idx);
+118 -37
View File
@@ -1,14 +1,18 @@
import type { CiphersImportPayload } from '@/lib/api/vault';
import {
addFolder,
addLoginUri,
cardBrand,
convertToNoteIfNeeded,
extractTotpValue,
isTotpFieldName,
makeLoginCipher,
normalizeUri,
parseCardExpiry,
parseCsv,
parseEpochMaybe,
processKvp,
setLoginUris,
txt,
val,
} from '@/lib/import-format-shared';
@@ -21,14 +25,57 @@ function onePasswordTypeHints(typeName: string): 1 | 2 | 3 | 4 {
return 1;
}
function onePasswordCategoryType(categoryUuid: string): 1 | 2 | 3 | 4 {
function onePasswordCategoryType(categoryUuid: string): 1 | 2 | 3 | 4 | 5 {
const c = txt(categoryUuid);
if (['002', '101'].includes(c)) return 3;
if (['004', '103', '104', '105', '106', '107', '108'].includes(c)) return 4;
if (['003', '100', '113'].includes(c)) return 2;
if (['003', '100', '111', '113'].includes(c)) return 2;
if (c === '114') return 5;
return 1;
}
function onePasswordCsvFieldLabel(property: string): string {
return txt(property)
.toLowerCase()
.replace(/^.*?:\s*/, '')
.replace(/\s+/g, ' ')
.trim();
}
function isOnePasswordUriField(property: string): boolean {
const label = onePasswordCsvFieldLabel(property);
return ['url', 'urls', 'website', 'web site'].includes(label);
}
function isOnePasswordUsernameField(property: string): boolean {
return ['username', 'user name', 'email', 'e-mail', 'login'].includes(onePasswordCsvFieldLabel(property));
}
function isOnePasswordPasswordField(property: string): boolean {
return ['password', 'passphrase'].includes(onePasswordCsvFieldLabel(property));
}
function readOnePasswordFieldValue(rawValue: unknown): { value: string; kind: string; raw: unknown } {
if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
return { value: txt(rawValue), kind: '', raw: rawValue };
}
const obj = rawValue as Record<string, unknown>;
const keys = Object.keys(obj);
const kind = keys.find((key) => obj[key] !== null && obj[key] !== undefined && txt(obj[key]) !== '') || keys[0] || '';
const raw = kind ? obj[kind] : undefined;
if (kind === 'date') {
const iso = parseEpochMaybe(raw);
return { value: iso ? new Date(iso).toUTCString() : txt(raw), kind, raw };
}
if (kind === 'monthYear') return { value: txt(raw), kind, raw };
if (kind === 'email' && raw && typeof raw === 'object') {
return { value: txt((raw as Record<string, unknown>).email_address), kind, raw };
}
if (kind === 'address' || kind === 'sshKey') return { value: '', kind, raw };
return { value: txt(raw), kind, raw };
}
export function parseOnePasswordCsv(textRaw: string, isMac: boolean): CiphersImportPayload {
const rows = parseCsv(textRaw);
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
@@ -83,17 +130,20 @@ export function parseOnePasswordCsv(textRaw: string, isMac: boolean): CiphersImp
if (Number(cipher.type) === 1) {
const login = cipher.login as Record<string, unknown>;
if (!txt(login.username) && lower === 'username') {
if (!txt(login.username) && isOnePasswordUsernameField(lower)) {
login.username = rawVal;
continue;
}
if (!txt(login.password) && lower === 'password') {
if (!txt(login.password) && isOnePasswordPasswordField(lower)) {
login.password = rawVal;
continue;
}
if ((!Array.isArray(login.uris) || !login.uris.length) && (lower === 'url' || lower === 'website')) {
const uri = normalizeUri(rawVal);
login.uris = uri ? [{ uri, match: null }] : null;
if (isOnePasswordUriField(lower)) {
addLoginUri(login, rawVal);
continue;
}
if (!txt(login.totp) && isTotpFieldName(onePasswordCsvFieldLabel(lower))) {
login.totp = rawVal;
continue;
}
} else if (Number(cipher.type) === 3 && cipher.card) {
@@ -149,7 +199,7 @@ export function parseOnePasswordCsv(textRaw: string, isMac: boolean): CiphersImp
}
}
if (!ignored.has(lower) && !lower.startsWith('section:') && !lower.startsWith('section ')) {
if (!ignored.has(lower) && !isTotpFieldName(onePasswordCsvFieldLabel(lower)) && !lower.startsWith('section:') && !lower.startsWith('section ')) {
if (!altUsername && lower === 'email') altUsername = rawVal;
if (lower === 'created date' || lower === 'modified date') {
const readable = parseEpochMaybe(rawVal);
@@ -197,8 +247,8 @@ function parseOnePasswordFieldsIntoCipher(
login.password = value;
continue;
}
if (!txt(login.totp) && designation.startsWith('totp_')) {
login.totp = value;
if (!txt(login.totp) && (designation.startsWith('totp_') || isTotpFieldName(designation) || isTotpFieldName(fieldName))) {
login.totp = extractTotpValue(raw) || value;
continue;
}
} else if (Number(cipher.type) === 3 && cipher.card) {
@@ -327,7 +377,7 @@ export function parseOnePassword1Pif(textRaw: string): CiphersImportPayload {
if (uri) uris.push(uri);
}
if (Number(cipher.type) === 1) {
(cipher.login as Record<string, unknown>).uris = uris.length ? uris.map((uri) => ({ uri, match: null })) : null;
setLoginUris(cipher.login as Record<string, unknown>, uris);
(cipher.login as Record<string, unknown>).password = val(details?.password);
}
cipher.notes = val(details?.notesPlain);
@@ -381,6 +431,15 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload
ssn: null,
licenseNumber: null,
};
} else if (categoryType === 5) {
cipher.type = 5;
cipher.login = null;
cipher.sshKey = {
privateKey: null,
publicKey: null,
keyFingerprint: null,
fingerprint: null,
};
}
cipher.favorite = Number(item?.favIndex) === 1;
cipher.name = val(item?.overview?.title, '--');
@@ -392,9 +451,14 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload
const uri = normalizeUri(u?.url || '');
if (uri) urls.push(uri);
}
const fallbackUrl = normalizeUri(item?.overview?.url || '');
if (fallbackUrl) urls.push(fallbackUrl);
(cipher.login as Record<string, unknown>).uris = urls.length ? urls.map((uri) => ({ uri, match: null })) : null;
if (!urls.length) {
const fallbackUrl = normalizeUri(item?.overview?.url || '');
if (fallbackUrl) urls.push(fallbackUrl);
}
setLoginUris(cipher.login as Record<string, unknown>, urls);
if (txt(item?.categoryUuid) === '005' && !txt((cipher.login as Record<string, unknown>).password)) {
(cipher.login as Record<string, unknown>).password = val(item?.details?.password);
}
}
for (const loginField of item?.details?.loginFields || []) {
@@ -402,18 +466,19 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload
if (!lv) continue;
const designation = txt(loginField?.designation).toLowerCase();
const fieldName = txt(loginField?.name);
const fieldLabel = onePasswordCsvFieldLabel(fieldName || designation);
const fieldType = txt(loginField?.fieldType);
if (Number(cipher.type) === 1) {
const login = cipher.login as Record<string, unknown>;
if (designation === 'username') {
if (designation === 'username' || isOnePasswordUsernameField(fieldLabel)) {
login.username = lv;
continue;
}
if (designation === 'password') {
if (designation === 'password' || isOnePasswordPasswordField(fieldLabel)) {
login.password = lv;
continue;
}
if (designation.includes('totp') || fieldName.toLowerCase().includes('totp')) {
if (designation.includes('totp') || isTotpFieldName(fieldName) || isTotpFieldName(fieldType)) {
login.totp = lv;
continue;
}
@@ -425,14 +490,11 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload
const fieldTitle = txt(section?.title);
for (const field of section?.fields || []) {
const fieldId = txt(field?.id);
const fieldType = txt(field?.value?.fieldType).toLowerCase();
const parsedField = readOnePasswordFieldValue(field?.value);
const fieldType = parsedField.kind.toLowerCase();
const fieldTitleLocal = txt(field?.title) || fieldTitle;
const fieldValueObj = field?.value?.value;
let fieldValue = txt(fieldValueObj);
if (!fieldValue && typeof fieldValueObj === 'number') {
const iso = parseEpochMaybe(fieldValueObj);
fieldValue = iso ? new Date(iso).toUTCString() : String(fieldValueObj);
}
const fieldValueObj = parsedField.raw;
const fieldValue = parsedField.value;
if (!fieldValue && !(fieldValueObj && typeof fieldValueObj === 'object')) continue;
if (Number(cipher.type) === 3 && cipher.card) {
@@ -509,36 +571,55 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload
}
} else if (Number(cipher.type) === 1) {
const login = cipher.login as Record<string, unknown>;
if (fieldId === 'url') {
const uri = normalizeUri(fieldValue);
if (uri) {
const uris = Array.isArray(login.uris) ? login.uris : [];
uris.push({ uri, match: null });
login.uris = uris;
}
if (fieldId === 'url' || fieldType === 'url') {
addLoginUri(login, fieldValue);
continue;
}
if (fieldId === 'username' && !txt(login.username)) {
if ((fieldId === 'username' || onePasswordCsvFieldLabel(fieldTitleLocal) === 'username') && !txt(login.username)) {
login.username = fieldValue;
continue;
}
if (fieldId === 'password' && !txt(login.password)) {
if ((fieldId === 'password' || onePasswordCsvFieldLabel(fieldTitleLocal) === 'password') && !txt(login.password)) {
login.password = fieldValue;
continue;
}
if ((fieldId === 'oneTimePassword' || fieldId === 'totp') && !txt(login.totp)) {
login.totp = fieldValue;
if (txt(item?.categoryUuid) === '112' && onePasswordCsvFieldLabel(fieldTitleLocal) === 'credential' && !txt(login.password)) {
login.password = fieldValue;
continue;
}
if (txt(item?.categoryUuid) === '112' && onePasswordCsvFieldLabel(fieldTitleLocal) === 'hostname') {
addLoginUri(login, fieldValue);
continue;
}
if (
(fieldId === 'oneTimePassword' || fieldId === 'totp' || fieldId.startsWith('TOTP_') || fieldType === 'totp' || fieldType === 'otp' || isTotpFieldName(fieldTitleLocal)) &&
!txt(login.totp)
) {
login.totp = extractTotpValue(fieldValueObj) || fieldValue;
continue;
}
} else if (Number(cipher.type) === 5 && cipher.sshKey && fieldType === 'sshkey' && fieldValueObj && typeof fieldValueObj === 'object') {
const ssh = fieldValueObj as Record<string, any>;
const metadata = ssh.metadata && typeof ssh.metadata === 'object' ? ssh.metadata : {};
cipher.sshKey = {
privateKey: val(metadata.privateKey ?? ssh.privateKey),
publicKey: val(metadata.publicKey),
keyFingerprint: val(metadata.fingerprint),
fingerprint: val(metadata.fingerprint),
};
continue;
}
const hidden = fieldType === 'concealed' || fieldType === 'otp';
const hidden = fieldType === 'concealed' || fieldType === 'totp' || fieldType === 'otp';
processKvp(cipher, fieldTitleLocal || fieldId || 'field', fieldValue, hidden);
}
}
parseOnePasswordPasswordHistory(cipher, item?.details?.passwordHistory || []);
convertToNoteIfNeeded(cipher);
const idx = result.ciphers.push(cipher) - 1;
if (vaultName) addFolder(result, vaultName, idx);
const tagFolder = Array.isArray(item?.overview?.tags) ? txt(item.overview.tags[0]) : '';
if (tagFolder) addFolder(result, tagFolder, idx);
else if (vaultName) addFolder(result, vaultName, idx);
}
}
}
+2
View File
@@ -25,6 +25,8 @@ export interface Folder {
id: string;
name: string;
decName?: string;
revisionDate?: string;
creationDate?: string;
}
export interface CipherLoginUri {
+108
View File
@@ -0,0 +1,108 @@
import type { Cipher, Folder } from './types';
export interface VaultCoreSnapshot {
ciphers: Cipher[];
folders: Folder[];
}
interface VaultCoreCacheRecord {
cacheKey: string;
revisionStamp: number;
savedAt: number;
snapshot: VaultCoreSnapshot;
}
const DB_NAME = 'nodewarden-web-cache';
const DB_VERSION = 1;
const VAULT_CORE_STORE = 'vault-core';
let dbPromise: Promise<IDBDatabase | null> | null = null;
function supportsIndexedDb(): boolean {
return typeof indexedDB !== 'undefined';
}
function openDatabase(): Promise<IDBDatabase | null> {
if (!supportsIndexedDb()) return Promise.resolve(null);
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
try {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(VAULT_CORE_STORE)) {
db.createObjectStore(VAULT_CORE_STORE, { keyPath: 'cacheKey' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => resolve(null);
request.onblocked = () => resolve(null);
} catch {
resolve(null);
}
});
return dbPromise;
}
function withStore<T>(
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => Promise<T>
): Promise<T | null> {
return openDatabase().then((db) => {
if (!db) return null;
return new Promise<T | null>((resolve) => {
try {
const tx = db.transaction(VAULT_CORE_STORE, mode);
const store = tx.objectStore(VAULT_CORE_STORE);
void run(store).then(resolve).catch(() => resolve(null));
tx.onerror = () => resolve(null);
tx.onabort = () => resolve(null);
} catch {
resolve(null);
}
});
});
}
export async function loadCachedVaultCoreSnapshot(cacheKey: string): Promise<VaultCoreCacheRecord | null> {
const normalized = String(cacheKey || '').trim();
if (!normalized) return null;
return withStore('readonly', (store) => new Promise<VaultCoreCacheRecord | null>((resolve) => {
const request = store.get(normalized);
request.onsuccess = () => {
const record = request.result as VaultCoreCacheRecord | undefined;
resolve(record || null);
};
request.onerror = () => resolve(null);
}));
}
export async function saveCachedVaultCoreSnapshot(
cacheKey: string,
revisionStamp: number,
snapshot: VaultCoreSnapshot
): Promise<void> {
const normalized = String(cacheKey || '').trim();
if (!normalized) return;
await withStore('readwrite', (store) => new Promise<void>((resolve) => {
const record: VaultCoreCacheRecord = {
cacheKey: normalized,
revisionStamp,
savedAt: Date.now(),
snapshot,
};
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
}));
}
export async function clearCachedVaultCoreSnapshot(cacheKey: string): Promise<void> {
const normalized = String(cacheKey || '').trim();
if (!normalized) return;
await withStore('readwrite', (store) => new Promise<void>((resolve) => {
const request = store.delete(normalized);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
}));
}
+245
View File
@@ -0,0 +1,245 @@
import { base64ToBytes, decryptBw, decryptStr } from './crypto';
import { deriveSendKeyParts } from './app-support';
import type { Cipher, Folder, Send } from './types';
export interface DecryptVaultCoreArgs {
folders: Folder[];
ciphers: Cipher[];
symEncKeyB64: string;
symMacKeyB64: string;
}
export interface DecryptVaultCoreResult {
folders: Folder[];
ciphers: Cipher[];
}
export interface DecryptSendsArgs {
sends: Send[];
symEncKeyB64: string;
symMacKeyB64: string;
origin: string;
}
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.byteLength !== b.byteLength) return false;
for (let i = 0; i < a.byteLength; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
async function decryptField(
value: string | null | undefined,
enc: Uint8Array,
mac: Uint8Array
): Promise<string> {
if (!value || typeof value !== 'string') return '';
try {
return await decryptStr(value, enc, mac);
} catch {
return value;
}
}
async function decryptFieldWithSource(
value: string | null | undefined,
itemEnc: Uint8Array,
itemMac: Uint8Array,
userEnc: Uint8Array,
userMac: Uint8Array,
canFallbackToUserKey: boolean
): Promise<{ text: string; source: 'item' | 'user' | 'plain' }> {
const raw = String(value || '').trim();
if (!raw) return { text: '', source: 'plain' };
try {
return { text: await decryptStr(raw, itemEnc, itemMac), source: 'item' };
} catch {
// Try legacy user-key fallback below.
}
if (canFallbackToUserKey) {
try {
return { text: await decryptStr(raw, userEnc, userMac), source: 'user' };
} catch {
// Keep plain fallback.
}
}
return { text: raw, source: 'plain' };
}
export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
const userEnc = base64ToBytes(args.symEncKeyB64);
const userMac = base64ToBytes(args.symMacKeyB64);
const folders = await Promise.all(
args.folders.map(async (folder) => ({
...folder,
decName: await decryptField(folder.name, userEnc, userMac),
}))
);
const ciphers = await Promise.all(
args.ciphers.map(async (cipher) => {
let itemEnc = userEnc;
let itemMac = userMac;
if (cipher.key) {
try {
const itemKey = await decryptBw(cipher.key, userEnc, userMac);
itemEnc = itemKey.slice(0, 32);
itemMac = itemKey.slice(32, 64);
} catch {
// Keep user key fallback.
}
}
const itemUsesUserKey = sameBytes(itemEnc, userEnc) && sameBytes(itemMac, userMac);
const nextCipher: Cipher = {
...cipher,
decName: await decryptField(cipher.name || '', itemEnc, itemMac),
decNotes: await decryptField(cipher.notes || '', itemEnc, itemMac),
};
if (cipher.login) {
nextCipher.login = {
...cipher.login,
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
uris: await Promise.all(
(cipher.login.uris || []).map(async (uri) => ({
...uri,
decUri: await decryptField(uri.uri || '', itemEnc, itemMac),
}))
),
};
}
if (Array.isArray(cipher.passwordHistory)) {
nextCipher.passwordHistory = await Promise.all(
cipher.passwordHistory.map(async (entry) => ({
...entry,
decPassword: await decryptField(entry?.password || '', itemEnc, itemMac),
}))
);
}
if (cipher.card) {
nextCipher.card = {
...cipher.card,
decCardholderName: await decryptField(cipher.card.cardholderName || '', itemEnc, itemMac),
decNumber: await decryptField(cipher.card.number || '', itemEnc, itemMac),
decBrand: await decryptField(cipher.card.brand || '', itemEnc, itemMac),
decExpMonth: await decryptField(cipher.card.expMonth || '', itemEnc, itemMac),
decExpYear: await decryptField(cipher.card.expYear || '', itemEnc, itemMac),
decCode: await decryptField(cipher.card.code || '', itemEnc, itemMac),
};
}
if (cipher.identity) {
nextCipher.identity = {
...cipher.identity,
decTitle: await decryptField(cipher.identity.title || '', itemEnc, itemMac),
decFirstName: await decryptField(cipher.identity.firstName || '', itemEnc, itemMac),
decMiddleName: await decryptField(cipher.identity.middleName || '', itemEnc, itemMac),
decLastName: await decryptField(cipher.identity.lastName || '', itemEnc, itemMac),
decUsername: await decryptField(cipher.identity.username || '', itemEnc, itemMac),
decCompany: await decryptField(cipher.identity.company || '', itemEnc, itemMac),
decSsn: await decryptField(cipher.identity.ssn || '', itemEnc, itemMac),
decPassportNumber: await decryptField(cipher.identity.passportNumber || '', itemEnc, itemMac),
decLicenseNumber: await decryptField(cipher.identity.licenseNumber || '', itemEnc, itemMac),
decEmail: await decryptField(cipher.identity.email || '', itemEnc, itemMac),
decPhone: await decryptField(cipher.identity.phone || '', itemEnc, itemMac),
decAddress1: await decryptField(cipher.identity.address1 || '', itemEnc, itemMac),
decAddress2: await decryptField(cipher.identity.address2 || '', itemEnc, itemMac),
decAddress3: await decryptField(cipher.identity.address3 || '', itemEnc, itemMac),
decCity: await decryptField(cipher.identity.city || '', itemEnc, itemMac),
decState: await decryptField(cipher.identity.state || '', itemEnc, itemMac),
decPostalCode: await decryptField(cipher.identity.postalCode || '', itemEnc, itemMac),
decCountry: await decryptField(cipher.identity.country || '', itemEnc, itemMac),
};
}
if (cipher.sshKey) {
const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || '';
nextCipher.sshKey = {
...cipher.sshKey,
decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac),
decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac),
keyFingerprint: encryptedFingerprint || null,
fingerprint: encryptedFingerprint || null,
decFingerprint: await decryptField(encryptedFingerprint, itemEnc, itemMac),
};
}
if (cipher.fields) {
nextCipher.fields = await Promise.all(
cipher.fields.map(async (field) => ({
...field,
decName: await decryptField(field.name || '', itemEnc, itemMac),
decValue: await decryptField(field.value || '', itemEnc, itemMac),
}))
);
}
if (Array.isArray(cipher.attachments)) {
nextCipher.attachments = await Promise.all(
cipher.attachments.map(async (attachment) => {
const fileNameResult = await decryptFieldWithSource(
attachment.fileName || '',
itemEnc,
itemMac,
userEnc,
userMac,
!itemUsesUserKey
);
return {
...attachment,
decFileName: fileNameResult.text,
};
})
);
}
return nextCipher;
})
);
return { folders, ciphers };
}
export async function decryptSends(args: DecryptSendsArgs): Promise<Send[]> {
const userEnc = base64ToBytes(args.symEncKeyB64);
const userMac = base64ToBytes(args.symMacKeyB64);
return Promise.all(
args.sends.map(async (send) => {
const nextSend: Send = { ...send };
try {
if (send.key) {
const sendKeyRaw = await decryptBw(send.key, userEnc, userMac);
const derived = await deriveSendKeyParts(sendKeyRaw);
nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac);
nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac);
nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac);
if (send.file?.fileName) {
const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac);
nextSend.file = {
...(send.file || {}),
fileName: decFileName || send.file.fileName,
};
}
nextSend.decShareKey = btoa(String.fromCharCode(...sendKeyRaw))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
nextSend.shareUrl = `${args.origin}/#/send/${send.accessId}/${nextSend.decShareKey}`;
} else {
nextSend.decName = '';
nextSend.decNotes = '';
nextSend.decText = '';
}
} catch {
nextSend.decName = 'Decrypt failed';
}
return nextSend;
})
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { Send } from './types';
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
type WorkerSuccess<T> = { id: number; ok: true; result: T };
type WorkerFailure = { id: number; ok: false; error: string };
type WorkerResponse<T> = WorkerSuccess<T> | WorkerFailure;
let worker: Worker | null = null;
let nextJobId = 1;
const pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>();
function getWorker(): Worker | null {
if (typeof Worker === 'undefined') return null;
if (worker) return worker;
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
const message = event.data;
const job = pending.get(message.id);
if (!job) return;
pending.delete(message.id);
if (message.ok) {
job.resolve(message.result);
return;
}
job.reject(new Error(message.error || 'Decrypt failed'));
});
worker.addEventListener('error', () => {
for (const [, job] of pending) {
job.reject(new Error('Decrypt worker failed'));
}
pending.clear();
worker = null;
});
return worker;
}
function postJob<T>(payload: { kind: 'vault-core'; payload: DecryptVaultCoreArgs } | { kind: 'sends'; payload: DecryptSendsArgs }): Promise<T> {
const instance = getWorker();
if (!instance) {
return Promise.reject(new Error('Decrypt worker unavailable'));
}
const id = nextJobId++;
return new Promise<T>((resolve, reject) => {
pending.set(id, { resolve, reject });
instance.postMessage({ id, ...payload });
});
}
export function decryptVaultCoreInWorker(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
return postJob<DecryptVaultCoreResult>({ kind: 'vault-core', payload: args });
}
export function decryptSendsInWorker(args: DecryptSendsArgs): Promise<Send[]> {
return postJob<Send[]>({ kind: 'sends', payload: args });
}
+88
View File
@@ -0,0 +1,88 @@
type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error';
interface WebsiteIconRecord {
status: WebsiteIconStatus;
promise: Promise<WebsiteIconStatus> | null;
listeners: Set<(status: WebsiteIconStatus) => void>;
}
const iconRecords = new Map<string, WebsiteIconRecord>();
function ensureRecord(host: string): WebsiteIconRecord {
let record = iconRecords.get(host);
if (!record) {
record = {
status: 'idle',
promise: null,
listeners: new Set(),
};
iconRecords.set(host, record);
}
return record;
}
function notifyRecord(host: string, status: WebsiteIconStatus): void {
const record = ensureRecord(host);
record.status = status;
for (const listener of Array.from(record.listeners)) {
listener(status);
}
}
export function getWebsiteIconStatus(host: string): WebsiteIconStatus {
if (!host) return 'idle';
return ensureRecord(host).status;
}
export function subscribeWebsiteIconStatus(host: string, listener: (status: WebsiteIconStatus) => void): () => void {
if (!host) return () => undefined;
const record = ensureRecord(host);
record.listeners.add(listener);
return () => {
record.listeners.delete(listener);
};
}
export function markWebsiteIconLoaded(host: string): void {
if (!host) return;
const record = ensureRecord(host);
record.promise = null;
notifyRecord(host, 'loaded');
}
export function markWebsiteIconErrored(host: string): void {
if (!host) return;
const record = ensureRecord(host);
record.promise = null;
notifyRecord(host, 'error');
}
export function preloadWebsiteIcon(host: string, src: string): Promise<WebsiteIconStatus> {
if (!host) return Promise.resolve('error');
const record = ensureRecord(host);
if (record.status === 'loaded' || record.status === 'error') {
return Promise.resolve(record.status);
}
if (record.promise) {
return record.promise;
}
record.status = 'loading';
record.promise = new Promise<WebsiteIconStatus>((resolve) => {
const img = new Image();
img.decoding = 'async';
img.referrerPolicy = 'no-referrer';
img.onload = () => {
markWebsiteIconLoaded(host);
resolve('loaded');
};
img.onerror = () => {
markWebsiteIconErrored(host);
resolve('error');
};
img.src = src;
});
return record.promise;
}
+24
View File
@@ -0,0 +1,24 @@
import type { Cipher } from './types';
export function firstCipherUri(cipher: Cipher): string {
const uris = cipher.login?.uris || [];
for (const uri of uris) {
const raw = uri.decUri || uri.uri || '';
if (raw.trim()) return raw.trim();
}
return '';
}
export function hostFromUri(uri: string): string {
if (!uri.trim()) return '';
try {
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
return new URL(normalized).hostname || '';
} catch {
return '';
}
}
export function websiteIconUrl(host: string): string {
return `/icons/${encodeURIComponent(host)}/icon.png?fallback=404`;
}
+13 -6
View File
@@ -1,6 +1,7 @@
import { render } from 'preact';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import { initI18n } from './lib/i18n';
import './tailwind.css';
import './styles.css';
@@ -9,13 +10,19 @@ const queryClient = new QueryClient({
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
document.getElementById('root')!
);
async function bootstrap(): Promise<void> {
await initI18n();
render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
document.getElementById('root')!
);
}
void bootstrap();
+77 -108
View File
@@ -10,7 +10,9 @@
@import './styles/responsive.css';
@import './styles/dark.css';
/* Unified product polish: clean, flat, quiet surfaces across desktop, mobile, and dark mode. */
/* Unified product polish: refined, smooth, comfortable surfaces across desktop, mobile, and dark mode. */
/* ── surface consistency ── */
.app-shell,
.auth-card,
.dialog-card,
@@ -48,69 +50,7 @@
border-color: var(--line-soft);
}
.brand-logo,
.brand-wordmark,
.standalone-brand-logo,
.standalone-brand-wordmark {
filter: none;
}
.btn,
.input,
.search-input,
.side-link,
.mobile-tab,
.tree-btn,
.list-item,
.dialog-card,
.mobile-sidebar-sheet,
.mobile-detail-sheet,
.create-menu,
.sort-menu,
.toast-item {
transition-duration: 150ms;
}
.btn:hover:not(:disabled),
.side-link:hover,
.mobile-tab:hover,
.tree-btn:hover,
.list-item:hover,
.create-menu-item:hover,
.sort-menu-item:hover,
.folder-delete-btn:hover,
.eye-btn:hover,
.password-toggle:hover {
transform: none;
}
.btn-primary {
background: var(--primary);
border-color: transparent;
color: #ffffff;
box-shadow: none;
}
.btn-primary:hover {
background: var(--primary-hover);
box-shadow: none;
}
.btn-secondary,
.btn-danger {
box-shadow: none;
}
.btn-secondary:hover,
.side-link:hover,
.mobile-tab:hover,
.tree-btn:hover,
.list-item:hover,
.backup-destination-item:hover,
.mobile-settings-link:hover {
background: var(--panel-subtle);
}
/* ── active states ── */
.side-link.active,
.mobile-tab.active,
.tree-btn.active,
@@ -122,32 +62,9 @@
background: color-mix(in srgb, var(--primary) 12%, var(--panel));
border-color: color-mix(in srgb, var(--primary) 32%, var(--line));
color: var(--primary-strong);
box-shadow: none;
}
.list-item::before,
.topbar-actions .btn::before,
.user-chip::before,
.side-link::before,
.mobile-tab::before {
display: none;
}
.stagger-item {
opacity: 1;
animation: none;
}
.dialog-mask {
background: rgba(15, 23, 42, 0.42);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.dialog-mask.warning {
background: rgba(15, 23, 42, 0.56);
}
/* ── danger / warning panels (cosmetic resets only) ── */
.dialog-card.warning,
:root[data-theme='dark'] .dialog-card.warning {
background: var(--panel);
@@ -159,7 +76,6 @@
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
color: var(--danger);
box-shadow: none;
}
.dialog-warning-kicker {
@@ -171,25 +87,10 @@
:root[data-theme='dark'] .dialog-message.warning {
background: color-mix(in srgb, var(--danger) 8%, var(--panel));
border-color: color-mix(in srgb, var(--danger) 20%, var(--line));
box-shadow: none;
color: var(--text);
}
.mobile-sidebar-sheet,
.mobile-detail-sheet {
transform: none;
box-shadow: var(--shadow-md);
}
.mobile-sidebar-sheet.open,
.mobile-detail-sheet.open {
transform: none;
}
.mobile-fab-trigger {
box-shadow: var(--shadow-md);
}
/* ── theme switch ── */
.theme-switch-slider,
.theme-switch-input:checked + .theme-switch-slider,
:root[data-theme='dark'] .theme-switch-slider {
@@ -203,6 +104,7 @@
box-shadow: var(--shadow-sm);
}
/* ── dark mode surface resets ── */
:root[data-theme='dark'] .app-shell,
:root[data-theme='dark'] .auth-card,
:root[data-theme='dark'] .dialog-card,
@@ -244,21 +146,18 @@
background: var(--panel);
border-color: var(--line);
color: var(--primary);
box-shadow: none;
}
:root[data-theme='dark'] .btn-primary {
background: var(--primary);
border-color: transparent;
color: #08111f;
box-shadow: none;
}
:root[data-theme='dark'] .btn-danger {
background: var(--panel);
border-color: color-mix(in srgb, var(--danger) 36%, var(--line));
color: var(--danger);
box-shadow: none;
}
:root[data-theme='dark'] .list-item:hover,
@@ -271,3 +170,73 @@
:root[data-theme='dark'] .tree-btn:hover {
background: var(--panel-subtle);
}
.loading-state,
.loading-state-card {
display: grid;
gap: 12px;
}
.loading-state.compact,
.loading-state-card.compact {
gap: 10px;
}
.loading-state-card {
padding: 16px;
}
.loading-state-row {
display: flex;
align-items: center;
gap: 12px;
}
.loading-state-icon {
width: 36px;
height: 36px;
flex: 0 0 36px;
border-radius: 10px;
background: color-mix(in srgb, var(--panel-muted) 78%, transparent);
}
.loading-state-text {
display: grid;
gap: 8px;
flex: 1;
}
.loading-state-line {
height: 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--panel-muted) 80%, transparent);
}
.loading-state-line.short {
width: 42%;
}
.shimmer {
position: relative;
overflow: hidden;
}
.shimmer::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, white 38%, transparent) 48%,
transparent 100%
);
animation: loading-shimmer 1.25s ease-in-out infinite;
}
@keyframes loading-shimmer {
100% {
transform: translateX(100%);
}
}
+7 -4
View File
@@ -46,14 +46,17 @@
}
.standalone-brand-logo {
@apply h-14 w-14 flex-shrink-0 object-contain;
@apply h-14 w-[70px] flex-shrink-0 object-contain;
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
}
.standalone-brand-wordmark {
@apply block h-auto max-w-full;
width: clamp(200px, 30vw, 360px);
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.18));
@apply block max-w-full;
width: clamp(200px, 30vw, 340px);
aspect-ratio: 862 / 102;
background: #116ff9;
mask: url('/nodewarden-wordmark.svg') center / contain no-repeat;
-webkit-mask: url('/nodewarden-wordmark.svg') center / contain no-repeat;
}
.standalone-title {
+37
View File
@@ -11,6 +11,10 @@ body,
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
}
html {
scroll-behavior: smooth;
}
body {
@apply relative antialiased;
transition: background-color var(--dur-medium) var(--ease-smooth), color var(--dur-medium) var(--ease-smooth);
@@ -20,3 +24,36 @@ body.dialog-open {
@apply overflow-hidden;
overscroll-behavior: contain;
}
::selection {
background: color-mix(in srgb, var(--primary) 20%, transparent);
color: var(--text);
}
:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 22%, transparent);
}
/* --- custom scrollbar --- */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted) 30%, transparent);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--muted) 50%, transparent);
}
::-webkit-scrollbar-corner {
background: transparent;
}
+153
View File
@@ -57,6 +57,7 @@
:root[data-theme='dark'] .backup-recommendation-linked-item,
:root[data-theme='dark'] .backup-inline-suffix,
:root[data-theme='dark'] .folder-delete-btn,
:root[data-theme='dark'] .folder-sort-btn,
:root[data-theme='dark'] .tree-label {
color: var(--muted);
}
@@ -143,3 +144,155 @@
background: #ffffff;
border-color: rgba(15, 23, 42, 0.12);
}
/* ── dark mode scrollbar ── */
:root[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted) 24%, transparent);
}
:root[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--muted) 44%, transparent);
}
/* ── dark mode backdrop-filter ── */
:root[data-theme='dark'] .dialog-mask {
backdrop-filter: blur(8px) brightness(0.7);
-webkit-backdrop-filter: blur(8px) brightness(0.7);
}
:root[data-theme='dark'] .topbar {
background: rgba(17, 24, 39, 0.78);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
:root[data-theme='dark'] .user-chip {
background: color-mix(in srgb, var(--panel) 86%, transparent);
border-color: var(--line);
color: var(--text);
box-shadow: var(--shadow-sm);
}
:root[data-theme='dark'] .user-chip:hover {
background: var(--panel-subtle);
border-color: color-mix(in srgb, var(--primary) 24%, var(--line));
}
/* ── dark mode depth ── */
:root[data-theme='dark'] .card,
:root[data-theme='dark'] .list-panel,
:root[data-theme='dark'] .sidebar-block,
:root[data-theme='dark'] .mobile-sidebar-sheet {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.20), 0 8px 24px rgba(0, 0, 0, 0.16);
}
:root[data-theme='dark'] .app-shell {
box-shadow: 0 4px 40px rgba(0, 0, 0, 0.30);
}
:root[data-theme='dark'] .list-item:hover {
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'] .mobile-sidebar-sheet,
:root[data-theme='dark'] .mobile-sidebar-close,
:root[data-theme='dark'] .table tr,
:root[data-theme='dark'] .settings-subcard,
:root[data-theme='dark'] .import-summary-table-wrap,
:root[data-theme='dark'] .backup-help-bubble,
:root[data-theme='dark'] .backup-recommendation-card,
:root[data-theme='dark'] .backup-recommendation-dav-item,
:root[data-theme='dark'] .backup-browser-path,
:root[data-theme='dark'] .backup-browser-list,
:root[data-theme='dark'] .restore-progress-card,
:root[data-theme='dark'] .restore-progress-current,
:root[data-theme='dark'] .restore-progress-elapsed {
background: var(--panel);
border-color: var(--line);
color: var(--text);
}
:root[data-theme='dark'] .mobile-sidebar-title,
:root[data-theme='dark'] .import-summary-close,
:root[data-theme='dark'] .backup-recommendation-group-title,
:root[data-theme='dark'] .backup-browser-path strong,
:root[data-theme='dark'] .restore-progress-current strong,
:root[data-theme='dark'] .custom-field-check span,
:root[data-theme='dark'] .notes {
color: var(--text);
}
:root[data-theme='dark'] .backup-help-bubble::before {
background: var(--panel);
border-color: var(--line);
}
:root[data-theme='dark'] .mobile-sidebar-close:hover,
:root[data-theme='dark'] .mobile-sidebar-sheet .tree-btn.active,
:root[data-theme='dark'] .mobile-settings-link.active,
:root[data-theme='dark'] .backup-destination-item.active,
:root[data-theme='dark'] .backup-interval-preset.active {
background: color-mix(in srgb, var(--primary) 14%, var(--panel));
color: var(--primary-strong);
}
:root[data-theme='dark'] .table td,
:root[data-theme='dark'] .attachment-row,
:root[data-theme='dark'] .custom-field-card,
:root[data-theme='dark'] .kv-line,
:root[data-theme='dark'] .kv-row,
:root[data-theme='dark'] .import-summary-table th,
:root[data-theme='dark'] .import-summary-table td,
:root[data-theme='dark'] .restore-progress-card,
:root[data-theme='dark'] .restore-progress-current,
:root[data-theme='dark'] .restore-progress-elapsed {
border-color: var(--line-soft);
}
:root[data-theme='dark'] .import-summary-table th {
background: var(--panel-muted);
color: var(--muted-strong);
}
:root[data-theme='dark'] .import-summary-failed-list {
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
border-color: color-mix(in srgb, var(--danger) 34%, var(--line));
color: var(--danger);
}
:root[data-theme='dark'] .backup-help-trigger,
:root[data-theme='dark'] .backup-destination-type,
:root[data-theme='dark'] .backup-interval-preset,
:root[data-theme='dark'] .restore-progress-meter {
background: var(--panel-muted);
border-color: var(--line);
color: var(--muted-strong);
}
:root[data-theme='dark'] .backup-destination-item:hover,
:root[data-theme='dark'] .backup-interval-preset:hover:not(:disabled) {
background: var(--panel-subtle);
border-color: color-mix(in srgb, var(--primary) 34%, var(--line));
color: var(--primary-strong);
}
:root[data-theme='dark'] .backup-help-bubble,
:root[data-theme='dark'] .backup-recommendation-step,
:root[data-theme='dark'] .backup-recommendation-inline-note,
:root[data-theme='dark'] .backup-recommendation-linked-item,
:root[data-theme='dark'] .backup-browser-meta,
:root[data-theme='dark'] .backup-browser-empty,
:root[data-theme='dark'] .backup-inline-note,
:root[data-theme='dark'] .restore-progress-kicker,
:root[data-theme='dark'] .restore-progress-subtitle,
:root[data-theme='dark'] .restore-progress-current p,
:root[data-theme='dark'] .restore-progress-item,
:root[data-theme='dark'] .check-line {
color: var(--muted);
}
:root[data-theme='dark'] .restore-progress-overlay {
background: var(--overlay-strong);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
+3 -1
View File
@@ -123,10 +123,12 @@ input[type='file'].input::file-selector-button:hover {
.btn:hover:not(:disabled) {
transform: translateY(-1px);
transition-duration: var(--dur-quick);
}
.btn:active:not(:disabled) {
transform: translateY(0) scale(0.985);
transform: translateY(0) scale(0.97);
transition-duration: var(--dur-instant);
}
.btn-icon {
+21
View File
@@ -392,6 +392,27 @@
@apply h-[180px] w-[180px] rounded-lg bg-white;
}
.totp-secret-input-wrap {
@apply relative;
}
.totp-secret-input {
padding-right: 84px;
}
.totp-secret-actions {
@apply absolute right-2 top-1/2 inline-flex items-center gap-1;
transform: translateY(-50%);
}
.totp-secret-icon-btn {
@apply h-8 w-8 min-w-8 gap-0 rounded-lg p-0;
}
.totp-secret-icon-btn .btn-icon {
@apply m-0;
}
.section-head {
@apply mb-2.5 flex items-center justify-between;
}
+22 -66
View File
@@ -10,102 +10,58 @@
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-in-up {
from {
opacity: 0;
}
to {
opacity: 1;
}
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shell-enter {
from {
opacity: 0;
}
to {
opacity: 1;
}
from { opacity: 0; transform: scale(0.98); }
to { opacity: 1; transform: scale(1); }
}
@keyframes surface-enter {
from {
opacity: 0;
}
to {
opacity: 1;
}
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes menu-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
from { opacity: 0; transform: translateY(-6px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes dialog-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
from { opacity: 0; transform: translateY(12px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toast-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
from { opacity: 0; transform: translateX(16px) scale(0.96); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes stagger-rise {
from {
opacity: 0;
}
to {
opacity: 1;
}
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-6px) scale(0.98); }
}
@keyframes dialog-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.97); }
}
@keyframes route-stage-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
+28 -14
View File
@@ -73,7 +73,7 @@
}
.standalone-brand-logo {
@apply h-11 w-11;
@apply h-11 w-[55px];
}
.auth-card {
@@ -111,7 +111,7 @@
}
.brand-logo {
@apply h-[34px] w-[34px];
@apply h-[34px] w-[43px];
}
.brand-wordmark {
@@ -173,13 +173,18 @@
@apply grid justify-items-center gap-1 rounded-xl px-1 py-1.5 text-[11px] font-bold no-underline;
color: #64748b;
transition:
transform 220ms var(--ease-out-soft),
transform 180ms var(--ease-spring),
background-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth);
}
.mobile-tab:hover {
transform: translateY(-1px);
transform: translateY(-2px);
}
.mobile-tab:active {
transform: translateY(0) scale(0.95);
transition-duration: 80ms;
}
.mobile-tab.active {
@@ -206,11 +211,11 @@
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
visibility: hidden;
pointer-events: none;
transform: translate3d(0, 10px, 0) scale(0.98);
transform: translate3d(0, 12px, 0) scale(0.97);
transition:
opacity 220ms var(--ease-smooth),
transform 240ms var(--ease-out-soft),
visibility 220ms var(--ease-smooth);
opacity 200ms var(--ease-out-expo),
transform 260ms var(--ease-spring),
visibility 200ms var(--ease-out-expo);
}
.mobile-sidebar-sheet.open {
@@ -322,12 +327,21 @@
.mobile-fab-wrap {
@apply fixed right-3.5 z-[45];
bottom: calc(14px + var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
bottom: calc(14px + var(--mobile-tabbar-height, 70px) + env(safe-area-inset-bottom));
}
.mobile-fab-trigger {
@apply h-14 w-9 gap-0 rounded-full p-0 text-[0];
@apply h-9 w-9 gap-0 rounded-full p-0 text-[0];
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28);
transition: transform 180ms var(--ease-spring), box-shadow var(--dur-fast) var(--ease-out-soft);
}
.mobile-fab-trigger:hover {
transform: scale(1.06);
}
.mobile-fab-trigger:active {
transform: scale(0.94);
}
.mobile-fab-trigger .btn-icon {
@@ -369,11 +383,11 @@
padding: 0 0 18px;
visibility: hidden;
pointer-events: none;
transform: translate3d(0, 18px, 0);
transform: translate3d(0, 20px, 0);
transition:
opacity 220ms var(--ease-smooth),
transform 260ms var(--ease-out-soft),
visibility 220ms var(--ease-smooth);
opacity 200ms var(--ease-out-expo),
transform 280ms var(--ease-spring),
visibility 200ms var(--ease-out-expo);
}
.mobile-detail-sheet.open {
+10 -5
View File
@@ -12,7 +12,9 @@
.topbar {
@apply flex h-[58px] items-center justify-between border-b px-[18px] text-slate-900 transition;
border-color: var(--line-soft);
background: rgba(244, 248, 255, 0.82);
background: rgba(244, 248, 255, 0.78);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.brand {
@@ -20,9 +22,12 @@
}
.brand-wordmark {
@apply block h-auto max-w-full;
width: clamp(210px, 20vw, 290px);
filter: drop-shadow(0 12px 24px rgba(43, 102, 217, 0.12));
@apply block max-w-full;
width: clamp(168px, 17vw, 224px);
aspect-ratio: 862 / 102;
background: #116ff9;
mask: url('/nodewarden-wordmark.svg') center / contain no-repeat;
-webkit-mask: url('/nodewarden-wordmark.svg') center / contain no-repeat;
}
.mobile-page-title {
@@ -31,7 +36,7 @@
}
.brand-logo {
@apply h-[42px] w-[42px] object-contain;
@apply h-[42px] w-[53px] object-contain;
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.22));
}

Some files were not shown because too many files have changed in this diff Show More