53 Commits

Author SHA1 Message Date
shuaiplus 246c73a3d3 Update version number to 1.5.1 2026-05-04 22:05:00 +08:00
shuaiplus 3d95c959f7 Added the preload demo experience feature to support presentation mode 2026-05-04 21:44:10 +08:00
shuaiplus e0737006c2 Optimize the public sending page and navigation logic in presentation mode to ensure consistency in user experience 2026-05-04 21:35:21 +08:00
shuaiplus 70dc9a76a9 Add isolated Pages demo mode with sample vault data 2026-05-04 21:09:10 +08:00
shuaiplus ba38b77387 Update UI translations 2026-05-04 04:20:41 +08:00
shuaiplus 1b4d263d6e Polish vault icons and mobile layout 2026-05-04 04:20:23 +08:00
shuaiplus 97a3aa691d Improve management page loading states 2026-05-04 04:19:59 +08:00
shuaiplus 0ab7c44981 Polish public Send pages 2026-05-04 04:19:17 +08:00
shuaiplus 75a6a593dc Improve app startup and route fallbacks 2026-05-04 04:19:02 +08:00
shuaiplus 45f0387526 feat: add TOTP QR code scanning functionality and related UI components 2026-05-04 01:44:27 +08:00
shuaiplus 851c9c4080 fix: update version display to be a link to the latest release 2026-05-01 05:34:05 +08:00
shuaiplus a73f9a6d87 chore: update version to 1.5.0 in package.json, package-lock.json, and app-version.ts 2026-05-01 05:30:44 +08:00
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
shuaiplus 15ee922777 chore: update version to 1.4.6 in package.json, package-lock.json, and app-version.ts 2026-04-25 16:05:07 +08:00
shuaiplus 2ea0b2c14c feat: Adds an API to update attachment metadata, supporting the repair of encrypted information of old attachments 2026-04-25 15:52:00 +08:00
shuaiplus 4ec1926888 fix: correct dialog-card width from 5000px to 500px for proper layout 2026-04-25 12:07:45 +08:00
shuaiplus 3995e01336 feat: enhance icon error handling and loading state management in TotpCodesPage and VaultListIcon components 2026-04-25 10:20:30 +08:00
shuaiplus 481536ba24 feat: update list icon opacity and z-index for improved loading behavior 2026-04-25 04:40:22 +08:00
shuaiplus db8b9263a1 feat: implement session timeout feature with customizable actions and update UI components 2026-04-25 03:49:15 +08:00
shuaiplus a1f7250e90 feat: update mobile layout query to 1180px and enhance icon loading experience 2026-04-25 03:19:06 +08:00
shuaiplus e4bc1b9bbe Refactor frontend styles toward Tailwind utilities and unified design system 2026-04-25 02:23:10 +08:00
shuaiplus 514889adfc feat: refactor TOTP code handling to improve state management and refresh logic 2026-04-25 01:48:20 +08:00
shuaiplus fccc85c4bb feat: enhance ConfirmDialog with focus management and accessibility improvements 2026-04-25 01:36:12 +08:00
shuaiplus acd59a7387 feat: add auto-lock feature with customizable timeout settings and update UI for security preferences 2026-04-24 15:27:46 +08:00
shuaiplus d40b0514fd Refactor styles to utilize Tailwind CSS utility classes for improved consistency and maintainability across forms, motion, shell, and vault components. Remove deprecated reduced-motion styles and consolidate motion-related animations. Update color tokens for better contrast and accessibility. Introduce a new Tailwind CSS configuration file. 2026-04-24 15:14:12 +08:00
shuaiplus 033d44808f chore: update version to 1.4.5 in package.json, package-lock.json, and app-version.ts 2026-04-24 00:51:27 +08:00
127 changed files with 14093 additions and 5354 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npx vite *)",
"Bash(npx tsc *)"
]
}
}
+2
View File
@@ -42,3 +42,5 @@ tmp/
.tmp/ .tmp/
nodewarden.wiki/ nodewarden.wiki/
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"> <p align="center">
<img src="./NodeWarden.png" alt="NodeWarden Logo" /> <img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p> </p>
<p align="center"> <p align="center">
运行在 Cloudflare Workers 上的第三方 Bitwarden 兼容服务端 运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
</p> </p>
[![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/) <p align="center">
[![License: LGPL-3.0](https://img.shields.io/badge/License-LGPL--3.0-2ea44f)](./LICENSE) <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>
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest) <a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml) <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) English: <a href="./README_EN.md"><code>README_EN.md</code></a>
[Telegram 频道](https://t.me/NodeWarden_News) | [Telegram 群组](https://t.me/NodeWarden_Official)
English: [`README_EN.md`](./README_EN.md)
> **免责声明** > **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。 > 本项目仅供学习与交流使用,请定期备份你的密码库。
+21 -9
View File
@@ -1,17 +1,29 @@
<p align="center"> <p align="center">
<img src="./NodeWarden.png" alt="NodeWarden Logo" /> <img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p> </p>
<p align="center"> <p align="center">
A third-party Bitwarden-compatible server running on Cloudflare Workers. Bitwarden-compatible server running on Cloudflare Workers
</p> </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) <p align="center">
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest) <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>
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml) <a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
[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) <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>
[Telegram Channel](https://t.me/NodeWarden_News) | [Telegram Group](https://t.me/NodeWarden_Official) <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>
中文说明:[`README.md`](./README.md) </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** > **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_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 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_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 ( CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -182,14 +183,6 @@ CREATE TABLE IF NOT EXISTS login_attempts_ip (
updated_at INTEGER NOT NULL 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 ( CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
jti TEXT PRIMARY KEY, jti TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL expires_at INTEGER NOT NULL
+959 -15
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.4", "version": "1.5.1",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
@@ -9,9 +9,14 @@
"scripts": { "scripts": {
"dev": "wrangler dev -c wrangler.toml", "dev": "wrangler dev -c wrangler.toml",
"dev:kv": "wrangler dev -c wrangler.kv.toml", "dev:kv": "wrangler dev -c wrangler.kv.toml",
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
"build": "vite build --config webapp/vite.config.ts", "build": "vite build --config webapp/vite.config.ts",
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
"i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs",
"deploy": "wrangler deploy", "deploy": "wrangler deploy",
"deploy:kv": "wrangler deploy -c wrangler.kv.toml" "deploy:kv": "wrangler deploy -c wrangler.kv.toml",
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
}, },
"keywords": [ "keywords": [
"bitwarden", "bitwarden",
@@ -40,6 +45,10 @@
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260131.0",
"@preact/preset-vite": "^2.10.3", "@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.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", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+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
View File
@@ -0,0 +1,7 @@
const fs = require('node:fs');
const path = require('node:path');
const distDir = path.resolve(__dirname, '..', 'dist');
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(path.join(distDir, '_redirects'), '/* /index.html 200\n');
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.4.4'; export const APP_VERSION = '1.5.1';
+7 -7
View File
@@ -1,13 +1,13 @@
export const BACKUP_DEFAULT_TIMEZONE = 'UTC'; export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
export const BACKUP_DEFAULT_RETENTION_COUNT = 30; 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_REMOTE_PATH = 'nodewarden';
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24; export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export const BACKUP_DEFAULT_START_TIME = '03:00'; export const BACKUP_DEFAULT_START_TIME = '03:00';
export type BackupDestinationType = 'e3' | 'webdav'; export type BackupDestinationType = 's3' | 'webdav';
export interface E3BackupDestination { export interface S3BackupDestination {
endpoint: string; endpoint: string;
bucket: string; bucket: string;
region: string; region: string;
@@ -24,7 +24,7 @@ export interface WebDavBackupDestination {
} }
export type BackupDestinationConfig = export type BackupDestinationConfig =
| E3BackupDestination | S3BackupDestination
| WebDavBackupDestination; | WebDavBackupDestination;
export interface BackupRuntimeState { export interface BackupRuntimeState {
@@ -91,11 +91,11 @@ export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFA
} }
export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig { export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig {
if (type === 'e3') { if (type === 's3') {
return { return {
endpoint: '', endpoint: '',
bucket: '', bucket: '',
region: BACKUP_DEFAULT_E3_REGION, region: BACKUP_DEFAULT_S3_REGION,
accessKeyId: '', accessKeyId: '',
secretAccessKey: '', secretAccessKey: '',
rootPath: BACKUP_DEFAULT_REMOTE_PATH, rootPath: BACKUP_DEFAULT_REMOTE_PATH,
@@ -110,7 +110,7 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
} }
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string { export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
if (type === 'e3') return `E3 ${index}`; if (type === 's3') return `S3 ${index}`;
return `WebDAV ${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'; import type { Env } from '../types';
const SIGNALR_RECORD_SEPARATOR = 0x1e; const SIGNALR_RECORD_SEPARATOR = 0x1e;
@@ -362,21 +362,21 @@ export class NotificationsHub extends DurableObject<Env> {
} }
} }
export async function notifyUserVaultSync( export function notifyUserVaultSync(
env: Env, env: Env,
userId: string, userId: string,
revisionDate: string, revisionDate: string,
contextId?: string | null contextId?: string | null
): Promise<void> { ): void {
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null); waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
} }
export async function notifyUserLogout( export function notifyUserLogout(
env: Env, env: Env,
userId: string, userId: string,
targetDeviceIdentifier?: string | null targetDeviceIdentifier?: string | null
): Promise<void> { ): void {
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null); 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[]> { 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. // Generate a random alphanumeric string of the given length using crypto.getRandomValues.
function randomStringAlphanum(length: number): string { function randomStringAlphanum(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const array = new Uint8Array(length);
crypto.getRandomValues(array);
let result = ''; let result = '';
for (let i = 0; i < length; i++) { const maxUnbiased = Math.floor(256 / chars.length) * chars.length;
result += chars[array[i] % 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; return result;
} }
+80 -12
View File
@@ -21,13 +21,13 @@ import {
putBlobObject, putBlobObject,
} from '../services/blob-store'; } from '../services/blob-store';
async function notifyVaultSyncForRequest( function notifyVaultSyncForRequest(
request: Request, request: Request,
env: Env, env: Env,
userId: string, userId: string,
revisionDate: string revisionDate: string
): Promise<void> { ): void {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
// Format file size to human readable // Format file size to human readable
@@ -93,7 +93,7 @@ async function processAttachmentUpload(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
} }
return new Response(null, { status: 201 }); return new Response(null, { status: 201 });
@@ -153,7 +153,7 @@ export async function handleCreateAttachment(
// Update cipher revision date // Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
} }
// Get updated cipher for response // Get updated cipher for response
@@ -279,6 +279,64 @@ export async function handleGetAttachment(
}); });
} }
// PUT /api/ciphers/{cipherId}/attachment/{attachmentId}/metadata
// 修正旧附件的加密元数据,供官方客户端按当前 Bitwarden 契约解密。
export async function handleUpdateAttachmentMetadata(
request: Request,
env: Env,
userId: string,
cipherId: string,
attachmentId: string
): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
const attachment = await storage.getAttachment(attachmentId);
if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404);
}
let body: { fileName?: string | null; key?: string | null };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (!Object.prototype.hasOwnProperty.call(body, 'fileName') && !Object.prototype.hasOwnProperty.call(body, 'key')) {
return errorResponse('No metadata fields supplied', 400);
}
if (Object.prototype.hasOwnProperty.call(body, 'fileName')) {
const fileName = String(body.fileName || '').trim();
if (!fileName) return errorResponse('fileName is required', 400);
attachment.fileName = fileName;
}
if (Object.prototype.hasOwnProperty.call(body, 'key')) {
const key = body.key == null ? null : String(body.key || '').trim();
attachment.key = key || null;
}
await storage.saveAttachment(attachment);
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
return jsonResponse({
object: 'attachment',
id: attachment.id,
fileName: attachment.fileName,
key: attachment.key,
size: String(Number(attachment.size) || 0),
sizeName: attachment.sizeName,
});
}
// GET /api/attachments/{cipherId}/{attachmentId}?token=xxx // GET /api/attachments/{cipherId}/{attachmentId}?token=xxx
// Public download endpoint (uses token for auth instead of header) // Public download endpoint (uses token for auth instead of header)
export async function handlePublicDownloadAttachment( export async function handlePublicDownloadAttachment(
@@ -368,13 +426,10 @@ export async function handleDeleteAttachment(
// Delete attachment metadata // Delete attachment metadata
await storage.deleteAttachment(attachmentId); await storage.deleteAttachment(attachmentId);
// Remove attachment from cipher
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
// Update cipher revision date // Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
} }
// Get updated cipher for response // Get updated cipher for response
@@ -390,12 +445,25 @@ export async function handleDeleteAttachment(
export async function deleteAllAttachmentsForCipher( export async function deleteAllAttachmentsForCipher(
env: Env, env: Env,
cipherId: string cipherId: string
): Promise<void> {
await deleteAllAttachmentsForCiphers(env, [cipherId]);
}
export async function deleteAllAttachmentsForCiphers(
env: Env,
cipherIds: string[]
): Promise<void> { ): Promise<void> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachmentsByCipher = await storage.getAttachmentsByCipherIds(cipherIds);
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => { 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); const path = getAttachmentObjectKey(cipherId, attachment.id);
await deleteBlobObject(env, path); 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, getBackupLocalDateKey,
getDefaultBackupSettings, getDefaultBackupSettings,
getBackupSettingsRepairState, getBackupSettingsRepairState,
hasBackupSlotBetween,
isBackupDueNow, isBackupDueNow,
loadBackupSettings, loadBackupSettings,
normalizeBackupSettingsInput, 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 { function ensureBackupBlobName(value: string): string {
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!normalized) { if (!normalized) {
@@ -160,6 +253,7 @@ async function executeConfiguredBackup(
actorUserId: string | null, actorUserId: string | null,
trigger: 'manual' | 'scheduled', trigger: 'manual' | 'scheduled',
destinationId?: string | null, destinationId?: string | null,
keepAlive?: (() => Promise<void>) | null,
progress?: ((event: { progress?: ((event: {
operation: 'backup-remote-run'; operation: 'backup-remote-run';
step: string; step: string;
@@ -172,6 +266,9 @@ async function executeConfiguredBackup(
}) => Promise<void>) | null }) => Promise<void>) | null
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> { ): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
const maxArchiveUploadAttempts = 3; const maxArchiveUploadAttempts = 3;
const touchLease = async () => {
await keepAlive?.();
};
const currentSettings = await loadBackupSettings(storage, env, 'UTC'); const currentSettings = await loadBackupSettings(storage, env, 'UTC');
const destination = requireBackupDestination(currentSettings, destinationId); const destination = requireBackupDestination(currentSettings, destinationId);
@@ -180,9 +277,11 @@ async function executeConfiguredBackup(
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone); destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
destination.runtime.lastErrorAt = null; destination.runtime.lastErrorAt = null;
destination.runtime.lastErrorMessage = null; destination.runtime.lastErrorMessage = null;
await touchLease();
await saveBackupSettings(storage, env, currentSettings); await saveBackupSettings(storage, env, currentSettings);
try { try {
await touchLease();
await progress?.({ await progress?.({
operation: 'backup-remote-run', operation: 'backup-remote-run',
step: 'remote_run_prepare', step: 'remote_run_prepare',
@@ -190,6 +289,7 @@ async function executeConfiguredBackup(
stageTitle: 'txt_backup_remote_run_progress_prepare_title', stageTitle: 'txt_backup_remote_run_progress_prepare_title',
stageDetail: 'txt_backup_remote_run_progress_prepare_detail', stageDetail: 'txt_backup_remote_run_progress_prepare_detail',
}); });
await touchLease();
const archive = await buildBackupArchive(env, now, { const archive = await buildBackupArchive(env, now, {
includeAttachments: destination.includeAttachments, includeAttachments: destination.includeAttachments,
timeZone: destination.schedule.timezone, timeZone: destination.schedule.timezone,
@@ -219,9 +319,11 @@ async function executeConfiguredBackup(
}); });
const remoteSession = createRemoteBackupTransferSession(destination); const remoteSession = createRemoteBackupTransferSession(destination);
if (destination.includeAttachments) { if (destination.includeAttachments) {
await touchLease();
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession); const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
let attachmentIndexChanged = false; let attachmentIndexChanged = false;
for (const attachment of archive.manifest.attachmentBlobs || []) { for (const attachment of archive.manifest.attachmentBlobs || []) {
await touchLease();
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) { if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
continue; continue;
} }
@@ -238,11 +340,13 @@ async function executeConfiguredBackup(
attachmentIndexChanged = true; attachmentIndexChanged = true;
} }
if (attachmentIndexChanged) { if (attachmentIndexChanged) {
await touchLease();
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex); await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
} }
} }
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null; let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) { for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
await touchLease();
await progress?.({ await progress?.({
operation: 'backup-remote-run', operation: 'backup-remote-run',
step: 'remote_run_upload_archive', step: 'remote_run_upload_archive',
@@ -252,6 +356,7 @@ async function executeConfiguredBackup(
}); });
upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName); upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName);
try { try {
await touchLease();
await progress?.({ await progress?.({
operation: 'backup-remote-run', operation: 'backup-remote-run',
step: 'remote_run_verify_archive', step: 'remote_run_verify_archive',
@@ -282,6 +387,7 @@ async function executeConfiguredBackup(
let prunedFileCount = 0; let prunedFileCount = 0;
let pruneErrorMessage: string | null = null; let pruneErrorMessage: string | null = null;
try { try {
await touchLease();
await progress?.({ await progress?.({
operation: 'backup-remote-run', operation: 'backup-remote-run',
step: 'remote_run_cleanup', step: 'remote_run_cleanup',
@@ -300,8 +406,10 @@ async function executeConfiguredBackup(
destination.runtime.lastUploadedFileName = archive.fileName; destination.runtime.lastUploadedFileName = archive.fileName;
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength; destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
destination.runtime.lastUploadedDestination = upload.remotePath; destination.runtime.lastUploadedDestination = upload.remotePath;
await touchLease();
await saveBackupSettings(storage, env, currentSettings); await saveBackupSettings(storage, env, currentSettings);
await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, { await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
...getBackupDestinationSummary(destination), ...getBackupDestinationSummary(destination),
provider: upload.provider, provider: upload.provider,
@@ -332,8 +440,10 @@ async function executeConfiguredBackup(
} catch (error) { } catch (error) {
destination.runtime.lastErrorAt = new Date().toISOString(); destination.runtime.lastErrorAt = new Date().toISOString();
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed'; destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
await touchLease();
await saveBackupSettings(storage, env, currentSettings); await saveBackupSettings(storage, env, currentSettings);
await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, { await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
...getBackupDestinationSummary(destination), ...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage, error: destination.runtime.lastErrorMessage,
@@ -404,13 +514,30 @@ async function runImportAndAudit(
} }
export async function runScheduledBackupIfDue(env: Env): Promise<void> { export async function runScheduledBackupIfDue(env: Env): Promise<void> {
const storage = new StorageService(env.DB); await withBackupRunnerLease(env, 'scheduled', async (keepAlive) => {
const settings = await loadBackupSettings(storage, env, 'UTC'); const storage = new StorageService(env.DB);
const now = new Date(); let scanStartMs = Date.now();
for (const destination of settings.destinations) {
if (!isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)) continue; while (true) {
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id); 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> { 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> { export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const storage = new StorageService(env.DB);
try { try {
let body: { destinationId?: string } | null = null; let body: { destinationId?: string } | null = null;
try { try {
@@ -536,17 +662,32 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
}) => { }) => {
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier); await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
}; };
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null, progress); const outcome = await withBackupRunnerLease(env, `manual:${actorUser.id}`, async (keepAlive) => {
const settings = await loadBackupSettings(storage, env, 'UTC'); 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({ return jsonResponse({
object: 'backup-run', object: 'backup-run',
result: { result: {
fileName: result.fileName, fileName: outcome.result.fileName,
fileSize: result.fileSize, fileSize: outcome.result.fileSize,
provider: result.provider, provider: outcome.result.provider,
remotePath: result.remotePath, remotePath: outcome.result.remotePath,
}, },
settings, settings: outcome.settings,
}); });
} catch (error) { } catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500); 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 { notifyUserVaultSync } from '../durable/notifications-hub';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher } from './attachments'; import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination'; import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
@@ -24,13 +24,13 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : null; return normalized ? normalized : null;
} }
async function notifyVaultSyncForRequest( function notifyVaultSyncForRequest(
request: Request, request: Request,
env: Env, env: Env,
userId: string, userId: string,
revisionDate: string revisionDate: string
): Promise<void> { ): void {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } { function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
@@ -78,6 +78,43 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher {
return 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 { function normalizeCipherForStorage(cipher: Cipher): Cipher {
cipher.login = normalizeCipherLoginForStorage(cipher.login); cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey); cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
@@ -100,7 +137,53 @@ export function normalizeCipherLoginForStorage(login: any): any {
export function normalizeCipherLoginForCompatibility(login: any): any { export function normalizeCipherLoginForCompatibility(login: any): any {
const normalized = normalizeCipherLoginForStorage(login); const normalized = normalizeCipherLoginForStorage(login);
if (!normalized || typeof normalized !== 'object') return normalized ?? null; 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. // Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
@@ -118,8 +201,18 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
? '' ? ''
: String(candidate); : String(candidate);
if (
!isValidEncString(sshKey.privateKey) ||
!isValidEncString(sshKey.publicKey) ||
!isValidEncString(normalizedFingerprint)
) {
return null;
}
return { return {
...sshKey, ...sshKey,
privateKey: String(sshKey.privateKey).trim(),
publicKey: String(sshKey.publicKey).trim(),
keyFingerprint: normalizedFingerprint, keyFingerprint: normalizedFingerprint,
fingerprint: normalizedFingerprint, fingerprint: normalizedFingerprint,
}; };
@@ -128,16 +221,52 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
// Format attachments for API response // Format attachments for API response
export function formatAttachments(attachments: Attachment[]): any[] | null { export function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null; if (attachments.length === 0) return null;
return attachments.map(a => ({ const formatted = attachments
id: a.id, .filter((a) => isValidEncString(a.fileName))
fileName: a.fileName, .map(a => ({
// Bitwarden clients decode attachment size as string in cipher payloads. id: a.id,
size: String(Number(a.size) || 0), fileName: a.fileName.trim(),
sizeName: a.sizeName, // Bitwarden clients decode attachment size as string in cipher payloads.
key: a.key, size: String(Number(a.size) || 0),
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url! sizeName: a.sizeName,
object: 'attachment', 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. // 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 // Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher; const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null); 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); const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
return { return {
@@ -174,8 +324,15 @@ export function cipherToResponse(
object: 'cipherDetails', object: 'cipherDetails',
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [], collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
attachments: formatAttachments(attachments), attachments: formatAttachments(attachments),
name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name,
notes: optionalEncString(cipher.notes),
login: normalizedLogin, login: normalizedLogin,
card: normalizedCard,
identity: normalizedIdentity,
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
sshKey: normalizedSshKey, sshKey: normalizedSshKey,
key: optionalEncString(cipher.key),
encryptedFor: (passthrough as any).encryptedFor ?? null, encryptedFor: (passthrough as any).encryptedFor ?? null,
}; };
} }
@@ -304,7 +461,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []), cipherToResponse(cipher, []),
@@ -398,7 +555,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
@@ -421,7 +578,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
syncCipherComputedAliases(cipher); syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []) cipherToResponse(cipher, [])
@@ -445,7 +602,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
await deleteAllAttachmentsForCipher(env, id); await deleteAllAttachmentsForCipher(env, id);
await storage.deleteCipher(id, userId); await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
@@ -466,7 +623,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
await storage.deleteCipher(id, userId); await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
@@ -485,7 +642,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
syncCipherComputedAliases(cipher); syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []) cipherToResponse(cipher, [])
@@ -524,7 +681,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []) cipherToResponse(cipher, [])
@@ -554,7 +711,7 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
const revisionDate = await storage.bulkMoveCiphers(body.ids, folderId, userId); const revisionDate = await storage.bulkMoveCiphers(body.ids, folderId, userId);
if (revisionDate) { if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
@@ -600,7 +757,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
normalizeCipherForStorage(cipher); normalizeCipherForStorage(cipher);
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
@@ -622,7 +779,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
normalizeCipherForStorage(cipher); normalizeCipherForStorage(cipher);
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
@@ -648,7 +805,7 @@ export async function handleBulkArchiveCiphers(request: Request, env: Env, userI
const revisionDate = await storage.bulkArchiveCiphers(ids, userId); const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
if (revisionDate) { if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
} }
return buildCipherListResponse(request, storage, userId, ids); 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); const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
if (revisionDate) { if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
} }
return buildCipherListResponse(request, storage, userId, ids); 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); const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
if (revisionDate) { if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
} }
return new Response(null, { status: 204 }); 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); const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
if (revisionDate) { if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
@@ -744,13 +901,17 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
for (const id of ids) { const ownedCiphers = await storage.getCiphersByIds(ids, userId);
await deleteAllAttachmentsForCipher(env, id); 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) { if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
+3 -3
View File
@@ -284,7 +284,7 @@ export async function handleDeleteDevice(
await storage.deleteRefreshTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) { if (deleted) {
await notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
return jsonResponse({ success: deleted }); return jsonResponse({ success: deleted });
} }
@@ -327,7 +327,7 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await notifyUserLogout(env, userId, null); notifyUserLogout(env, userId, null);
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices }); return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
} }
@@ -458,7 +458,7 @@ export async function handleDeactivateDevice(
await storage.deleteRefreshTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) { if (deleted) {
await notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
return jsonResponse({ success: deleted }); return jsonResponse({ success: deleted });
} }
+8 -7
View File
@@ -6,13 +6,13 @@ import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination'; import { parsePagination, encodeContinuationToken } from '../utils/pagination';
async function notifyVaultSyncForRequest( function notifyVaultSyncForRequest(
request: Request, request: Request,
env: Env, env: Env,
userId: string, userId: string,
revisionDate: string revisionDate: string
): Promise<void> { ): void {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
// Convert internal folder to API response format // Convert internal folder to API response format
@@ -21,6 +21,7 @@ function folderToResponse(folder: Folder): FolderResponse {
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
revisionDate: folder.updatedAt, revisionDate: folder.updatedAt,
creationDate: folder.createdAt,
object: 'folder', object: 'folder',
}; };
} }
@@ -87,7 +88,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
await storage.saveFolder(folder); await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(folderToResponse(folder), 200); return jsonResponse(folderToResponse(folder), 200);
} }
@@ -115,7 +116,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
await storage.saveFolder(folder); await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(folderToResponse(folder)); 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.clearFolderFromCiphers(userId, id);
await storage.deleteFolder(id, userId); await storage.deleteFolder(id, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 }); 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); const revisionDate = await storage.bulkDeleteFolders(ids, userId);
if (revisionDate) { if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
} }
return new Response(null, { status: 204 }); 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 // Update revision date
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
if (returnCipherMap) { if (returnCipherMap) {
return jsonResponse({ return jsonResponse({
+8 -8
View File
@@ -76,7 +76,7 @@ async function processSendFileUpload(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const revisionDate = await storage.updateRevisionDate(send.userId); 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 }); return new Response(null, { status: 201 });
} }
@@ -226,7 +226,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send)); return jsonResponse(sendToResponse(send));
} }
@@ -349,7 +349,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
const jwtSecret = getSafeJwtSecret(env); const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) { if (!jwtSecret) {
return errorResponse('Server configuration error', 500); 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(); send.updatedAt = new Date().toISOString();
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send)); return jsonResponse(sendToResponse(send));
} }
@@ -619,7 +619,7 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
await storage.deleteSend(sendId, userId); await storage.deleteSend(sendId, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 200 }); 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); const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
if (revisionDate) { if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
} }
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
@@ -668,7 +668,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
send.updatedAt = new Date().toISOString(); send.updatedAt = new Date().toISOString();
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send)); return jsonResponse(sendToResponse(send));
} }
@@ -686,7 +686,7 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
send.updatedAt = new Date().toISOString(); send.updatedAt = new Date().toISOString();
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send)); 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; send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId); 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); const creatorIdentifier = await getCreatorIdentifier(storage, send);
@@ -162,7 +162,7 @@ export async function handleAccessSendFile(
} }
send.accessCount += 1; send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId); 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 token = await createSendFileDownloadToken(send.id, fileId, secret);
const url = new URL(request.url); const url = new URL(request.url);
@@ -202,7 +202,7 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise<Re
} }
send.accessCount += 1; send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId); 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); const creatorIdentifier = await getCreatorIdentifier(storage, send);
@@ -241,7 +241,7 @@ export async function handleAccessSendFileV2(request: Request, env: Env, fileId:
} }
send.accessCount += 1; send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId); 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 downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
const url = new URL(request.url); 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; const SEND_PASSWORD_ITERATIONS = 100_000;
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password'; export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
export async function notifyVaultSyncForRequest( export function notifyVaultSyncForRequest(
request: Request, request: Request,
env: Env, env: Env,
userId: string, userId: string,
revisionDate: string revisionDate: string
): Promise<void> { ): void {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } { 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 { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response'; import { errorResponse } from '../utils/response';
import { cipherToResponse } from './ciphers'; import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers';
import { sendToResponse } from './sends'; import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { import {
@@ -10,10 +10,10 @@ import {
buildUserDecryptionOptions, buildUserDecryptionOptions,
} from '../utils/user-decryption'; } 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 url = new URL(request.url);
const cacheUrl = new 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 url.origin
); );
return new Request(cacheUrl.toString(), { method: 'GET' }); 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 url = new URL(request.url);
const excludeDomainsParam = url.searchParams.get('excludeDomains'); const excludeDomainsParam = url.searchParams.get('excludeDomains');
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam); 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); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
@@ -42,7 +44,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
} }
const revisionDate = await storage.getRevisionDate(userId); 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); const cachedResponse = await readSyncCache(cacheRequest);
if (cachedResponse) { if (cachedResponse) {
return 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([ const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
storage.getAllCiphers(userId), storage.getAllCiphers(userId),
storage.getAllFolders(userId), storage.getAllFolders(userId),
storage.getAllSends(userId), excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
storage.getAttachmentsByUserId(userId), storage.getAttachmentsByUserId(userId),
]); ]);
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
@@ -84,7 +86,10 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) { 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[] = []; const folderResponses: FolderResponse[] = [];
@@ -93,6 +98,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
revisionDate: folder.updatedAt, revisionDate: folder.updatedAt,
creationDate: folder.createdAt,
object: 'folder', object: 'folder',
}); });
} }
+6
View File
@@ -60,6 +60,7 @@ import {
handleCreateAttachment, handleCreateAttachment,
handleUploadAttachment, handleUploadAttachment,
handleGetAttachment, handleGetAttachment,
handleUpdateAttachmentMetadata,
handleDeleteAttachment, handleDeleteAttachment,
} from './handlers/attachments'; } from './handlers/attachments';
import { handleAuthenticatedDeviceRoute } from './router-devices'; import { handleAuthenticatedDeviceRoute } from './router-devices';
@@ -201,6 +202,11 @@ export async function handleAuthenticatedRoute(
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId); if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
} }
const attachmentMetadataMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/metadata$/i);
if (attachmentMetadataMatch && (method === 'POST' || method === 'PUT')) {
return handleUpdateAttachmentMetadata(request, env, userId, cipherId, attachmentMetadataMatch[1]);
}
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i); const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
if (attachmentDeleteMatch && method === 'POST') { if (attachmentDeleteMatch && method === 'POST') {
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]); return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
+29 -11
View File
@@ -61,7 +61,7 @@ function handleNwFavicon(): Response {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'image/svg+xml; charset=utf-8', '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`,
}, },
}); });
} }
@@ -126,7 +126,12 @@ function buildConfigResponse(origin: string) {
} }
function normalizeIconHost(rawHost: string): string | null { function normalizeIconHost(rawHost: string): string | null {
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, ''); let decoded: string;
try {
decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
} catch {
return null;
}
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null; if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
try { try {
const parsed = new URL(`https://${decoded}`); const parsed = new URL(`https://${decoded}`);
@@ -136,6 +141,26 @@ function normalizeIconHost(rawHost: string): string | null {
} }
} }
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
try {
return await fetch(source.url, {
headers: source.headers,
redirect: 'follow',
signal: controller.signal,
cf: {
cacheEverything: true,
cacheTtl: LIMITS.cache.iconTtlSeconds,
},
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
} finally {
clearTimeout(timeout);
}
}
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> { async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
const normalizedHost = normalizeIconHost(host); const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon(); if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
@@ -159,14 +184,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
try { try {
for (const source of upstreamSources) { for (const source of upstreamSources) {
const resp = await fetch(source.url, { const resp = await fetchIconSource(source);
headers: source.headers,
redirect: 'follow',
cf: {
cacheEverything: true,
cacheTtl: LIMITS.cache.iconTtlSeconds,
},
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
if (!resp.ok) continue; if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
@@ -176,7 +194,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
status: 200, status: 200,
headers: { headers: {
'Content-Type': resp.headers.get('Content-Type') || 'image/png', '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`,
}, },
}); });
} }
+86 -2
View File
@@ -6,6 +6,17 @@ import { StorageService } from './storage';
// The client already does heavy PBKDF2 (600k iterations). // The client already does heavy PBKDF2 (600k iterations).
// This second layer only needs to be non-trivial, not expensive. // This second layer only needs to be non-trivial, not expensive.
const SERVER_HASH_ITERATIONS = 100_000; 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 { export interface VerifiedAccessContext {
payload: JWTPayload; payload: JWTPayload;
@@ -14,11 +25,77 @@ export interface VerifiedAccessContext {
export class AuthService { export class AuthService {
private storage: StorageService; private storage: StorageService;
private static userCache = new Map<string, CachedUserEntry>();
private static deviceCache = new Map<string, CachedDeviceEntry>();
constructor(private env: Env) { constructor(private env: Env) {
this.storage = new StorageService(env.DB); 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 async getFreshUser(userId: string): Promise<User | null> {
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;
}
private async getFreshDevice(userId: string, deviceId: string) {
const device = await this.storage.getDevice(userId, deviceId);
this.writeCachedDevice(userId, deviceId, device);
return device;
}
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations). // Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense). // 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. // Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
@@ -97,15 +174,22 @@ export class AuthService {
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET); const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
if (!payload) return null; if (!payload) return null;
const user = await this.storage.getUserById(payload.sub); let user = await this.getCachedUser(payload.sub);
if (!user || user.status !== 'active' || payload.sstamp !== user.securityStamp) {
user = await this.getFreshUser(payload.sub);
}
if (!user) return null; if (!user) return null;
if (user.status !== 'active') return null;
if (payload.sstamp !== user.securityStamp) { if (payload.sstamp !== user.securityStamp) {
return null; return null;
} }
if (payload.did) { if (payload.did) {
const device = await this.storage.getDevice(user.id, payload.did); let device = await this.getCachedDevice(user.id, payload.did);
if (!device || !payload.dstamp || payload.dstamp !== device.sessionStamp) {
device = await this.getFreshDevice(user.id, payload.did);
}
if (!device) return null; if (!device) return null;
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null; if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
} }
+57 -12
View File
@@ -16,7 +16,7 @@ import {
type BackupRuntimeState, type BackupRuntimeState,
type BackupScheduleConfig, type BackupScheduleConfig,
type BackupSettings, type BackupSettings,
type E3BackupDestination, type S3BackupDestination,
type WebDavBackupDestination, type WebDavBackupDestination,
createBackupRandomId, createBackupRandomId,
createDefaultBackupDestinationName, createDefaultBackupDestinationName,
@@ -35,7 +35,7 @@ export type {
BackupRuntimeState, BackupRuntimeState,
BackupScheduleConfig, BackupScheduleConfig,
BackupSettings, BackupSettings,
E3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '../../shared/backup-schema'; } 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')}`; 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 source = isPlainObject(value) ? value : {};
const endpoint = asTrimmedString(source.endpoint); const endpoint = asTrimmedString(source.endpoint);
const bucket = asTrimmedString(source.bucket); const bucket = asTrimmedString(source.bucket);
@@ -115,17 +115,17 @@ function normalizeE3Destination(value: unknown, allowIncomplete = false): E3Back
const rootPath = normalizePath(source.rootPath); const rootPath = normalizePath(source.rootPath);
if (!allowIncomplete || endpoint) { if (!allowIncomplete || endpoint) {
if (!endpoint) throw new Error('E3 endpoint is required'); if (!endpoint) throw new Error('S3 endpoint is required');
if (!/^https?:\/\//i.test(endpoint)) throw new Error('E3 endpoint must start with http:// or https://'); if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
} }
if (!allowIncomplete || bucket) { 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 (!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 (!allowIncomplete || secretAccessKey) {
if (!secretAccessKey) throw new Error('E3 secret key is required'); if (!secretAccessKey) throw new Error('S3 secret key is required');
} }
return { return {
@@ -169,7 +169,7 @@ function normalizeDestination(
destination: unknown, destination: unknown,
allowIncomplete = false allowIncomplete = false
): BackupDestinationConfig { ): BackupDestinationConfig {
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete); if (destinationType === 's3') return normalizeS3Destination(destination, allowIncomplete);
return normalizeWebDavDestination(destination, allowIncomplete); return normalizeWebDavDestination(destination, allowIncomplete);
} }
@@ -204,7 +204,8 @@ function defaultDestinationName(type: BackupDestinationType, index: number): str
function getDestinationType(raw: unknown): BackupDestinationType { function getDestinationType(raw: unknown): BackupDestinationType {
const value = asTrimmedString(raw); 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'); throw new Error('Backup destination type is invalid');
} }
@@ -266,8 +267,8 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
: BACKUP_DEFAULT_INTERVAL_HOURS; : BACKUP_DEFAULT_INTERVAL_HOURS;
const destinationTypeRaw = asTrimmedString(rawValue.destinationType); const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
const destinationType: BackupDestinationType = const destinationType: BackupDestinationType =
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav' destinationTypeRaw === 'e3' || destinationTypeRaw === 's3' || destinationTypeRaw === 'webdav'
? destinationTypeRaw ? getDestinationType(destinationTypeRaw)
: 'webdav'; : 'webdav';
const destination = { const destination = {
id: createBackupRandomId(), id: createBackupRandomId(),
@@ -598,6 +599,50 @@ function getBackupSlotStartsForLocalDay(
return slots; 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( export function isBackupDueNow(
destination: BackupDestinationRecord, destination: BackupDestinationRecord,
now: Date, now: Date,
+62 -62
View File
@@ -1,7 +1,7 @@
import { import {
BackupDestinationRecord, BackupDestinationRecord,
BackupDestinationType, BackupDestinationType,
E3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from './backup-config'; } from './backup-config';
@@ -213,13 +213,13 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
if (!String(config.password || '')) throw new Error('WebDAV password is required'); if (!String(config.password || '')) throw new Error('WebDAV password is required');
return; return;
} }
if (destination.type === 'e3') { if (destination.type === 's3') {
const config = destination.destination as E3BackupDestination; const config = destination.destination as S3BackupDestination;
if (!String(config.endpoint || '').trim()) throw new Error('E3 endpoint is required'); if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('E3 endpoint must start with http:// or https://'); 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('E3 bucket is required'); if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
if (!String(config.accessKeyId || '').trim()) throw new Error('E3 access key is required'); if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
if (!String(config.secretAccessKey || '')) throw new Error('E3 secret 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; return true;
} }
function e3BucketBaseUrl(config: E3BackupDestination): URL { function s3BucketBaseUrl(config: S3BackupDestination): URL {
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`); 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)); return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
} }
async function signedE3Request( async function signedS3Request(
config: E3BackupDestination, config: S3BackupDestination,
method: 'GET' | 'PUT' | 'DELETE' | 'HEAD', method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',
url: URL, url: URL,
body?: Uint8Array, body?: Uint8Array,
@@ -494,41 +494,41 @@ async function signedE3Request(
}); });
} }
async function putToE3( async function putToS3(
config: E3BackupDestination, config: S3BackupDestination,
relativePath: string, relativePath: string,
bytes: Uint8Array, bytes: Uint8Array,
options: RemoteBackupFilePutOptions = {} options: RemoteBackupFilePutOptions = {}
): Promise<void> { ): Promise<void> {
const objectKey = normalizeE3ObjectKey(config, relativePath); const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedE3Request(config, 'PUT', url, bytes, options.contentType); const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
if (!response.ok) { 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> { async function uploadToS3(config: S3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
await putToE3(config, fileName, archive, { contentType: 'application/zip' }); await putToS3(config, fileName, archive, { contentType: 'application/zip' });
return { return {
provider: 'e3', provider: 's3',
remotePath: normalizeE3ObjectKey(config, fileName), 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 currentPath = normalizeRelativePath(relativePath);
const targetPrefixBase = normalizeE3ObjectKey(config, currentPath); const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : ''; const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
const url = e3BucketBaseUrl(config); const url = s3BucketBaseUrl(config);
url.searchParams.set('list-type', '2'); url.searchParams.set('list-type', '2');
url.searchParams.set('delimiter', '/'); url.searchParams.set('delimiter', '/');
if (targetPrefix) url.searchParams.set('prefix', targetPrefix); if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
const response = await signedE3Request(config, 'GET', url); const response = await signedS3Request(config, 'GET', url);
if (!response.ok) { if (!response.ok) {
throw new Error(`E3 listing failed: ${response.status}`); throw new Error(`S3 listing failed: ${response.status}`);
} }
const xml = await response.text(); 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); for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
return { return {
provider: 'e3', provider: 's3',
currentPath, currentPath,
parentPath: parentPath(currentPath), parentPath: parentPath(currentPath),
items: sortRemoteItems(Array.from(deduped.values())), 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); const normalized = normalizeRelativePath(relativePath);
if (!normalized || normalized.endsWith('/')) { if (!normalized || normalized.endsWith('/')) {
throw new Error('Please select a backup file'); throw new Error('Please select a backup file');
} }
const objectKey = normalizeE3ObjectKey(config, normalized); const objectKey = normalizeS3ObjectKey(config, normalized);
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedE3Request(config, 'GET', url); const response = await signedS3Request(config, 'GET', url);
if (!response.ok) { if (!response.ok) {
throw new Error(`E3 download failed: ${response.status}`); throw new Error(`S3 download failed: ${response.status}`);
} }
return { return {
provider: 'e3', provider: 's3',
remotePath: normalized, remotePath: normalized,
fileName: basename(normalized) || 'backup.zip', fileName: basename(normalized) || 'backup.zip',
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/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> { async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
const objectKey = normalizeE3ObjectKey(config, relativePath); const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedE3Request(config, 'DELETE', url); const response = await signedS3Request(config, 'DELETE', url);
if (!response.ok && response.status !== 404) { if (!response.ok && response.status !== 404) {
throw new Error(`E3 delete failed: ${response.status}`); throw new Error(`S3 delete failed: ${response.status}`);
} }
} }
async function existsInE3(config: E3BackupDestination, relativePath: string): Promise<boolean> { async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
const objectKey = normalizeE3ObjectKey(config, relativePath); const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
const response = await signedE3Request(config, 'HEAD', url); const response = await signedS3Request(config, 'HEAD', url);
if (response.status === 404) return false; if (response.status === 404) return false;
if (!response.ok) { if (!response.ok) {
throw new Error(`E3 existence check failed: ${response.status}`); throw new Error(`S3 existence check failed: ${response.status}`);
} }
return true; return true;
} }
interface ConfiguredDestinationAdapter { interface ConfiguredDestinationAdapter {
provider: 'webdav' | 'e3'; provider: 'webdav' | 's3';
config: WebDavBackupDestination | E3BackupDestination; config: WebDavBackupDestination | S3BackupDestination;
upload: (config: WebDavBackupDestination | E3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>; upload: (config: WebDavBackupDestination | S3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
putFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>; putFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>; list: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>; download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<void>; deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>; exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
} }
export interface RemoteBackupTransferSession { export interface RemoteBackupTransferSession {
@@ -666,16 +666,16 @@ function resolveConfiguredDestinationAdapter(
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath), exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
}; };
} }
if (destination.type === 'e3') { if (destination.type === 's3') {
return { return {
provider: 'e3', provider: 's3',
config: destination.destination as E3BackupDestination, config: destination.destination as S3BackupDestination,
upload: (config, archive, fileName) => uploadToE3(config as E3BackupDestination, archive, fileName), upload: (config, archive, fileName) => uploadToS3(config as S3BackupDestination, archive, fileName),
putFile: (config, relativePath, bytes, options) => putToE3(config as E3BackupDestination, relativePath, bytes, options), putFile: (config, relativePath, bytes, options) => putToS3(config as S3BackupDestination, relativePath, bytes, options),
list: (config, relativePath) => listE3Entries(config as E3BackupDestination, relativePath), list: (config, relativePath) => listS3Entries(config as S3BackupDestination, relativePath),
download: (config, relativePath) => downloadFromE3(config as E3BackupDestination, relativePath), download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromE3(config as E3BackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
exists: (config, relativePath) => existsInE3(config as E3BackupDestination, relativePath), exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
}; };
} }
@@ -703,7 +703,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
provider: adapter.provider, provider: adapter.provider,
remotePath: adapter.provider === 'webdav' remotePath: adapter.provider === 'webdav'
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName) ? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
: normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName), : normalizeS3ObjectKey(adapter.config as S3BackupDestination, fileName),
}; };
}, },
putFile, 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(); 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[]> { export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
const res = await db const res = await db
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?') .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(); 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> { export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run(); 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; 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 { function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
if (!row?.data) return null; if (!row?.data) return null;
try { 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> { export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
const folderId = normalizeOptionalId(cipher.folderId); const folderId = normalizeOptionalId(cipher.folderId);
const data = JSON.stringify({ const data = buildCipherData(cipher, folderId);
...cipher,
folderId,
});
const stmt = db.prepare( 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) ' + 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
@@ -117,8 +151,7 @@ export async function bulkSoftDeleteCiphers(
if (!uniqueIds.length) return null; if (!uniqueIds.length) return null;
const now = new Date().toISOString(); const now = new Date().toISOString();
const patch = JSON.stringify({ deletedAt: now, updatedAt: now }); const chunkSize = sqlChunkSize(3);
const chunkSize = sqlChunkSize(4);
for (let i = 0; i < uniqueIds.length; i += chunkSize) { for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize); const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -126,10 +159,11 @@ export async function bulkSoftDeleteCiphers(
await db await db
.prepare( .prepare(
`UPDATE ciphers `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})` WHERE user_id = ? AND id IN (${placeholders})`
) )
.bind(now, now, patch, userId, ...chunk) .bind(now, now, userId, ...chunk)
.run(); .run();
} }
@@ -148,8 +182,7 @@ export async function bulkRestoreCiphers(
if (!uniqueIds.length) return null; if (!uniqueIds.length) return null;
const now = new Date().toISOString(); const now = new Date().toISOString();
const patch = JSON.stringify({ deletedAt: null, updatedAt: now }); const chunkSize = sqlChunkSize(2);
const chunkSize = sqlChunkSize(3);
for (let i = 0; i < uniqueIds.length; i += chunkSize) { for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize); const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -157,10 +190,11 @@ export async function bulkRestoreCiphers(
await db await db
.prepare( .prepare(
`UPDATE ciphers `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})` WHERE user_id = ? AND id IN (${placeholders})`
) )
.bind(now, patch, userId, ...chunk) .bind(now, userId, ...chunk)
.run(); .run();
} }
@@ -262,8 +296,7 @@ export async function bulkMoveCiphers(
const now = new Date().toISOString(); const now = new Date().toISOString();
const normalizedFolderId = normalizeOptionalId(folderId); const normalizedFolderId = normalizeOptionalId(folderId);
const uniqueIds = sanitizeIds(ids); const uniqueIds = sanitizeIds(ids);
const patch = JSON.stringify({ folderId: normalizedFolderId, updatedAt: now }); const chunkSize = sqlChunkSize(3);
const chunkSize = sqlChunkSize(4);
for (let i = 0; i < uniqueIds.length; i += chunkSize) { for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize); const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -271,10 +304,11 @@ export async function bulkMoveCiphers(
await db await db
.prepare( .prepare(
`UPDATE ciphers `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})` WHERE user_id = ? AND id IN (${placeholders})`
) )
.bind(normalizedFolderId, now, patch, userId, ...chunk) .bind(normalizedFolderId, now, userId, ...chunk)
.run(); .run();
} }
@@ -293,8 +327,7 @@ export async function bulkArchiveCiphers(
if (!uniqueIds.length) return null; if (!uniqueIds.length) return null;
const now = new Date().toISOString(); const now = new Date().toISOString();
const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now }); const chunkSize = sqlChunkSize(3);
const chunkSize = sqlChunkSize(4);
for (let i = 0; i < uniqueIds.length; i += chunkSize) { for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize); const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -302,10 +335,11 @@ export async function bulkArchiveCiphers(
await db await db
.prepare( .prepare(
`UPDATE ciphers `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` WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
) )
.bind(now, now, patch, userId, ...chunk) .bind(now, now, userId, ...chunk)
.run(); .run();
} }
@@ -324,8 +358,7 @@ export async function bulkUnarchiveCiphers(
if (!uniqueIds.length) return null; if (!uniqueIds.length) return null;
const now = new Date().toISOString(); const now = new Date().toISOString();
const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now }); const chunkSize = sqlChunkSize(2);
const chunkSize = sqlChunkSize(3);
for (let i = 0; i < uniqueIds.length; i += chunkSize) { for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize); const chunk = uniqueIds.slice(i, i + chunkSize);
@@ -333,10 +366,11 @@ export async function bulkUnarchiveCiphers(
await db await db
.prepare( .prepare(
`UPDATE ciphers `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})` WHERE user_id = ? AND id IN (${placeholders})`
) )
.bind(now, patch, userId, ...chunk) .bind(now, userId, ...chunk)
.run(); .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 { function mapFolderRow(row: any): Folder {
return { return {
@@ -36,26 +36,18 @@ export async function deleteFolder(db: D1Database, id: string, userId: string):
export async function clearFolderFromCiphers( export async function clearFolderFromCiphers(
db: D1Database, db: D1Database,
userId: string, userId: string,
folderId: string, folderId: string
saveCipher: (cipher: Cipher) => Promise<void>
): Promise<void> { ): Promise<void> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const res = await db await db
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?') .prepare(
.bind(userId, folderId) `UPDATE ciphers
.all<{ data: string }>(); SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
for (const row of (res.results || [])) { WHERE user_id = ? AND folder_id = ?`
let cipher: Cipher; )
try { .bind(now, userId, folderId)
cipher = JSON.parse(row.data) as Cipher; .run();
} catch {
continue;
}
cipher.folderId = null;
cipher.updatedAt = now;
await saveCipher(cipher);
}
} }
export async function bulkDeleteFolders( export async function bulkDeleteFolders(
@@ -63,34 +55,26 @@ export async function bulkDeleteFolders(
userId: string, userId: string,
ids: string[], ids: string[],
sqlChunkSize: (fixedBindCount: number) => number, sqlChunkSize: (fixedBindCount: number) => number,
saveCipher: (cipher: Cipher) => Promise<void>,
updateRevisionDate: (userId: string) => Promise<string> updateRevisionDate: (userId: string) => Promise<string>
): Promise<string | null> { ): Promise<string | null> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
if (!uniqueIds.length) return null; if (!uniqueIds.length) return null;
const chunkSize = sqlChunkSize(1);
const now = new Date().toISOString(); const now = new Date().toISOString();
const chunkSize = sqlChunkSize(2);
for (let i = 0; i < uniqueIds.length; i += chunkSize) { for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize); const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(','); const placeholders = chunk.map(() => '?').join(',');
const res = await db await db
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`) .prepare(
.bind(userId, ...chunk) `UPDATE ciphers
.all<{ data: string }>(); SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
for (const row of res.results || []) { WHERE user_id = ? AND folder_id IN (${placeholders})`
let cipher: Cipher; )
try { .bind(now, userId, ...chunk)
cipher = JSON.parse(row.data) as Cipher; .run();
} catch {
continue;
}
cipher.folderId = null;
cipher.updatedAt = now;
await saveCipher(cipher);
}
await db await db
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`) .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_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 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_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 (' + '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, ' + '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)', '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 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 (' + 'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)', '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'; } from './storage-cipher-repo';
import { import {
addAttachmentToCipher as attachStoredAttachmentToCipher, addAttachmentToCipher as attachStoredAttachmentToCipher,
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher, deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
deleteAttachment as deleteStoredAttachment, deleteAttachment as deleteStoredAttachment,
getAttachment as findStoredAttachment, getAttachment as findStoredAttachment,
getAttachmentsByCipher as listStoredAttachmentsByCipher, getAttachmentsByCipher as listStoredAttachmentsByCipher,
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds, getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
getAttachmentsByUserId as listStoredAttachmentsByUserId, getAttachmentsByUserId as listStoredAttachmentsByUserId,
removeAttachmentFromCipher as detachStoredAttachmentFromCipher,
saveAttachment as saveStoredAttachment, saveAttachment as saveStoredAttachment,
updateCipherRevisionDate as updateStoredCipherRevisionDate, updateCipherRevisionDate as updateStoredCipherRevisionDate,
} from './storage-attachment-repo'; } from './storage-attachment-repo';
@@ -108,7 +108,7 @@ import {
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
const STORAGE_SCHEMA_VERSION = '2026-04-22'; const STORAGE_SCHEMA_VERSION = '2026-04-28';
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -340,7 +340,6 @@ export class StorageService {
userId, userId,
ids, ids,
this.sqlChunkSize.bind(this), this.sqlChunkSize.bind(this),
this.saveCipher.bind(this),
this.updateRevisionDate.bind(this) this.updateRevisionDate.bind(this)
); );
} }
@@ -348,7 +347,7 @@ export class StorageService {
// Clear folder references from all ciphers owned by the user. // Clear folder references from all ciphers owned by the user.
// Without this, deleting a folder leaves stale folderId values in cipher JSON. // Without this, deleting a folder leaves stale folderId values in cipher JSON.
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> { 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[]> { async getAllFolders(userId: string): Promise<Folder[]> {
@@ -373,6 +372,10 @@ export class StorageService {
await deleteStoredAttachment(this.db, id); 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[]> { async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
return listStoredAttachmentsByCipher(this.db, cipherId); return listStoredAttachmentsByCipher(this.db, cipherId);
} }
@@ -389,10 +392,6 @@ export class StorageService {
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId); await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
} }
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
await detachStoredAttachmentFromCipher(cipherId, attachmentId);
}
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> { async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
await deleteStoredAttachmentsByCipher(this.db, cipherId); await deleteStoredAttachmentsByCipher(this.db, cipherId);
} }
+1
View File
@@ -450,6 +450,7 @@ export interface FolderResponse {
id: string; id: string;
name: string; name: string;
revisionDate: string; revisionDate: string;
creationDate: string;
object: string; object: string;
} }
+31 -84
View File
@@ -1,6 +1,8 @@
import { JWTPayload } from '../types'; import { JWTPayload } from '../types';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
const hmacKeyCache = new Map<string, Promise<CryptoKey>>();
// Base64 URL encode // Base64 URL encode
function base64UrlEncode(data: Uint8Array): string { function base64UrlEncode(data: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...data)); const base64 = btoa(String.fromCharCode(...data));
@@ -19,6 +21,23 @@ function base64UrlDecode(str: string): Uint8Array {
return bytes; 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 // 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> { 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' }; 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 data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature)); 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 [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const data = `${headerB64}.${payloadB64}`; const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64); const signature = base64UrlDecode(signatureB64);
@@ -133,13 +140,7 @@ export async function createFileDownloadToken(
const data = `${headerB64}.${payloadB64}`; const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature)); const signatureB64 = base64UrlEncode(new Uint8Array(signature));
@@ -159,13 +160,7 @@ export async function verifyFileDownloadToken(
const [headerB64, payloadB64, signatureB64] = parts; const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const data = `${headerB64}.${payloadB64}`; const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64); const signature = base64UrlDecode(signatureB64);
@@ -205,13 +200,7 @@ export async function createAttachmentUploadToken(
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`; const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature)); const signatureB64 = base64UrlEncode(new Uint8Array(signature));
@@ -229,13 +218,7 @@ export async function verifyAttachmentUploadToken(
const [headerB64, payloadB64, signatureB64] = parts; const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const data = `${headerB64}.${payloadB64}`; const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64); const signature = base64UrlDecode(signatureB64);
@@ -285,13 +268,7 @@ export async function createSendFileDownloadToken(
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`; const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature)); const signatureB64 = base64UrlEncode(new Uint8Array(signature));
@@ -309,13 +286,7 @@ export async function verifySendFileDownloadToken(
const [headerB64, payloadB64, signatureB64] = parts; const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const data = `${headerB64}.${payloadB64}`; const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64); const signature = base64UrlDecode(signatureB64);
@@ -361,13 +332,7 @@ export async function createSendFileUploadToken(
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`; const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature)); const signatureB64 = base64UrlEncode(new Uint8Array(signature));
@@ -385,13 +350,7 @@ export async function verifySendFileUploadToken(
const [headerB64, payloadB64, signatureB64] = parts; const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const data = `${headerB64}.${payloadB64}`; const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64); 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 payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`; const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature)); const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`; return `${data}.${signatureB64}`;
@@ -450,13 +403,7 @@ export async function verifySendAccessToken(token: string, secret: string): Prom
const [headerB64, payloadB64, signatureB64] = parts; const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const key = await crypto.subtle.importKey( const key = await getHmacKey(secret);
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const data = `${headerB64}.${payloadB64}`; const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64); 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 }; return { allowOrigin: origin, allowCredentials: true };
} }
if (isExtensionOrigin(origin)) { if (isExtensionOrigin(origin)) {
return { allowOrigin: origin, allowCredentials: false }; return { allowOrigin: origin, allowCredentials: true };
} }
return { allowOrigin: null, allowCredentials: false }; return { allowOrigin: null, allowCredentials: false };
} }
+33
View File
@@ -0,0 +1,33 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./webapp/index.html', './webapp/src/**/*.{ts,tsx}'],
darkMode: ['class', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
canvas: 'var(--bg-accent)',
panel: 'var(--panel)',
'panel-soft': 'var(--panel-soft)',
'panel-muted': 'var(--panel-muted)',
line: 'var(--line)',
'line-soft': 'var(--line-soft)',
ink: 'var(--text)',
muted: 'var(--muted)',
'muted-strong': 'var(--muted-strong)',
brand: 'var(--primary)',
'brand-hover': 'var(--primary-hover)',
'brand-strong': 'var(--primary-strong)',
danger: 'var(--danger)',
},
boxShadow: {
soft: 'var(--shadow-sm)',
panel: 'var(--shadow-md)',
elevated: 'var(--shadow-lg)',
},
fontFamily: {
sans: ['Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', 'sans-serif'],
},
},
},
plugins: [],
};
+82 -3
View File
@@ -3,13 +3,92 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>NodeWarden</title> <title>NodeWarden</title>
<style>
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
background: #eef4ff;
color: #0f172a;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.boot-screen {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
.boot-card {
width: min(420px, 100%);
display: grid;
gap: 14px;
justify-items: center;
padding: 28px;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 22px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.10);
}
.boot-logo {
width: 74px;
height: 58px;
object-fit: contain;
}
.boot-line {
width: 72%;
height: 12px;
border-radius: 999px;
background: linear-gradient(90deg, #dbeafe, #bfdbfe, #dbeafe);
background-size: 180% 100%;
animation: boot-shimmer 1.2s ease-in-out infinite;
}
.boot-line.short {
width: 46%;
}
@keyframes boot-shimmer {
0% { background-position: 180% 0; }
100% { background-position: -180% 0; }
}
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root">
<div class="boot-screen">
<div class="boot-card" aria-label="Loading NodeWarden">
<img class="boot-logo" src="/nodewarden-logo.svg" alt="" />
<div class="boot-line"></div>
<div class="boot-line short"></div>
</div>
</div>
</div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </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"> <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="#006DF4"/> <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="#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="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="#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="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="#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="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="#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="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="#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="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="#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="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="#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="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="#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="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="#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="black"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

+577 -310
View File
File diff suppressed because it is too large Load Diff
+47 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact'; import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import LoadingState from '@/components/LoadingState';
import type { AdminInvite, AdminUser } from '@/lib/types'; import type { AdminInvite, AdminUser } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -8,6 +9,8 @@ interface AdminPageProps {
currentUserId: string; currentUserId: string;
users: AdminUser[]; users: AdminUser[];
invites: AdminInvite[]; invites: AdminInvite[];
loading: boolean;
error: string;
onRefresh: () => void; onRefresh: () => void;
onCreateInvite: (hours: number) => Promise<void>; onCreateInvite: (hours: number) => Promise<void>;
onDeleteAllInvites: () => Promise<void>; onDeleteAllInvites: () => Promise<void>;
@@ -48,8 +51,22 @@ export default function AdminPage(props: AdminPageProps) {
return ( return (
<div className="stack"> <div className="stack">
{!!props.error && (
<div className="local-error">
<span>{props.error}</span>
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
</div>
)}
<section className="card"> <section className="card">
<h3>{t('txt_users')}</h3> <div className="section-head">
<h3>{t('txt_users')}</h3>
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
</button>
</div>
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
@@ -94,6 +111,20 @@ export default function AdminPage(props: AdminPageProps) {
</tr> </tr>
); );
})} })}
{props.loading && !props.users.length && (
<tr>
<td colSpan={5}>
<LoadingState lines={4} compact />
</td>
</tr>
)}
{!props.loading && !props.users.length && (
<tr>
<td colSpan={5}>
<div className="empty empty-comfortable">{t('txt_no_users_found')}</div>
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</section> </section>
@@ -101,7 +132,7 @@ export default function AdminPage(props: AdminPageProps) {
<section className="card"> <section className="card">
<div className="section-head"> <div className="section-head">
<h3>{t('txt_invites')}</h3> <h3>{t('txt_invites')}</h3>
<button type="button" className="btn btn-secondary" onClick={props.onRefresh}> <button type="button" className="btn btn-secondary" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')} <RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
</button> </button>
</div> </div>
@@ -160,6 +191,20 @@ export default function AdminPage(props: AdminPageProps) {
</td> </td>
</tr> </tr>
))} ))}
{props.loading && !props.invites.length && (
<tr>
<td colSpan={4}>
<LoadingState lines={4} compact />
</td>
</tr>
)}
{!props.loading && !props.invites.length && (
<tr>
<td colSpan={4}>
<div className="empty empty-comfortable">{t('txt_no_invites_found')}</div>
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
<div className="actions"> <div className="actions">
@@ -21,19 +21,25 @@ interface AppAuthenticatedShellProps {
onLock: () => void; onLock: () => void;
onLogout: () => void; onLogout: () => void;
onToggleTheme: () => void; onToggleTheme: () => void;
onToggleMobileSidebar: () => void;
mainRoutesProps: AppMainRoutesProps; mainRoutesProps: AppMainRoutesProps;
} }
function isAdminProfile(profile: Profile | null): boolean {
return String(profile?.role || '').toLowerCase() === 'admin';
}
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) { export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location; const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
const isAdmin = isAdminProfile(props.profile);
return ( return (
<div className="app-page"> <div className="app-page">
<div className="app-shell"> <div className="app-shell">
<header className="topbar"> <header className="topbar">
<div className="brand"> <div className="brand">
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" /> <img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="brand-logo" />
<img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="brand-wordmark" /> <span className="brand-wordmark" role="img" aria-label="NodeWarden" />
<span className="mobile-page-title">{props.currentPageTitle}</span> <span className="mobile-page-title">{props.currentPageTitle}</span>
</div> </div>
<div className="topbar-actions"> <div className="topbar-actions">
@@ -51,7 +57,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
className="btn btn-secondary small mobile-sidebar-toggle" className="btn btn-secondary small mobile-sidebar-toggle"
aria-label={props.sidebarToggleTitle} aria-label={props.sidebarToggleTitle}
title={props.sidebarToggleTitle} title={props.sidebarToggleTitle}
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))} onClick={props.onToggleMobileSidebar}
> >
<FolderIcon size={16} className="btn-icon" /> <FolderIcon size={16} className="btn-icon" />
</button> </button>
@@ -82,7 +88,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<SendIcon size={16} /> <SendIcon size={16} />
<span>{t('nav_sends')}</span> <span>{t('nav_sends')}</span>
</Link> </Link>
{props.profile?.role === 'admin' && ( {isAdmin && (
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}> <Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
<ShieldUser size={16} /> <ShieldUser size={16} />
<span>{t('nav_admin_panel')}</span> <span>{t('nav_admin_panel')}</span>
@@ -96,7 +102,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<Shield size={16} /> <Shield size={16} />
<span>{t('nav_device_management')}</span> <span>{t('nav_device_management')}</span>
</Link> </Link>
{props.profile?.role === 'admin' && ( {isAdmin && (
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}> <Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
<Cloud size={16} /> <Cloud size={16} />
<span>{t('nav_backup_strategy')}</span> <span>{t('nav_backup_strategy')}</span>
+1 -1
View File
@@ -76,7 +76,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
<span>{t('txt_totp_code')}</span> <span>{t('txt_totp_code')}</span>
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} /> <input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label> </label>
<label className="check-line" style={{ marginBottom: 0 }}> <label className="check-line check-line-compact">
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} /> <input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
<span>{t('txt_trust_this_device_for_30_days')}</span> <span>{t('txt_trust_this_device_for_30_days')}</span>
</label> </label>
+35 -9
View File
@@ -3,15 +3,16 @@ import { useEffect } from 'preact/hooks';
import { Link, Route, Switch } from 'wouter'; import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import LoadingState from '@/components/LoadingState';
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
import type { CiphersImportPayload } from '@/lib/api/vault'; import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
import type { ExportRequest } from '@/lib/export-formats'; import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage'));
const SendsPage = lazy(() => import('@/components/SendsPage')); const SendsPage = lazy(() => import('@/components/SendsPage'));
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage')); const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
const VaultPage = lazy(() => import('@/components/VaultPage'));
const SettingsPage = lazy(() => import('@/components/SettingsPage')); const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage')); const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
const AdminPage = lazy(() => import('@/components/AdminPage')); const AdminPage = lazy(() => import('@/components/AdminPage'));
@@ -19,7 +20,7 @@ const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
const ImportPage = lazy(() => import('@/components/ImportPage')); const ImportPage = lazy(() => import('@/components/ImportPage'));
function RouteContentFallback() { function RouteContentFallback() {
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>; return <LoadingState card lines={5} />;
} }
function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) { function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
@@ -31,22 +32,30 @@ function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
export interface AppMainRoutesProps { export interface AppMainRoutesProps {
profile: Profile | null; profile: Profile | null;
profileLoading: boolean;
session: SessionState | null; session: SessionState | null;
mobileLayout: boolean; mobileLayout: boolean;
mobileSidebarToggleKey: number;
importRoute: string; importRoute: string;
settingsHomeRoute: string; settingsHomeRoute: string;
settingsAccountRoute: string; settingsAccountRoute: string;
decryptedCiphers: Cipher[]; decryptedCiphers: Cipher[];
decryptedFolders: VaultFolder[]; decryptedFolders: VaultFolder[];
decryptedSends: Send[]; decryptedSends: Send[];
vaultError: string;
ciphersLoading: boolean; ciphersLoading: boolean;
foldersLoading: boolean; foldersLoading: boolean;
sendsLoading: boolean; sendsLoading: boolean;
users: AdminUser[]; users: AdminUser[];
invites: AdminInvite[]; invites: AdminInvite[];
adminLoading: boolean;
adminError: string;
totpEnabled: boolean; totpEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[]; authorizedDevices: AuthorizedDevice[];
authorizedDevicesLoading: boolean; authorizedDevicesLoading: boolean;
authorizedDevicesError: string;
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
onLogout: () => void; onLogout: () => void;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -96,6 +105,8 @@ export interface AppMainRoutesProps {
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>; onRotateApiKey: (masterPassword: string) => Promise<string>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onRefreshAuthorizedDevices: () => Promise<void>; onRefreshAuthorizedDevices: () => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>; onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
@@ -124,6 +135,7 @@ export interface AppMainRoutesProps {
export default function AppMainRoutes(props: 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 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 = ( const importPageContent = (
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
<ImportPage <ImportPage
@@ -165,6 +177,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onBulkDelete={props.onBulkDeleteSends} onBulkDelete={props.onBulkDeleteSends}
uploadingSendFileName={props.uploadingSendFileName} uploadingSendFileName={props.uploadingSendFileName}
sendUploadPercent={props.sendUploadPercent} sendUploadPercent={props.sendUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
onNotify={props.onNotify} onNotify={props.onNotify}
/> />
</Suspense> </Suspense>
@@ -180,6 +193,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
ciphers={props.decryptedCiphers} ciphers={props.decryptedCiphers}
folders={props.decryptedFolders} folders={props.decryptedFolders}
loading={props.ciphersLoading || props.foldersLoading} loading={props.ciphersLoading || props.foldersLoading}
error={props.vaultError}
emailForReprompt={props.profile?.email || props.session?.email || ''} emailForReprompt={props.profile?.email || props.session?.email || ''}
onRefresh={props.onRefreshVault} onRefresh={props.onRefreshVault}
onCreate={props.onCreateVaultItem} onCreate={props.onCreateVaultItem}
@@ -204,11 +218,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
attachmentDownloadPercent={props.attachmentDownloadPercent} attachmentDownloadPercent={props.attachmentDownloadPercent}
uploadingAttachmentName={props.uploadingAttachmentName} uploadingAttachmentName={props.uploadingAttachmentName}
attachmentUploadPercent={props.attachmentUploadPercent} attachmentUploadPercent={props.attachmentUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
/> />
</Suspense> </Suspense>
</Route> </Route>
<Route path={props.settingsAccountRoute}> <Route path={props.settingsAccountRoute}>
{props.profile && ( {props.profile ? (
<div className="stack"> <div className="stack">
{props.mobileLayout && ( {props.mobileLayout && (
<div className="mobile-settings-subhead"> <div className="mobile-settings-subhead">
@@ -222,6 +237,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsPage <SettingsPage
profile={props.profile} profile={props.profile}
totpEnabled={props.totpEnabled} totpEnabled={props.totpEnabled}
lockTimeoutMinutes={props.lockTimeoutMinutes}
sessionTimeoutAction={props.sessionTimeoutAction}
onChangePassword={props.onChangePassword} onChangePassword={props.onChangePassword}
onSavePasswordHint={props.onSavePasswordHint} onSavePasswordHint={props.onSavePasswordHint}
onEnableTotp={props.onEnableTotp} onEnableTotp={props.onEnableTotp}
@@ -229,14 +246,18 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onGetRecoveryCode={props.onGetRecoveryCode} onGetRecoveryCode={props.onGetRecoveryCode}
onGetApiKey={props.onGetApiKey} onGetApiKey={props.onGetApiKey}
onRotateApiKey={props.onRotateApiKey} onRotateApiKey={props.onRotateApiKey}
onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify} onNotify={props.onNotify}
/> />
</Suspense> </Suspense>
</div> </div>
)} ) : props.profileLoading ? (
<LoadingState card lines={5} />
) : null}
</Route> </Route>
<Route path="/settings"> <Route path="/settings">
{props.profile && ( {props.profile ? (
<section className="card mobile-settings-card"> <section className="card mobile-settings-card">
<div className="mobile-settings-links"> <div className="mobile-settings-links">
<Link href={props.settingsAccountRoute} className="mobile-settings-link"> <Link href={props.settingsAccountRoute} className="mobile-settings-link">
@@ -251,13 +272,13 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<ArrowUpDown size={18} /> <ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span> <span>{t('nav_import_export')}</span>
</Link> </Link>
{props.profile.role === 'admin' && ( {isAdmin && (
<Link href="/admin" className="mobile-settings-link"> <Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} /> <ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span> <span>{t('nav_admin_panel')}</span>
</Link> </Link>
)} )}
{props.profile.role === 'admin' && ( {isAdmin && (
<Link href="/backup" className="mobile-settings-link"> <Link href="/backup" className="mobile-settings-link">
<Cloud size={18} /> <Cloud size={18} />
<span>{t('nav_backup_strategy')}</span> <span>{t('nav_backup_strategy')}</span>
@@ -269,7 +290,9 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
{t('txt_sign_out')} {t('txt_sign_out')}
</button> </button>
</section> </section>
)} ) : props.profileLoading ? (
<LoadingState card lines={4} />
) : null}
</Route> </Route>
<Route path="/security/devices"> <Route path="/security/devices">
<div className="stack"> <div className="stack">
@@ -285,6 +308,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SecurityDevicesPage <SecurityDevicesPage
devices={props.authorizedDevices} devices={props.authorizedDevices}
loading={props.authorizedDevicesLoading} loading={props.authorizedDevicesLoading}
error={props.authorizedDevicesError}
onRefresh={() => void props.onRefreshAuthorizedDevices()} onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRenameDevice={props.onRenameAuthorizedDevice} onRenameDevice={props.onRenameAuthorizedDevice}
onRevokeTrust={props.onRevokeDeviceTrust} onRevokeTrust={props.onRevokeDeviceTrust}
@@ -310,6 +334,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
currentUserId={props.profile?.id || ''} currentUserId={props.profile?.id || ''}
users={props.users} users={props.users}
invites={props.invites} invites={props.invites}
loading={props.adminLoading}
error={props.adminError}
onRefresh={props.onRefreshAdmin} onRefresh={props.onRefreshAdmin}
onCreateInvite={props.onCreateInvite} onCreateInvite={props.onCreateInvite}
onDeleteAllInvites={props.onDeleteAllInvites} onDeleteAllInvites={props.onDeleteAllInvites}
@@ -329,7 +355,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<LegacyBackupRedirect onNavigate={props.onNavigate} /> <LegacyBackupRedirect onNavigate={props.onNavigate} />
</Route> </Route>
<Route path="/backup"> <Route path="/backup">
{props.profile?.role === 'admin' ? ( {isAdmin ? (
<div className="stack"> <div className="stack">
{props.mobileLayout && ( {props.mobileLayout && (
<div className="mobile-settings-subhead"> <div className="mobile-settings-subhead">
+9 -1
View File
@@ -19,6 +19,9 @@ interface RegisterValues {
interface AuthViewsProps { interface AuthViewsProps {
mode: 'login' | 'register' | 'locked'; mode: 'login' | 'register' | 'locked';
relaxedLoginInput?: boolean;
authPlaceholder?: string;
unlockPlaceholder?: string;
pendingAction: 'login' | 'register' | 'unlock' | null; pendingAction: 'login' | 'register' | 'unlock' | null;
unlockReady: boolean; unlockReady: boolean;
unlockPreparing: boolean; unlockPreparing: boolean;
@@ -46,6 +49,7 @@ function PasswordField(props: {
onInput: (v: string) => void; onInput: (v: string) => void;
autoFocus?: boolean; autoFocus?: boolean;
autoComplete?: string; autoComplete?: string;
placeholder?: string;
}) { }) {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
return ( return (
@@ -59,6 +63,7 @@ function PasswordField(props: {
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)} onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
autoFocus={props.autoFocus} autoFocus={props.autoFocus}
autoComplete={props.autoComplete} autoComplete={props.autoComplete}
placeholder={props.placeholder}
/> />
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}> <button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
{show ? <EyeOff size={16} /> : <Eye size={16} />} {show ? <EyeOff size={16} /> : <Eye size={16} />}
@@ -90,6 +95,7 @@ export default function AuthViews(props: AuthViewsProps) {
value={props.unlockPassword} value={props.unlockPassword}
autoFocus autoFocus
autoComplete="current-password" autoComplete="current-password"
placeholder={props.unlockPlaceholder}
onInput={props.onChangeUnlock} onInput={props.onChangeUnlock}
/> />
<div className="auth-support-row"> <div className="auth-support-row">
@@ -217,9 +223,10 @@ export default function AuthViews(props: AuthViewsProps) {
<span>{t('txt_email')}</span> <span>{t('txt_email')}</span>
<input <input
className="input" className="input"
type="email" type={props.relaxedLoginInput ? 'text' : 'email'}
value={props.loginValues.email} value={props.loginValues.email}
autoComplete="username" autoComplete="username"
placeholder={props.authPlaceholder}
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })} onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
/> />
</label> </label>
@@ -227,6 +234,7 @@ export default function AuthViews(props: AuthViewsProps) {
label={t('txt_master_password')} label={t('txt_master_password')}
value={props.loginValues.password} value={props.loginValues.password}
autoComplete="current-password" autoComplete="current-password"
placeholder={props.authPlaceholder}
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })} onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus autoFocus
/> />
+100 -5
View File
@@ -1,5 +1,5 @@
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { TriangleAlert } from 'lucide-preact'; import { TriangleAlert } from 'lucide-preact';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -42,6 +42,24 @@ function decrementDialogBodyLock() {
body.dataset.dialogCount = String(nextCount); body.dataset.dialogCount = String(nextCount);
} }
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
let dialogIdCounter = 0;
function getFocusableElements(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((element) => {
if (element.hasAttribute('disabled') || element.getAttribute('aria-hidden') === 'true') return false;
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
});
}
export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) { export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) {
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
@@ -64,7 +82,12 @@ export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | nu
export default function ConfirmDialog(props: ConfirmDialogProps) { export default function ConfirmDialog(props: ConfirmDialogProps) {
const [present, setPresent] = useState(props.open); const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const canDismiss = !props.cancelDisabled && !closing && !props.hideCancel; const cardRef = useRef<HTMLFormElement | null>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`;
const messageId = `${dialogId}-message`;
const canDismiss = !props.cancelDisabled && !closing;
useEffect(() => { useEffect(() => {
if (props.open) { if (props.open) {
@@ -83,6 +106,72 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
useDialogLifecycle(present, canDismiss ? props.onCancel : null); useDialogLifecycle(present, canDismiss ? props.onCancel : null);
useEffect(() => {
if (!props.open || typeof document === 'undefined') return;
const activeElement = document.activeElement;
restoreFocusRef.current = activeElement instanceof HTMLElement ? activeElement : null;
const frameId = window.requestAnimationFrame(() => {
const card = cardRef.current;
if (!card) return;
const focusable = getFocusableElements(card);
const firstField = focusable.find((element) => (
element instanceof HTMLInputElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLTextAreaElement
));
const cancelButton = focusable.find((element) => element.dataset.dialogCancel === 'true');
const confirmButton = focusable.find((element) => element.dataset.dialogConfirm === 'true');
const target = firstField || (props.danger ? cancelButton : confirmButton) || cancelButton || focusable[0] || card;
target.focus({ preventScroll: true });
});
return () => window.cancelAnimationFrame(frameId);
}, [props.open, props.danger]);
useEffect(() => {
if (props.open || present || typeof document === 'undefined') return;
const target = restoreFocusRef.current;
restoreFocusRef.current = null;
if (!target || !document.contains(target)) return;
target.focus({ preventScroll: true });
}, [props.open, present]);
useEffect(() => {
return () => {
const target = restoreFocusRef.current;
if (!target || typeof document === 'undefined' || !document.contains(target)) return;
target.focus({ preventScroll: true });
};
}, []);
function handleDialogKeyDown(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const card = cardRef.current;
if (!card) return;
const focusable = getFocusableElements(card);
if (focusable.length === 0) {
event.preventDefault();
card.focus({ preventScroll: true });
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const activeElement = document.activeElement;
if (event.shiftKey) {
if (activeElement === first || activeElement === card || !card.contains(activeElement)) {
event.preventDefault();
last.focus({ preventScroll: true });
}
return;
}
if (activeElement === last || activeElement === card || !card.contains(activeElement)) {
event.preventDefault();
first.focus({ preventScroll: true });
}
}
if (!present || typeof document === 'undefined') return null; if (!present || typeof document === 'undefined') return null;
return createPortal(( return createPortal((
<div <div
@@ -93,10 +182,14 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
}} }}
> >
<form <form
ref={cardRef}
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`} className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={props.title} aria-labelledby={titleId}
aria-describedby={messageId}
tabIndex={-1}
onKeyDown={handleDialogKeyDown}
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (props.confirmDisabled || closing) return; if (props.confirmDisabled || closing) return;
@@ -114,13 +207,14 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
</div> </div>
</> </>
) : null} ) : null}
<h3 className="dialog-title">{props.title}</h3> <h3 id={titleId} className="dialog-title">{props.title}</h3>
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div> <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
{props.children} {props.children}
<button <button
type="submit" type="submit"
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`} className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
disabled={props.confirmDisabled} disabled={props.confirmDisabled}
data-dialog-confirm="true"
> >
{props.confirmText || t('txt_yes')} {props.confirmText || t('txt_yes')}
</button> </button>
@@ -129,6 +223,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
type="button" type="button"
className="btn btn-secondary dialog-btn" className="btn btn-secondary dialog-btn"
disabled={props.cancelDisabled} disabled={props.cancelDisabled}
data-dialog-cancel="true"
onClick={() => { onClick={() => {
if (props.cancelDisabled) return; if (props.cancelDisabled) return;
props.onCancel(); props.onCancel();
+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>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Home } from 'lucide-preact';
import { t } from '@/lib/i18n';
interface NotFoundPageProps {
title?: string;
message?: string;
homeHref?: string;
}
export default function NotFoundPage(props: NotFoundPageProps) {
const starBoxes = [1, 2, 3, 4];
const stars = [1, 2, 3, 4, 5, 6, 7];
return (
<main className="not-found-page">
<div className="not-found-space" aria-hidden="true">
{starBoxes.map((box) => (
<div key={box} className={`not-found-star-box not-found-star-box-${box}`}>
{stars.map((star) => (
<span key={star} className={`not-found-star not-found-star-position-${star}`} />
))}
</div>
))}
</div>
<section className="not-found-shell" aria-labelledby="not-found-title">
<div className="not-found-brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
</div>
<div className="not-found-astro-stage" aria-hidden="true">
<div className="not-found-astronaut">
<div className="not-found-astro-head" />
<div className="not-found-astro-arm not-found-astro-arm-left" />
<div className="not-found-astro-arm not-found-astro-arm-right" />
<div className="not-found-astro-body">
<div className="not-found-astro-panel" />
</div>
<div className="not-found-astro-leg not-found-astro-leg-left" />
<div className="not-found-astro-leg not-found-astro-leg-right" />
<div className="not-found-astro-pack" />
</div>
</div>
<div className="not-found-copy">
<div className="not-found-code">404</div>
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
<p>{props.message || t('txt_page_not_found_hint')}</p>
<a className="btn btn-primary not-found-action" href={props.homeHref || '/'}>
<Home size={14} className="btn-icon" />
{t('txt_back_to_home')}
</a>
</div>
</section>
</main>
);
}
+158 -11
View File
@@ -1,9 +1,12 @@
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact'; import { Clipboard, Download, Eye, Lock } from 'lucide-preact';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send'; import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
import { copyTextToClipboard } from '@/lib/clipboard';
import { toBufferSource } from '@/lib/crypto'; import { toBufferSource } from '@/lib/crypto';
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download'; import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
import NotFoundPage from '@/components/NotFoundPage';
import StandalonePageFrame from '@/components/StandalonePageFrame'; import StandalonePageFrame from '@/components/StandalonePageFrame';
import { getDemoPublicSend, IS_DEMO_MODE } from '@/lib/demo';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface PublicSendPageProps { interface PublicSendPageProps {
@@ -11,38 +14,147 @@ interface PublicSendPageProps {
keyPart: string | null; keyPart: string | null;
} }
interface PublicSendFileData {
id: string;
fileName?: string | null;
sizeName?: string | null;
}
interface PublicSendData {
id: string;
type: 0 | 1;
decName?: string | null;
decText?: string | null;
decFileName?: string | null;
expirationDate?: string | null;
file?: PublicSendFileData | null;
}
function decodeBase64Url(value: string): Uint8Array | null {
try {
const raw = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = raw + '='.repeat((4 - (raw.length % 4)) % 4);
const decoded = atob(padded);
const out = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; i += 1) out[i] = decoded.charCodeAt(i);
return out;
} catch {
return null;
}
}
function hasUsableSendKey(keyPart: string | null): boolean {
if (!keyPart) return false;
const bytes = decodeBase64Url(keyPart);
return !!bytes && bytes.length >= 16;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
}
function optionalString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function parsePublicSendData(value: unknown): PublicSendData | null {
const source = asRecord(value);
if (!source) return null;
const id = optionalString(source.id);
const rawType = Number(source.type);
if (!id || (rawType !== 0 && rawType !== 1)) return null;
const fileSource = asRecord(source.file);
const fileId = optionalString(fileSource?.id);
const file = fileSource && fileId
? {
id: fileId,
fileName: optionalString(fileSource.fileName),
sizeName: optionalString(fileSource.sizeName),
}
: null;
if (rawType === 1 && !file) return null;
return {
id,
type: rawType,
decName: optionalString(source.decName),
decText: optionalString(source.decText),
decFileName: optionalString(source.decFileName),
expirationDate: optionalString(source.expirationDate),
file,
};
}
export default function PublicSendPage(props: PublicSendPageProps) { export default function PublicSendPage(props: PublicSendPageProps) {
const [loading, setLoading] = useState(true); const initialDemoSend = IS_DEMO_MODE ? getDemoPublicSend(props.accessId) : null;
const [loading, setLoading] = useState(!IS_DEMO_MODE);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [needPassword, setNeedPassword] = useState(false); const [needPassword, setNeedPassword] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [sendData, setSendData] = useState<any>(null); const [notFound, setNotFound] = useState(IS_DEMO_MODE && !initialDemoSend);
const [sendData, setSendData] = useState<PublicSendData | null>(initialDemoSend);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [downloadPercent, setDownloadPercent] = useState<number | null>(null); const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
const loadRequestRef = useRef(0);
const loadAbortRef = useRef<AbortController | null>(null);
async function loadSend(pass?: string): Promise<void> { async function loadSend(pass?: string): Promise<void> {
loadAbortRef.current?.abort();
const controller = new AbortController();
const requestId = loadRequestRef.current + 1;
loadRequestRef.current = requestId;
loadAbortRef.current = controller;
setBusy(true); setBusy(true);
setError(''); setError('');
setNotFound(false);
setLoading(true);
try { try {
const data = await accessPublicSend(props.accessId, props.keyPart, pass); if (IS_DEMO_MODE) {
const demoSend = getDemoPublicSend(props.accessId);
if (!demoSend) {
setNotFound(true);
setSendData(null);
return;
}
setSendData(demoSend);
setNeedPassword(false);
return;
}
if (!hasUsableSendKey(props.keyPart)) {
setNotFound(true);
setSendData(null);
return;
}
const data = await accessPublicSend(props.accessId, props.keyPart, pass, { signal: controller.signal });
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
if (!props.keyPart) { if (!props.keyPart) {
setError(t('txt_this_link_is_missing_decryption_key')); setError(t('txt_this_link_is_missing_decryption_key'));
setSendData(null); setSendData(null);
return; return;
} }
const decrypted = await decryptPublicSend(data, props.keyPart); const decrypted = await decryptPublicSend(data, props.keyPart);
setSendData(decrypted); if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
const parsed = parsePublicSendData(decrypted);
if (!parsed) throw new Error(t('txt_send_unavailable'));
setSendData(parsed);
setNeedPassword(false); setNeedPassword(false);
} catch (e) { } catch (e) {
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
const err = e as Error & { status?: number }; const err = e as Error & { status?: number };
if (err.status === 401) { if (err.status === 401) {
setNeedPassword(true); setNeedPassword(true);
setError(t('txt_this_send_is_password_protected')); setError(t('txt_this_send_is_password_protected'));
} else if (err.status === 404) {
setNeedPassword(false);
setNotFound(true);
setError('');
} else { } else {
setError(err.message || t('txt_failed_to_open_send')); setError(err.message || t('txt_failed_to_open_send'));
} }
setSendData(null); setSendData(null);
} finally { } finally {
if (controller.signal.aborted || requestId !== loadRequestRef.current) return;
setBusy(false); setBusy(false);
setLoading(false); setLoading(false);
} }
@@ -54,6 +166,11 @@ export default function PublicSendPage(props: PublicSendPageProps) {
setDownloadPercent(null); setDownloadPercent(null);
setError(''); setError('');
try { try {
if (IS_DEMO_MODE) {
const bytes = new TextEncoder().encode('NodeWarden demo file Send.\nThis download is generated locally in demo mode.\n');
downloadBytesAsFile(bytes, sendData.decFileName || sendData.file?.fileName || 'nodewarden-demo-send.txt', 'application/octet-stream');
return;
}
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined); const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
const resp = await fetch(url); const resp = await fetch(url);
if (!resp.ok) throw new Error(t('txt_download_failed')); if (!resp.ok) throw new Error(t('txt_download_failed'));
@@ -85,12 +202,31 @@ export default function PublicSendPage(props: PublicSendPageProps) {
} }
useEffect(() => { useEffect(() => {
if (IS_DEMO_MODE) {
const demoSend = getDemoPublicSend(props.accessId);
setSendData(demoSend);
setNotFound(!demoSend);
setNeedPassword(false);
setError('');
setLoading(false);
return;
}
void loadSend(); void loadSend();
return () => {
loadAbortRef.current?.abort();
};
}, [props.accessId, props.keyPart]); }, [props.accessId, props.keyPart]);
if (!loading && notFound) {
return <NotFoundPage title={t('txt_page_not_found')} message={t('txt_send_unavailable')} />;
}
return ( return (
<div className="auth-page public-send-page"> <div className="auth-page public-send-page">
<StandalonePageFrame title={t('txt_nodewarden_send')}> <StandalonePageFrame
title={sendData ? (sendData.decName || t('txt_no_name')) : t('txt_nodewarden_send')}
eyebrow={sendData ? t('txt_nodewarden_send') : undefined}
>
{loading && <p className="muted">{t('txt_loading')}</p>} {loading && <p className="muted">{t('txt_loading')}</p>}
{!loading && needPassword && ( {!loading && needPassword && (
@@ -120,13 +256,24 @@ export default function PublicSendPage(props: PublicSendPageProps) {
{!loading && sendData && ( {!loading && sendData && (
<> <>
<h2 style={{ marginTop: '8px' }}>{sendData.decName || t('txt_no_name')}</h2>
{sendData.type === 0 ? ( {sendData.type === 0 ? (
<div className="card" style={{ marginTop: '10px' }}> <div className="card public-send-card">
<div className="public-send-card-head">
<span>{t('txt_text_send')}</span>
<button
type="button"
className="btn btn-secondary small public-send-copy-btn"
disabled={!sendData.decText}
onClick={() => void copyTextToClipboard(sendData.decText || '')}
>
<Clipboard size={14} className="btn-icon" />
{t('txt_copy')}
</button>
</div>
<div className="notes">{sendData.decText || ''}</div> <div className="notes">{sendData.decText || ''}</div>
</div> </div>
) : ( ) : (
<div className="card" style={{ marginTop: '10px' }}> <div className="card public-send-card">
<div className="kv-line"> <div className="kv-line">
<span>{t('txt_file')}</span> <span>{t('txt_file')}</span>
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong> <strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
@@ -142,7 +289,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
{!loading && !sendData && !needPassword && !error && ( {!loading && !sendData && !needPassword && !error && (
<p className="muted"> <p className="muted">
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> {t('txt_send_unavailable')} <Eye size={14} className="inline-status-icon" /> {t('txt_send_unavailable')}
</p> </p>
)} )}
{!!error && <p className="local-error">{error}</p>} {!!error && <p className="local-error">{error}</p>}
+23 -5
View File
@@ -1,12 +1,14 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact'; import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import LoadingState from '@/components/LoadingState';
import type { AuthorizedDevice } from '@/lib/types'; import type { AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface SecurityDevicesPageProps { interface SecurityDevicesPageProps {
devices: AuthorizedDevice[]; devices: AuthorizedDevice[];
loading: boolean; loading: boolean;
error: string;
onRefresh: () => void; onRefresh: () => void;
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>; onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeTrust: (device: AuthorizedDevice) => void; onRevokeTrust: (device: AuthorizedDevice) => void;
@@ -66,13 +68,13 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<section className="card"> <section className="card">
<div className="section-head"> <div className="section-head">
<div> <div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3> <h3 className="flush-title">{t('txt_device_management')}</h3>
<div className="muted-inline" style={{ marginTop: 4 }}> <div className="muted-inline section-note">
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')} {t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
</div> </div>
</div> </div>
<div className="actions"> <div className="actions">
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}> <button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> <RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')} {t('txt_refresh')}
</button> </button>
@@ -89,7 +91,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</section> </section>
<section className="card"> <section className="card">
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3> <h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
{!!props.error && (
<div className="local-error">
<span>{props.error}</span>
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
</div>
)}
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
@@ -166,10 +177,17 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</td> </td>
</tr> </tr>
))} ))}
{props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={7}>
<LoadingState lines={5} compact />
</td>
</tr>
)}
{!props.loading && props.devices.length === 0 && ( {!props.loading && props.devices.length === 0 && (
<tr> <tr>
<td colSpan={7}> <td colSpan={7}>
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div> <div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
</td> </td>
</tr> </tr>
)} )}
+27 -16
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 { 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 { copyTextToClipboard } from '@/lib/clipboard';
import LoadingState from '@/components/LoadingState';
import type { Send, SendDraft } from '@/lib/types'; import type { Send, SendDraft } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -14,12 +15,13 @@ interface SendsPageProps {
onBulkDelete: (ids: string[]) => Promise<void>; onBulkDelete: (ids: string[]) => Promise<void>;
uploadingSendFileName: string; uploadingSendFileName: string;
sendUploadPercent: number | null; sendUploadPercent: number | null;
mobileSidebarToggleKey: number;
onNotify: (type: 'success' | 'error', text: string) => void; onNotify: (type: 'success' | 'error', text: string) => void;
} }
type SendTypeFilter = 'all' | 'text' | 'file'; type SendTypeFilter = 'all' | 'text' | 'file';
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1'; const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
const MOBILE_LAYOUT_QUERY = '(max-width: 900px)'; const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
function daysFromNow(iso: string | null | undefined, fallback: number): string { function daysFromNow(iso: string | null | undefined, fallback: number): string {
if (!iso) return String(fallback); if (!iso) return String(fallback);
@@ -78,6 +80,7 @@ export default function SendsPage(props: SendsPageProps) {
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout); const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list'); const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey);
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => { const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
try { try {
return localStorage.getItem(AUTO_COPY_KEY) === '1'; return localStorage.getItem(AUTO_COPY_KEY) === '1';
@@ -107,12 +110,10 @@ export default function SendsPage(props: SendsPageProps) {
}, []); }, []);
useEffect(() => { useEffect(() => {
const onToggleSidebar = () => { if (props.mobileSidebarToggleKey === mobileSidebarToggleKeyRef.current) return;
setMobileSidebarOpen((open) => !open); mobileSidebarToggleKeyRef.current = props.mobileSidebarToggleKey;
}; setMobileSidebarOpen((open) => !open);
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar); }, [props.mobileSidebarToggleKey]);
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
}, []);
useEffect(() => { useEffect(() => {
try { try {
@@ -223,8 +224,17 @@ export default function SendsPage(props: SendsPageProps) {
} }
} }
function getAccessUrl(send: Send): string {
const rawUrl = send.shareUrl || `/send/${send.accessId}`;
if (/^https?:\/\//i.test(rawUrl)) return rawUrl;
if (rawUrl.startsWith('/#/')) return `${window.location.origin}${rawUrl}`;
if (rawUrl.startsWith('#/')) return `${window.location.origin}/${rawUrl}`;
if (rawUrl.startsWith('/')) return `${window.location.origin}/#${rawUrl}`;
return `${window.location.origin}/#/${rawUrl.replace(/^\/+/, '')}`;
}
function copyAccessUrl(send: Send): void { function copyAccessUrl(send: Send): void {
const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`; const url = getAccessUrl(send);
void copyTextToClipboard(url, { successMessage: t('txt_link_copied') }); void copyTextToClipboard(url, { successMessage: t('txt_link_copied') });
} }
@@ -322,11 +332,11 @@ export default function SendsPage(props: SendsPageProps) {
</button> </button>
</div> </div>
<div className="list-panel"> <div className="list-panel">
{props.loading && !filteredSends.length && <LoadingState lines={6} compact />}
{filteredSends.map((send, index) => ( {filteredSends.map((send, index) => (
<div <div
key={send.id} key={send.id}
className={`list-item stagger-item ${selectedId === send.id ? 'active' : ''}`} className={`list-item stagger-item stagger-delay-${Math.min(index, 10)} ${selectedId === send.id ? 'active' : ''}`}
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
onClick={(event) => { onClick={(event) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.closest('.row-check')) return; if (target.closest('.row-check')) return;
@@ -376,7 +386,7 @@ export default function SendsPage(props: SendsPageProps) {
</button> </button>
</div> </div>
))} ))}
{!filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>} {!props.loading && !filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
</div> </div>
</section> </section>
@@ -405,7 +415,7 @@ export default function SendsPage(props: SendsPageProps) {
)} )}
{isEditing && draft && ( {isEditing && draft && (
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage"> <div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
<div className="card stagger-item" style={{ animationDelay: '0ms' }}> <div className="card stagger-item stagger-delay-0">
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3> <h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>} {!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
<div className="field-grid"> <div className="field-grid">
@@ -505,12 +515,12 @@ export default function SendsPage(props: SendsPageProps) {
{!isEditing && selectedSend && ( {!isEditing && selectedSend && (
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage"> <div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
<div className="card stagger-item" style={{ animationDelay: '36ms' }}> <div className="card stagger-item stagger-delay-1">
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3> <h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div> <div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
</div> </div>
<div className="card stagger-item" style={{ animationDelay: '72ms' }}> <div className="card stagger-item stagger-delay-2">
<h4>{t('txt_send_details')}</h4> <h4>{t('txt_send_details')}</h4>
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div> <div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div> <div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
@@ -533,7 +543,7 @@ export default function SendsPage(props: SendsPageProps) {
</div> </div>
{!!(selectedSend.decNotes || '').trim() && ( {!!(selectedSend.decNotes || '').trim() && (
<div className="card stagger-item" style={{ animationDelay: '108ms' }}> <div className="card stagger-item stagger-delay-3">
<h4>{t('txt_notes')}</h4> <h4>{t('txt_notes')}</h4>
<div className="notes">{selectedSend.decNotes || ''}</div> <div className="notes">{selectedSend.decNotes || ''}</div>
</div> </div>
@@ -554,6 +564,7 @@ export default function SendsPage(props: SendsPageProps) {
</div> </div>
</div> </div>
)} )}
{!isEditing && !selectedSend && props.loading && <LoadingState card lines={4} />}
</section> </section>
</div> </div>
); );
+241 -133
View File
@@ -3,12 +3,14 @@ import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-p
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator'; import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types'; 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'; import ConfirmDialog from '@/components/ConfirmDialog';
interface SettingsPageProps { interface SettingsPageProps {
profile: Profile; profile: Profile;
totpEnabled: boolean; totpEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout';
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string) => Promise<void>;
@@ -16,9 +18,19 @@ interface SettingsPageProps {
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>; onRotateApiKey: (masterPassword: string) => Promise<string>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onNotify?: (type: 'success' | 'error', text: string) => void; onNotify?: (type: 'success' | 'error', text: string) => void;
} }
const LOCK_TIMEOUT_OPTIONS = [
{ value: 1, labelKey: 'txt_timeout_1_minute' },
{ value: 5, labelKey: 'txt_timeout_5_minutes' },
{ value: 15, labelKey: 'txt_timeout_15_minutes' },
{ value: 30, labelKey: 'txt_timeout_30_minutes' },
{ value: 0, labelKey: 'txt_timeout_never' },
] as const;
function randomBase32Secret(length: number): string { function randomBase32Secret(length: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let out = ''; let out = '';
@@ -39,21 +51,39 @@ function buildOtpUri(email: string, secret: string): string {
return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`; return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
} }
function clearLegacyTotpSetupSecrets(): void {
if (typeof window === 'undefined') return;
const prefix = 'nodewarden.totp.secret.';
const keys: string[] = [];
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index);
if (key?.startsWith(prefix)) keys.push(key);
}
for (const key of keys) {
window.localStorage.removeItem(key);
}
}
export default function SettingsPage(props: SettingsPageProps) { export default function SettingsPage(props: SettingsPageProps) {
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [newPassword2, setNewPassword2] = useState(''); const [newPassword2, setNewPassword2] = useState('');
const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || ''); const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || '');
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32)); const [secret, setSecret] = useState(() => randomBase32Secret(32));
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
const [recoveryCode, setRecoveryCode] = useState(''); const [recoveryCode, setRecoveryCode] = useState('');
const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState('');
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false); const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
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();
}, []);
useEffect(() => { useEffect(() => {
if (!props.totpEnabled) { if (!props.totpEnabled) {
@@ -79,40 +109,57 @@ export default function SettingsPage(props: SettingsPageProps) {
async function enableTotp(): Promise<void> { async function enableTotp(): Promise<void> {
try { try {
await props.onEnableTotp(secret, token); await props.onEnableTotp(secret, token);
// Secret is now stored on the server; remove plaintext copy from localStorage.
localStorage.removeItem(totpSecretStorageKey);
setTotpLocked(true); setTotpLocked(true);
} catch { } catch {
// Keep inputs editable after a failed attempt. // Keep inputs editable after a failed attempt.
} }
} }
async function loadRecoveryCode(): Promise<void> { function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void {
const code = await props.onGetRecoveryCode(recoveryMasterPassword); setMasterPasswordPrompt(action);
setRecoveryCode(code); setMasterPasswordPromptValue('');
props.onNotify?.('success', t('txt_recovery_code_loaded'));
} }
async function loadApiKey(): Promise<void> { function closeMasterPasswordPrompt(): void {
if (masterPasswordPromptSubmitting) return;
setMasterPasswordPrompt(null);
setMasterPasswordPromptValue('');
}
async function submitMasterPasswordPrompt(): Promise<void> {
if (!masterPasswordPrompt || masterPasswordPromptSubmitting) return;
const masterPassword = masterPasswordPromptValue;
setMasterPasswordPromptSubmitting(true);
try { try {
const key = await props.onGetApiKey(apiKeyMasterPassword); if (masterPasswordPrompt === 'recovery') {
setApiKey(key); const code = await props.onGetRecoveryCode(masterPassword);
setApiKeyDialogOpen(true); setRecoveryCode(code);
props.onNotify?.('success', t('txt_recovery_code_loaded'));
} else if (masterPasswordPrompt === 'apiKey') {
const key = await props.onGetApiKey(masterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
} else {
const key = await props.onRotateApiKey(masterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
props.onNotify?.('success', t('txt_api_key_rotated'));
}
setMasterPasswordPrompt(null);
setMasterPasswordPromptValue('');
} catch (error) { } catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty')); props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2'));
} finally {
setMasterPasswordPromptSubmitting(false);
} }
} }
async function doRotateApiKey(): Promise<void> { const masterPasswordPromptTitle =
try { masterPasswordPrompt === 'recovery'
const key = await props.onRotateApiKey(apiKeyMasterPassword); ? t('txt_view_recovery_code')
setApiKey(key); : masterPasswordPrompt === 'rotateApiKey'
setApiKeyDialogOpen(true); ? t('txt_rotate_api_key')
props.onNotify?.('success', t('txt_api_key_rotated')); : t('txt_view_api_key');
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty'));
}
}
function formatDateTime(value: string | null | undefined): string { function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash'); if (!value) return t('txt_dash');
@@ -121,31 +168,66 @@ export default function SettingsPage(props: SettingsPageProps) {
return parsed.toLocaleString(); return parsed.toLocaleString();
} }
async function changeLocale(next: Locale): Promise<void> {
if (next === getLocale()) return;
setSelectedLocale(next);
await setLocale(next);
window.location.reload();
}
return ( return (
<div className="stack"> <div className="settings-modules-grid">
<section className="card"> <section className="card settings-module">
<h3>{t('txt_profile')}</h3> <h3>{t('txt_session_timeout')}</h3>
<label className="field"> <div className="session-timeout-fields">
<span>{t('txt_password_hint_optional')}</span> <label className="field">
<input <span>{t('txt_timeout_time')}</span>
className="input" <select
maxLength={120} className="input"
value={passwordHint} value={String(props.lockTimeoutMinutes)}
placeholder={t('txt_password_hint_placeholder')} onInput={(e) => props.onLockTimeoutChange(Number((e.currentTarget as HTMLSelectElement).value) as 0 | 1 | 5 | 15 | 30)}
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)} >
/> {LOCK_TIMEOUT_OPTIONS.map((option) => (
<div className="field-help">{t('txt_password_hint_register_help')}</div> <option key={option.value} value={option.value}>
</label> {t(option.labelKey)}
<button </option>
type="button" ))}
className="btn btn-secondary" </select>
onClick={() => void props.onSavePasswordHint(passwordHint)} </label>
> <label className="field">
{t('txt_save_profile')} <span>{t('txt_timeout_action')}</span>
</button> <select
className="input"
value={props.sessionTimeoutAction}
onInput={(e) => props.onSessionTimeoutActionChange((e.currentTarget as HTMLSelectElement).value === 'logout' ? 'logout' : 'lock')}
>
<option value="logout">{t('txt_timeout_action_logout')}</option>
<option value="lock">{t('txt_timeout_action_lock')}</option>
</select>
</label>
</div>
</section> </section>
<section className="card"> <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">
<h3>{t('txt_change_master_password')}</h3> <h3>{t('txt_change_master_password')}</h3>
<label className="field"> <label className="field">
<span>{t('txt_current_password')}</span> <span>{t('txt_current_password')}</span>
@@ -176,71 +258,98 @@ export default function SettingsPage(props: SettingsPageProps) {
</button> </button>
</section> </section>
<section className="card"> <section className="card settings-module">
<div className="settings-twofactor-grid"> <h3>{t('txt_password_hint_optional')}</h3>
<div className="settings-subcard"> <label className="field">
<h3>{t('txt_totp')}</h3> <span>{t('txt_password_hint')}</span>
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>} <input
<div className="totp-grid"> className="input"
<div className="totp-qr"> maxLength={120}
<img src={qrDataUrl} alt="TOTP QR" /> value={passwordHint}
</div> placeholder={t('txt_password_hint_placeholder')}
<div> onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
<div> />
<label className="field"> <div className="field-help">{t('txt_password_hint_register_help')}</div>
<span>{t('txt_authenticator_key')}</span> </label>
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} /> <button
</label> type="button"
<label className="field"> className="btn btn-secondary"
<span>{t('txt_verification_code')}</span> onClick={() => void props.onSavePasswordHint(passwordHint)}
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} /> >
</label> {t('txt_save_profile')}
<div className="actions"> </button>
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}> </section>
<ShieldCheck size={14} className="btn-icon" />
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')} <section className="card settings-module">
</button> <h3>{t('txt_totp')}</h3>
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}> {totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
<div className="totp-grid">
<div className="totp-qr">
<img src={qrDataUrl} alt="TOTP QR" />
</div>
<div>
<div>
<label className="field">
<span>{t('txt_authenticator_key')}</span>
<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" /> <RefreshCw size={14} className="btn-icon" />
{t('txt_regenerate')}
</button> </button>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary small totp-secret-icon-btn"
disabled={totpLocked} disabled={totpLocked}
title={t('txt_copy_secret')}
aria-label={t('txt_copy_secret')}
onClick={() => { onClick={() => {
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') }); void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
}} }}
> >
<Clipboard size={14} className="btn-icon" /> <Clipboard size={14} className="btn-icon" />
{t('txt_copy_secret')}
</button> </button>
</div> </div>
</div> </div>
</label>
<label className="field">
<span>{t('txt_verification_code')}</span>
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
</label>
<div className="actions">
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
<ShieldCheck size={14} className="btn-icon" />
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')}
</button>
<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>
</div> </div>
</div>
</section>
<div className="settings-subcard"> <section className="card settings-module">
<h3>{t('txt_recovery_code')}</h3> <h3>{t('txt_recovery_code_and_api_key')}</h3>
<p className="muted-inline" style={{ marginBottom: 8 }}> <div className="sensitive-actions-grid">
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')} <div className="sensitive-action">
</p> <div>
<label className="field"> <h4>{t('txt_recovery_code')}</h4>
<span>{t('txt_master_password')}</span> <p className="muted-inline settings-field-note">
<input {t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
className="input" </p>
type="password" </div>
value={recoveryMasterPassword}
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions"> <div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}> <button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('recovery')}>
<ShieldCheck size={14} className="btn-icon" /> <ShieldCheck size={14} className="btn-icon" />
{t('txt_view_recovery_code')} {t('txt_view_recovery_code')}
</button> </button>
@@ -257,25 +366,19 @@ export default function SettingsPage(props: SettingsPageProps) {
</button> </button>
</div> </div>
{recoveryCode && ( {recoveryCode && (
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}> <div className="recovery-code-card">
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div> <div className="recovery-code-value">{recoveryCode}</div>
</div> </div>
)} )}
</div> </div>
<div className="settings-subcard"> <div className="sensitive-action">
<h3>{t('txt_api_key')}</h3> <div>
<label className="field"> <h4>{t('txt_api_key')}</h4>
<span>{t('txt_master_password')}</span> <p className="muted-inline settings-field-note">{t('txt_api_key_dialog_intro')}</p>
<input </div>
className="input"
type="password"
value={apiKeyMasterPassword}
onInput={(e) => setApiKeyMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions"> <div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadApiKey()}> <button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('apiKey')}>
<KeyRound size={14} className="btn-icon" /> <KeyRound size={14} className="btn-icon" />
{t('txt_view_api_key')} {t('txt_view_api_key')}
</button> </button>
@@ -291,6 +394,28 @@ export default function SettingsPage(props: SettingsPageProps) {
</div> </div>
</div> </div>
</section> </section>
<ConfirmDialog
open={masterPasswordPrompt !== null}
title={masterPasswordPromptTitle}
message={t('txt_enter_master_password_to_continue')}
confirmText={t('txt_continue')}
cancelText={t('txt_cancel')}
confirmDisabled={masterPasswordPromptSubmitting || !masterPasswordPromptValue.trim()}
cancelDisabled={masterPasswordPromptSubmitting}
onConfirm={() => void submitMasterPasswordPrompt()}
onCancel={closeMasterPasswordPrompt}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={masterPasswordPromptValue}
onInput={(e) => setMasterPasswordPromptValue((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={apiKeyDialogOpen} open={apiKeyDialogOpen}
title={t('txt_api_key')} title={t('txt_api_key')}
@@ -300,30 +425,13 @@ export default function SettingsPage(props: SettingsPageProps) {
onConfirm={() => setApiKeyDialogOpen(false)} onConfirm={() => setApiKeyDialogOpen(false)}
onCancel={() => setApiKeyDialogOpen(false)} onCancel={() => setApiKeyDialogOpen(false)}
> >
<div <div className="api-key-warning-panel">
style={{ <div className="api-key-warning-title">{t('txt_warning')}</div>
border: '1px solid color-mix(in srgb, var(--danger) 24%, transparent)', <div className="api-key-warning-body">{t('txt_api_key_warning_body')}</div>
background: 'color-mix(in srgb, var(--danger) 7%, var(--surface))',
borderRadius: 8,
padding: 14,
marginTop: 12,
marginBottom: 14,
}}
>
<div style={{ fontWeight: 800, color: 'var(--danger)', marginBottom: 8 }}>{t('txt_warning')}</div>
<div style={{ color: 'var(--text)', lineHeight: 1.55 }}>{t('txt_api_key_warning_body')}</div>
</div> </div>
<div <div className="api-key-credentials-panel">
style={{ <div className="api-key-credentials-title">
border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)',
background: 'color-mix(in srgb, var(--primary) 7%, var(--surface))',
borderRadius: 8,
padding: 14,
marginBottom: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 800, color: 'var(--primary)', marginBottom: 10 }}>
<KeyRound size={15} /> <KeyRound size={15} />
<span>{t('txt_oauth_client_credentials')}</span> <span>{t('txt_oauth_client_credentials')}</span>
</div> </div>
@@ -335,7 +443,7 @@ export default function SettingsPage(props: SettingsPageProps) {
] as [string, string][]).map(([label, value]) => ( ] as [string, string][]).map(([label, value]) => (
<label key={label} className="field"> <label key={label} className="field">
<span>{label}</span> <span>{label}</span>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', gap: 8 }}> <div className="api-key-credential-row">
<input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} /> <input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} />
<button <button
type="button" type="button"
@@ -357,7 +465,7 @@ export default function SettingsPage(props: SettingsPageProps) {
danger danger
onConfirm={() => { onConfirm={() => {
setRotateApiKeyConfirmOpen(false); setRotateApiKeyConfirmOpen(false);
void doRotateApiKey(); openMasterPasswordPrompt('rotateApiKey');
}} }}
onCancel={() => setRotateApiKeyConfirmOpen(false)} onCancel={() => setRotateApiKeyConfirmOpen(false)}
/> />
+12 -3
View File
@@ -3,6 +3,7 @@ import { APP_VERSION } from '@shared/app-version';
interface StandalonePageFrameProps { interface StandalonePageFrameProps {
title: string; title: string;
eyebrow?: ComponentChildren;
children: ComponentChildren; children: ComponentChildren;
} }
@@ -10,13 +11,14 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
return ( return (
<div className="standalone-shell"> <div className="standalone-shell">
<div className="standalone-brand standalone-brand-outside"> <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> <div>
<img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="standalone-brand-wordmark" /> <span className="standalone-brand-wordmark" role="img" aria-label="NodeWarden" />
</div> </div>
</div> </div>
<div className="auth-card"> <div className="auth-card">
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
<h1 className="standalone-title">{props.title}</h1> <h1 className="standalone-title">{props.title}</h1>
{props.children} {props.children}
</div> </div>
@@ -26,7 +28,14 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
<span> | </span> <span> | </span>
<a href="https://github.com/shuaiplus" target="_blank" rel="noreferrer">Author: @shuaiplus</a> <a href="https://github.com/shuaiplus" target="_blank" rel="noreferrer">Author: @shuaiplus</a>
<span> | </span> <span> | </span>
<span className="standalone-version">v{APP_VERSION}</span> <a
href="https://github.com/shuaiplus/NodeWarden/releases/latest"
target="_blank"
rel="noreferrer"
className="standalone-version"
>
v{APP_VERSION}
</a>
</div> </div>
</div> </div>
); );
+84 -74
View File
@@ -21,7 +21,9 @@ import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
import { calcTotpNow } from '@/lib/crypto'; import { calcTotpNow } from '@/lib/crypto';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types'; 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 { interface TotpCodesPageProps {
ciphers: Cipher[]; ciphers: Cipher[];
@@ -33,7 +35,14 @@ const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14; const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order'; 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 {
windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
};
}
function formatTotp(code: string): string { function formatTotp(code: string): string {
if (!code) return code; if (!code) return code;
@@ -42,53 +51,8 @@ function formatTotp(code: string): string {
return `${code.slice(0, 3)} ${code.slice(3, 6)}`; 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 }) { function TotpListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher); return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
}, [host]);
if (host && !errored) {
return (
<img
className="list-icon"
src={websiteIconUrl(host)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => {
failedIconHosts.add(host);
setErrored(true);
}}
/>
);
}
return (
<span className="list-icon-fallback">
<Globe size={18} />
</span>
);
} }
interface SortableTotpRowProps { interface SortableTotpRowProps {
@@ -168,7 +132,8 @@ function SortableTotpRow(props: SortableTotpRowProps) {
} }
export default function TotpCodesPage(props: TotpCodesPageProps) { export default function TotpCodesPage(props: TotpCodesPageProps) {
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({}); const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
const [columnCount, setColumnCount] = useState(1); const [columnCount, setColumnCount] = useState(1);
const [orderedIds, setOrderedIds] = useState<string[]>(() => { const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (typeof window === 'undefined') return []; if (typeof window === 'undefined') return [];
@@ -199,16 +164,21 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') }); await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
} }
const nameCollator = useMemo(
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
[]
);
const baseTotpItems = useMemo( const baseTotpItems = useMemo(
() => () =>
props.ciphers props.ciphers
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp) .filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
.sort((a, b) => { .sort((a, b) => {
const nameA = (a.decName || a.name || '').trim().toLowerCase(); const nameA = (a.decName || a.name || '').trim();
const nameB = (b.decName || b.name || '').trim().toLowerCase(); const nameB = (b.decName || b.name || '').trim();
return nameA.localeCompare(nameB); return nameCollator.compare(nameA, nameB);
}), }),
[props.ciphers] [props.ciphers, nameCollator]
); );
const totpItems = useMemo(() => { const totpItems = useMemo(() => {
@@ -220,11 +190,13 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
if (orderA != null && orderB != null) return orderA - orderB; if (orderA != null && orderB != null) return orderA - orderB;
if (orderA != null) return -1; if (orderA != null) return -1;
if (orderB != null) return 1; if (orderB != null) return 1;
const nameA = (a.decName || a.name || '').trim().toLowerCase(); const nameA = (a.decName || a.name || '').trim();
const nameB = (b.decName || b.name || '').trim().toLowerCase(); const nameB = (b.decName || b.name || '').trim();
return nameA.localeCompare(nameB); return nameCollator.compare(nameA, nameB);
}); });
}, [baseTotpItems, orderedIds]); }, [baseTotpItems, orderedIds, nameCollator]);
const sortableTotpItems = useMemo(() => totpItems.map((cipher) => cipher.id), [totpItems]);
useEffect(() => { useEffect(() => {
if (!baseTotpItems.length) return; if (!baseTotpItems.length) return;
@@ -251,26 +223,63 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
useEffect(() => { useEffect(() => {
if (!totpItems.length) { if (!totpItems.length) {
setTotpMap({}); setTotpCodes({});
return; return;
} }
let stopped = false; let stopped = false;
let activeRun = 0;
let timer = 0; let timer = 0;
const tick = async () => { let currentWindowId = -1;
const entries = await Promise.all(
totpItems.map(async (cipher) => { const refreshCodes = async () => {
try { const runId = ++activeRun;
const next = await calcTotpNow(cipher.login?.decTotp || ''); const nextCodes: Record<string, string | null> = {};
return [cipher.id, next] as const; for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
} catch { if (stopped || runId !== activeRun) return;
return [cipher.id, null] as const; const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
} const entries = await Promise.all(
}) batch.map(async (cipher) => {
); try {
if (!stopped) setTotpMap(Object.fromEntries(entries)); 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;
});
}; };
void tick();
timer = window.setInterval(() => void tick(), 1000); const tick = () => {
const next = getTotpTimeState();
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
if (next.windowId === currentWindowId) return;
currentWindowId = next.windowId;
void refreshCodes();
};
tick();
timer = window.setInterval(tick, 1000);
return () => { return () => {
stopped = true; stopped = true;
window.clearInterval(timer); window.clearInterval(timer);
@@ -319,14 +328,15 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
className="totp-codes-list" className="totp-codes-list"
style={{ '--totp-columns': String(columnCount) } as Record<string, string>} 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>} {!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={totpItems.map((cipher) => cipher.id)} strategy={rectSortingStrategy}> <SortableContext items={sortableTotpItems} strategy={rectSortingStrategy}>
{totpItems.map((cipher) => ( {totpItems.map((cipher) => (
<SortableTotpRow <SortableTotpRow
key={cipher.id} key={cipher.id}
cipher={cipher} cipher={cipher}
live={totpMap[cipher.id] || null} live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
onCopy={(value) => void copyToClipboard(value)} onCopy={(value) => void copyToClipboard(value)}
/> />
))} ))}
+262 -111
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 VaultDialogs from '@/components/vault/VaultDialogs';
import VaultDetailView from '@/components/vault/VaultDetailView'; import VaultDetailView from '@/components/vault/VaultDetailView';
import VaultEditor from '@/components/vault/VaultEditor'; import VaultEditor from '@/components/vault/VaultEditor';
@@ -8,6 +9,7 @@ import {
MOBILE_LAYOUT_QUERY, MOBILE_LAYOUT_QUERY,
VAULT_LIST_OVERSCAN, VAULT_LIST_OVERSCAN,
VAULT_LIST_ROW_HEIGHT, VAULT_LIST_ROW_HEIGHT,
FOLDER_SORT_STORAGE_KEY,
VAULT_SORT_STORAGE_KEY, VAULT_SORT_STORAGE_KEY,
cipherTypeKey, cipherTypeKey,
cipherTypeLabel, cipherTypeLabel,
@@ -34,6 +36,7 @@ interface VaultPageProps {
ciphers: Cipher[]; ciphers: Cipher[];
folders: Folder[]; folders: Folder[];
loading: boolean; loading: boolean;
error: string;
emailForReprompt: string; emailForReprompt: string;
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>; onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
@@ -58,6 +61,7 @@ interface VaultPageProps {
attachmentDownloadPercent: number | null; attachmentDownloadPercent: number | null;
uploadingAttachmentName: string; uploadingAttachmentName: string;
attachmentUploadPercent: number | null; attachmentUploadPercent: number | null;
mobileSidebarToggleKey: number;
} }
@@ -71,6 +75,8 @@ export default function VaultPage(props: VaultPageProps) {
const [searchComposing, setSearchComposing] = useState(false); const [searchComposing, setSearchComposing] = useState(false);
const [sortMode, setSortMode] = useState<VaultSortMode>('edited'); const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
const [sortMenuOpen, setSortMenuOpen] = useState(false); const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' }); const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({}); const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
@@ -110,10 +116,14 @@ export default function VaultPage(props: VaultPageProps) {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const createMenuRef = useRef<HTMLDivElement | null>(null); const createMenuRef = useRef<HTMLDivElement | null>(null);
const sortMenuRef = useRef<HTMLDivElement | null>(null); const sortMenuRef = useRef<HTMLDivElement | null>(null);
const folderSortMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null); const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const listPanelRef = useRef<HTMLDivElement | null>(null); const listPanelRef = useRef<HTMLDivElement | null>(null);
const mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey);
const sshSeedTicketRef = useRef(0); const sshSeedTicketRef = useRef(0);
const sshFingerprintTicketRef = useRef(0); const sshFingerprintTicketRef = useRef(0);
const listScrollBucketRef = useRef(0);
const [listScrollTop, setListScrollTop] = useState(0); const [listScrollTop, setListScrollTop] = useState(0);
const [listViewportHeight, setListViewportHeight] = useState(0); const [listViewportHeight, setListViewportHeight] = useState(0);
@@ -131,12 +141,10 @@ export default function VaultPage(props: VaultPageProps) {
}, []); }, []);
useEffect(() => { useEffect(() => {
const onToggleSidebar = () => { if (props.mobileSidebarToggleKey === mobileSidebarToggleKeyRef.current) return;
setMobileSidebarOpen((open) => !open); mobileSidebarToggleKeyRef.current = props.mobileSidebarToggleKey;
}; setMobileSidebarOpen((open) => !open);
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar); }, [props.mobileSidebarToggleKey]);
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
}, []);
useEffect(() => { useEffect(() => {
const onQuickAdd = () => { const onQuickAdd = () => {
@@ -165,6 +173,25 @@ export default function VaultPage(props: VaultPageProps) {
} }
}, [sortMode]); }, [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(() => { useEffect(() => {
const node = listPanelRef.current; const node = listPanelRef.current;
if (!node) return; if (!node) return;
@@ -213,6 +240,25 @@ export default function VaultPage(props: VaultPageProps) {
}; };
}, [sortMenuOpen]); }, [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(() => { useEffect(() => {
setRepromptApprovedCipherId(null); setRepromptApprovedCipherId(null);
setRepromptPassword(''); setRepromptPassword('');
@@ -243,29 +289,75 @@ export default function VaultPage(props: VaultPageProps) {
void recalculateSshFingerprint(draft.sshPublicKey); void recalculateSshFingerprint(draft.sshPublicKey);
}, [isEditing, draft?.id, draft?.type]); }, [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>(); const counts = new Map<string, number>();
for (const cipher of props.ciphers) { for (const cipher of props.ciphers) {
if (!isCipherVisibleInNormalVault(cipher)) continue; if (!isCipherVisibleInNormalVault(cipher)) continue;
const signature = buildCipherDuplicateSignature(cipher); const signature = buildCipherDuplicateSignature(cipher);
byId.set(cipher.id, signature);
counts.set(signature, (counts.get(signature) || 0) + 1); counts.set(signature, (counts.get(signature) || 0) + 1);
} }
return counts; return { byId, counts };
}, [props.ciphers]); }, [props.ciphers, sidebarFilter.kind]);
const filteredCiphers = useMemo(() => { const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => { const next = props.ciphers.filter((cipher) => {
const meta = cipherMetaById.get(cipher.id);
if (sidebarFilter.kind === 'trash') { if (sidebarFilter.kind === 'trash') {
if (!isCipherVisibleInTrash(cipher)) return false; if (!isCipherVisibleInTrash(cipher)) return false;
} else if (sidebarFilter.kind === 'archive') { } else if (sidebarFilter.kind === 'archive') {
if (!isCipherVisibleInArchive(cipher)) return false; if (!isCipherVisibleInArchive(cipher)) return false;
} else { } else {
if (!isCipherVisibleInNormalVault(cipher)) return false; if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) { if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) {
return false; return false;
} }
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) 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.kind === 'folder') {
if (sidebarFilter.folderId === null) { if (sidebarFilter.folderId === null) {
if (cipher.folderId) return false; if (cipher.folderId) return false;
@@ -275,24 +367,20 @@ export default function VaultPage(props: VaultPageProps) {
} }
} }
if (!searchQuery) return true; if (!searchQuery) return true;
const name = (cipher.decName || '').toLowerCase(); return !!meta?.searchText.includes(searchQuery);
const username = (cipher.login?.decUsername || '').toLowerCase();
const uri = firstCipherUri(cipher).toLowerCase();
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
}); });
next.sort((a, b) => { next.sort((a, b) => {
const metaA = cipherMetaById.get(a.id);
const metaB = cipherMetaById.get(b.id);
if (sortMode === 'edited') { if (sortMode === 'edited') {
const diff = sortTimeValue(b) - sortTimeValue(a); const diff = (metaB?.sortTime || 0) - (metaA?.sortTime || 0);
if (diff !== 0) return diff; if (diff !== 0) return diff;
} else if (sortMode === 'created') { } else if (sortMode === 'created') {
const diff = creationTimeValue(b) - creationTimeValue(a); const diff = (metaB?.creationTime || 0) - (metaA?.creationTime || 0);
if (diff !== 0) return diff; if (diff !== 0) return diff;
} else { } else {
const nameDiff = String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || ''), undefined, { const nameDiff = nameCollator.compare(metaA?.name || '', metaB?.name || '');
sensitivity: 'base',
numeric: true,
});
if (nameDiff !== 0) return nameDiff; if (nameDiff !== 0) return nameDiff;
} }
@@ -300,7 +388,13 @@ export default function VaultPage(props: VaultPageProps) {
}); });
return next; 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(() => { const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`; if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
@@ -310,6 +404,7 @@ export default function VaultPage(props: VaultPageProps) {
useEffect(() => { useEffect(() => {
setListScrollTop(0); setListScrollTop(0);
listScrollBucketRef.current = 0;
listPanelRef.current?.scrollTo({ top: 0 }); listPanelRef.current?.scrollTo({ top: 0 });
}, [searchQuery, sortMode, sidebarFilterKey]); }, [searchQuery, sortMode, sidebarFilterKey]);
@@ -325,15 +420,12 @@ export default function VaultPage(props: VaultPageProps) {
if (selectedCipherId) setSelectedCipherId(''); if (selectedCipherId) setSelectedCipherId('');
return; return;
} }
if (!selectedCipherId || !filteredCiphers.some((x) => x.id === selectedCipherId)) { if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
setSelectedCipherId(filteredCiphers[0].id); setSelectedCipherId(filteredCiphers[0].id);
} }
}, [filteredCiphers, selectedCipherId, isCreating]); }, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]);
const selectedCipher = useMemo( const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
[props.ciphers, selectedCipherId]
);
const virtualRange = useMemo(() => { const virtualRange = useMemo(() => {
if (!filteredCiphers.length) { if (!filteredCiphers.length) {
return { start: 0, end: 0, padTop: 0, padBottom: 0 }; return { start: 0, end: 0, padTop: 0, padBottom: 0 };
@@ -397,20 +489,27 @@ export default function VaultPage(props: VaultPageProps) {
); );
const totalCipherCount = filteredCiphers.length; 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'); 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; return folder?.decName || folder?.name || id;
} }, [folderById]);
function listSubtitle(cipher: Cipher): string { const listSubtitle = useCallback((cipher: Cipher): string => {
if (Number(cipher.type || 1) === 1) { 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)); 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)); setDraft(createEmptyDraft(type));
setIsCreating(true); setIsCreating(true);
setIsEditing(true); setIsEditing(true);
@@ -423,9 +522,9 @@ function folderName(id: string | null | undefined): string {
if (isMobileLayout) setMobilePanel('edit'); if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
if (type === 5) void seedSshDefaults(); if (type === 5) void seedSshDefaults();
} }, [isMobileLayout]);
function startEdit(): void { const startEdit = useCallback((): void => {
if (!selectedCipher) return; if (!selectedCipher) return;
setDraft(draftFromCipher(selectedCipher)); setDraft(draftFromCipher(selectedCipher));
setIsCreating(false); setIsCreating(false);
@@ -436,9 +535,9 @@ function folderName(id: string | null | undefined): string {
setRemovedAttachmentIds({}); setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('edit'); if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
} }, [selectedCipher, isMobileLayout]);
function cancelEdit(): void { const cancelEdit = useCallback((): void => {
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher; const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
setDraft(null); setDraft(null);
setIsEditing(false); setIsEditing(false);
@@ -448,11 +547,11 @@ function folderName(id: string | null | undefined): string {
setRemovedAttachmentIds({}); setRemovedAttachmentIds({});
setPendingDeletePasskeyIndex(null); setPendingDeletePasskeyIndex(null);
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list'); 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)); setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
} }, []);
function confirmDeleteLoginPasskey(): void { function confirmDeleteLoginPasskey(): void {
if (pendingDeletePasskeyIndex == null) return; if (pendingDeletePasskeyIndex == null) return;
@@ -817,16 +916,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 ( return (
<> <>
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}> <div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
{isMobileLayout && ( {isMobileLayout && (
<div <div
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`} className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
onClick={() => { onClick={handleMobileSidebarMaskClick}
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}}
/> />
)} )}
<VaultSidebar <VaultSidebar
@@ -835,20 +1006,23 @@ function folderName(id: string | null | undefined): string {
busy={busy} busy={busy}
isMobileLayout={isMobileLayout} isMobileLayout={isMobileLayout}
mobileSidebarOpen={mobileSidebarOpen} mobileSidebarOpen={mobileSidebarOpen}
onCloseMobileSidebar={() => setMobileSidebarOpen(false)} folderSortMode={folderSortMode}
folderSortMenuOpen={folderSortMenuOpen}
folderSortMenuRef={folderSortMenuRef}
onCloseMobileSidebar={handleCloseMobileSidebar}
onChangeFilter={setSidebarFilter} onChangeFilter={setSidebarFilter}
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)} onOpenDeleteAllFolders={handleOpenDeleteAllFolders}
onOpenCreateFolder={() => setCreateFolderOpen(true)} onOpenCreateFolder={handleOpenCreateFolder}
onOpenRenameFolder={(folder) => { onOpenRenameFolder={handleOpenRenameFolder}
setPendingRenameFolder(folder);
setRenameFolderName(folder.decName || folder.name || '');
}}
onOpenDeleteFolder={setPendingDeleteFolder} onOpenDeleteFolder={setPendingDeleteFolder}
onToggleFolderSortMenu={handleToggleFolderSortMenu}
onSelectFolderSortMode={handleSelectFolderSortMode}
/> />
<VaultListPanel <VaultListPanel
busy={busy} busy={busy}
loading={props.loading} loading={props.loading}
error={props.error}
searchInput={searchInput} searchInput={searchInput}
sortMode={sortMode} sortMode={sortMode}
sortMenuOpen={sortMenuOpen} sortMenuOpen={sortMenuOpen}
@@ -860,68 +1034,32 @@ function folderName(id: string | null | undefined): string {
selectedCipherId={selectedCipherId} selectedCipherId={selectedCipherId}
selectedMap={selectedMap} selectedMap={selectedMap}
sidebarFilter={sidebarFilter} sidebarFilter={sidebarFilter}
isMobileLayout={isMobileLayout}
mobileFabVisible={!isMobileLayout || mobilePanel === 'list'}
createMenuOpen={createMenuOpen} createMenuOpen={createMenuOpen}
createMenuRef={createMenuRef} createMenuRef={createMenuRef}
sortMenuRef={sortMenuRef} sortMenuRef={sortMenuRef}
listPanelRef={listPanelRef} listPanelRef={listPanelRef}
onSearchInput={setSearchInput} onSearchInput={setSearchInput}
onClearSearch={() => setSearchInput('')} onClearSearch={handleClearSearch}
onSearchCompositionStart={() => setSearchComposing(true)} onSearchCompositionStart={handleSearchCompositionStart}
onSearchCompositionEnd={(value) => { onSearchCompositionEnd={handleSearchCompositionEnd}
setSearchComposing(false); onToggleSortMenu={handleToggleSortMenu}
setSearchInput(value); onSelectSortMode={handleSelectSortMode}
}} onSyncVault={handleSyncVault}
onToggleSortMenu={() => setSortMenuOpen((open) => !open)} onOpenBulkDelete={handleOpenBulkDelete}
onSelectSortMode={(value) => { onSelectDuplicates={handleSelectDuplicates}
setSortMode(value); onSelectAll={handleSelectAll}
setSortMenuOpen(false); onToggleCreateMenu={handleToggleCreateMenu}
}}
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)}
onStartCreate={startCreate} onStartCreate={startCreate}
onBulkRestore={() => void confirmBulkRestore()} onBulkRestore={handleBulkRestore}
onBulkArchive={() => setBulkArchiveOpen(true)} onBulkArchive={handleBulkArchive}
onBulkUnarchive={() => void confirmBulkUnarchive()} onBulkUnarchive={handleBulkUnarchive}
onOpenMove={() => { onOpenMove={handleOpenMove}
setMoveFolderId('__none__'); onClearSelection={handleClearSelection}
setMoveOpen(true); onScroll={handleListScroll}
}} onToggleSelected={handleToggleSelected}
onClearSelection={() => setSelectedMap({})} onSelectCipher={handleSelectCipher}
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);
}}
listSubtitle={listSubtitle} listSubtitle={listSubtitle}
/> />
@@ -1004,7 +1142,20 @@ function folderName(id: string | null | undefined): string {
</div> </div>
)} )}
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>} {!isEditing && !selectedCipher && (
props.loading
? <LoadingState card lines={5} />
: props.error
? (
<div className="empty card vault-error-state">
<strong>{props.error}</strong>
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={handleSyncVault}>
{t('txt_retry_sync')}
</button>
</div>
)
: <div className="empty card">{t('txt_select_an_item')}</div>
)}
</section> </section>
</div> </div>
@@ -1,8 +1,8 @@
import { CloudUpload, Save, Trash2 } from 'lucide-preact'; import { CloudUpload, Save, Trash2 } from 'lucide-preact';
import type { import type {
BackupDestinationRecord, BackupDestinationRecord,
E3BackupDestination,
RemoteBackupBrowserResponse, RemoteBackupBrowserResponse,
S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '@/lib/api/backup'; } from '@/lib/api/backup';
import { COMMON_TIME_ZONES, getDestinationTypeLabel } from '@/lib/backup-center'; import { COMMON_TIME_ZONES, getDestinationTypeLabel } from '@/lib/backup-center';
@@ -399,97 +399,97 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
</div> </div>
) : null} ) : null}
{props.selectedDestination.type === 'e3' ? ( {props.selectedDestination.type === 's3' ? (
<div className="field-grid"> <div className="field-grid">
<label className="field field-span-2"> <label className="field field-span-2">
<span>{t('txt_backup_e3_endpoint')}</span> <span>{t('txt_backup_s3_endpoint')}</span>
<input <input
className="input" className="input"
value={(props.selectedDestination.destination as E3BackupDestination).endpoint} value={(props.selectedDestination.destination as S3BackupDestination).endpoint}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="https://s3.example.com" placeholder="https://s3.example.com"
onInput={(event) => props.onUpdateDestination((destination) => ({ onInput={(event) => props.onUpdateDestination((destination) => ({
...destination, ...destination,
destination: { destination: {
...(destination.destination as E3BackupDestination), ...(destination.destination as S3BackupDestination),
endpoint: (event.currentTarget as HTMLInputElement).value, endpoint: (event.currentTarget as HTMLInputElement).value,
}, },
}))} }))}
/> />
</label> </label>
<label className="field"> <label className="field">
<span>{t('txt_backup_e3_bucket')}</span> <span>{t('txt_backup_s3_bucket')}</span>
<input <input
className="input" className="input"
value={(props.selectedDestination.destination as E3BackupDestination).bucket} value={(props.selectedDestination.destination as S3BackupDestination).bucket}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({ onInput={(event) => props.onUpdateDestination((destination) => ({
...destination, ...destination,
destination: { destination: {
...(destination.destination as E3BackupDestination), ...(destination.destination as S3BackupDestination),
bucket: (event.currentTarget as HTMLInputElement).value, bucket: (event.currentTarget as HTMLInputElement).value,
}, },
}))} }))}
/> />
</label> </label>
<label className="field"> <label className="field">
<span>{t('txt_backup_e3_region')}</span> <span>{t('txt_backup_s3_region')}</span>
<input <input
className="input" className="input"
value={(props.selectedDestination.destination as E3BackupDestination).region} value={(props.selectedDestination.destination as S3BackupDestination).region}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="auto" placeholder="auto"
onInput={(event) => props.onUpdateDestination((destination) => ({ onInput={(event) => props.onUpdateDestination((destination) => ({
...destination, ...destination,
destination: { destination: {
...(destination.destination as E3BackupDestination), ...(destination.destination as S3BackupDestination),
region: (event.currentTarget as HTMLInputElement).value, region: (event.currentTarget as HTMLInputElement).value,
}, },
}))} }))}
/> />
</label> </label>
<label className="field"> <label className="field">
<span>{t('txt_backup_e3_access_key')}</span> <span>{t('txt_backup_s3_access_key')}</span>
<input <input
className="input" className="input"
value={(props.selectedDestination.destination as E3BackupDestination).accessKeyId} value={(props.selectedDestination.destination as S3BackupDestination).accessKeyId}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({ onInput={(event) => props.onUpdateDestination((destination) => ({
...destination, ...destination,
destination: { destination: {
...(destination.destination as E3BackupDestination), ...(destination.destination as S3BackupDestination),
accessKeyId: (event.currentTarget as HTMLInputElement).value, accessKeyId: (event.currentTarget as HTMLInputElement).value,
}, },
}))} }))}
/> />
</label> </label>
<label className="field"> <label className="field">
<span>{t('txt_backup_e3_secret_key')}</span> <span>{t('txt_backup_s3_secret_key')}</span>
<input <input
className="input" className="input"
type="password" type="password"
value={(props.selectedDestination.destination as E3BackupDestination).secretAccessKey} value={(props.selectedDestination.destination as S3BackupDestination).secretAccessKey}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({ onInput={(event) => props.onUpdateDestination((destination) => ({
...destination, ...destination,
destination: { destination: {
...(destination.destination as E3BackupDestination), ...(destination.destination as S3BackupDestination),
secretAccessKey: (event.currentTarget as HTMLInputElement).value, secretAccessKey: (event.currentTarget as HTMLInputElement).value,
}, },
}))} }))}
/> />
</label> </label>
<label className="field field-span-2"> <label className="field field-span-2">
<span>{t('txt_backup_e3_path')}</span> <span>{t('txt_backup_s3_path')}</span>
<input <input
className="input" className="input"
value={(props.selectedDestination.destination as E3BackupDestination).rootPath} value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="nodewarden/backups" placeholder="nodewarden/backups"
onInput={(event) => props.onUpdateDestination((destination) => ({ onInput={(event) => props.onUpdateDestination((destination) => ({
...destination, ...destination,
destination: { destination: {
...(destination.destination as E3BackupDestination), ...(destination.destination as S3BackupDestination),
rootPath: (event.currentTarget as HTMLInputElement).value, 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')}> <button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('webdav')}>
{t('txt_backup_protocol_webdav')} {t('txt_backup_protocol_webdav')}
</button> </button>
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('e3')}> <button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('s3')}>
{t('txt_backup_protocol_e3')} {t('txt_backup_protocol_s3')}
</button> </button>
</div> </div>
) : null} ) : null}
@@ -1,12 +1,13 @@
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useMemo, useState } from 'preact/hooks'; import { useMemo, useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact'; import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
import { useDialogLifecycle } from '@/components/ConfirmDialog'; import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
TOTP_PERIOD_SECONDS, TOTP_PERIOD_SECONDS,
TOTP_RING_CIRCUMFERENCE, TOTP_RING_CIRCUMFERENCE,
VaultListIcon,
copyToClipboard, copyToClipboard,
formatAttachmentSize, formatAttachmentSize,
formatHistoryTime, formatHistoryTime,
@@ -105,7 +106,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="card"> <div className="card">
<h4>{t('txt_master_password_reprompt_2')}</h4> <h4>{t('txt_master_password_reprompt_2')}</h4>
<div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div> <div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div>
<div className="actions" style={{ marginTop: '10px' }}> <div className="actions detail-unlock-actions">
<button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}> <button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}>
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')} <Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
</button> </button>
@@ -115,9 +116,19 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
{(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && ( {(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && (
<> <>
<div className="card"> <div className="card">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3> <div className="detail-title-row">
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div> <span className="detail-title-icon" aria-hidden="true">
{isArchived && <div className="list-badge" style={{ marginTop: '8px', width: 'fit-content' }}>{t('txt_archived')}</div>} <VaultListIcon cipher={props.selectedCipher} />
</span>
<div className="detail-title-main">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
<div className="detail-folder-line">
<Folder size={13} aria-hidden="true" />
<span>{props.folderName(props.selectedCipher.folderId)}</span>
</div>
</div>
</div>
{isArchived && <div className="list-badge archive-badge">{t('txt_archived')}</div>}
</div> </div>
{props.selectedCipher.login && ( {props.selectedCipher.login && (
+3 -2
View File
@@ -1,6 +1,6 @@
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import type { CustomFieldType, Folder } from '@/lib/types'; 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'; import { t } from '@/lib/i18n';
interface VaultDialogsProps { interface VaultDialogsProps {
@@ -61,6 +61,7 @@ interface VaultDialogsProps {
} }
export default function VaultDialogs(props: VaultDialogsProps) { export default function VaultDialogs(props: VaultDialogsProps) {
const fieldTypeOptions = getFieldTypeOptions();
return ( return (
<> <>
<ConfirmDialog <ConfirmDialog
@@ -75,7 +76,7 @@ export default function VaultDialogs(props: VaultDialogsProps) {
<label className="field"> <label className="field">
<span>{t('txt_field_type')}</span> <span>{t('txt_field_type')}</span>
<select className="input" value={props.fieldType} onInput={(e) => props.onFieldTypeChange(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}> <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 key={option.value} value={option.value}>
{option.label} {option.label}
</option> </option>
+208 -7
View File
@@ -1,6 +1,8 @@
import type { JSX, RefObject } from 'preact'; import type { JSX, RefObject } from 'preact';
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact'; import { createPortal } from 'preact/compat';
import { CheckCheck, Download, GripVertical, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
import { import {
closestCenter, closestCenter,
DndContext, DndContext,
@@ -21,13 +23,13 @@ import { CSS } from '@dnd-kit/utilities';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
CREATE_TYPE_OPTIONS,
cipherTypeLabel, cipherTypeLabel,
createEmptyLoginUri, createEmptyLoginUri,
formatAttachmentSize, formatAttachmentSize,
formatHistoryTime, formatHistoryTime,
getCreateTypeOptions,
getWebsiteMatchOptions,
toBooleanFieldValue, toBooleanFieldValue,
WEBSITE_MATCH_OPTIONS,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
interface VaultEditorProps { interface VaultEditorProps {
@@ -77,6 +79,7 @@ interface SortableWebsiteRowProps {
} }
function SortableWebsiteRow(props: SortableWebsiteRowProps) { function SortableWebsiteRow(props: SortableWebsiteRowProps) {
const websiteMatchOptions = getWebsiteMatchOptions();
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id, id: props.id,
}); });
@@ -117,7 +120,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
props.onUpdateMatch(props.index, raw === '' ? null : Number(raw)); 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 key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}>
{option.label} {option.label}
</option> </option>
@@ -134,9 +137,18 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
} }
export default function VaultEditor(props: VaultEditorProps) { export default function VaultEditor(props: VaultEditorProps) {
const createTypeOptions = getCreateTypeOptions();
const uriIdSeedRef = useRef(0); const uriIdSeedRef = useRef(0);
const totpQrVideoRef = useRef<HTMLVideoElement | null>(null);
const totpQrFileRef = useRef<HTMLInputElement | null>(null);
const totpQrStreamRef = useRef<MediaStream | null>(null);
const totpQrFrameRef = useRef<number | null>(null);
const [uriItemIds, setUriItemIds] = useState<string[]>([]); const [uriItemIds, setUriItemIds] = useState<string[]>([]);
const [activeUriId, setActiveUriId] = useState<string | null>(null); const [activeUriId, setActiveUriId] = useState<string | null>(null);
const [totpQrOpen, setTotpQrOpen] = useState(false);
const [totpQrStatus, setTotpQrStatus] = useState('');
const [totpQrBusy, setTotpQrBusy] = useState(false);
useDialogLifecycle(totpQrOpen, () => setTotpQrOpen(false));
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {
@@ -153,6 +165,63 @@ export default function VaultEditor(props: VaultEditorProps) {
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`; const createUriId = () => `login-uri-${uriIdSeedRef.current++}`;
const stopTotpQrScanner = () => {
if (totpQrFrameRef.current != null) {
window.cancelAnimationFrame(totpQrFrameRef.current);
totpQrFrameRef.current = null;
}
if (totpQrStreamRef.current) {
for (const track of totpQrStreamRef.current.getTracks()) track.stop();
totpQrStreamRef.current = null;
}
if (totpQrVideoRef.current) {
totpQrVideoRef.current.srcObject = null;
}
};
const applyTotpQrValue = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return false;
props.onUpdateDraft({ loginTotp: trimmed });
setTotpQrStatus(t('txt_totp_qr_scanned'));
setTotpQrOpen(false);
return true;
};
const createTotpQrDetector = (): BarcodeDetector | null => {
if (typeof window === 'undefined' || !window.BarcodeDetector) return null;
return new window.BarcodeDetector({ formats: ['qr_code'] });
};
const decodeTotpQrImage = async (source: ImageBitmapSource): Promise<boolean> => {
const detector = createTotpQrDetector();
if (!detector) {
setTotpQrStatus(t('txt_totp_qr_unsupported'));
return false;
}
const results = await detector.detect(source);
const value = String(results[0]?.rawValue || '').trim();
if (!value) return false;
return applyTotpQrValue(value);
};
const handleTotpQrFile = async (file: File | null) => {
if (!file) return;
setTotpQrBusy(true);
setTotpQrStatus(t('txt_totp_qr_scanning'));
let bitmap: ImageBitmap | null = null;
try {
bitmap = await createImageBitmap(file);
const found = await decodeTotpQrImage(bitmap);
if (!found) setTotpQrStatus(t('txt_totp_qr_not_found'));
} catch {
setTotpQrStatus(t('txt_totp_qr_scan_failed'));
} finally {
bitmap?.close();
setTotpQrBusy(false);
}
};
useEffect(() => { useEffect(() => {
setUriItemIds((prev) => { setUriItemIds((prev) => {
if (prev.length === props.draft.loginUris.length) return prev; if (prev.length === props.draft.loginUris.length) return prev;
@@ -168,6 +237,77 @@ export default function VaultEditor(props: VaultEditorProps) {
setActiveUriId(null); setActiveUriId(null);
}, [props.draft.id, props.isCreating]); }, [props.draft.id, props.isCreating]);
useEffect(() => {
if (!totpQrOpen) {
stopTotpQrScanner();
return;
}
let stopped = false;
const detector = createTotpQrDetector();
if (!detector) {
setTotpQrStatus(t('txt_totp_qr_unsupported'));
return () => {
stopped = true;
stopTotpQrScanner();
};
}
if (!navigator.mediaDevices?.getUserMedia) {
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
return () => {
stopped = true;
stopTotpQrScanner();
};
}
const scan = async () => {
if (stopped) return;
const video = totpQrVideoRef.current;
if (!video || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
totpQrFrameRef.current = window.requestAnimationFrame(scan);
return;
}
try {
const results = await detector.detect(video);
const value = String(results[0]?.rawValue || '').trim();
if (value && applyTotpQrValue(value)) return;
} catch {
// Keep the camera active; transient frame decode failures are common.
}
totpQrFrameRef.current = window.requestAnimationFrame(scan);
};
setTotpQrBusy(true);
setTotpQrStatus(t('txt_totp_qr_starting_camera'));
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
.then((stream) => {
if (stopped) {
for (const track of stream.getTracks()) track.stop();
return;
}
totpQrStreamRef.current = stream;
const video = totpQrVideoRef.current;
if (!video) return;
video.srcObject = stream;
setTotpQrStatus(t('txt_totp_qr_point_camera'));
void video.play().then(() => {
setTotpQrBusy(false);
totpQrFrameRef.current = window.requestAnimationFrame(scan);
}).catch(() => {
setTotpQrBusy(false);
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
});
})
.catch(() => {
setTotpQrBusy(false);
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
});
return () => {
stopped = true;
stopTotpQrScanner();
};
}, [totpQrOpen]);
const formatDownloadLabel = (attachmentId: string) => { const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`; const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -232,7 +372,7 @@ export default function VaultEditor(props: VaultEditorProps) {
if (nextType === 5) props.onSeedSshDefaults(); if (nextType === 5) props.onSeedSshDefaults();
}} }}
> >
{CREATE_TYPE_OPTIONS.map((option) => ( {createTypeOptions.map((option) => (
<option key={option.type} value={option.type}> <option key={option.type} value={option.type}>
{option.label} {option.label}
</option> </option>
@@ -272,7 +412,22 @@ export default function VaultEditor(props: VaultEditorProps) {
</div> </div>
<label className="field"> <label className="field">
<span>{t('txt_totp_secret')}</span> <span>{t('txt_totp_secret')}</span>
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} /> <div className="input-action-wrap">
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} />
<button
type="button"
className="input-icon-btn"
title={t('txt_scan_totp_qr')}
aria-label={t('txt_scan_totp_qr')}
disabled={props.busy}
onClick={() => {
setTotpQrStatus('');
setTotpQrOpen(true);
}}
>
<QrCode size={18} className="btn-icon" />
</button>
</div>
</label> </label>
<div className="section-head"> <div className="section-head">
<h4>{t('txt_websites')}</h4> <h4>{t('txt_websites')}</h4>
@@ -299,7 +454,7 @@ export default function VaultEditor(props: VaultEditorProps) {
</DndContext> </DndContext>
{props.draft.loginFido2Credentials.length > 0 && ( {props.draft.loginFido2Credentials.length > 0 && (
<> <>
<div className="section-head" style={{ marginTop: '18px' }}> <div className="section-head passkeys-section-head">
<h4>{t('txt_passkeys')}</h4> <h4>{t('txt_passkeys')}</h4>
</div> </div>
<div className="attachment-list"> <div className="attachment-list">
@@ -569,6 +724,52 @@ export default function VaultEditor(props: VaultEditorProps) {
)} )}
</div> </div>
{props.localError && <div className="local-error">{props.localError}</div>} {props.localError && <div className="local-error">{props.localError}</div>}
{totpQrOpen && typeof document !== 'undefined' ? createPortal((
<div className="dialog-mask totp-scan-mask open" onClick={(event) => event.target === event.currentTarget && setTotpQrOpen(false)}>
<section className="dialog-card totp-scan-dialog open" role="dialog" aria-modal="true" aria-label={t('txt_scan_totp_qr')}>
<div className="totp-scan-head">
<h3 className="dialog-title">{t('txt_scan_totp_qr')}</h3>
<button
type="button"
className="totp-scan-close"
onClick={() => setTotpQrOpen(false)}
title={t('txt_close')}
aria-label={t('txt_close')}
>
<X size={20} className="btn-icon" />
</button>
</div>
<div className="totp-scan-frame">
<video ref={totpQrVideoRef} className="totp-scan-video" muted playsInline />
<div className="totp-scan-corners" aria-hidden="true" />
</div>
<div className="totp-scan-footer">
<div className="dialog-message totp-scan-status">{totpQrStatus || t('txt_totp_qr_point_camera')}</div>
<div className="actions totp-scan-actions">
<button type="button" className="btn btn-secondary dialog-btn" disabled={totpQrBusy} onClick={() => totpQrFileRef.current?.click()}>
<Upload size={14} className="btn-icon" />
{t('txt_totp_qr_choose_image')}
</button>
<button type="button" className="btn btn-primary dialog-btn" onClick={() => setTotpQrOpen(false)}>
<X size={14} className="btn-icon" />
{t('txt_close')}
</button>
</div>
</div>
<input
ref={totpQrFileRef}
type="file"
accept="image/*"
className="attachment-file-input"
onChange={(event) => {
const input = event.currentTarget as HTMLInputElement;
void handleTotpQrFile(input.files?.[0] || null);
input.value = '';
}}
/>
</section>
</div>
), document.body) : null}
</> </>
); );
} }
+97 -52
View File
@@ -1,11 +1,14 @@
import type { RefObject } from 'preact'; 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 { 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 type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
CREATE_TYPE_OPTIONS,
CreateTypeIcon, CreateTypeIcon,
VAULT_SORT_OPTIONS, getCreateTypeOptions,
getVaultSortOptions,
VaultListIcon, VaultListIcon,
type SidebarFilter, type SidebarFilter,
type VaultSortMode, type VaultSortMode,
@@ -21,6 +24,7 @@ interface VirtualRange {
interface VaultListPanelProps { interface VaultListPanelProps {
busy: boolean; busy: boolean;
loading: boolean; loading: boolean;
error: string;
searchInput: string; searchInput: string;
sortMode: VaultSortMode; sortMode: VaultSortMode;
sortMenuOpen: boolean; sortMenuOpen: boolean;
@@ -32,6 +36,8 @@ interface VaultListPanelProps {
selectedCipherId: string; selectedCipherId: string;
selectedMap: Record<string, boolean>; selectedMap: Record<string, boolean>;
sidebarFilter: SidebarFilter; sidebarFilter: SidebarFilter;
isMobileLayout: boolean;
mobileFabVisible: boolean;
createMenuOpen: boolean; createMenuOpen: boolean;
createMenuRef: RefObject<HTMLDivElement>; createMenuRef: RefObject<HTMLDivElement>;
sortMenuRef: RefObject<HTMLDivElement>; sortMenuRef: RefObject<HTMLDivElement>;
@@ -59,7 +65,74 @@ interface VaultListPanelProps {
listSubtitle: (cipher: Cipher) => string; 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) { 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 ( return (
<section className="list-col"> <section className="list-col">
<div className="list-head"> <div className="list-head">
@@ -101,7 +174,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</button> </button>
{props.sortMenuOpen && ( {props.sortMenuOpen && (
<div className="sort-menu"> <div className="sort-menu">
{VAULT_SORT_OPTIONS.map((option) => ( {vaultSortOptions.map((option) => (
<button <button
key={option.value} key={option.value}
type="button" type="button"
@@ -159,65 +232,37 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}> <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')} <CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
</button> </button>
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}> {props.isMobileLayout && typeof document !== 'undefined'
<button ? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
type="button" : createMenu}
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>
</div> </div>
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> <div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
{!props.loading && !!props.error && !props.filteredCiphers.length && (
<div className="empty vault-error-state">
<strong>{props.error}</strong>
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onSyncVault}>
{t('txt_retry_sync')}
</button>
</div>
)}
{!!props.filteredCiphers.length && ( {!!props.filteredCiphers.length && (
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}> <div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
{props.visibleCiphers.map((cipher) => ( {props.visibleCiphers.map((cipher) => (
<div <CipherListItem
key={cipher.id} key={cipher.id}
className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`} cipher={cipher}
onClick={(event) => { selected={props.selectedCipherId === cipher.id}
const target = event.target as HTMLElement; checked={!!props.selectedMap[cipher.id]}
if (target.closest('.row-check')) return; subtitle={props.listSubtitle(cipher)}
props.onSelectCipher(cipher.id); onToggleSelected={props.onToggleSelected}
}} onSelectCipher={props.onSelectCipher}
> />
<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>
))} ))}
</div> </div>
)} )}
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>} {!props.loading && !props.error && !props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
</div> </div>
</section> </section>
); );
+75 -2
View File
@@ -1,5 +1,9 @@
import { useMemo } from 'preact/hooks';
import type { RefObject } from 'preact';
import { import {
Archive, Archive,
ArrowUpDown,
Check,
Copy, Copy,
CreditCard, CreditCard,
Folder as FolderIcon, Folder as FolderIcon,
@@ -17,7 +21,7 @@ import {
} from 'lucide-preact'; } from 'lucide-preact';
import type { Folder } from '@/lib/types'; import type { Folder } from '@/lib/types';
import { t } from '@/lib/i18n'; 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 { interface VaultSidebarProps {
folders: Folder[]; folders: Folder[];
@@ -25,15 +29,58 @@ interface VaultSidebarProps {
busy: boolean; busy: boolean;
isMobileLayout: boolean; isMobileLayout: boolean;
mobileSidebarOpen: boolean; mobileSidebarOpen: boolean;
folderSortMode: VaultSortMode;
folderSortMenuOpen: boolean;
folderSortMenuRef: RefObject<HTMLDivElement>;
onCloseMobileSidebar: () => void; onCloseMobileSidebar: () => void;
onChangeFilter: (filter: SidebarFilter) => void; onChangeFilter: (filter: SidebarFilter) => void;
onOpenDeleteAllFolders: () => void; onOpenDeleteAllFolders: () => void;
onOpenCreateFolder: () => void; onOpenCreateFolder: () => void;
onOpenRenameFolder: (folder: Folder) => void; onOpenRenameFolder: (folder: Folder) => void;
onOpenDeleteFolder: (folder: Folder) => void; onOpenDeleteFolder: (folder: Folder) => void;
onToggleFolderSortMenu: () => void;
onSelectFolderSortMode: (value: VaultSortMode) => void;
} }
export default function VaultSidebar(props: VaultSidebarProps) { 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 ( return (
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}> <aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
{props.isMobileLayout && ( {props.isMobileLayout && (
@@ -85,6 +132,32 @@ export default function VaultSidebar(props: VaultSidebarProps) {
<div className="sidebar-title-row"> <div className="sidebar-title-row">
<div className="sidebar-title">{t('txt_folders')}</div> <div className="sidebar-title">{t('txt_folders')}</div>
<div className="folder-title-actions"> <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 <button
type="button" type="button"
className="folder-delete-btn" 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 })}> <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> <FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
</button> </button>
{props.folders.map((folder) => ( {sortedFolders.map((folder) => (
<div key={folder.id} className="folder-row"> <div key={folder.id} className="folder-row">
<button <button
type="button" type="button"
+125
View File
@@ -0,0 +1,125 @@
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 {
getWebsiteIconImageUrl,
getWebsiteIconStatus,
preloadWebsiteIcon,
subscribeWebsiteIconStatus,
} from '@/lib/website-icon-cache';
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
const SHOULD_LOAD_DEMO_BRAND_ICONS = __NODEWARDEN_DEMO__;
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'));
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
useEffect(() => {
if (!host) {
setShouldLoad(true);
setStatus('idle');
setImageUrl('');
return;
}
const nextStatus = getWebsiteIconStatus(host);
setShouldLoad(nextStatus === 'loaded');
setStatus(nextStatus);
setImageUrl(getWebsiteIconImageUrl(host));
return subscribeWebsiteIconStatus(host, (next) => {
setStatus(next);
setImageUrl(getWebsiteIconImageUrl(host));
});
}, [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 (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
if (demoIconUrl) return;
if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return;
let disposed = false;
void preloadWebsiteIcon(host, src).then((nextStatus) => {
if (disposed) return;
setStatus(nextStatus);
setImageUrl(getWebsiteIconImageUrl(host));
});
return () => {
disposed = true;
};
}, [demoIconUrl, host, src, shouldLoad, status]);
if (demoIconUrl) {
return (
<span className="list-icon-stack" ref={nodeRef}>
<img
className="list-icon loaded"
src={demoIconUrl}
alt=""
loading="lazy"
decoding="async"
/>
</span>
);
}
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' && imageUrl && (
<img
className="list-icon loaded"
src={imageUrl}
alt=""
loading="lazy"
decoding="async"
/>
)}
</span>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { import {
CreditCard, CreditCard,
FileKey2, FileKey2,
@@ -10,6 +10,7 @@ import {
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types'; import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
import WebsiteIcon from './WebsiteIcon';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
export type VaultSortMode = 'edited' | 'created' | 'name'; export type VaultSortMode = 'edited' | 'created' | 'name';
@@ -27,39 +28,56 @@ interface TypeOption {
label: string; label: string;
} }
export const CREATE_TYPE_OPTIONS: TypeOption[] = [ export function getCreateTypeOptions(): TypeOption[] {
{ type: 1, label: t('txt_login') }, return [
{ type: 3, label: t('txt_card') }, { type: 1, label: t('txt_login') },
{ type: 4, label: t('txt_identity') }, { type: 3, label: t('txt_card') },
{ type: 2, label: t('txt_note') }, { type: 4, label: t('txt_identity') },
{ type: 5, label: t('txt_ssh_key') }, { 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 VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)'; 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_ROW_HEIGHT = 74;
export const VAULT_LIST_OVERSCAN = 10; export const VAULT_LIST_OVERSCAN = 10;
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [ export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
{ value: 'edited', label: t('txt_sort_last_edited') }, return [
{ value: 'created', label: t('txt_sort_created') }, { value: 'edited', label: t('txt_sort_last_edited') },
{ value: 'name', label: t('txt_sort_name') }, { value: 'created', label: t('txt_sort_created') },
]; { value: 'name', label: t('txt_sort_name') },
];
}
export const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [ export function getFolderSortOptions(): Array<{ value: VaultSortMode; label: string }> {
{ value: 0, label: t('txt_text') }, return [
{ value: 1, label: t('txt_hidden') }, { value: 'edited', label: t('txt_sort_last_edited') },
{ value: 2, label: t('txt_boolean') }, { 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 }> = [ export function getFieldTypeOptions(): Array<{ value: CustomFieldType; label: string }> {
{ value: null, label: t('txt_uri_match_default_base_domain') }, return [
{ value: 0, label: t('txt_uri_match_base_domain') }, { value: 0, label: t('txt_text') },
{ value: 1, label: t('txt_uri_match_host') }, { value: 1, label: t('txt_hidden') },
{ value: 3, label: t('txt_uri_match_exact') }, { value: 2, label: t('txt_boolean') },
{ 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 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_PERIOD_SECONDS = 30;
export const TOTP_RING_RADIUS = 14; 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'; return v === '1' || v === 'true' || v === 'yes' || v === 'on';
} }
export function firstCipherUri(cipher: Cipher): string { export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
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 function createEmptyLoginUri(): VaultDraftLoginUri { export function createEmptyLoginUri(): VaultDraftLoginUri {
return { uri: '', match: null, originalUri: '', extra: {} }; return { uri: '', match: null, originalUri: '', extra: {} };
@@ -170,7 +167,7 @@ export function createEmptyLoginUri(): VaultDraftLoginUri {
export function websiteMatchLabel(value: number | null | undefined): string { export function websiteMatchLabel(value: number | null | undefined): string {
const normalized = typeof value === 'number' && Number.isFinite(value) ? value : null; 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 { function valueOrFallback(value: string | null | undefined): string {
@@ -427,36 +424,8 @@ export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
return null; return null;
} }
const failedIconHosts = new Set<string>();
export function VaultListIcon({ cipher }: { cipher: Cipher }) { export function VaultListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher); return <WebsiteIcon cipher={cipher} fallback={<TypeIcon type={Number(cipher.type || 1)} />} />;
const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
}, [host]);
if (host && !errored) {
return (
<img
className="list-icon"
src={websiteIconUrl(host)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => {
failedIconHosts.add(host);
setErrored(true);
}}
/>
);
}
return (
<span className="list-icon-fallback">
<TypeIcon type={Number(cipher.type || 1)} />
</span>
);
} }
export function copyToClipboard(value: string): void { export function copyToClipboard(value: string): void {
@@ -131,7 +131,6 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
try { try {
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations); const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash }); await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
if (profile.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
clearDisableTotpDialog(); clearDisableTotpDialog();
await refetchTotpStatus(); await refetchTotpStatus();
onNotify('success', t('txt_totp_disabled')); onNotify('success', t('txt_totp_disabled'));
+38 -17
View File
@@ -20,26 +20,39 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
return useMemo( return useMemo(
() => ({ () => ({
refreshAdmin() { refreshAdmin() {
void refetchUsers(); void Promise.all([refetchUsers(), refetchInvites()]).catch((error) => {
void refetchInvites(); onNotify('error', error instanceof Error ? error.message : t('txt_load_admin_data_failed'));
});
}, },
async createInvite(hours: number) { async createInvite(hours: number) {
await createInvite(authedFetch, hours); try {
await refetchInvites(); await createInvite(authedFetch, hours);
onNotify('success', t('txt_invite_created')); await refetchInvites();
onNotify('success', t('txt_invite_created'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
}
}, },
async toggleUserStatus(userId: string, status: 'active' | 'banned') { async toggleUserStatus(userId: string, status: 'active' | 'banned') {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); try {
await refetchUsers(); await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
onNotify('success', t('txt_user_status_updated')); await refetchUsers();
onNotify('success', t('txt_user_status_updated'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
}
}, },
async revokeInvite(code: string) { async revokeInvite(code: string) {
await revokeInvite(authedFetch, code); try {
await refetchInvites(); await revokeInvite(authedFetch, code);
onNotify('success', t('txt_invite_revoked')); await refetchInvites();
onNotify('success', t('txt_invite_revoked'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_invite_failed'));
}
}, },
async deleteAllInvites() { async deleteAllInvites() {
@@ -50,9 +63,13 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await deleteAllInvites(authedFetch); try {
await refetchInvites(); await deleteAllInvites(authedFetch);
onNotify('success', t('txt_all_invites_deleted')); await refetchInvites();
onNotify('success', t('txt_all_invites_deleted'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_invites_failed'));
}
})(); })();
}, },
}); });
@@ -66,9 +83,13 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await deleteUser(authedFetch, userId); try {
await refetchUsers(); await deleteUser(authedFetch, userId);
onNotify('success', t('txt_user_deleted')); await refetchUsers();
onNotify('success', t('txt_user_deleted'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_user_failed'));
}
})(); })();
}, },
}); });
+318 -21
View File
@@ -13,6 +13,7 @@ import {
encryptZipBytesWithPassword, encryptZipBytesWithPassword,
} from '@/lib/export-formats'; } from '@/lib/export-formats';
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr } from '@/lib/crypto'; import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr } from '@/lib/crypto';
import { decryptSingleCipher } from '@/lib/decrypt-cipher';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
buildPublicSendUrl, buildPublicSendUrl,
@@ -66,6 +67,8 @@ interface UseVaultSendActionsOptions {
refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>; refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>;
refetchSends: () => Promise<unknown>; refetchSends: () => Promise<unknown>;
onNotify: Notify; onNotify: Notify;
patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void;
} }
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) { function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
@@ -82,6 +85,144 @@ function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
return { byIndex, bySourceId }; 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) { export default function useVaultSendActions(options: UseVaultSendActionsOptions) {
const { const {
authedFetch, authedFetch,
@@ -95,6 +236,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
refetchFolders, refetchFolders,
refetchSends, refetchSends,
onNotify, onNotify,
patchDecryptedCiphers,
patchDecryptedFolders,
} = options; } = options;
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState(''); const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null); const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
@@ -108,6 +251,91 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]); 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 ( const uploadImportedAttachments = async (
attachments: ImportAttachmentFile[], attachments: ImportAttachmentFile[],
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> } 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[] = []) { async createVaultItem(draft: VaultDraft, attachments: File[] = []) {
if (!session) return; if (!session) return;
const optimistic = optimisticCipherFromDraft(draft, null);
patchDecryptedCiphers((prev) => [optimistic, ...prev.filter((cipher) => cipher.id !== optimistic.id)]);
try { try {
const created = await createCipher(authedFetch, session, draft); const created = await createCipher(authedFetch, session, draft);
for (const file of attachments) { for (const file of attachments) {
@@ -175,9 +405,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
setAttachmentUploadPercent(0); setAttachmentUploadPercent(0);
await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent); 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')); onNotify('success', t('txt_item_created'));
} catch (error) { } catch (error) {
patchDecryptedCiphers((prev) => prev.filter((cipher) => cipher.id !== optimistic.id));
onNotify('error', error instanceof Error ? error.message : t('txt_create_item_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_create_item_failed'));
throw error; throw error;
} finally { } finally {
@@ -190,8 +422,26 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
if (!session) return; if (!session) return;
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : []; const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : []; 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 { try {
await updateCipher(authedFetch, session, cipher, draft); const updated = await updateCipher(authedFetch, session, cipher, draft);
for (const attachmentId of removeAttachmentIds) { for (const attachmentId of removeAttachmentIds) {
const id = String(attachmentId || '').trim(); const id = String(attachmentId || '').trim();
if (!id) continue; if (!id) continue;
@@ -202,9 +452,16 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
setAttachmentUploadPercent(0); setAttachmentUploadPercent(0);
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent); 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')); onNotify('success', t('txt_item_updated'));
} catch (error) { } catch (error) {
patchCipherBatch([cipher.id], () => previousCipher);
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
throw error; throw error;
} finally { } finally {
@@ -232,33 +489,48 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}, },
async deleteVaultItem(cipher: Cipher) { async deleteVaultItem(cipher: Cipher) {
const previousCipher = { ...cipher };
const deletedDate = new Date().toISOString();
patchCipherBatch([cipher.id], (current) => ({ ...current, deletedDate, archivedDate: null, revisionDate: deletedDate }));
try { try {
await deleteCipher(authedFetch, cipher.id); const deleted = await deleteCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]); await decryptAndPatch(deleted);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_item_deleted')); onNotify('success', t('txt_item_deleted'));
} catch (error) { } catch (error) {
patchCipherBatch([cipher.id], () => previousCipher);
onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
throw error; throw error;
} }
}, },
async archiveVaultItem(cipher: Cipher) { async archiveVaultItem(cipher: Cipher) {
const previousCipher = { ...cipher };
const archivedDate = new Date().toISOString();
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate, deletedDate: null, revisionDate: archivedDate }));
try { try {
await archiveCipher(authedFetch, cipher.id); const archived = await archiveCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]); await decryptAndPatch(archived);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_item_archived')); onNotify('success', t('txt_item_archived'));
} catch (error) { } catch (error) {
patchCipherBatch([cipher.id], () => previousCipher);
onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed'));
throw error; throw error;
} }
}, },
async unarchiveVaultItem(cipher: Cipher) { async unarchiveVaultItem(cipher: Cipher) {
const previousCipher = { ...cipher };
const revisionDate = new Date().toISOString();
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate: null, revisionDate }));
try { try {
await unarchiveCipher(authedFetch, cipher.id); const unarchived = await unarchiveCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]); await decryptAndPatch(unarchived);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_item_unarchived')); onNotify('success', t('txt_item_unarchived'));
} catch (error) { } catch (error) {
patchCipherBatch([cipher.id], () => previousCipher);
onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed'));
throw error; throw error;
} }
@@ -267,7 +539,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async bulkDeleteVaultItems(ids: string[]) { async bulkDeleteVaultItems(ids: string[]) {
try { try {
await bulkDeleteCiphers(authedFetch, ids); 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')); onNotify('success', t('txt_deleted_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed')); 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[]) { async bulkArchiveVaultItems(ids: string[]) {
try { try {
await bulkArchiveCiphers(authedFetch, ids); 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')); onNotify('success', t('txt_archived_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed')); 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[]) { async bulkUnarchiveVaultItems(ids: string[]) {
try { try {
await bulkUnarchiveCiphers(authedFetch, ids); 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')); onNotify('success', t('txt_unarchived_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed')); 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) { async bulkMoveVaultItems(ids: string[], folderId: string | null) {
try { try {
await bulkMoveCiphers(authedFetch, ids, folderId); 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')); onNotify('success', t('txt_moved_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
@@ -316,8 +594,16 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
} }
try { try {
if (!session) throw new Error(t('txt_vault_key_unavailable')); if (!session) throw new Error(t('txt_vault_key_unavailable'));
await createFolder(authedFetch, session, folderName); const created = await createFolder(authedFetch, session, folderName);
await refetchFolders(); patchDecryptedFolders((prev) => [
{
id: created.id,
name: created.name || folderName,
decName: folderName,
},
...prev,
]);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_folder_created')); onNotify('success', t('txt_folder_created'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
@@ -333,7 +619,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
} }
try { try {
await deleteFolder(authedFetch, id); 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')); onNotify('success', t('txt_folder_deleted'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
@@ -355,7 +643,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
if (!session) throw new Error(t('txt_vault_key_unavailable')); if (!session) throw new Error(t('txt_vault_key_unavailable'));
await updateFolder(authedFetch, session, id, nextName); await updateFolder(authedFetch, session, id, nextName);
await refetchFolders(); patchFolderBatch([id], (folder) => ({ ...folder, decName: nextName }));
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_folder_updated')); onNotify('success', t('txt_folder_updated'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed')); 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[]) { async bulkRestoreVaultItems(ids: string[]) {
try { try {
await bulkRestoreCiphers(authedFetch, ids); 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')); onNotify('success', t('txt_restored_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed')); 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[]) { async bulkPermanentDeleteVaultItems(ids: string[]) {
try { try {
await bulkPermanentDeleteCiphers(authedFetch, ids); await bulkPermanentDeleteCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]); patchCipherBatch(ids, () => null);
syncVaultCoreInBackground({ includeFolders: true });
onNotify('success', t('txt_deleted_selected_items_permanently')); onNotify('success', t('txt_deleted_selected_items_permanently'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed')); 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; if (!ids.length) return;
try { try {
await bulkDeleteFolders(authedFetch, ids); 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')); onNotify('success', t('txt_folders_deleted'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed')); 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++) { for (let i = 0; i < payload.ciphers.length; i++) {
const raw = (payload.ciphers[i] || {}) as Record<string, unknown>; const raw = (payload.ciphers[i] || {}) as Record<string, unknown>;
const draft = importCipherToDraft(raw, mode === 'target' ? targetFolderId : null); 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, { const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, {
+114 -9
View File
@@ -46,6 +46,8 @@ interface RefreshSuccess {
type RefreshResult = RefreshFailure | RefreshSuccess; type RefreshResult = RefreshFailure | RefreshSuccess;
const pendingRefreshes = new Map<string, Promise<RefreshResult>>();
function randomHex(length: number): string { function randomHex(length: number): string {
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2)))); const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length); return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
@@ -122,9 +124,11 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY); const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Profile; const parsed = JSON.parse(raw) as Profile;
if (!parsed?.email || !parsed?.key) return null; if (!parsed?.email) return null;
if (email && parsed.email !== email) return null; if (email && parsed.email !== email) return null;
return parsed; const snapshot = stripProfileSecrets(parsed);
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(snapshot));
return snapshot;
} catch { } catch {
return null; return null;
} }
@@ -132,13 +136,48 @@ export function loadProfileSnapshot(email?: string | null): Profile | null {
export function saveProfileSnapshot(profile: Profile | null): void { export function saveProfileSnapshot(profile: Profile | null): void {
if (!profile) return; if (!profile) return;
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(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 { export function clearProfileSnapshot(): void {
localStorage.removeItem(PROFILE_SNAPSHOT_KEY); localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
} }
export function stripProfileSecrets(profile: Profile | null): Profile | null {
if (!profile) return null;
return {
id: String(profile.id || ''),
email: String(profile.email || ''),
name: String(profile.name || ''),
role: profile.role === 'admin' ? 'admin' : 'user',
masterPasswordHint: profile.masterPasswordHint ?? null,
publicKey: profile.publicKey ?? null,
key: '',
privateKey: null,
};
}
export function getCurrentDeviceIdentifier(): string { export function getCurrentDeviceIdentifier(): string {
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim(); return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
} }
@@ -275,6 +314,25 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
} }
} }
function refreshKey(session: SessionState): string {
if (session.authMode === 'web-cookie') return `web-cookie:${session.email || ''}`;
return `token:${session.refreshToken || ''}`;
}
function refreshAccessTokenOnce(session: SessionState): Promise<RefreshResult> {
const key = refreshKey(session);
const existing = pendingRefreshes.get(key);
if (existing) return existing;
const request = refreshAccessToken(session).finally(() => {
if (pendingRefreshes.get(key) === request) {
pendingRefreshes.delete(key);
}
});
pendingRefreshes.set(key, request);
return request;
}
export async function revokeCurrentSession(session: SessionState | null): Promise<void> { export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
const body = new URLSearchParams(); const body = new URLSearchParams();
if (session?.authMode !== 'web-cookie' && session?.refreshToken) { if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
@@ -366,15 +424,49 @@ export async function getPasswordHint(email: string): Promise<{ masterPasswordHi
export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) { export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) {
return async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> { 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(); const session = getSession();
if (!session?.accessToken) throw new Error('Unauthorized'); if (!session?.accessToken) throw new Error('Unauthorized');
const headers = new Headers(init.headers || {}); const headers = new Headers(init.headers || {});
headers.set('Authorization', `Bearer ${session.accessToken}`); 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; if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
const refreshed = await refreshAccessToken(session); const latest = getSession();
if (latest?.accessToken && latest.accessToken !== session.accessToken) {
const latestHeaders = new Headers(init.headers || {});
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
resp = await retryableRequest(latestHeaders);
if (resp.status !== 401) return resp;
}
const refreshSource = latest || session;
const refreshed = await refreshAccessTokenOnce(refreshSource);
if (!refreshed.ok) { if (!refreshed.ok) {
if (refreshed.transient) { if (refreshed.transient) {
throw new Error(refreshed.error || 'Session refresh temporarily unavailable'); throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
@@ -384,17 +476,17 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
} }
const nextSession: SessionState = { const nextSession: SessionState = {
...session, ...refreshSource,
accessToken: refreshed.token.access_token, accessToken: refreshed.token.access_token,
refreshToken: refreshed.token.refresh_token || session.refreshToken, refreshToken: refreshed.token.refresh_token || refreshSource.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'), authMode: refreshed.token.web_session ? 'web-cookie' : (refreshSource.authMode || 'token'),
}; };
setSession(nextSession); setSession(nextSession);
saveSession(nextSession); saveSession(nextSession);
const retryHeaders = new Headers(init.headers || {}); const retryHeaders = new Headers(init.headers || {});
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`); retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
resp = await fetch(input, { ...init, headers: retryHeaders }); resp = await retryableRequest(retryHeaders);
return resp; return resp;
}; };
} }
@@ -502,6 +594,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 }> { export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> {
const resp = await authedFetch('/api/accounts/totp'); const resp = await authedFetch('/api/accounts/totp');
if (!resp.ok) throw new Error('Failed to load TOTP status'); if (!resp.ok) throw new Error('Failed to load TOTP status');
+2 -2
View File
@@ -6,7 +6,7 @@ import type {
BackupRuntimeState, BackupRuntimeState,
BackupScheduleConfig, BackupScheduleConfig,
BackupSettings as AdminBackupSettings, BackupSettings as AdminBackupSettings,
E3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '@shared/backup-schema'; } from '@shared/backup-schema';
import { import {
@@ -26,7 +26,7 @@ export type {
BackupRuntimeState, BackupRuntimeState,
BackupScheduleConfig, BackupScheduleConfig,
AdminBackupSettings, AdminBackupSettings,
E3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
}; };
+23 -13
View File
@@ -1,7 +1,6 @@
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto'; import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
import type { Send, SendDraft, SessionState } from '../types'; import type { Send, SendDraft, SessionState } from '../types';
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared'; import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
import { loadVaultSyncSnapshot } from './vault-sync';
function toIsoDateFromDays(value: string, required: boolean): string | null { function toIsoDateFromDays(value: string, required: boolean): string | null {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
@@ -62,8 +61,10 @@ function parseMaxAccessCountRaw(value: string): number | null {
} }
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> { export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
const body = await loadVaultSyncSnapshot(authedFetch); const resp = await authedFetch('/api/sends');
return body.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( export async function createSend(
@@ -260,18 +261,24 @@ async function buildPublicSendAccessPayload(password?: string, keyPart?: string
return payload; return payload;
} }
export async function accessPublicSend(accessId: string, keyPart?: string | null, password?: string): Promise<any> { export async function accessPublicSend(
accessId: string,
keyPart?: string | null,
password?: string,
options?: { signal?: AbortSignal }
): Promise<unknown> {
const payload = await buildPublicSendAccessPayload(password, keyPart); const payload = await buildPublicSendAccessPayload(password, keyPart);
const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, { const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal: options?.signal,
}); });
if (!resp.ok) { if (!resp.ok) {
const message = await parseErrorMessage(resp, 'Failed to access send'); const message = await parseErrorMessage(resp, 'Failed to access send');
throw createApiError(message, resp.status); throw createApiError(message, resp.status);
} }
return (await parseJson<any>(resp)) || null; return (await parseJson<unknown>(resp)) || null;
} }
export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise<string> { export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise<string> {
@@ -290,19 +297,22 @@ export async function accessPublicSendFile(sendId: string, fileId: string, keyPa
return body.url; return body.url;
} }
export async function decryptPublicSend(accessData: any, urlSafeKey: string): Promise<any> { export async function decryptPublicSend(accessData: unknown, urlSafeKey: string): Promise<unknown> {
const sendKeyMaterial = base64UrlToBytes(urlSafeKey); const sendKeyMaterial = base64UrlToBytes(urlSafeKey);
const sendKey = await toSendKeyParts(sendKeyMaterial); const sendKey = await toSendKeyParts(sendKeyMaterial);
const out: any = { ...accessData }; const source = accessData && typeof accessData === 'object' ? accessData as Record<string, unknown> : {};
out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac); const text = source.text && typeof source.text === 'object' ? source.text as Record<string, unknown> : null;
if (accessData?.text?.text) { const file = source.file && typeof source.file === 'object' ? source.file as Record<string, unknown> : null;
out.decText = await decryptStr(accessData.text.text, sendKey.enc, sendKey.mac); const out: Record<string, unknown> = { ...source };
out.decName = await decryptStr(String(source.name || ''), sendKey.enc, sendKey.mac);
if (text?.text) {
out.decText = await decryptStr(String(text.text), sendKey.enc, sendKey.mac);
} }
if (accessData?.file?.fileName) { if (file?.fileName) {
try { try {
out.decFileName = await decryptStr(accessData.file.fileName, sendKey.enc, sendKey.mac); out.decFileName = await decryptStr(String(file.fileName), sendKey.enc, sendKey.mac);
} catch { } catch {
out.decFileName = String(accessData.file.fileName); out.decFileName = String(file.fileName);
} }
} }
return out; return out;
+91 -16
View File
@@ -1,4 +1,6 @@
import type { Cipher, Folder, Send } from '../types'; 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'; import { parseJson, type AuthedFetch } from './shared';
interface VaultSyncResponse { interface VaultSyncResponse {
@@ -7,31 +9,104 @@ interface VaultSyncResponse {
sends?: Send[]; 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> { function normalizeSnapshot(body: VaultSyncResponse | null | undefined): VaultCoreSnapshot {
const existing = pendingSyncRequests.get(authedFetch); return {
ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [],
folders: Array.isArray(body?.folders) ? body!.folders! : [],
sends: Array.isArray(body?.sends) ? body!.sends! : [],
};
}
function normalizeCachedSnapshot(snapshot: Partial<VaultCoreSnapshot> | null | undefined): VaultCoreSnapshot {
return {
ciphers: Array.isArray(snapshot?.ciphers) ? snapshot.ciphers : [],
folders: Array.isArray(snapshot?.folders) ? snapshot.folders : [],
sends: Array.isArray(snapshot?.sends) ? snapshot.sends : [],
};
}
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;
const snapshot = normalizeCachedSnapshot(cached.snapshot);
memoryVaultCoreCache.set(normalizedKey, {
revisionStamp: cached.revisionStamp,
snapshot,
});
return snapshot;
}
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
const normalizedKey = String(cacheKey || '').trim();
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };
const existing = pendingVaultCoreRequests.get(normalizedKey);
if (existing) return existing; if (existing) return existing;
const request = (async () => { const request = (async () => {
const resp = await authedFetch('/api/sync', { const memory = memoryVaultCoreCache.get(normalizedKey);
cache: 'no-store', let cached = await loadCachedVaultCoreSnapshot(normalizedKey);
headers: { if (!memory && cached?.snapshot) {
'Cache-Control': 'no-cache', const snapshot = normalizeCachedSnapshot(cached.snapshot);
Pragma: 'no-cache', memoryVaultCoreCache.set(normalizedKey, {
}, revisionStamp: cached.revisionStamp,
}); snapshot,
if (!resp.ok) throw new Error('Failed to load vault'); });
const body = await parseJson<VaultSyncResponse>(resp); }
return body || {};
try {
const revisionStamp = await getVaultRevisionDate(authedFetch);
const currentMemory = memoryVaultCoreCache.get(normalizedKey);
if (currentMemory?.revisionStamp === revisionStamp) {
return currentMemory.snapshot;
}
if (!cached) {
cached = await loadCachedVaultCoreSnapshot(normalizedKey);
}
if (cached?.revisionStamp === revisionStamp && cached.snapshot) {
const snapshot = normalizeCachedSnapshot(cached.snapshot);
memoryVaultCoreCache.set(normalizedKey, {
revisionStamp,
snapshot,
});
return snapshot;
}
const resp = await authedFetch('/api/sync', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
},
});
if (!resp.ok) throw new Error('Failed to load vault');
const body = await parseJson<VaultSyncResponse>(resp);
const snapshot = normalizeSnapshot(body);
memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot });
void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot);
return snapshot;
} catch (error) {
const fallbackMemory = memoryVaultCoreCache.get(normalizedKey);
if (fallbackMemory?.snapshot) return fallbackMemory.snapshot;
if (cached?.snapshot) return normalizeCachedSnapshot(cached.snapshot);
throw error;
}
})(); })();
pendingSyncRequests.set(authedFetch, request); pendingVaultCoreRequests.set(normalizedKey, request);
try { try {
return await request; return await request;
} finally { } finally {
if (pendingSyncRequests.get(authedFetch) === request) { if (pendingVaultCoreRequests.get(normalizedKey) === request) {
pendingSyncRequests.delete(authedFetch); pendingVaultCoreRequests.delete(normalizedKey);
} }
} }
} }
+173 -28
View File
@@ -13,13 +13,14 @@ import {
parseErrorMessage, parseErrorMessage,
parseJson, parseJson,
uploadDirectEncryptedPayload, uploadDirectEncryptedPayload,
uploadWithProgress,
type AuthedFetch, type AuthedFetch,
} from './shared'; } from './shared';
import { readResponseBytesWithProgress } from '../download'; import { readResponseBytesWithProgress } from '../download';
import { loadVaultSyncSnapshot } from './vault-sync'; import { loadVaultCoreSyncSnapshot } from './vault-sync';
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> { export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise<Folder[]> {
const body = await loadVaultSyncSnapshot(authedFetch); const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
return body.folders || []; return body.folders || [];
} }
@@ -91,8 +92,8 @@ export async function updateFolder(
if (!resp.ok) throw new Error('Update folder failed'); if (!resp.ok) throw new Error('Update folder failed');
} }
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> { export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Promise<Cipher[]> {
const body = await loadVaultSyncSnapshot(authedFetch); const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
return body.ciphers || []; return body.ciphers || [];
} }
@@ -273,6 +274,98 @@ export async function deleteCipherAttachment(
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed')); if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed'));
} }
export async function repairCipherAttachmentMetadata(
authedFetch: AuthedFetch,
cipherId: string,
attachmentId: string,
metadata: { fileName?: string; key?: string | null }
): Promise<void> {
const resp = await authedFetch(
`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}/metadata`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metadata),
}
);
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Update attachment metadata failed'));
}
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 decryptCipherStringWithKey(
value: string,
enc: Uint8Array,
mac: Uint8Array
): Promise<Uint8Array | null> {
try {
return await decryptBw(value, enc, mac);
} catch {
return null;
}
}
async function decryptAttachmentFileName(
rawFileName: string,
itemKeys: { enc: Uint8Array; mac: Uint8Array },
userKeys: { enc: Uint8Array; mac: Uint8Array }
): Promise<{ fileName: string; source: 'plain' | 'item' | 'user' }> {
const fallback = rawFileName || 'attachment.bin';
if (!rawFileName || !looksLikeCipherString(rawFileName)) return { fileName: fallback, source: 'plain' };
try {
const fileName = await decryptStr(rawFileName, itemKeys.enc, itemKeys.mac);
if (fileName) return { fileName, source: 'item' };
} catch {
// 继续尝试旧 user key 文件名。
}
if (!sameBytes(itemKeys.enc, userKeys.enc) || !sameBytes(itemKeys.mac, userKeys.mac)) {
try {
const fileName = await decryptStr(rawFileName, userKeys.enc, userKeys.mac);
if (fileName) return { fileName, source: 'user' };
} catch {
// 保留原始文件名。
}
}
return { fileName: fallback, source: 'plain' };
}
type AttachmentDecryptMode = 'attachment-item' | 'attachment-user' | 'legacy-item' | 'legacy-user';
interface AttachmentDecryptCandidate {
mode: AttachmentDecryptMode;
enc: Uint8Array;
mac: Uint8Array;
rawAttachmentKey: Uint8Array | null;
}
async function uploadRepairedAttachmentBlob(
authedFetch: AuthedFetch,
session: SessionState,
cipherId: string,
attachmentId: string,
encryptedBytes: Uint8Array
): Promise<void> {
if (!session.accessToken) throw new Error('Unauthorized');
const payload = new ArrayBuffer(encryptedBytes.byteLength);
new Uint8Array(payload).set(encryptedBytes);
const resp = await uploadWithProgress(`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}`, {
accessToken: session.accessToken,
method: 'PUT',
headers: { 'Content-Type': 'application/octet-stream' },
body: payload,
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair attachment upload failed'));
}
export async function downloadCipherAttachmentDecrypted( export async function downloadCipherAttachmentDecrypted(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
session: SessionState, session: SessionState,
@@ -293,32 +386,76 @@ export async function downloadCipherAttachmentDecrypted(
const userEnc = base64ToBytes(session.symEncKey); const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey); const userMac = base64ToBytes(session.symMacKey);
const itemKeys = await getCipherKeys(cipher, userEnc, userMac); const itemKeys = await getCipherKeys(cipher, userEnc, userMac);
const userKeys = { enc: userEnc, mac: userMac };
let fileEnc = itemKeys.enc; const candidates: AttachmentDecryptCandidate[] = [];
let fileMac = itemKeys.mac;
const keyCipher = String(info.key || '').trim(); const keyCipher = String(info.key || '').trim();
if (keyCipher && looksLikeCipherString(keyCipher)) { if (keyCipher && looksLikeCipherString(keyCipher)) {
try { const itemWrappedKey = await decryptCipherStringWithKey(keyCipher, itemKeys.enc, itemKeys.mac);
const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac); if (itemWrappedKey && itemWrappedKey.length >= 64) {
if (fileRawKey.length >= 64) { candidates.push({
fileEnc = fileRawKey.slice(0, 32); mode: 'attachment-item',
fileMac = fileRawKey.slice(32, 64); enc: itemWrappedKey.slice(0, 32),
mac: itemWrappedKey.slice(32, 64),
rawAttachmentKey: itemWrappedKey,
});
}
if (!sameBytes(itemKeys.enc, userEnc) || !sameBytes(itemKeys.mac, userMac)) {
const userWrappedKey = await decryptCipherStringWithKey(keyCipher, userEnc, userMac);
if (userWrappedKey && userWrappedKey.length >= 64) {
candidates.push({
mode: 'attachment-user',
enc: userWrappedKey.slice(0, 32),
mac: userWrappedKey.slice(32, 64),
rawAttachmentKey: userWrappedKey,
});
} }
} catch {
// fallback to item key
} }
} }
candidates.push({ mode: 'legacy-item', enc: itemKeys.enc, mac: itemKeys.mac, rawAttachmentKey: null });
if (!sameBytes(itemKeys.enc, userEnc) || !sameBytes(itemKeys.mac, userMac)) {
candidates.push({ mode: 'legacy-user', enc: userEnc, mac: userMac, rawAttachmentKey: null });
}
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac); let plainBytes: Uint8Array | null = null;
let usedCandidate: AttachmentDecryptCandidate | null = null;
for (const candidate of candidates) {
try {
plainBytes = await decryptBwFileData(encryptedBytes, candidate.enc, candidate.mac);
usedCandidate = candidate;
break;
} catch {
// 继续尝试下一种旧附件格式。
}
}
if (!plainBytes || !usedCandidate) throw new Error('Attachment decryption failed');
const fileNameRaw = String(info.fileName || '').trim(); const fileNameRaw = String(info.fileName || '').trim();
let fileName = fileNameRaw || `attachment-${aid}`; const nameResult = await decryptAttachmentFileName(fileNameRaw, itemKeys, userKeys);
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) { const fileName = nameResult.fileName || `attachment-${aid}`;
try {
fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName; try {
} catch { const metadata: { fileName?: string; key?: string | null } = {};
// keep fallback name if (nameResult.source === 'user') {
metadata.fileName = await encryptTextValue(fileName, itemKeys.enc, itemKeys.mac) || undefined;
} }
if (usedCandidate.mode === 'attachment-user' && usedCandidate.rawAttachmentKey) {
metadata.key = await encryptBw(usedCandidate.rawAttachmentKey, itemKeys.enc, itemKeys.mac);
} else if (usedCandidate.mode === 'legacy-item') {
metadata.key = null;
} else if (usedCandidate.mode === 'legacy-user') {
const repairedBytes = await encryptBwFileData(plainBytes, itemKeys.enc, itemKeys.mac);
await uploadRepairedAttachmentBlob(authedFetch, session, cid, aid, repairedBytes);
metadata.key = null;
}
if (Object.keys(metadata).length > 0) {
await repairCipherAttachmentMetadata(authedFetch, cid, aid, metadata);
}
} catch {
// 修复失败不影响本次下载,旧附件内容已经成功解密。
} }
return { fileName, bytes: plainBytes }; return { fileName, bytes: plainBytes };
@@ -426,9 +563,13 @@ async function encryptUris(
mac: Uint8Array mac: Uint8Array
): Promise<Array<Record<string, unknown>>> { ): Promise<Array<Record<string, unknown>>> {
const out: Array<Record<string, unknown>> = []; const out: Array<Record<string, unknown>> = [];
const seen = new Set<string>();
for (const entry of uris || []) { for (const entry of uris || []) {
const trimmed = String(entry?.uri || '').trim(); const trimmed = String(entry?.uri || '').trim();
if (!trimmed) continue; if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
const preservedExtra = const preservedExtra =
entry?.extra && typeof entry.extra === 'object' entry?.extra && typeof entry.extra === 'object'
? { ...entry.extra } ? { ...entry.extra }
@@ -625,7 +766,7 @@ export async function createCipher(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
session: SessionState, session: SessionState,
draft: VaultDraft draft: VaultDraft
): Promise<{ id: string }> { ): Promise<Cipher> {
const payload = await buildCipherPayload(session, draft, null); const payload = await buildCipherPayload(session, draft, null);
const resp = await authedFetch('/api/ciphers', { const resp = await authedFetch('/api/ciphers', {
@@ -634,9 +775,9 @@ export async function createCipher(
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!resp.ok) throw new Error('Create item failed'); 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'); if (!body?.id) throw new Error('Create item failed');
return { id: body.id }; return body;
} }
export async function updateCipher( export async function updateCipher(
@@ -644,7 +785,7 @@ export async function updateCipher(
session: SessionState, session: SessionState,
cipher: Cipher, cipher: Cipher,
draft: VaultDraft draft: VaultDraft
): Promise<void> { ): Promise<Cipher> {
const payload = await buildCipherPayload(session, draft, cipher); const payload = await buildCipherPayload(session, draft, cipher);
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
@@ -653,25 +794,29 @@ export async function updateCipher(
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!resp.ok) throw new Error('Update item failed'); 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' }); const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Delete item failed'); 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(); const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required'); if (!id) throw new Error('Cipher id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/archive`, { method: 'PUT' }); const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/archive`, { method: 'PUT' });
if (!resp.ok) throw new Error('Archive item failed'); 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(); const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required'); if (!id) throw new Error('Cipher id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/unarchive`, { method: 'PUT' }); const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/unarchive`, { method: 'PUT' });
if (!resp.ok) throw new Error('Unarchive item failed'); if (!resp.ok) throw new Error('Unarchive item failed');
return (await parseJson<Cipher>(resp))!;
} }
export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> { export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
+34 -11
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 claims = decodeAccessTokenClaims(token.access_token);
const normalizedEmail = String(claims.email || email || '').trim().toLowerCase(); const normalizedEmail = String(claims.email || email || '').trim().toLowerCase();
const accountKeys = token.accountKeys ?? token.AccountKeys ?? null; const accountKeys = token.accountKeys ?? token.AccountKeys ?? null;
@@ -154,9 +154,11 @@ function buildTransientProfile(token: TokenSuccess, email: string): Profile {
name: String(claims.name || normalizedEmail || ''), name: String(claims.name || normalizedEmail || ''),
key: String(token.Key || ''), key: String(token.Key || ''),
privateKey: token.PrivateKey ?? null, privateKey: token.PrivateKey ?? null,
role: 'user', role: fallbackProfile?.role === 'admin' ? 'admin' : 'user',
premium: !!claims.premium, premium: !!claims.premium,
accountKeys, accountKeys,
masterPasswordHint: fallbackProfile?.masterPasswordHint ?? null,
publicKey: fallbackProfile?.publicKey ?? null,
object: 'profile', object: 'profile',
}; };
} }
@@ -256,6 +258,7 @@ export async function completeLogin(
masterKey: Uint8Array masterKey: Uint8Array
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
const baseSession: SessionState = { const baseSession: SessionState = {
accessToken: token.access_token, accessToken: token.access_token,
refreshToken: token.refresh_token, refreshToken: token.refresh_token,
@@ -266,7 +269,7 @@ export async function completeLogin(
() => baseSession, () => baseSession,
() => {} () => {}
); );
const profile = buildTransientProfile(token, normalizedEmail); const profile = buildTransientProfile(token, normalizedEmail, fallbackProfile);
if (!profile.key) { if (!profile.key) {
throw new Error('Missing profile key'); throw new Error('Missing profile key');
} }
@@ -372,16 +375,36 @@ export async function performRegistration(args: {
export async function performUnlock( export async function performUnlock(
session: SessionState, session: SessionState,
profile: Profile, profile: Profile | null,
password: string, password: string,
fallbackIterations: number fallbackIterations: number
): Promise<SessionState> { ): Promise<PasswordLoginResult> {
const derived = await deriveLoginHashLocally(profile.email || session.email, password, fallbackIterations); const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
const keys = await unlockVaultKey(profile.key, derived.masterKey); const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
const refreshedSession = await maybeRefreshSession(session); const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
if (!refreshedSession) {
throw new Error('Session expired'); if ('access_token' in token && token.access_token) {
return {
kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey),
};
} }
return { ...refreshedSession, ...keys };
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
if (tokenError.TwoFactorProviders) {
return {
kind: 'totp',
pendingTotp: {
email: normalizedEmail,
passwordHash: derived.hash,
masterKey: derived.masterKey,
},
};
}
return {
kind: 'error',
message: tokenError.error_description || tokenError.error || 'Unlock failed',
};
} }
+73
View File
@@ -0,0 +1,73 @@
let workspacePreload: Promise<unknown> | null = null;
let adminPreload: Promise<unknown> | null = null;
let demoExperiencePreloadStarted = false;
export function preloadAuthenticatedWorkspace(isAdmin: boolean): Promise<unknown> {
if (!workspacePreload) {
workspacePreload = Promise.allSettled([
import('@/components/SendsPage'),
import('@/components/TotpCodesPage'),
import('@/components/SettingsPage'),
import('@/components/SecurityDevicesPage'),
]);
}
if (!isAdmin) {
return workspacePreload;
}
if (!adminPreload) {
adminPreload = Promise.allSettled([
workspacePreload,
import('@/components/AdminPage'),
import('@/components/BackupCenterPage'),
]);
}
return adminPreload;
}
export function preloadDemoExperience(): () => void {
if (demoExperiencePreloadStarted || typeof window === 'undefined') {
return () => undefined;
}
demoExperiencePreloadStarted = true;
let cancelled = false;
let timerId: number | null = null;
const tasks = [
() => import('@/components/VaultPage'),
() => import('@/components/SendsPage'),
() => import('@/components/TotpCodesPage'),
() => import('@/components/SettingsPage'),
() => import('@/components/SecurityDevicesPage'),
() => import('@/components/AdminPage'),
() => import('@/components/BackupCenterPage'),
() => import('@/components/ImportPage'),
];
const wait = (ms: number) => new Promise<void>((resolve) => {
timerId = window.setTimeout(() => {
timerId = null;
resolve();
}, ms);
});
void (async () => {
await wait(120);
for (const task of tasks) {
if (cancelled) return;
await task().catch(() => undefined);
await wait(180);
}
})();
return () => {
cancelled = true;
if (timerId !== null) {
window.clearTimeout(timerId);
timerId = null;
}
};
}
+37 -7
View File
@@ -1,6 +1,6 @@
import { hkdf } from '@/lib/crypto'; import { hkdf } from '@/lib/crypto';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { Cipher, VaultDraft } from '@/lib/types'; import type { VaultDraft } from '@/lib/types';
import type { ImportResultSummary } from '@/components/ImportPage'; import type { ImportResultSummary } from '@/components/ImportPage';
const SEND_KEY_SALT = 'bitwarden-send'; 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()); 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 ''; if (value === null || value === undefined) return '';
return String(value); 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 { export function readInviteCodeFromUrl(): string {
if (typeof window === 'undefined') return ''; if (typeof window === 'undefined') return '';
@@ -87,7 +106,7 @@ export function summarizeImportResult(
}; };
} }
export function buildEmptyImportDraft(type: number): VaultDraft { function buildEmptyImportDraft(type: number): VaultDraft {
return { return {
type, type,
favorite: false, favorite: false,
@@ -162,6 +181,7 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
draft.loginPassword = asText(login.password); draft.loginPassword = asText(login.password);
draft.loginTotp = asText(login.totp); draft.loginTotp = asText(login.totp);
const urisRaw = Array.isArray(login.uris) ? login.uris : []; const urisRaw = Array.isArray(login.uris) ? login.uris : [];
const seenUris = new Set<string>();
const uris = urisRaw const uris = urisRaw
.map((u) => { .map((u) => {
const row = (u || {}) as Record<string, unknown>; 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: {} }]; 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) draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
? login.fido2Credentials.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object') ? 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) }; 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 { 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) }); 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 { 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'); 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; 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 { function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
let diff = 0; let diff = 0;
@@ -91,17 +134,17 @@ export async function hkdf(
} }
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> { 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))); return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes)));
} }
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> { 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))); 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> { 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))); 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;
}
File diff suppressed because one or more lines are too long
+42
View File
@@ -0,0 +1,42 @@
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
import type { CompletedLogin, InitialAppBootstrapState } from '@/lib/app-auth';
import type { AdminBackupSettings } from '@/lib/api/backup';
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder, Send } from '@/lib/types';
export const IS_DEMO_MODE = false;
export const DEMO_CIPHERS: Cipher[] = [];
export const DEMO_ADMIN_INVITES: AdminInvite[] = [];
export const DEMO_ADMIN_USERS: AdminUser[] = [];
export const DEMO_AUTHORIZED_DEVICES: AuthorizedDevice[] = [];
export const DEMO_FOLDERS: Folder[] = [];
export const DEMO_SENDS: Send[] = [];
export function createDemoBackupSettings(): AdminBackupSettings {
return { destinations: [] };
}
export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
return {
defaultKdfIterations: 600000,
jwtWarning: null,
session: null,
phase: 'login',
};
}
export function createDemoCompletedLogin(): CompletedLogin {
throw new Error('Demo mode is not available in this build.');
}
export function createDemoMainRoutesProps(base: AppMainRoutesProps): AppMainRoutesProps {
return base;
}
export function getDemoPublicSend(): null {
return null;
}
export function demoBrandIconUrl(_host: string): string {
return '';
}
File diff suppressed because it is too large Load Diff
-79
View File
@@ -380,85 +380,6 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
return JSON.stringify(doc, null, 2); 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> { export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
const userEnc = base64ToBytes(args.userEncB64); const userEnc = base64ToBytes(args.userEncB64);
const userMac = base64ToBytes(args.userMacB64); const userMac = base64ToBytes(args.userMacB64);
+69 -1647
View File
File diff suppressed because it is too large Load Diff
+880
View File
@@ -0,0 +1,880 @@
// 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",
"txt_page_not_found": "Page Not Found",
"txt_page_not_found_hint": "The page may have been removed, expired, or the link is incomplete.",
"txt_back_to_home": "Back To Home",
"backup_strategy_title": "Cloud Backup",
"backup_strategy_under_construction": "Under construction.",
"import_export_title": "Import & Export",
"import_export_under_construction": "Under construction.",
"txt_demo_admin_refreshed": "Demo admin data refreshed.",
"txt_demo_auth_placeholder": "Demo: enter anything, or leave it empty",
"txt_demo_data_reset": "Demo data reset to defaults.",
"txt_demo_devices_refreshed": "Demo devices refreshed.",
"txt_demo_download_prepared": "Demo download prepared.",
"txt_demo_master_password_hint": "In demo mode, any input unlocks the vault.",
"txt_demo_readonly_message": "Demo mode is read-only for this action. No changes were saved.",
"txt_demo_unlock_placeholder": "Demo: any password works, even empty",
"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_delete_all_invites_failed": "Failed to delete all invites",
"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_admin_data_failed": "Failed to load admin data",
"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_create_invite_failed": "Failed to create invite",
"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_revoke_invite_failed": "Failed to revoke invite",
"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_loading_vault": "Loading vault...",
"txt_load_vault_failed": "Failed to load vault.",
"txt_retry_sync": "Retry sync",
"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_invites_found": "No invites found.",
"txt_no_items": "No items",
"txt_no_users_found": "No users found.",
"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_scan_totp_qr": "Scan TOTP QR code",
"txt_totp_qr_starting_camera": "Starting camera...",
"txt_totp_qr_point_camera": "Point the camera at a TOTP QR code.",
"txt_totp_qr_scanning": "Scanning QR code...",
"txt_totp_qr_scanned": "TOTP value added.",
"txt_totp_qr_not_found": "No QR code found in that image.",
"txt_totp_qr_scan_failed": "Failed to scan QR code.",
"txt_totp_qr_unsupported": "This browser does not support QR scanning. Try Chrome or Edge, or paste the TOTP link or secret manually.",
"txt_totp_qr_camera_unavailable": "Camera is unavailable. Check browser permission, or choose an image.",
"txt_totp_qr_choose_image": "Choose image",
"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_update_user_status_failed": "Failed to update user status",
"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_delete_user_failed": "Failed to delete user",
"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;
+880
View File
@@ -0,0 +1,880 @@
// 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",
"txt_page_not_found": "Página no encontrada",
"txt_page_not_found_hint": "La página pudo haberse eliminado, expirado, o el enlace está incompleto.",
"txt_back_to_home": "Volver al inicio",
"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_demo_admin_refreshed": "Datos de administración de la demo actualizados.",
"txt_demo_auth_placeholder": "Demo: escribe cualquier cosa, o déjalo vacío",
"txt_demo_data_reset": "Los datos de la demo volvieron a sus valores predeterminados.",
"txt_demo_devices_refreshed": "Dispositivos de la demo actualizados.",
"txt_demo_download_prepared": "Descarga de la demo preparada.",
"txt_demo_master_password_hint": "En modo demo, cualquier entrada desbloquea la bóveda.",
"txt_demo_readonly_message": "En modo demo, esta acción es de solo lectura. No se guardaron cambios.",
"txt_demo_unlock_placeholder": "Demo: cualquier contraseña funciona, incluso vacío",
"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_delete_all_invites_failed": "Error al eliminar todas las invitaciones",
"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_admin_data_failed": "Error al cargar datos de administración",
"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_create_invite_failed": "Error al crear invitación",
"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_revoke_invite_failed": "Error al revocar invitación",
"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_loading_vault": "Cargando bóveda...",
"txt_load_vault_failed": "No se pudo cargar la bóveda.",
"txt_retry_sync": "Reintentar sincronización",
"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_invites_found": "No se encontraron invitaciones.",
"txt_no_items": "No hay elementos",
"txt_no_users_found": "No se encontraron usuarios.",
"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_scan_totp_qr": "Escanear QR TOTP",
"txt_totp_qr_starting_camera": "Iniciando cámara...",
"txt_totp_qr_point_camera": "Apunte la cámara a un código QR TOTP.",
"txt_totp_qr_scanning": "Escaneando código QR...",
"txt_totp_qr_scanned": "Valor TOTP agregado.",
"txt_totp_qr_not_found": "No se encontró ningún código QR en esa imagen.",
"txt_totp_qr_scan_failed": "No se pudo escanear el código QR.",
"txt_totp_qr_unsupported": "Este navegador no admite escaneo QR. Pruebe Chrome o Edge, o pegue manualmente el enlace o secreto TOTP.",
"txt_totp_qr_camera_unavailable": "La cámara no está disponible. Revise el permiso del navegador o elija una imagen.",
"txt_totp_qr_choose_image": "Elegir imagen",
"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_update_user_status_failed": "Error al actualizar estado de usuario",
"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_delete_user_failed": "Error al eliminar usuario",
"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;
+880
View File
@@ -0,0 +1,880 @@
// 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": "Импорт и экспорт",
"txt_page_not_found": "Страница не найдена",
"txt_page_not_found_hint": "Страница могла быть удалена, срок ее действия истек, или ссылка неполная.",
"txt_back_to_home": "На главную",
"backup_strategy_title": "Облачное резервное копирование",
"backup_strategy_under_construction": "В стадии строительства.",
"import_export_title": "Импорт и экспорт",
"import_export_under_construction": "В стадии строительства.",
"txt_demo_admin_refreshed": "Демо-данные администратора обновлены.",
"txt_demo_auth_placeholder": "Демо: введите что угодно или оставьте пустым",
"txt_demo_data_reset": "Демо-данные сброшены к значениям по умолчанию.",
"txt_demo_devices_refreshed": "Демо-устройства обновлены.",
"txt_demo_download_prepared": "Демо-загрузка подготовлена.",
"txt_demo_master_password_hint": "В демо-режиме любое значение разблокирует хранилище.",
"txt_demo_readonly_message": "В демо-режиме это действие только для чтения. Изменения не сохранены.",
"txt_demo_unlock_placeholder": "Демо: подойдет любой пароль, даже пустой",
"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_delete_all_invites_failed": "Не удалось удалить все приглашения",
"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_admin_data_failed": "Не удалось загрузить данные администрирования",
"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_create_invite_failed": "Не удалось создать приглашение",
"txt_invite_code_optional": "Пригласительный код (не требуется для первой учетной записи; требуется для всех остальных)",
"txt_invite_created": "Приглашение создано",
"txt_invite_revoked": "Приглашение отозвано",
"txt_revoke_invite_failed": "Не удалось отозвать приглашение",
"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_loading_vault": "Загрузка хранилища...",
"txt_load_vault_failed": "Не удалось загрузить хранилище.",
"txt_retry_sync": "Повторить синхронизацию",
"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_invites_found": "Приглашения не найдены.",
"txt_no_items": "Нет товаров",
"txt_no_users_found": "Пользователи не найдены.",
"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_scan_totp_qr": "Сканировать QR TOTP",
"txt_totp_qr_starting_camera": "Запуск камеры...",
"txt_totp_qr_point_camera": "Наведите камеру на QR-код TOTP.",
"txt_totp_qr_scanning": "Сканирование QR-кода...",
"txt_totp_qr_scanned": "Значение TOTP добавлено.",
"txt_totp_qr_not_found": "QR-код на этом изображении не найден.",
"txt_totp_qr_scan_failed": "Не удалось отсканировать QR-код.",
"txt_totp_qr_unsupported": "Этот браузер не поддерживает сканирование QR. Попробуйте Chrome или Edge либо вставьте ссылку или секрет TOTP вручную.",
"txt_totp_qr_camera_unavailable": "Камера недоступна. Проверьте разрешение браузера или выберите изображение.",
"txt_totp_qr_choose_image": "Выбрать изображение",
"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_update_user_status_failed": "Не удалось обновить статус пользователя",
"txt_use_recovery_code": "Использовать код восстановления",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "Используйте одноразовый код восстановления, чтобы отключить двухэтапную проверку.",
"txt_delete_user_failed": "Не удалось удалить пользователя",
"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;
+880
View File
@@ -0,0 +1,880 @@
// 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": "导入导出",
"txt_page_not_found": "页面不存在",
"txt_page_not_found_hint": "这个页面可能已经删除、过期,或者链接不完整。",
"txt_back_to_home": "回到首页",
"backup_strategy_title": "云端备份",
"backup_strategy_under_construction": "正在搭建中",
"import_export_title": "导入导出",
"import_export_under_construction": "正在搭建中",
"txt_demo_admin_refreshed": "Demo 管理数据已刷新。",
"txt_demo_auth_placeholder": "Demo:随便输入,也可以留空",
"txt_demo_data_reset": "Demo 数据已恢复为默认状态。",
"txt_demo_devices_refreshed": "Demo 设备已刷新。",
"txt_demo_download_prepared": "Demo 下载已准备好。",
"txt_demo_master_password_hint": "Demo 模式下,任意输入都可以解锁保险库。",
"txt_demo_readonly_message": "Demo 模式下此操作为只读,未保存任何更改。",
"txt_demo_unlock_placeholder": "Demo:任意密码都可解锁,留空也可以",
"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_delete_all_invites_failed": "删除所有邀请码失败",
"txt_all_items": "所有项目",
"txt_all_sends": "所有 Send",
"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": "批量删除 Send 失败",
"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": "创建 Send 失败",
"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": "删除 Send 失败",
"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": "已删除所选 Send",
"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_admin_data_failed": "加载管理数据失败",
"txt_load_devices_failed": "加载设备失败",
"txt_disable_this_send": "禁用此 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": "编辑 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": "打开 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": "文件 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_create_invite_failed": "创建邀请码失败",
"txt_invite_code_optional": "邀请码(首位注册者无需填写,其他人必填)",
"txt_invite_created": "邀请码已创建",
"txt_invite_revoked": "邀请码已撤销",
"txt_revoke_invite_failed": "撤销邀请码失败",
"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_loading_vault": "正在加载保管库...",
"txt_load_vault_failed": "保管库加载失败。",
"txt_retry_sync": "重试同步",
"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": "新建 Send",
"txt_next": "下一页",
"txt_no": "否",
"txt_no_devices_found": "未找到设备",
"txt_no_folder": "无文件夹",
"txt_no_invites_found": "暂无邀请码",
"txt_no_items": "没有项目",
"txt_no_users_found": "暂无用户",
"txt_no_username": "无用户名",
"txt_no_verification_codes": "没有验证码",
"txt_no_name": "(无名称)",
"txt_no_sends": "没有 Send",
"txt_nodewarden_send": "NodeWarden Send",
"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": "搜索 Send...",
"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": "Send 已创建",
"txt_send_deleted": "Send 已删除",
"txt_send_details": "Send 详情",
"txt_send_file": "Send 文件",
"txt_send_unavailable": "Send 不可用。",
"txt_send_updated": "Send 已更新",
"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": "文本 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": "此 Send 受密码保护",
"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_scan_totp_qr": "扫描 TOTP 二维码",
"txt_totp_qr_starting_camera": "正在启动摄像头...",
"txt_totp_qr_point_camera": "把摄像头对准 TOTP 二维码。",
"txt_totp_qr_scanning": "正在扫描二维码...",
"txt_totp_qr_scanned": "TOTP 内容已填入。",
"txt_totp_qr_not_found": "这张图片里没有识别到二维码。",
"txt_totp_qr_scan_failed": "二维码扫描失败。",
"txt_totp_qr_unsupported": "当前浏览器不支持二维码扫描。可尝试 Chrome 或 Edge,或手动粘贴 TOTP 链接/密钥。",
"txt_totp_qr_camera_unavailable": "无法使用摄像头。请检查浏览器权限,或选择图片。",
"txt_totp_qr_choose_image": "选择图片",
"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": "解锁 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_update_user_status_failed": "更新用户状态失败",
"txt_use_recovery_code": "使用恢复代码",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "使用一次性恢复代码禁用两步验证。",
"txt_delete_user_failed": "删除用户失败",
"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;
+880
View File
@@ -0,0 +1,880 @@
// 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": "導入導出",
"txt_page_not_found": "頁面不存在",
"txt_page_not_found_hint": "這個頁面可能已經刪除、過期,或者連結不完整。",
"txt_back_to_home": "回到首頁",
"backup_strategy_title": "雲端備份",
"backup_strategy_under_construction": "正在搭建中",
"import_export_title": "導入導出",
"import_export_under_construction": "正在搭建中",
"txt_demo_admin_refreshed": "Demo 管理數據已刷新。",
"txt_demo_auth_placeholder": "Demo:隨便輸入,也可以留空",
"txt_demo_data_reset": "Demo 數據已恢復為默認狀態。",
"txt_demo_devices_refreshed": "Demo 設備已刷新。",
"txt_demo_download_prepared": "Demo 下載已準備好。",
"txt_demo_master_password_hint": "Demo 模式下,任意輸入都可以解鎖保險庫。",
"txt_demo_readonly_message": "Demo 模式下此操作為只讀,未保存任何更改。",
"txt_demo_unlock_placeholder": "Demo:任意密碼都可解鎖,留空也可以",
"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_delete_all_invites_failed": "刪除所有邀請碼失敗",
"txt_all_items": "所有項目",
"txt_all_sends": "所有 Send",
"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": "批量刪除 Send 失敗",
"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": "創建 Send 失敗",
"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": "刪除 Send 失敗",
"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": "已刪除所選 Send",
"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_admin_data_failed": "加載管理數據失敗",
"txt_load_devices_failed": "加載設備失敗",
"txt_disable_this_send": "禁用此 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": "編輯 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": "打開 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": "文件 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_create_invite_failed": "創建邀請碼失敗",
"txt_invite_code_optional": "邀請碼(首位註冊者無需填寫,其他人必填)",
"txt_invite_created": "邀請碼已創建",
"txt_invite_revoked": "邀請碼已撤銷",
"txt_revoke_invite_failed": "撤銷邀請碼失敗",
"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_loading_vault": "正在加載保管庫...",
"txt_load_vault_failed": "保管庫加載失敗。",
"txt_retry_sync": "重試同步",
"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": "新建 Send",
"txt_next": "下一頁",
"txt_no": "否",
"txt_no_devices_found": "未找到設備",
"txt_no_folder": "無文件夾",
"txt_no_invites_found": "暫無邀請碼",
"txt_no_items": "沒有項目",
"txt_no_users_found": "暫無用戶",
"txt_no_username": "無用戶名",
"txt_no_verification_codes": "沒有驗證碼",
"txt_no_name": "(無名稱)",
"txt_no_sends": "沒有 Send",
"txt_nodewarden_send": "NodeWarden Send",
"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": "搜索 Send...",
"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": "Send 已創建",
"txt_send_deleted": "Send 已刪除",
"txt_send_details": "Send 詳情",
"txt_send_file": "Send 文件",
"txt_send_unavailable": "Send 不可用。",
"txt_send_updated": "Send 已更新",
"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": "文本 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": "此 Send 受密碼保護",
"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_scan_totp_qr": "掃描 TOTP 二維碼",
"txt_totp_qr_starting_camera": "正在啟動攝影機...",
"txt_totp_qr_point_camera": "把攝影機對準 TOTP 二維碼。",
"txt_totp_qr_scanning": "正在掃描二維碼...",
"txt_totp_qr_scanned": "TOTP 內容已填入。",
"txt_totp_qr_not_found": "這張圖片裡沒有識別到二維碼。",
"txt_totp_qr_scan_failed": "二維碼掃描失敗。",
"txt_totp_qr_unsupported": "目前瀏覽器不支援二維碼掃描。可嘗試 Chrome 或 Edge,或手動貼上 TOTP 連結/密鑰。",
"txt_totp_qr_camera_unavailable": "無法使用攝影機。請檢查瀏覽器權限,或選擇圖片。",
"txt_totp_qr_choose_image": "選擇圖片",
"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": "解鎖 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_update_user_status_failed": "更新用戶狀態失敗",
"txt_use_recovery_code": "使用恢復代碼",
"txt_use_your_one_time_recovery_code_to_disable_two_step_verification": "使用一次性恢復代碼禁用兩步驗證。",
"txt_delete_user_failed": "刪除用戶失敗",
"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;

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