15 Commits

Author SHA1 Message Date
shuaiplus 9e892e85a2 feat: update version to 1.4.1 and enhance drag-and-drop functionality for TOTP and website entries 2026-03-27 00:54:24 +08:00
shuaiplus 3e5a80e498 Refactor code structure for improved readability and maintainability 2026-03-27 00:08:29 +08:00
shuaiplus 89308fc8a6 feat: enhance login URI handling with match options and improve UI components 2026-03-26 21:59:50 +08:00
shuaiplus fe0bd80f43 feat: improve handling of archived timestamps in cipher storage normalization 2026-03-24 00:56:56 +08:00
shuaiplus 0062fd6c48 feat: enhance dark theme styles for mobile settings and table elements 2026-03-23 09:06:36 +08:00
shuaiplus 7373eeb501 feat: add backup start time configuration and theme switch functionality
- Introduced BACKUP_DEFAULT_START_TIME constant for backup scheduling.
- Updated BackupScheduleConfig interface to include startTime.
- Implemented normalizeStartTime function for validating and normalizing start time input.
- Enhanced backup settings parsing to accommodate start time.
- Added start time input field in BackupDestinationDetail component.
- Created ThemeSwitch component for toggling between light and dark themes.
- Integrated theme preference management in App component.
- Updated styles for dark mode support across the application.
- Added translations for theme toggle and backup start time labels.
2026-03-23 08:53:18 +08:00
shuaiplus 8b07cd4409 feat: refactor unarchive handling to support bulk unarchive and update prop types 2026-03-23 08:40:40 +08:00
shuaiplus 0fc7bd7985 feat: implement unarchive functionality for selected ciphers with state management 2026-03-23 08:32:43 +08:00
shuaiplus 58c029beba feat: add .tmp/ directory to .gitignore 2026-03-23 08:28:15 +08:00
shuaiplus ac79cbd8bd feat: remove temporary subproject references for bitwarden components 2026-03-23 08:28:07 +08:00
shuaiplus 96fc3ae485 feat: implement archive and bulk archive functionality with confirmation dialogs 2026-03-23 08:22:08 +08:00
shuaiplus cb4632cd04 feat: add bulk unarchive functionality for ciphers 2026-03-23 08:18:15 +08:00
shuaiplus f7b5534cd0 feat: add archiving functionality for ciphers
- Introduced `archive` and `unarchive` endpoints in the API for ciphers.
- Implemented bulk archiving and unarchiving of ciphers in the vault.
- Updated the storage schema to include `archived_at` timestamps for ciphers.
- Enhanced user interface to support archiving actions in the vault.
- Added necessary translations for archive-related actions.
- Updated user and device models to accommodate new fields related to archiving.
2026-03-23 01:10:48 +08:00
shuaiplus b50673f7d9 feat: update README files to clarify cloud backup center and password hint features 2026-03-20 06:55:20 +08:00
shuaiplus 98e94e766f feat: update README files for clarity and consistency in descriptions 2026-03-20 06:47:25 +08:00
45 changed files with 3941 additions and 584 deletions
+1
View File
@@ -39,3 +39,4 @@ npm-debug.log*
# package-lock.json # package-lock.json
tmp/ tmp/
.tmp/
+62 -53
View File
@@ -3,7 +3,7 @@
</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/) [![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
@@ -11,51 +11,50 @@
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest) [![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest)
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml) [![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
[更新日志](./RELEASE_NOTES.md) [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest) [更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
English[`README_EN.md`](./README_EN.md)
English: [`README_EN.md`](./README_EN.md)
> **免责声明** > **免责声明**
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份的密码库。 > 本项目仅供学习交流使用,请定期备份的密码库。
> 本项目与 Bitwarden 官方无关,请向 Bitwarden 官方反馈问题。 > 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
--- ---
## 与 Bitwarden 官方服务端能力对比 ## 与 Bitwarden 官方服务端能力对比
| 能力 | Bitwarden | NodeWarden | 说明 | | 能力 | Bitwarden | NodeWarden | 说明 |
|---|---|---|---| |---|---|---|---|
| Web Vault(登录/笔记/卡片/身份) | ✅ | ✅ | 网页端密码库管理页面 | | 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 | | 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 | | 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| 附件上传/下载 | ✅ | ✅ | Cloudflare R2 和 KV 二选一 | | Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入导出功能 | ✅ | ✅ | 完整实现,含 Bitwarden 密码库+附件 ZIP 导入 | | 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| 网站图标代理 | | ✅ | 通过 `/icons/{hostname}/icon.png` | | **云端备份中心** | | ✅ | **支持 WebDAV / E3 定时备份** |
| passkey、TOTP 字段 | ✅ | ✅ | 完全支持,无需高级版 | | 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| Send | ✅ | ✅ | Cloudflare R2 和 KV 二选一 | | TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 | | 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 | | 组织 / 集合 / 成员权限 | ✅ | ❌ | 实现 |
| 登录 2FATOTP/WebAuthn/Duo/Email | ✅ | ⚠️ 部分支持 | 仅支持用户级 TOTP | | 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 | | SSO / SCIM / 企业目录 | ✅ | ❌ | 实现 |
| 紧急访问 | ✅ | ❌ | 没必要实现 |
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
## 测试情况:
- ✅ Windows 客户端(v2026.1.0
- ✅ 手机 Appv2026.1.0
- ✅ 浏览器扩展(v2026.1.0
- ✅ Linux 客户端(v2026.1.0
- ⬜ macOS 客户端(未测试)
--- ---
## 已测试客户端
- ✅ Windows 桌面端
- ✅ 手机 App
- ✅ 浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
---
## 网页部署 ## 网页部署
1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。 1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可切换为 KV,并将部署命令改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接 2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可用 KV 来代替,将**部署命令**改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接
| 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 | | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|---|---|---|---| |---|---|---|---|
@@ -72,51 +71,60 @@ English[`README_EN.md`](./README_EN.md)
## CLI 部署 ## CLI 部署
```powershell ```powershell
# 先把仓库拉到本地
git clone https://github.com/shuaiplus/NodeWarden.git git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden cd NodeWarden
# 安装依赖
npm install npm install
# Cloudflare CLI 登录
npx wrangler login npx wrangler login
# 部署到 Cloudflare # 默认:R2 模式
npm run deploy npm run deploy
# 可选KV 模式(无 R2 / 无信用卡) # 可选KV 模式
npm run deploy:kv npm run deploy:kv
# 本地开发 # 本地开发
npm run dev npm run dev
npm run dev:kv npm run dev:kv
# 后续更新时重新拉取仓库并重新部署即可,无需重复创建云资源
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm run deploy
``` ```
---
## 云端备份说明
- 远程备份支持 **WebDAV****E3**
- 勾选“包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
- 远程还原时:
- 会从 `attachments/` 目录按需读取附件
- 缺失的附件会被安全跳过
- 被跳过的附件不会在恢复后的数据库中留下脏记录
--- ---
## 常见问题
**Q: 如何备份数据?** ## 导入 / 导出
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
**Q: 导入导出支持哪些格式?** 当前支持的导入来源包括:
A: 支持 Bitwarden `json/csv/密码库+附件 zip` 和 NodeWarden `密码库+附件 json`(均含加密模式),且导入下拉中看到的格式都可直接导入。
A: 另外还支持直接导入 Bitwarden `密码库+附件 zip`,这条路径官方 Bitwarden Web 暂不支持。
**Q: 忘记主密码怎么办?** - Bitwarden JSON
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。 - Bitwarden CSV
- Bitwarden 密码库 + 附件 ZIP
- NodeWarden JSON
- 网页导入器里可见的多种浏览器 / 密码管理器格式
**Q: 可以多人使用吗?** 当前支持的导出方式包括:
A: 支持。第一个注册的用户会自动成为管理员;管理员可在管理页面生成邀请码,其他用户凭邀请码注册。
- Bitwarden JSON
- Bitwarden 加密 JSON
- 带附件的 ZIP 导出
- NodeWarden JSON 系列
- 备份中心中的实例级完整手动导出
--- ---
## 开源协议 ## 开源协议
LGPL-3.0 License LGPL-3.0 License
@@ -125,11 +133,12 @@ LGPL-3.0 License
## 致谢 ## 致谢
- [Bitwarden](https://bitwarden.com/) - 原始设计客户端 - [Bitwarden](https://bitwarden.com/) - 原始设计客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务实现参考 - [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台 - [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
--- ---
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+70 -62
View File
@@ -3,7 +3,7 @@
</p> </p>
<p align="center"> <p align="center">
A third-party Bitwarden server running on Cloudflare Workers, fully compatible with official clients. A third-party 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/) [![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
@@ -11,105 +11,114 @@
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest) [![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest)
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml) [![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
[Release Notes](./RELEASE_NOTES.md) [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest) [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)
中文文档:[`README.md`](./README.md) English: [`README.md`](./README.md)
> **Disclaimer** > **Disclaimer**
> This project is for learning and communication purposes only. We are not responsible for any data loss; regular vault backups are strongly recommended. > This project is for learning and communication purposes only. Please back up your vault regularly.
> This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team. > This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
--- ---
## Feature Comparison Table (vs Official Bitwarden Server) ## Feature Comparison with Official Bitwarden Server
| Capability | Bitwarden | NodeWarden | Notes | | Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---| |---|---|---|---|
| Web Vault (logins/notes/cards/identities) | ✅ | ✅ | Web-based vault management UI | | Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| Folders / Favorites | ✅ | ✅ | Common vault organization supported | | Full sync `/api/sync` | ✅ | ✅ | Optimized for official clients |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized | | Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Attachment upload/download | ✅ | ✅ | Choose either Cloudflare R2 or KV | | Send | ✅ | ✅ | Supports both text and file Sends |
| Import / export | ✅ | ✅ | Fully implemented, including Bitwarden vault + attachments ZIP import | | Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| Website icon proxy | | ✅ | Via `/icons/{hostname}/icon.png` | | **Cloud Backup Center** | | ✅ | **Supports scheduled backups with WebDAV / E3** |
| passkey / TOTP fields | ✅ | ✅ | Fully supported, no premium required | | Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| Send | ✅ | ✅ | Choose either Cloudflare R2 or KV | | TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Full user management with invitation mechanism | | Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement | | Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | User-level TOTP only | | Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement | | SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
| Emergency access | ✅ | ❌ | Not necessary to implement |
| Admin console / Billing & subscription | ✅ | ❌ | Free only |
| Full push notification pipeline | ✅ | ❌ | Not necessary to implement |
## Tested clients / platforms
- ✅ Windows desktop client (v2026.1.0)
- ✅ Mobile app (v2026.1.0)
- ✅ Browser extension (v2026.1.0)
- ✅ Linux desktop client (v2026.1.0)
- ⬜ macOS desktop client (not tested)
--- ---
## Web deploy ## Tested Clients
1. Fork this repository. If you find this project helpful, please consider giving it a Star. - ✅ Windows desktop client
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> `Next` -> (R2 storage is used by default; if R2 is unavailable for your account, switch to KV and change the deploy command to `npm run deploy:kv`) -> deploy -> open the generated URL. - ✅ Mobile app
- ✅ Browser extension
- ✅ Linux desktop client
- ⚠️ macOS desktop client not fully verified
---
## Web Deploy
1. Fork this repository. If this project helps you, please consider giving it a Star.
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> `Next` -> deploy.
R2 is used by default. If R2 is unavailable for your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
| Storage | Card required | Single attachment / Send file limit | Free tier | | Storage | Card required | Single attachment / Send file limit | Free tier |
|---|---|---|---| |---|---|---|---|
| R2 | Yes | 100 MB (soft limit, can be changed) | 10 GB | | R2 | Yes | 100 MB (soft limit, can be adjusted) | 10 GB |
| KV | No | 25 MiB (Cloudflare limit, cannot be changed) | 1 GB | | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
> [!TIP] > [!TIP]
> Sync upstream (keep your fork updated): > How to keep your fork updated:
>- Manual: open your fork on GitHub, click `Sync fork`, then click `Update branch`. > - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
>- Automatic: in your fork, go to `Actions` -> `Sync upstream` -> `Enable workflow`. It will automatically sync from upstream every day at 3 AM. > - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
## CLI deploy ## CLI Deploy
```powershell ```powershell
# Clone repository
git clone https://github.com/shuaiplus/NodeWarden.git git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden cd NodeWarden
# Install dependencies
npm install npm install
# Cloudflare CLI login
npx wrangler login npx wrangler login
# Deploy to Cloudflare # Default: R2 mode
npm run deploy npm run deploy
# (Optional) KV mode (no R2 / no credit card) # Optional: KV mode
npm run deploy:kv npm run deploy:kv
# Local development # Local development
npm run dev npm run dev
npm run dev:kv npm run dev:kv
# To update later, pull the repository again and redeploy
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm run deploy
``` ```
--- ---
## FAQ ## Cloud Backup Notes
**Q: How do I back up my data?** - Remote backup supports **WebDAV** and **E3**
A: Use **Export vault** in your client and save the JSON file. - When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json`
- real attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of uploading everything again
- During remote restore:
- required attachment files are loaded from `attachments/`
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
**Q: Which import/export formats are supported?** ---
A: NodeWarden supports Bitwarden `json/csv/vault + attachments zip` and NodeWarden `vault + attachments json` in both plain and encrypted modes, and every format visible in the import selector is directly importable.
A: It also supports direct import of Bitwarden `vault + attachments zip`, which is not directly supported by official Bitwarden Web import.
**Q: What if I forget the master password?** ## Import / Export
A: It cant be recovered (end-to-end encryption). Keep it safe.
**Q: Can multiple people use it?** Current supported import sources include:
A: Yes. The first registered user becomes the admin. The admin can generate invite codes from the admin panel, and other users register with those codes.
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden vault + attachments ZIP
- NodeWarden JSON
- Multiple browser / password-manager formats visible in the web import selector
Current supported export formats include:
- Bitwarden JSON
- Bitwarden encrypted JSON
- ZIP export with attachments
- NodeWarden JSON variants
- Full manual instance export from the backup center
--- ---
@@ -125,9 +134,8 @@ LGPL-3.0 License
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference - [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform - [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
--- ---
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+7
View File
@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS users (
security_stamp TEXT NOT NULL, security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
verify_devices INTEGER NOT NULL DEFAULT 1,
totp_secret TEXT, totp_secret TEXT,
totp_recovery_code TEXT, totp_recovery_code TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -51,10 +52,12 @@ CREATE TABLE IF NOT EXISTS ciphers (
key TEXT, key TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
archived_at TEXT,
deleted_at TEXT, deleted_at TEXT,
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_ciphers_user_updated ON ciphers(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_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 TABLE IF NOT EXISTS folders ( CREATE TABLE IF NOT EXISTS folders (
@@ -144,6 +147,10 @@ CREATE TABLE IF NOT EXISTS devices (
device_identifier TEXT NOT NULL, device_identifier TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
type INTEGER NOT NULL, type INTEGER NOT NULL,
session_stamp TEXT,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_identifier), PRIMARY KEY (user_id, device_identifier),
+79 -5
View File
@@ -1,14 +1,17 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.0", "version": "1.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.0", "version": "1.4.1",
"license": "LGPL-3.0", "license": "LGPL-3.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22", "@zip.js/zip.js": "^2.8.22",
@@ -507,6 +510,60 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
@@ -2746,6 +2803,19 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
}
},
"node_modules/regexparam": { "node_modules/regexparam": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-3.0.0.tgz", "resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-3.0.0.tgz",
@@ -2811,6 +2881,12 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
@@ -2943,9 +3019,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.21.0", "version": "4.21.0",
+4 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.0", "version": "1.4.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",
@@ -46,6 +46,9 @@
"wrangler": "^4.71.0" "wrangler": "^4.71.0"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22", "@zip.js/zip.js": "^2.8.22",
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.4.0'; export const APP_VERSION = '1.4.1';
+3
View File
@@ -3,6 +3,7 @@ export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
export const BACKUP_DEFAULT_E3_REGION = 'auto'; export const BACKUP_DEFAULT_E3_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 type BackupDestinationType = 'e3' | 'webdav'; export type BackupDestinationType = 'e3' | 'webdav';
@@ -40,6 +41,7 @@ export interface BackupRuntimeState {
export interface BackupScheduleConfig { export interface BackupScheduleConfig {
enabled: boolean; enabled: boolean;
intervalHours: number; intervalHours: number;
startTime: string;
timezone: string; timezone: string;
retentionCount: number | null; retentionCount: number | null;
} }
@@ -82,6 +84,7 @@ export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFA
return { return {
enabled: false, enabled: false,
intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS, intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
startTime: BACKUP_DEFAULT_START_TIME,
timezone, timezone,
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT, retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
}; };
+46
View File
@@ -75,6 +75,16 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
return null; return null;
} }
async function verifyUserSecret(
auth: AuthService,
user: User,
secret: string | null | undefined
): Promise<boolean> {
const normalized = String(secret || '').trim();
if (!normalized) return false;
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
}
function toProfile(user: User, env: Env): ProfileResponse { function toProfile(user: User, env: Env): ProfileResponse {
void env; void env;
return { return {
@@ -98,6 +108,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
forcePasswordReset: false, forcePasswordReset: false,
avatarColor: null, avatarColor: null,
creationDate: user.createdAt, creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
role: user.role, role: user.role,
status: user.status, status: user.status,
object: 'profile', object: 'profile',
@@ -194,6 +205,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
securityStamp: generateUUID(), securityStamp: generateUUID(),
role: 'user', role: 'user',
status: 'active', status: 'active',
verifyDevices: true,
totpSecret: null, totpSecret: null,
totpRecoveryCode: null, totpRecoveryCode: null,
createdAt: now, createdAt: now,
@@ -363,6 +375,40 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
return jsonResponse(toProfile(user, env)); return jsonResponse(toProfile(user, env));
} }
// PUT/POST /api/accounts/verify-devices
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: {
secret?: string;
masterPasswordHash?: string;
verifyDevices?: boolean;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (typeof body.verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400);
}
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
if (!verified) {
return errorResponse('User verification failed.', 400);
}
user.verifyDevices = body.verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
return new Response(null, { status: 200 });
}
// POST /api/accounts/keys // POST /api/accounts/keys
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
+162 -6
View File
@@ -26,6 +26,34 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
return { present: false, value: undefined }; return { present: false, value: undefined };
} }
function normalizeCipherTimestamp(value: unknown): string | null {
if (value == null || value === '') return null;
const parsed = new Date(String(value));
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toISOString();
}
function readCipherArchivedAt(source: any, fallback: string | null = null): string | null {
const archived = getAliasedProp(source, ['archivedAt', 'ArchivedAt', 'archivedDate', 'ArchivedDate']);
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
}
function syncCipherComputedAliases(cipher: Cipher): Cipher {
cipher.archivedDate = cipher.archivedAt ?? null;
cipher.deletedDate = cipher.deletedAt ?? null;
return cipher;
}
function normalizeCipherForStorage(cipher: Cipher): Cipher {
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
cipher.archivedAt = hasArchivedAt
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
: normalizeCipherTimestamp(cipher.archivedDate) ?? null;
return syncCipherComputedAliases(cipher);
}
function looksLikeCipherString(value: unknown): boolean { function looksLikeCipherString(value: unknown): 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());
} }
@@ -149,7 +177,7 @@ export function cipherToResponse(
options?: { omitFido2Credentials?: boolean } options?: { omitFido2Credentials?: boolean }
): CipherResponse { ): CipherResponse {
// 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, deletedAt, ...passthrough } = cipher; const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options); const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null); const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
@@ -163,7 +191,7 @@ export function cipherToResponse(
creationDate: createdAt, creationDate: createdAt,
revisionDate: updatedAt, revisionDate: updatedAt,
deletedDate: deletedAt, deletedDate: deletedAt,
archivedDate: null, archivedDate: archivedAt ?? null,
edit: true, edit: true,
viewPassword: true, viewPassword: true,
permissions: { permissions: {
@@ -273,12 +301,12 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
reprompt: cipherData.reprompt || 0, reprompt: cipherData.reprompt || 0,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
archivedAt: readCipherArchivedAt(cipherData, null),
deletedAt: null, deletedAt: null,
}; };
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
normalizeCipherForStorage(cipher);
// Prevent referencing a folder owned by another user. // Prevent referencing a folder owned by another user.
if (cipher.folderId) { if (cipher.folderId) {
@@ -331,10 +359,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
reprompt: cipherData.reprompt ?? existingCipher.reprompt, reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt, createdAt: existingCipher.createdAt,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt, deletedAt: existingCipher.deletedAt,
}; };
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
// Custom fields deletion compatibility: // Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields". // - Accept both camelCase "fields" and PascalCase "Fields".
@@ -346,6 +373,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
} else if (request.method === 'PUT' || request.method === 'POST') { } else if (request.method === 'PUT' || request.method === 'POST') {
cipher.fields = null; cipher.fields = null;
} }
normalizeCipherForStorage(cipher);
// Prevent referencing a folder owned by another user. // Prevent referencing a folder owned by another user.
if (cipher.folderId) { if (cipher.folderId) {
@@ -376,6 +404,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
// Soft delete // Soft delete
cipher.deletedAt = new Date().toISOString(); cipher.deletedAt = new Date().toISOString();
cipher.updatedAt = cipher.deletedAt; cipher.updatedAt = cipher.deletedAt;
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); await notifyVaultSyncForRequest(request, env, userId, revisionDate);
@@ -441,6 +470,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
cipher.deletedAt = null; cipher.deletedAt = null;
cipher.updatedAt = new Date().toISOString(); cipher.updatedAt = new Date().toISOString();
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); await notifyVaultSyncForRequest(request, env, userId, revisionDate);
@@ -479,6 +509,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
cipher.favorite = body.favorite; cipher.favorite = body.favorite;
} }
cipher.updatedAt = new Date().toISOString(); cipher.updatedAt = new Date().toISOString();
syncCipherComputedAliases(cipher);
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
@@ -519,6 +550,131 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
async function buildCipherListResponse(
request: Request,
storage: StorageService,
userId: string,
ids: string[]
): Promise<Response> {
const ciphers = await storage.getCiphersByIds(ids, userId);
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
return jsonResponse({
data: ciphers.map((cipher) =>
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], {
omitFido2Credentials,
})
),
object: 'list',
continuationToken: null,
});
}
function parseCipherIdList(body: { ids?: unknown }): string[] | null {
if (!Array.isArray(body.ids)) return null;
return Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean)));
}
// PUT/POST /api/ciphers/:id/archive
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
if (cipher.deletedAt) {
return errorResponse('Cannot archive a deleted cipher', 400);
}
cipher.archivedAt = new Date().toISOString();
cipher.updatedAt = cipher.archivedAt;
normalizeCipherForStorage(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// PUT/POST /api/ciphers/:id/unarchive
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
cipher.archivedAt = null;
cipher.updatedAt = new Date().toISOString();
normalizeCipherForStorage(cipher);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
);
}
// PUT/POST /api/ciphers/archive
export async function handleBulkArchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: unknown };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const ids = parseCipherIdList(body);
if (!ids) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return buildCipherListResponse(request, storage, userId, ids);
}
// PUT/POST /api/ciphers/unarchive
export async function handleBulkUnarchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
let body: { ids?: unknown };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const ids = parseCipherIdList(body);
if (!ids) {
return errorResponse('ids array is required', 400);
}
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return buildCipherListResponse(request, storage, userId, ids);
}
// POST /api/ciphers/delete - Bulk soft delete // POST /api/ciphers/delete - Bulk soft delete
export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> { export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
+302 -20
View File
@@ -1,3 +1,4 @@
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
import { Env } from '../types'; import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
@@ -5,6 +6,101 @@ import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device'; import { readKnownDeviceProbe } from '../utils/device';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
function normalizeIdentifier(value: string | null | undefined): string {
return String(value || '').trim();
}
function buildDevicePendingAuthRequest(value?: { id?: string | null; creationDate?: string | null } | null): DevicePendingAuthRequest | null {
if (!value?.id || !value.creationDate) return null;
return {
id: String(value.id),
creationDate: String(value.creationDate),
};
}
function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPublicKey'>): boolean {
return !!(device.encryptedUserKey && device.encryptedPublicKey);
}
function buildDeviceResponse(device: Device): DeviceResponse {
const response = {
Id: device.deviceIdentifier,
id: device.deviceIdentifier,
UserId: device.userId,
userId: device.userId,
Name: device.name,
name: device.name,
Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier,
Type: device.type,
type: device.type,
CreationDate: device.createdAt,
creationDate: device.createdAt,
RevisionDate: device.updatedAt,
revisionDate: device.updatedAt,
IsTrusted: isTrustedDevice(device),
isTrusted: isTrustedDevice(device),
EncryptedUserKey: device.encryptedUserKey,
encryptedUserKey: device.encryptedUserKey,
EncryptedPublicKey: device.encryptedPublicKey,
encryptedPublicKey: device.encryptedPublicKey,
DevicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
devicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
object: 'device',
};
return response as DeviceResponse;
}
function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireResponse {
const response = {
Id: device.deviceIdentifier,
id: device.deviceIdentifier,
Name: device.name,
name: device.name,
Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier,
Type: device.type,
type: device.type,
CreationDate: device.createdAt,
creationDate: device.createdAt,
EncryptedUserKey: device.encryptedUserKey,
encryptedUserKey: device.encryptedUserKey,
EncryptedPublicKey: device.encryptedPublicKey,
encryptedPublicKey: device.encryptedPublicKey,
object: 'protectedDevice',
};
return response as ProtectedDeviceWireResponse;
}
function parseKeysBody(body: any, fallback?: Device): {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
} {
return {
encryptedUserKey:
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedUserKey')
? body?.encryptedUserKey ?? null
: fallback?.encryptedUserKey ?? null,
encryptedPublicKey:
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPublicKey')
? body?.encryptedPublicKey ?? null
: fallback?.encryptedPublicKey ?? null,
encryptedPrivateKey:
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPrivateKey')
? body?.encryptedPrivateKey ?? null
: fallback?.encryptedPrivateKey ?? null,
};
}
async function readJsonBody(request: Request): Promise<any> {
try {
return await request.json();
} catch {
return null;
}
}
// GET /api/devices/knowndevice // GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior: // Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding // - X-Request-Email: base64url(email) without padding
@@ -28,20 +124,42 @@ export async function handleGetDevices(request: Request, env: Env, userId: strin
const devices = await storage.getDevicesByUserId(userId); const devices = await storage.getDevicesByUserId(userId);
return jsonResponse({ return jsonResponse({
data: devices.map(device => ({ data: devices.map((device) => buildDeviceResponse(device)),
id: device.deviceIdentifier,
name: device.name,
identifier: device.deviceIdentifier,
type: device.type,
creationDate: device.createdAt,
revisionDate: device.updatedAt,
object: 'device',
})),
object: 'list', object: 'list',
continuationToken: null, continuationToken: null,
}); });
} }
// GET /api/devices/identifier/:deviceIdentifier
export async function handleGetDeviceByIdentifier(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) {
return errorResponse('Device not found', 404);
}
return jsonResponse(buildDeviceResponse(device));
}
// GET /api/devices/:deviceIdentifier
export async function handleGetDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
}
// GET /api/devices/authorized // GET /api/devices/authorized
// Returns known devices together with active 2FA remember-token expiry. // Returns known devices together with active 2FA remember-token expiry.
export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> {
@@ -64,12 +182,7 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
knownIdentifiers.add(device.deviceIdentifier); knownIdentifiers.add(device.deviceIdentifier);
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier); const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
return { return {
id: device.deviceIdentifier, ...buildDeviceResponse(device),
name: device.name,
identifier: device.deviceIdentifier,
type: device.type,
creationDate: device.createdAt,
revisionDate: device.updatedAt,
online: onlineSet.has(device.deviceIdentifier), online: onlineSet.has(device.deviceIdentifier),
trusted: !!trustedInfo, trusted: !!trustedInfo,
trustedTokenCount: trustedInfo?.tokenCount || 0, trustedTokenCount: trustedInfo?.tokenCount || 0,
@@ -80,13 +193,22 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
for (const row of trusted) { for (const row of trusted) {
if (knownIdentifiers.has(row.deviceIdentifier)) continue; if (knownIdentifiers.has(row.deviceIdentifier)) continue;
data.push({ const placeholderDevice: Device = {
id: row.deviceIdentifier, userId,
deviceIdentifier: row.deviceIdentifier,
name: 'Unknown device', name: 'Unknown device',
identifier: row.deviceIdentifier,
type: 14, type: 14,
creationDate: '', sessionStamp: '',
revisionDate: '', encryptedUserKey: null,
encryptedPublicKey: null,
encryptedPrivateKey: null,
devicePendingAuthRequest: null,
createdAt: '',
updatedAt: '',
};
data.push({
...buildDeviceResponse(placeholderDevice),
isTrusted: true,
online: onlineSet.has(row.deviceIdentifier), online: onlineSet.has(row.deviceIdentifier),
trusted: true, trusted: true,
trustedTokenCount: row.tokenCount, trustedTokenCount: row.tokenCount,
@@ -166,6 +288,138 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices }); return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
} }
// PUT/POST /api/devices/identifier/:deviceIdentifier/keys
export async function handleUpdateDeviceKeys(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const body = await readJsonBody(request);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) {
return errorResponse('Device not found', 404);
}
const updated = await storage.updateDeviceKeys(userId, normalized, parseKeysBody(body, device));
if (!updated) {
return errorResponse('Device not found', 404);
}
const nextDevice = await storage.getDevice(userId, normalized);
return jsonResponse(buildDeviceResponse(nextDevice || device));
}
// POST /api/devices/update-trust
export async function handleUpdateDeviceTrust(
request: Request,
env: Env,
userId: string
): Promise<Response> {
const body = await readJsonBody(request);
const storage = new StorageService(env.DB);
const currentDeviceIdentifier =
normalizeIdentifier(request.headers.get('Device-Identifier')) ||
normalizeIdentifier(request.headers.get('X-Device-Identifier'));
const updates: Array<{
deviceIdentifier: string;
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
};
}> = [];
if (currentDeviceIdentifier && body?.currentDevice) {
updates.push({
deviceIdentifier: currentDeviceIdentifier,
keys: parseKeysBody(body.currentDevice, await storage.getDevice(userId, currentDeviceIdentifier) || undefined),
});
}
if (Array.isArray(body?.otherDevices)) {
for (const item of body.otherDevices) {
const deviceIdentifier = normalizeIdentifier(item?.deviceId);
if (!deviceIdentifier) continue;
updates.push({
deviceIdentifier,
keys: parseKeysBody(item, await storage.getDevice(userId, deviceIdentifier) || undefined),
});
}
}
let updatedCount = 0;
for (const update of updates) {
const ok = await storage.updateDeviceKeys(userId, update.deviceIdentifier, update.keys);
if (ok) updatedCount++;
}
return jsonResponse({ success: true, updated: updatedCount });
}
// POST /api/devices/untrust
export async function handleUntrustDevices(
request: Request,
env: Env,
userId: string
): Promise<Response> {
const body = await readJsonBody(request);
const storage = new StorageService(env.DB);
const devices = Array.isArray(body?.devices) ? body.devices.map((id: unknown) => normalizeIdentifier(String(id))) : [];
const removed = await storage.clearDeviceKeys(userId, devices);
for (const deviceIdentifier of devices) {
if (!deviceIdentifier) continue;
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
}
return jsonResponse({ success: true, removed });
}
// POST /api/devices/:deviceIdentifier/retrieve-keys
export async function handleRetrieveDeviceKeys(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) {
return errorResponse('Device not found', 404);
}
return jsonResponse(buildProtectedDeviceResponse(device));
}
// POST /api/devices/:id/deactivate
export async function handleDeactivateDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = normalizeIdentifier(deviceIdentifier);
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) {
await notifyUserLogout(env, userId, normalized);
}
return jsonResponse({ success: deleted });
}
// PUT /api/devices/identifier/{deviceIdentifier}/token // PUT /api/devices/identifier/{deviceIdentifier}/token
// Bitwarden mobile reports push token updates to this endpoint. // Bitwarden mobile reports push token updates to this endpoint.
// NodeWarden does not implement push notifications, so accept and no-op. // NodeWarden does not implement push notifications, so accept and no-op.
@@ -182,3 +436,31 @@ export async function handleUpdateDeviceToken(
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
// PUT/POST /api/devices/:deviceIdentifier/web-push-auth
export async function handleUpdateDeviceWebPushAuth(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
return new Response(null, { status: 200 });
}
// PUT/POST /api/devices/:deviceIdentifier/clear-token
export async function handleClearDeviceToken(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
return new Response(null, { status: 200 });
}
+23 -6
View File
@@ -31,6 +31,28 @@ function resolveTotpSecret(userSecret: string | null): string | null {
return null; return null;
} }
function buildPreloginResponse(
email: string,
kdfType: number,
kdfIterations: number,
kdfMemory: number | null,
kdfParallelism: number | null
): Record<string, unknown> {
return {
kdf: kdfType,
kdfIterations,
kdfMemory,
kdfParallelism,
KdfSettings: {
KdfType: kdfType,
Iterations: kdfIterations,
Memory: kdfMemory,
Parallelism: kdfParallelism,
},
Salt: email.toLowerCase(),
};
}
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response { function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
const providers = includeRecoveryCode const providers = includeRecoveryCode
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE] ? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
@@ -426,12 +448,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
const kdfMemory = user?.kdfMemory ?? null; const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null; const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse({ return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
kdf: kdfType,
kdfIterations: kdfIterations,
kdfMemory: kdfMemory,
kdfParallelism: kdfParallelism,
});
} }
// POST /identity/connect/revocation // POST /identity/connect/revocation
+5 -3
View File
@@ -232,6 +232,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
key: (c as any).key ?? null, key: (c as any).key ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
archivedAt: null,
deletedAt: null, deletedAt: null,
}; };
cipher.login = normalizeCipherLoginForStorage(cipher.login); cipher.login = normalizeCipherLoginForStorage(cipher.login);
@@ -245,10 +246,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const data = JSON.stringify(cipher); const data = JSON.stringify(cipher);
return env.DB return env.DB
.prepare( .prepare(
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at' 'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
) )
.bind( .bind(
cipher.id, cipher.id,
@@ -263,6 +264,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
bindNull(cipher.key), bindNull(cipher.key),
cipher.createdAt, cipher.createdAt,
cipher.updatedAt, cipher.updatedAt,
bindNull(cipher.archivedAt),
bindNull(cipher.deletedAt) bindNull(cipher.deletedAt)
); );
}); });
+7
View File
@@ -148,6 +148,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
forcePasswordReset: false, forcePasswordReset: false,
avatarColor: null, avatarColor: null,
creationDate: user.createdAt, creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
object: 'profile', object: 'profile',
}; };
@@ -180,6 +181,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
}, },
policies: [], policies: [],
sends: sends.map(sendToResponse), sends: sends.map(sendToResponse),
UserDecryption: {
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
TrustedDeviceOption: null,
KeyConnectorOption: null,
Object: 'userDecryption',
},
// PascalCase for desktop/browser clients // PascalCase for desktop/browser clients
UserDecryptionOptions: buildUserDecryptionOptions(user), UserDecryptionOptions: buildUserDecryptionOptions(user),
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption")) // camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
+19
View File
@@ -7,6 +7,7 @@ import {
handleGetRevisionDate, handleGetRevisionDate,
handleVerifyPassword, handleVerifyPassword,
handleChangePassword, handleChangePassword,
handleSetVerifyDevices,
handleGetTotpStatus, handleGetTotpStatus,
handleSetTotpStatus, handleSetTotpStatus,
handleGetTotpRecoveryCode, handleGetTotpRecoveryCode,
@@ -20,11 +21,15 @@ import {
handleDeleteCipherCompat, handleDeleteCipherCompat,
handlePermanentDeleteCipher, handlePermanentDeleteCipher,
handleRestoreCipher, handleRestoreCipher,
handleBulkArchiveCiphers,
handlePartialUpdateCipher, handlePartialUpdateCipher,
handleBulkUnarchiveCiphers,
handleBulkMoveCiphers, handleBulkMoveCiphers,
handleBulkDeleteCiphers, handleBulkDeleteCiphers,
handleBulkPermanentDeleteCiphers, handleBulkPermanentDeleteCiphers,
handleBulkRestoreCiphers, handleBulkRestoreCiphers,
handleArchiveCipher,
handleUnarchiveCipher,
} from './handlers/ciphers'; } from './handlers/ciphers';
import { import {
handleGetFolders, handleGetFolders,
@@ -110,6 +115,10 @@ export async function handleAuthenticatedRoute(
return handleVerifyPassword(request, env, userId); return handleVerifyPassword(request, env, userId);
} }
if (path === '/api/accounts/verify-devices' && (method === 'PUT' || method === 'POST')) {
return handleSetVerifyDevices(request, env, userId);
}
if (path === '/api/sync' && method === 'GET') { if (path === '/api/sync' && method === 'GET') {
return handleSync(request, env, userId); return handleSync(request, env, userId);
} }
@@ -140,6 +149,14 @@ export async function handleAuthenticatedRoute(
return handleBulkRestoreCiphers(request, env, userId); return handleBulkRestoreCiphers(request, env, userId);
} }
if (path === '/api/ciphers/archive' && (method === 'PUT' || method === 'POST')) {
return handleBulkArchiveCiphers(request, env, userId);
}
if (path === '/api/ciphers/unarchive' && (method === 'PUT' || method === 'POST')) {
return handleBulkUnarchiveCiphers(request, env, userId);
}
if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) { if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) {
return handleBulkMoveCiphers(request, env, userId); return handleBulkMoveCiphers(request, env, userId);
} }
@@ -158,6 +175,8 @@ export async function handleAuthenticatedRoute(
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId); if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId);
if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId); if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId);
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId); if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
if (subPath === '/archive' && (method === 'PUT' || method === 'POST')) return handleArchiveCipher(request, env, userId, cipherId);
if (subPath === '/unarchive' && (method === 'PUT' || method === 'POST')) return handleUnarchiveCipher(request, env, userId, cipherId);
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId); if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId);
if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId); if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId);
if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId); if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId);
+60 -3
View File
@@ -1,12 +1,21 @@
import type { Env } from './types'; import type { Env } from './types';
import { import {
handleGetAuthorizedDevices, handleGetAuthorizedDevices,
handleGetDevice,
handleGetDevices, handleGetDevices,
handleGetDeviceByIdentifier,
handleUpdateDeviceKeys,
handleUpdateDeviceTrust,
handleUntrustDevices,
handleRetrieveDeviceKeys,
handleDeactivateDevice,
handleRevokeAllTrustedDevices, handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice, handleRevokeTrustedDevice,
handleDeleteAllDevices, handleDeleteAllDevices,
handleDeleteDevice, handleDeleteDevice,
handleUpdateDeviceToken, handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth,
handleClearDeviceToken,
} from './handlers/devices'; } from './handlers/devices';
export async function handleAuthenticatedDeviceRoute( export async function handleAuthenticatedDeviceRoute(
@@ -35,16 +44,64 @@ export async function handleAuthenticatedDeviceRoute(
} }
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i); const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
if (deleteDeviceMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleGetDevice(request, env, userId, deviceIdentifier);
}
if (deleteDeviceMatch && method === 'DELETE') { if (deleteDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleDeleteDevice(request, env, userId, deviceIdentifier); return handleDeleteDevice(request, env, userId, deviceIdentifier);
} }
const deviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i); const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
if (deviceTokenMatch && (method === 'PUT' || method === 'POST')) { if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deviceTokenMatch[1]); const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
}
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i);
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
}
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier); return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
} }
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i);
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
}
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
}
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i);
if (identifierRetrieveKeysMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
}
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i);
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
}
if (path === '/api/devices/update-trust' && method === 'POST') {
return handleUpdateDeviceTrust(request, env, userId);
}
if (path === '/api/devices/untrust' && method === 'POST') {
return handleUntrustDevices(request, env, userId);
}
return null; return null;
} }
+49 -22
View File
@@ -78,6 +78,43 @@ function buildIconServiceCsp(origin: string): string {
return `img-src 'self' data: ${origin}`; return `img-src 'self' data: ${origin}`;
} }
function buildConfigResponse(origin: string) {
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
cloudRegion: 'self-hosted',
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: null,
},
push: {
pushTechnology: 0,
vapidPublicKey: null,
},
communication: null,
settings: {
disableUserRegistration: false,
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'duo-redirect': true,
'email-verification': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'unauth-ui-refresh': true,
'web-push': false,
},
object: 'config',
};
}
function normalizeIconHost(rawHost: string): string | null { function normalizeIconHost(rawHost: string): string | null {
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, ''); const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null; if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
@@ -243,6 +280,11 @@ export async function handlePublicRoute(
return handleKnownDevice(request, env); return handleKnownDevice(request, env);
} }
const clearDeviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
if (clearDeviceTokenMatch && (method === 'PUT' || method === 'POST')) {
return new Response(null, { status: 200 });
}
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') { if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
@@ -255,6 +297,12 @@ export async function handlePublicRoute(
return handlePrelogin(request, env); return handlePrelogin(request, env);
} }
if (path === '/identity/accounts/prelogin/password' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handlePrelogin(request, env);
}
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') { if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
return handleRecoverTwoFactor(request, env); return handleRecoverTwoFactor(request, env);
} }
@@ -275,28 +323,7 @@ export async function handlePublicRoute(
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
const origin = new URL(request.url).origin; const origin = new URL(request.url).origin;
return jsonResponse({ return jsonResponse(buildConfigResponse(origin));
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
server: null,
environment: {
vault: origin,
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'duo-redirect': true,
'email-verification': true,
'pm-19051-send-email-verification': false,
'unauth-ui-refresh': true,
},
object: 'config',
});
} }
if (path === '/api/version' && method === 'GET') { if (path === '/api/version' && method === 'GET') {
+96 -3
View File
@@ -8,6 +8,7 @@ import {
} from './backup-settings-crypto'; } from './backup-settings-crypto';
import { import {
BACKUP_DEFAULT_INTERVAL_HOURS, BACKUP_DEFAULT_INTERVAL_HOURS,
BACKUP_DEFAULT_START_TIME,
BACKUP_DEFAULT_TIMEZONE, BACKUP_DEFAULT_TIMEZONE,
type BackupDestinationConfig, type BackupDestinationConfig,
type BackupDestinationRecord, type BackupDestinationRecord,
@@ -90,6 +91,20 @@ function normalizeIntervalHours(value: unknown, fallback: number = BACKUP_DEFAUL
return raw; return raw;
} }
function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_START_TIME): string {
const raw = asTrimmedString(value) || fallback;
const match = raw.match(/^(\d{1,2})(?::(\d{1,2}))?$/);
if (!match) {
throw new Error('Backup start time must be in HH:mm format');
}
const hour = Number(match[1]);
const minute = Number(match[2] ?? '0');
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
throw new Error('Backup start time must be in HH:mm format');
}
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination { function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
const source = isPlainObject(value) ? value : {}; const source = isPlainObject(value) ? value : {};
const endpoint = asTrimmedString(source.endpoint); const endpoint = asTrimmedString(source.endpoint);
@@ -219,6 +234,10 @@ function normalizeDestinationRecord(
scheduleSource.intervalHours ?? previousSchedule.intervalHours, scheduleSource.intervalHours ?? previousSchedule.intervalHours,
previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS
), ),
startTime: normalizeStartTime(
scheduleSource.startTime ?? previousSchedule.startTime,
previousSchedule.startTime || BACKUP_DEFAULT_START_TIME
),
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE), timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount), retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
}; };
@@ -259,6 +278,7 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
schedule: { schedule: {
enabled: !!rawValue.enabled, enabled: !!rawValue.enabled,
intervalHours, intervalHours,
startTime: BACKUP_DEFAULT_START_TIME,
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE), timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
retentionCount: 30, retentionCount: 30,
}, },
@@ -326,6 +346,7 @@ export function parseBackupSettings(raw: string | null, fallbackTimezone: string
schedule: { schedule: {
enabled: scheduleEnabled, enabled: scheduleEnabled,
intervalHours: globalIntervalHours, intervalHours: globalIntervalHours,
startTime: BACKUP_DEFAULT_START_TIME,
timezone: globalTimezone, timezone: globalTimezone,
retentionCount: 30, retentionCount: 30,
}, },
@@ -495,15 +516,87 @@ export function getBackupLocalTime(date: Date, timezone: string): string {
return `${parts.hour}:${parts.minute}`; return `${parts.hour}:${parts.minute}`;
} }
function parseLocalDateKey(dateKey: string): { year: number; month: number; day: number } | null {
const match = String(dateKey || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
return { year, month, day };
}
function getUtcDateForLocalTime(timezone: string, year: number, month: number, day: number, hour: number, minute: number): Date {
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
const actual = getDateTimeParts(new Date(utcGuess), timezone);
const actualUtc = Date.UTC(
Number(actual.year),
Number(actual.month) - 1,
Number(actual.day),
Number(actual.hour),
Number(actual.minute),
0,
0
);
const desiredUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
return new Date(utcGuess - (actualUtc - desiredUtc));
}
function getBackupSlotStartsForLocalDay(
dateKey: string,
timezone: string,
startTime: string,
intervalHours: number
): Date[] {
const parsedDate = parseLocalDateKey(dateKey);
const parsedTime = normalizeStartTime(startTime).split(':').map((value) => Number(value));
if (!parsedDate || parsedTime.length !== 2) return [];
const [hour, minute] = parsedTime;
const firstSlot = getUtcDateForLocalTime(timezone, parsedDate.year, parsedDate.month, parsedDate.day, hour, minute);
const nextLocalDay = new Date(Date.UTC(parsedDate.year, parsedDate.month - 1, parsedDate.day, 0, 0, 0, 0));
nextLocalDay.setUTCDate(nextLocalDay.getUTCDate() + 1);
const nextDay = getUtcDateForLocalTime(
timezone,
nextLocalDay.getUTCFullYear(),
nextLocalDay.getUTCMonth() + 1,
nextLocalDay.getUTCDate(),
0,
0
);
const intervalMs = intervalHours * 60 * 60 * 1000;
const slots: Date[] = [];
for (let slotMs = firstSlot.getTime(); slotMs < nextDay.getTime(); slotMs += intervalMs) {
slots.push(new Date(slotMs));
}
return slots;
}
export function isBackupDueNow( export function isBackupDueNow(
destination: BackupDestinationRecord, destination: BackupDestinationRecord,
now: Date, now: Date,
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
): boolean { ): boolean {
if (!destination.schedule.enabled) return false; if (!destination.schedule.enabled) return false;
const intervalMs = destination.schedule.intervalHours * 60 * 60 * 1000;
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000; const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null; const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
if (!lastAttemptAt || !Number.isFinite(lastAttemptAt.getTime())) return true; const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
return now.getTime() - lastAttemptAt.getTime() >= Math.max(0, intervalMs - toleranceMs); ? lastAttemptAt.getTime()
: Number.NEGATIVE_INFINITY;
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
const slotStarts = getBackupSlotStartsForLocalDay(
localDateKey,
destination.schedule.timezone,
destination.schedule.startTime,
destination.schedule.intervalHours
);
for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime();
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
if (lastAttemptMs >= slotStartMs) return false;
return true;
}
return false;
} }
+78 -9
View File
@@ -17,6 +17,7 @@ interface CipherRow {
key: string | null; key: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
archived_at: string | null;
deleted_at: string | null; deleted_at: string | null;
} }
@@ -37,6 +38,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
key: row.key ?? parsed.key ?? null, key: row.key ?? parsed.key ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
deletedAt: row.deleted_at ?? null, deletedAt: row.deleted_at ?? null,
}; };
} catch { } catch {
@@ -46,7 +48,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
} }
function selectCipherColumns(): string { function selectCipherColumns(): string {
return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at'; return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at';
} }
export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> { export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> {
@@ -60,10 +62,10 @@ 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 data = JSON.stringify(cipher); const data = JSON.stringify(cipher);
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, 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at' 'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
); );
await safeBind( await safeBind(
stmt, stmt,
@@ -79,10 +81,15 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
cipher.key, cipher.key,
cipher.createdAt, cipher.createdAt,
cipher.updatedAt, cipher.updatedAt,
cipher.archivedAt ?? null,
cipher.deletedAt cipher.deletedAt
).run(); ).run();
} }
function sanitizeIds(ids: string[]): string[] {
return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
}
export async function deleteCipher(db: D1Database, id: string, userId: string): Promise<void> { export async function deleteCipher(db: D1Database, id: string, userId: string): Promise<void> {
await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run(); await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
} }
@@ -95,7 +102,7 @@ export async function bulkSoftDeleteCiphers(
userId: string userId: string
): Promise<string | null> { ): Promise<string | null> {
if (ids.length === 0) return null; if (ids.length === 0) return null;
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null; if (!uniqueIds.length) return null;
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -126,7 +133,7 @@ export async function bulkRestoreCiphers(
userId: string userId: string
): Promise<string | null> { ): Promise<string | null> {
if (ids.length === 0) return null; if (ids.length === 0) return null;
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null; if (!uniqueIds.length) return null;
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -157,7 +164,7 @@ export async function bulkDeleteCiphers(
userId: string userId: string
): Promise<string | null> { ): Promise<string | null> {
if (ids.length === 0) return null; if (ids.length === 0) return null;
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null; if (!uniqueIds.length) return null;
const chunkSize = sqlChunkSize(1); const chunkSize = sqlChunkSize(1);
@@ -212,7 +219,7 @@ export async function getCiphersByIds(
userId: string userId: string
): Promise<Cipher[]> { ): Promise<Cipher[]> {
if (ids.length === 0) return []; if (ids.length === 0) return [];
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return []; if (!uniqueIds.length) return [];
const chunkSize = sqlChunkSize(1); const chunkSize = sqlChunkSize(1);
@@ -242,7 +249,7 @@ export async function bulkMoveCiphers(
): Promise<string | null> { ): Promise<string | null> {
if (ids.length === 0) return null; if (ids.length === 0) return null;
const now = new Date().toISOString(); const now = new Date().toISOString();
const uniqueIds = Array.from(new Set(ids)); const uniqueIds = sanitizeIds(ids);
const patch = JSON.stringify({ folderId, updatedAt: now }); const patch = JSON.stringify({ folderId, updatedAt: now });
const chunkSize = sqlChunkSize(4); const chunkSize = sqlChunkSize(4);
@@ -261,3 +268,65 @@ export async function bulkMoveCiphers(
return updateRevisionDate(userId); return updateRevisionDate(userId);
} }
export async function bulkArchiveCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now });
const chunkSize = sqlChunkSize(4);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET archived_at = ?, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
)
.bind(now, now, patch, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
export async function bulkUnarchiveCiphers(
db: D1Database,
sqlChunkSize: SqlChunkSize,
updateRevisionDate: UpdateRevisionDate,
ids: string[],
userId: string
): Promise<string | null> {
if (ids.length === 0) return null;
const uniqueIds = sanitizeIds(ids);
if (!uniqueIds.length) return null;
const now = new Date().toISOString();
const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now });
const chunkSize = sqlChunkSize(3);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
const chunk = uniqueIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
await db
.prepare(
`UPDATE ciphers
SET archived_at = NULL, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(now, patch, userId, ...chunk)
.run();
}
return updateRevisionDate(userId);
}
+82 -6
View File
@@ -10,6 +10,9 @@ function mapDeviceRow(row: any): Device {
name: row.name, name: row.name,
type: row.type, type: row.type,
sessionStamp: row.session_stamp || '', sessionStamp: row.session_stamp || '',
encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@@ -22,19 +25,92 @@ export async function upsertDevice(
deviceIdentifier: string, deviceIdentifier: string,
name: string, name: string,
type: number, type: number,
sessionStamp?: string sessionStamp?: string,
keys?: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<void> { ): Promise<void> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || ''; const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
await db await db
.prepare( .prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, 0, NULL, ?, ?) ' + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, updated_at=excluded.updated_at' 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
'updated_at=excluded.updated_at'
)
.bind(
userId,
deviceIdentifier,
name,
type,
effectiveSessionStamp,
keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null,
now,
now
) )
.bind(userId, deviceIdentifier, name, type, effectiveSessionStamp, now, now)
.run(); .run();
} }
export async function updateDeviceKeys(
db: D1Database,
userId: string,
deviceIdentifier: string,
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
'UPDATE devices SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, updated_at = ? ' +
'WHERE user_id = ? AND device_identifier = ?'
)
.bind(
keys.encryptedUserKey ?? null,
keys.encryptedPublicKey ?? null,
keys.encryptedPrivateKey ?? null,
now,
userId,
deviceIdentifier
)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function clearDeviceKeys(
db: D1Database,
userId: string,
deviceIdentifiers: string[]
): Promise<number> {
const uniqueIds = Array.from(
new Set(deviceIdentifiers.map((id) => String(id || '').trim()).filter(Boolean))
);
if (!uniqueIds.length) return 0;
const placeholders = uniqueIds.map(() => '?').join(',');
const result = await db
.prepare(
`UPDATE devices
SET encrypted_user_key = NULL,
encrypted_public_key = NULL,
encrypted_private_key = NULL,
updated_at = ?
WHERE user_id = ? AND device_identifier IN (${placeholders})`
)
.bind(new Date().toISOString(), userId, ...uniqueIds)
.run();
return Number(result.meta.changes ?? 0);
}
export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> { export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
const row = await db const row = await db
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1') .prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
@@ -57,7 +133,7 @@ export async function isKnownDeviceByEmail(
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> { export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db const res = await db
.prepare( .prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC' 'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
) )
.bind(userId) .bind(userId)
@@ -68,7 +144,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> { export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
const row = await db const row = await db
.prepare( .prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
) )
.bind(userId, deviceIdentifier) .bind(userId, deviceIdentifier)
+9 -3
View File
@@ -6,10 +6,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT', 'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
@@ -20,9 +21,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE TABLE IF NOT EXISTS ciphers (' + 'CREATE TABLE IF NOT EXISTS ciphers (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' + 'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, archived_at TEXT, deleted_at TEXT, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'ALTER TABLE ciphers ADD COLUMN archived_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)', 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_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 TABLE IF NOT EXISTS folders (' + 'CREATE TABLE IF NOT EXISTS folders (' +
@@ -68,12 +71,15 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' + 'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' + 'PRIMARY KEY (user_id, device_identifier), ' +
'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_devices_user_updated ON devices(user_id, updated_at)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
'ALTER TABLE devices ADD COLUMN session_stamp TEXT', 'ALTER TABLE devices ADD COLUMN session_stamp TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT', 'ALTER TABLE devices ADD COLUMN banned_at TEXT',
+15 -14
View File
@@ -1,6 +1,10 @@
import type { User } from '../types'; import type { User } from '../types';
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement; type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
const USER_SELECT_COLUMNS =
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
'totp_secret, totp_recovery_code, created_at, updated_at';
function mapUserRow(row: any): User { function mapUserRow(row: any): User {
return { return {
@@ -19,6 +23,7 @@ function mapUserRow(row: any): User {
securityStamp: row.security_stamp, securityStamp: row.security_stamp,
role: row.role === 'admin' ? 'admin' : 'user', role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active', status: row.status === 'banned' ? 'banned' : 'active',
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
totpSecret: row.totp_secret ?? null, totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null, totpRecoveryCode: row.totp_recovery_code ?? null,
createdAt: row.created_at, createdAt: row.created_at,
@@ -28,9 +33,7 @@ function mapUserRow(row: any): User {
export async function getUser(db: D1Database, email: string): Promise<User | null> { export async function getUser(db: D1Database, email: string): Promise<User | null> {
const row = await db const row = await db
.prepare( .prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE email = ?`)
'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?'
)
.bind(email.toLowerCase()) .bind(email.toLowerCase())
.first<any>(); .first<any>();
if (!row) return null; if (!row) return null;
@@ -39,9 +42,7 @@ export async function getUser(db: D1Database, email: string): Promise<User | nul
export async function getUserById(db: D1Database, id: string): Promise<User | null> { export async function getUserById(db: D1Database, id: string): Promise<User | null> {
const row = await db const row = await db
.prepare( .prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE id = ?`)
'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?'
)
.bind(id) .bind(id)
.first<any>(); .first<any>();
if (!row) return null; if (!row) return null;
@@ -55,9 +56,7 @@ export async function getUserCount(db: D1Database): Promise<number> {
export async function getAllUsers(db: D1Database): Promise<User[]> { export async function getAllUsers(db: D1Database): Promise<User[]> {
const res = await db const res = await db
.prepare( .prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users ORDER BY created_at ASC`)
'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'
)
.all<any>(); .all<any>();
return (res.results || []).map((row) => mapUserRow(row)); return (res.results || []).map((row) => mapUserRow(row));
} }
@@ -65,11 +64,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> { export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + 'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at' 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
); );
await safeBind( await safeBind(
stmt, stmt,
@@ -88,6 +87,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
user.securityStamp, user.securityStamp,
user.role, user.role,
user.status, user.status,
user.verifyDevices ? 1 : 0,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode, user.totpRecoveryCode,
user.createdAt, user.createdAt,
@@ -102,8 +102,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> { export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
); );
const result = await safeBind( const result = await safeBind(
@@ -123,6 +123,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
user.securityStamp, user.securityStamp,
user.role, user.role,
user.status, user.status,
user.verifyDevices ? 1 : 0,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode, user.totpRecoveryCode,
user.createdAt, user.createdAt,
+42 -3
View File
@@ -36,10 +36,12 @@ import {
saveFolder as saveStoredFolder, saveFolder as saveStoredFolder,
} from './storage-folder-repo'; } from './storage-folder-repo';
import { import {
bulkArchiveCiphers as archiveStoredCiphers,
bulkDeleteCiphers as deleteStoredCiphers, bulkDeleteCiphers as deleteStoredCiphers,
bulkMoveCiphers as moveStoredCiphers, bulkMoveCiphers as moveStoredCiphers,
bulkRestoreCiphers as restoreStoredCiphers, bulkRestoreCiphers as restoreStoredCiphers,
bulkSoftDeleteCiphers as softDeleteStoredCiphers, bulkSoftDeleteCiphers as softDeleteStoredCiphers,
bulkUnarchiveCiphers as unarchiveStoredCiphers,
getAllCiphers as listStoredCiphers, getAllCiphers as listStoredCiphers,
getCipher as findStoredCipher, getCipher as findStoredCipher,
getCiphersByIds as listStoredCiphersByIds, getCiphersByIds as listStoredCiphersByIds,
@@ -80,6 +82,7 @@ import {
import { import {
deleteDevice as deleteStoredDevice, deleteDevice as deleteStoredDevice,
deleteDevicesByUserId as deleteStoredDevicesByUserId, deleteDevicesByUserId as deleteStoredDevicesByUserId,
clearDeviceKeys as clearStoredDeviceKeys,
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice, deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId, deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
getDevice as findStoredDevice, getDevice as findStoredDevice,
@@ -90,6 +93,7 @@ import {
isKnownDeviceByEmail as getKnownStoredDeviceByEmail, isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken, saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
upsertDevice as saveStoredDevice, upsertDevice as saveStoredDevice,
updateDeviceKeys as updateStoredDeviceKeys,
} from './storage-device-repo'; } from './storage-device-repo';
import { import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
@@ -102,7 +106,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-03-19.1'; const STORAGE_SCHEMA_VERSION = '2026-03-23.1';
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -286,6 +290,14 @@ export class StorageService {
return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
} }
async bulkArchiveCiphers(ids: string[], userId: string): Promise<string | null> {
return archiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async bulkUnarchiveCiphers(ids: string[], userId: string): Promise<string | null> {
return unarchiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
}
async bulkDeleteCiphers(ids: string[], userId: string): Promise<string | null> { async bulkDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
} }
@@ -495,8 +507,19 @@ export class StorageService {
// --- Devices --- // --- Devices ---
async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number, sessionStamp?: string): Promise<void> { async upsertDevice(
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp); userId: string,
deviceIdentifier: string,
name: string,
type: number,
sessionStamp?: string,
keys?: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<void> {
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp, keys);
} }
async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> { async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
@@ -515,6 +538,22 @@ export class StorageService {
return findStoredDevice(this.db, userId, deviceIdentifier); return findStoredDevice(this.db, userId, deviceIdentifier);
} }
async updateDeviceKeys(
userId: string,
deviceIdentifier: string,
keys: {
encryptedUserKey?: string | null;
encryptedPublicKey?: string | null;
encryptedPrivateKey?: string | null;
}
): Promise<boolean> {
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
}
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
}
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> { async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
return deleteStoredDevice(this.db, userId, deviceIdentifier); return deleteStoredDevice(this.db, userId, deviceIdentifier);
} }
+47
View File
@@ -47,6 +47,7 @@ export interface User {
securityStamp: string; securityStamp: string;
role: UserRole; role: UserRole;
status: UserStatus; status: UserStatus;
verifyDevices?: boolean;
totpSecret: string | null; totpSecret: string | null;
totpRecoveryCode: string | null; totpRecoveryCode: string | null;
createdAt: string; createdAt: string;
@@ -169,6 +170,7 @@ export interface Cipher {
key: string | null; key: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
archivedAt: string | null;
deletedAt: string | null; deletedAt: string | null;
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */ /** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
[key: string]: any; [key: string]: any;
@@ -189,10 +191,47 @@ export interface Device {
name: string; name: string;
type: number; type: number;
sessionStamp: string; sessionStamp: string;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface DevicePendingAuthRequest {
id: string;
creationDate: string;
}
export interface DeviceResponse {
id: string;
userId?: string | null;
name: string;
identifier: string;
type: number;
creationDate: string;
revisionDate: string;
isTrusted: boolean;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
devicePendingAuthRequest: DevicePendingAuthRequest | null;
object: string;
[key: string]: any;
}
export interface ProtectedDeviceResponse {
id: string;
name: string;
identifier: string;
type: number;
creationDate: string;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
object: string;
[key: string]: any;
}
export interface RefreshTokenRecord { export interface RefreshTokenRecord {
userId: string; userId: string;
expiresAt: number; expiresAt: number;
@@ -351,6 +390,7 @@ export interface ProfileResponse {
forcePasswordReset: boolean; forcePasswordReset: boolean;
avatarColor: string | null; avatarColor: string | null;
creationDate: string; creationDate: string;
verifyDevices?: boolean;
role?: UserRole; role?: UserRole;
status?: UserStatus; status?: UserStatus;
object: string; object: string;
@@ -409,6 +449,13 @@ export interface SyncResponse {
domains: any; domains: any;
policies: any[]; policies: any[];
sends: SendResponse[]; sends: SendResponse[];
UserDecryption?: {
MasterPasswordUnlock: MasterPasswordUnlock | null;
TrustedDeviceOption?: null;
KeyConnectorOption?: null;
WebAuthnPrfOption?: null;
Object?: string;
} | null;
// PascalCase for desktop/browser clients // PascalCase for desktop/browser clients
UserDecryptionOptions: UserDecryptionOptions | null; UserDecryptionOptions: UserDecryptionOptions | null;
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption")) // camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
+101
View File
@@ -57,11 +57,68 @@ const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE)); const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
const SETTINGS_HOME_ROUTE = '/settings'; const SETTINGS_HOME_ROUTE = '/settings';
const SETTINGS_ACCOUNT_ROUTE = '/settings/account'; const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
type ThemePreference = 'system' | 'light' | 'dark';
const MAGNETIC_SELECTOR = '.topbar .btn, .topbar .user-chip, .side-link, .mobile-tab';
function installMagneticUiFeedback() {
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {};
if (typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {};
if (typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches) return () => {};
const resetNode = (node: HTMLElement) => {
node.style.setProperty('--mag-x', '0px');
node.style.setProperty('--mag-y', '0px');
node.style.removeProperty('--mx');
node.style.removeProperty('--my');
};
const onPointerMove = (event: PointerEvent) => {
const node = event.target instanceof Element ? event.target.closest<HTMLElement>(MAGNETIC_SELECTOR) : null;
if (!node) return;
const rect = node.getBoundingClientRect();
const localX = event.clientX - rect.left;
const localY = event.clientY - rect.top;
const dx = (localX - rect.width / 2) / Math.max(rect.width / 2, 1);
const dy = (localY - rect.height / 2) / Math.max(rect.height / 2, 1);
node.style.setProperty('--mx', `${localX}px`);
node.style.setProperty('--my', `${localY}px`);
node.style.setProperty('--mag-x', `${dx * 6}px`);
node.style.setProperty('--mag-y', `${dy * 4}px`);
};
const onPointerLeave = (event: Event) => {
const node = event.target instanceof Element ? event.target.closest<HTMLElement>(MAGNETIC_SELECTOR) : null;
if (!node) return;
resetNode(node);
};
document.addEventListener('pointermove', onPointerMove, { passive: true });
document.addEventListener('pointerleave', onPointerLeave, true);
return () => {
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerleave', onPointerLeave, true);
};
}
function readThemePreference(): ThemePreference {
if (typeof window === 'undefined') return 'system';
const stored = String(window.localStorage.getItem(THEME_STORAGE_KEY) || '').trim();
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
return 'system';
}
function resolveSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export default function App() { export default function App() {
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []); const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []); const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
@@ -100,6 +157,8 @@ export default function App() {
const [disableTotpOpen, setDisableTotpOpen] = useState(false); const [disableTotpOpen, setDisableTotpOpen] = useState(false);
const [disableTotpPassword, setDisableTotpPassword] = useState(''); const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
const [confirm, setConfirm] = useState<AppConfirmState | null>(null); const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false); const [mobileLayout, setMobileLayout] = useState(false);
@@ -175,6 +234,41 @@ export default function App() {
return () => media.removeListener(sync); return () => media.removeListener(sync);
}, []); }, []);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const sync = () => setSystemTheme(media.matches ? 'dark' : 'light');
sync();
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync);
}
media.addListener(sync);
return () => media.removeListener(sync);
}, []);
const resolvedTheme = themePreference === 'system' ? systemTheme : themePreference;
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.style.colorScheme = resolvedTheme;
}, [resolvedTheme]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
}, [themePreference]);
useEffect(() => installMagneticUiFeedback(), []);
function handleToggleTheme() {
setThemePreference((prev) => {
const current = prev === 'system' ? systemTheme : prev;
return current === 'dark' ? 'light' : 'dark';
});
}
function setSession(next: SessionState | null) { function setSession(next: SessionState | null) {
sessionRef.current = next; sessionRef.current = next;
setSessionState(next); setSessionState(next);
@@ -974,9 +1068,13 @@ export default function App() {
onCreateVaultItem: vaultSendActions.createVaultItem, onCreateVaultItem: vaultSendActions.createVaultItem,
onUpdateVaultItem: vaultSendActions.updateVaultItem, onUpdateVaultItem: vaultSendActions.updateVaultItem,
onDeleteVaultItem: vaultSendActions.deleteVaultItem, onDeleteVaultItem: vaultSendActions.deleteVaultItem,
onArchiveVaultItem: vaultSendActions.archiveVaultItem,
onUnarchiveVaultItem: vaultSendActions.unarchiveVaultItem,
onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems, onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems,
onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems, onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems,
onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems, onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems,
onBulkArchiveVaultItems: vaultSendActions.bulkArchiveVaultItems,
onBulkUnarchiveVaultItems: vaultSendActions.bulkUnarchiveVaultItems,
onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems, onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems,
onVerifyMasterPassword: vaultSendActions.verifyMasterPassword, onVerifyMasterPassword: vaultSendActions.verifyMasterPassword,
onCreateFolder: vaultSendActions.createFolder, onCreateFolder: vaultSendActions.createFolder,
@@ -1131,8 +1229,11 @@ export default function App() {
settingsAccountRoute={SETTINGS_ACCOUNT_ROUTE} settingsAccountRoute={SETTINGS_ACCOUNT_ROUTE}
importRoute={IMPORT_ROUTE} importRoute={IMPORT_ROUTE}
isImportRoute={isImportRoute} isImportRoute={isImportRoute}
darkMode={resolvedTheme === 'dark'}
themeToggleTitle={resolvedTheme === 'dark' ? t('txt_switch_to_light_mode') : t('txt_switch_to_dark_mode')}
onLock={handleLock} onLock={handleLock}
onLogout={handleLogout} onLogout={handleLogout}
onToggleTheme={handleToggleTheme}
mainRoutesProps={mainRoutesProps} mainRoutesProps={mainRoutesProps}
/> />
@@ -1,6 +1,7 @@
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import { Link } from 'wouter'; import { Link } from 'wouter';
import AppMainRoutes from '@/components/AppMainRoutes'; import AppMainRoutes from '@/components/AppMainRoutes';
import ThemeSwitch from '@/components/ThemeSwitch';
import type { AppMainRoutesProps } from '@/components/AppMainRoutes'; import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { Profile } from '@/lib/types'; import type { Profile } from '@/lib/types';
@@ -15,12 +16,17 @@ interface AppAuthenticatedShellProps {
settingsAccountRoute: string; settingsAccountRoute: string;
importRoute: string; importRoute: string;
isImportRoute: boolean; isImportRoute: boolean;
darkMode: boolean;
themeToggleTitle: string;
onLock: () => void; onLock: () => void;
onLogout: () => void; onLogout: () => void;
onToggleTheme: () => void;
mainRoutesProps: AppMainRoutesProps; mainRoutesProps: AppMainRoutesProps;
} }
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) { export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
return ( return (
<div className="app-page"> <div className="app-page">
<div className="app-shell"> <div className="app-shell">
@@ -35,6 +41,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<ShieldUser size={16} /> <ShieldUser size={16} />
<span>{props.profile?.email}</span> <span>{props.profile?.email}</span>
</div> </div>
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
<button type="button" className="btn btn-secondary small" onClick={props.onLock}> <button type="button" className="btn btn-secondary small" onClick={props.onLock}>
<Lock size={14} className="btn-icon" /> {t('txt_lock')} <Lock size={14} className="btn-icon" /> {t('txt_lock')}
</button> </button>
@@ -49,6 +56,9 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<FolderIcon size={16} className="btn-icon" /> <FolderIcon size={16} className="btn-icon" />
</button> </button>
)} )}
<div className="mobile-theme-btn">
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
</div>
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={props.onLock}> <button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={props.onLock}>
<Lock size={14} className="btn-icon" /> <Lock size={14} className="btn-icon" />
</button> </button>
@@ -98,7 +108,9 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
</Link> </Link>
</aside> </aside>
<main className="content"> <main className="content">
<AppMainRoutes {...props.mainRoutesProps} /> <div key={routeAnimationKey} className="route-stage">
<AppMainRoutes {...props.mainRoutesProps} />
</div>
</main> </main>
</div> </div>
+8
View File
@@ -64,9 +64,13 @@ export interface AppMainRoutesProps {
onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>; onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>; onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
onDeleteVaultItem: (cipher: Cipher) => Promise<void>; onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>; onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>; onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>; onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
onBulkArchiveVaultItems: (ids: string[]) => Promise<void>;
onBulkUnarchiveVaultItems: (ids: string[]) => Promise<void>;
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>; onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
onVerifyMasterPassword: (email: string, password: string) => Promise<void>; onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onCreateFolder: (name: string) => Promise<void>; onCreateFolder: (name: string) => Promise<void>;
@@ -174,9 +178,13 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onCreate={props.onCreateVaultItem} onCreate={props.onCreateVaultItem}
onUpdate={props.onUpdateVaultItem} onUpdate={props.onUpdateVaultItem}
onDelete={props.onDeleteVaultItem} onDelete={props.onDeleteVaultItem}
onArchive={props.onArchiveVaultItem}
onUnarchive={props.onUnarchiveVaultItem}
onBulkDelete={props.onBulkDeleteVaultItems} onBulkDelete={props.onBulkDeleteVaultItems}
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems} onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
onBulkRestore={props.onBulkRestoreVaultItems} onBulkRestore={props.onBulkRestoreVaultItems}
onBulkArchive={props.onBulkArchiveVaultItems}
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
onBulkMove={props.onBulkMoveVaultItems} onBulkMove={props.onBulkMoveVaultItems}
onVerifyMasterPassword={props.onVerifyMasterPassword} onVerifyMasterPassword={props.onVerifyMasterPassword}
onNotify={props.onNotify} onNotify={props.onNotify}
+23 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { Check, X } from 'lucide-preact';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface ConfirmDialogProps { interface ConfirmDialogProps {
@@ -20,14 +20,32 @@ interface ConfirmDialogProps {
} }
export default function ConfirmDialog(props: ConfirmDialogProps) { export default function ConfirmDialog(props: ConfirmDialogProps) {
if (!props.open) return null; const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false);
useEffect(() => {
if (props.open) {
setPresent(true);
setClosing(false);
return;
}
if (!present) return;
setClosing(true);
const timer = window.setTimeout(() => {
setPresent(false);
setClosing(false);
}, 240);
return () => window.clearTimeout(timer);
}, [props.open, present]);
if (!present) return null;
return ( return (
<div className="dialog-mask"> <div className={`dialog-mask ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}>
<form <form
className="dialog-card" className={`dialog-card ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (props.confirmDisabled) return; if (props.confirmDisabled || closing) return;
props.onConfirm(); props.onConfirm();
}} }}
> >
@@ -39,7 +57,6 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
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}
> >
<Check size={14} className="btn-icon" />
{props.confirmText || t('txt_yes')} {props.confirmText || t('txt_yes')}
</button> </button>
{!props.hideCancel && ( {!props.hideCancel && (
@@ -52,7 +69,6 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
props.onCancel(); props.onCancel();
}} }}
> >
<X size={14} className="btn-icon" />
{props.cancelText || t('txt_no')} {props.cancelText || t('txt_no')}
</button> </button>
)} )}
+44 -15
View File
@@ -62,6 +62,10 @@ function draftFromSend(send: Send): SendDraft {
} }
export default function SendsPage(props: SendsPageProps) { export default function SendsPage(props: SendsPageProps) {
const getInitialIsMobileLayout = () =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(MOBILE_LAYOUT_QUERY).matches
: false;
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all'); const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -71,7 +75,7 @@ export default function SendsPage(props: SendsPageProps) {
const [draft, setDraft] = useState<SendDraft | null>(null); const [draft, setDraft] = useState<SendDraft | null>(null);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({}); const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const [isMobileLayout, setIsMobileLayout] = useState(false); 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 [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => { const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
@@ -226,7 +230,15 @@ export default function SendsPage(props: SendsPageProps) {
return ( return (
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}> <div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
{isMobileLayout && mobileSidebarOpen && <div className="mobile-sidebar-mask" onClick={() => setMobileSidebarOpen(false)} />} {isMobileLayout && (
<div
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
onClick={() => {
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}}
/>
)}
<aside className={`sidebar ${isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${isMobileLayout && mobileSidebarOpen ? 'open' : ''}`}> <aside className={`sidebar ${isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${isMobileLayout && mobileSidebarOpen ? 'open' : ''}`}>
{isMobileLayout && ( {isMobileLayout && (
<div className="mobile-sidebar-head"> <div className="mobile-sidebar-head">
@@ -310,12 +322,27 @@ export default function SendsPage(props: SendsPageProps) {
</button> </button>
</div> </div>
<div className="list-panel"> <div className="list-panel">
{filteredSends.map((send) => ( {filteredSends.map((send, index) => (
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}> <div
key={send.id}
className={`list-item stagger-item ${selectedId === send.id ? 'active' : ''}`}
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
setSelectedId(send.id);
setIsEditing(false);
setIsCreating(false);
setDraft(null);
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}}
>
<input <input
type="checkbox" type="checkbox"
className="row-check" className="row-check"
checked={!!selectedMap[send.id]} checked={!!selectedMap[send.id]}
onClick={(event) => event.stopPropagation()}
onInput={(e) => onInput={(e) =>
setSelectedMap((prev) => ({ setSelectedMap((prev) => ({
...prev, ...prev,
@@ -377,10 +404,11 @@ export default function SendsPage(props: SendsPageProps) {
</div> </div>
)} )}
{isEditing && draft && ( {isEditing && draft && (
<div className="card"> <div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3> <div className="card stagger-item" style={{ animationDelay: '0ms' }}>
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>} <h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
<div className="field-grid"> {!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
<div className="field-grid">
<label className="field field-span-2"> <label className="field field-span-2">
<span>{t('txt_name')}</span> <span>{t('txt_name')}</span>
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} /> <input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
@@ -451,8 +479,8 @@ export default function SendsPage(props: SendsPageProps) {
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_auto_copy_link_after_save')}</label> <label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_auto_copy_link_after_save')}</label>
</div> </div>
</label> </label>
</div> </div>
<div className="detail-actions"> <div className="detail-actions">
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}> <button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
<Save size={14} className="btn-icon" /> {t('txt_save')} <Save size={14} className="btn-icon" /> {t('txt_save')}
</button> </button>
@@ -470,18 +498,19 @@ export default function SendsPage(props: SendsPageProps) {
> >
<X size={14} className="btn-icon" /> {t('txt_cancel')} <X size={14} className="btn-icon" /> {t('txt_cancel')}
</button> </button>
</div>
</div> </div>
</div> </div>
)} )}
{!isEditing && selectedSend && ( {!isEditing && selectedSend && (
<> <div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
<div className="card"> <div className="card stagger-item" style={{ animationDelay: '36ms' }}>
<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"> <div className="card stagger-item" style={{ animationDelay: '72ms' }}>
<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>
@@ -504,7 +533,7 @@ export default function SendsPage(props: SendsPageProps) {
</div> </div>
{!!(selectedSend.decNotes || '').trim() && ( {!!(selectedSend.decNotes || '').trim() && (
<div className="card"> <div className="card stagger-item" style={{ animationDelay: '108ms' }}>
<h4>{t('txt_notes')}</h4> <h4>{t('txt_notes')}</h4>
<div className="notes">{selectedSend.decNotes || ''}</div> <div className="notes">{selectedSend.decNotes || ''}</div>
</div> </div>
@@ -523,7 +552,7 @@ export default function SendsPage(props: SendsPageProps) {
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')} <Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
</button> </button>
</div> </div>
</> </div>
)} )}
</section> </section>
</div> </div>
+29
View File
@@ -0,0 +1,29 @@
interface ThemeSwitchProps {
checked: boolean;
title: string;
onToggle: () => void;
}
export default function ThemeSwitch(props: ThemeSwitchProps) {
return (
<div className="theme-switch-wrap" title={props.title}>
<label className="theme-switch" aria-label={props.title}>
<span className="sun" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g fill="#ffd43b">
<circle r={5} cy={12} cx={12} />
<path d="m21 13h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm-17 0h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm13.66-5.66a1 1 0 0 1 -.66-.29 1 1 0 0 1 0-1.41l.71-.71a1 1 0 1 1 1.41 1.41l-.71.71a1 1 0 0 1 -.75.29zm-12.02 12.02a1 1 0 0 1 -.71-.29 1 1 0 0 1 0-1.41l.71-.66a1 1 0 0 1 1.41 1.41l-.71.71a1 1 0 0 1 -.7.24zm6.36-14.36a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm0 17a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm-5.66-14.66a1 1 0 0 1 -.7-.29l-.71-.71a1 1 0 0 1 1.41-1.41l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.29zm12.02 12.02a1 1 0 0 1 -.7-.29l-.66-.71a1 1 0 0 1 1.36-1.36l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.24z" />
</g>
</svg>
</span>
<span className="moon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
<path d="m223.5 32c-123.5 0-223.5 100.3-223.5 224s100 224 223.5 224c60.6 0 115.5-24.2 155.8-63.4 5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6-96.9 0-175.5-78.8-175.5-176 0-65.8 36-123.1 89.3-153.3 6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z" />
</svg>
</span>
<input type="checkbox" className="theme-switch-input" checked={props.checked} onInput={props.onToggle} />
<span className="theme-switch-slider" />
</label>
</div>
);
}
+179 -55
View File
@@ -1,10 +1,26 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Clipboard, Globe } from 'lucide-preact'; import { Clipboard, Globe, GripVertical } from 'lucide-preact';
import {
closestCenter,
DndContext,
type DragEndEvent,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
rectSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard'; 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 { websiteIconUrl } from '@/components/vault/vault-page-helpers'; import { isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers';
interface TotpCodesPageProps { interface TotpCodesPageProps {
ciphers: Cipher[]; ciphers: Cipher[];
@@ -15,6 +31,7 @@ interface TotpCodesPageProps {
const TOTP_PERIOD_SECONDS = 30; 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 failedIconHosts = new Set<string>(); const failedIconHosts = new Set<string>();
function formatTotp(code: string): string { function formatTotp(code: string): string {
@@ -69,22 +86,117 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
); );
} }
interface SortableTotpRowProps {
cipher: Cipher;
live: { code: string; remain: number } | null;
onCopy: (value: string) => void;
}
function SortableTotpRow(props: SortableTotpRowProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.cipher.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
const username = props.cipher.login?.decUsername || '';
return (
<div ref={setNodeRef} style={style} className={`totp-code-row${isDragging ? ' is-dragging' : ''}`}>
<button
type="button"
ref={setActivatorNodeRef}
className="btn btn-secondary small totp-drag-btn"
title={t('txt_drag_to_reorder')}
aria-label={t('txt_drag_to_reorder')}
{...attributes}
{...listeners}
>
<GripVertical size={14} className="btn-icon" />
</button>
<div className="totp-code-info">
<div className="list-icon-wrap">
<TotpListIcon cipher={props.cipher} />
</div>
<div className="totp-code-meta">
<div className="totp-code-name" title={name}>{name}</div>
<div className="totp-code-username" title={username}>{username || t('txt_no_username')}</div>
</div>
</div>
<div className="totp-code-main">
<strong>{props.live ? formatTotp(props.live.code) : t('txt_text_3')}</strong>
<div
className="totp-timer"
title={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
aria-label={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
>
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
<circle
className="totp-ring-progress"
cx="18"
cy="18"
r={TOTP_RING_RADIUS}
style={{
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE -
TOTP_RING_CIRCUMFERENCE *
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
),
}}
/>
</svg>
<span className="totp-timer-value">{props.live ? props.live.remain : 0}</span>
</div>
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => props.onCopy(props.live?.code || '')} aria-label={t('txt_copy')}>
<Clipboard size={14} className="btn-icon" />
</button>
</div>
</div>
);
}
export default function TotpCodesPage(props: TotpCodesPageProps) { export default function TotpCodesPage(props: TotpCodesPageProps) {
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({}); const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
const [columnCount, setColumnCount] = useState(1); const [columnCount, setColumnCount] = useState(1);
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (typeof window === 'undefined') return [];
try {
const parsed = JSON.parse(String(window.localStorage.getItem(TOTP_ORDER_STORAGE_KEY) || '[]'));
return Array.isArray(parsed) ? parsed.map((id) => String(id || '').trim()).filter(Boolean) : [];
} catch {
return [];
}
});
const listRef = useRef<HTMLDivElement | null>(null); const listRef = useRef<HTMLDivElement | null>(null);
const hasLoadedTotpItemsRef = useRef(false);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 120,
tolerance: 8,
},
}),
);
async function copyToClipboard(value: string): Promise<void> { async function copyToClipboard(value: string): Promise<void> {
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') }); await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
} }
const totpItems = useMemo( const baseTotpItems = useMemo(
() => () =>
props.ciphers props.ciphers
.filter((cipher) => { .filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
return !isDeleted && !!cipher.login?.decTotp;
})
.sort((a, b) => { .sort((a, b) => {
const nameA = (a.decName || a.name || '').trim().toLowerCase(); const nameA = (a.decName || a.name || '').trim().toLowerCase();
const nameB = (b.decName || b.name || '').trim().toLowerCase(); const nameB = (b.decName || b.name || '').trim().toLowerCase();
@@ -93,6 +205,44 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
[props.ciphers] [props.ciphers]
); );
const totpItems = useMemo(() => {
if (!baseTotpItems.length) return [];
const orderMap = new Map(orderedIds.map((id, index) => [id, index]));
return [...baseTotpItems].sort((a, b) => {
const orderA = orderMap.get(a.id);
const orderB = orderMap.get(b.id);
if (orderA != null && orderB != null) return orderA - orderB;
if (orderA != null) return -1;
if (orderB != null) return 1;
const nameA = (a.decName || a.name || '').trim().toLowerCase();
const nameB = (b.decName || b.name || '').trim().toLowerCase();
return nameA.localeCompare(nameB);
});
}, [baseTotpItems, orderedIds]);
useEffect(() => {
if (!baseTotpItems.length) return;
hasLoadedTotpItemsRef.current = true;
const validIds = new Set(baseTotpItems.map((cipher) => cipher.id));
setOrderedIds((prev) => {
const filtered = prev.filter((id) => validIds.has(id));
const missing = baseTotpItems.map((cipher) => cipher.id).filter((id) => !filtered.includes(id));
const next = [...filtered, ...missing];
if (next.length === prev.length && next.every((id, index) => id === prev[index])) return prev;
return next;
});
}, [baseTotpItems]);
useEffect(() => {
if (typeof window === 'undefined') return;
if (!hasLoadedTotpItemsRef.current) return;
try {
window.localStorage.setItem(TOTP_ORDER_STORAGE_KEY, JSON.stringify(orderedIds));
} catch {
// ignore storage write failures
}
}, [orderedIds]);
useEffect(() => { useEffect(() => {
if (!totpItems.length) { if (!totpItems.length) {
setTotpMap({}); setTotpMap({});
@@ -142,6 +292,16 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
const handleDragEnd = (event: DragEndEvent) => {
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : null;
if (!overId || activeId === overId) return;
const fromIndex = orderedIds.indexOf(activeId);
const toIndex = orderedIds.indexOf(overId);
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
setOrderedIds((prev) => arrayMove(prev, fromIndex, toIndex));
};
return ( return (
<div className="totp-codes-page"> <div className="totp-codes-page">
<div className="card"> <div className="card">
@@ -154,54 +314,18 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
style={{ '--totp-columns': String(columnCount) } as Record<string, string>} style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
> >
{!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>}
{totpItems.map((cipher) => { <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
const live = totpMap[cipher.id] || null; <SortableContext items={totpItems.map((cipher) => cipher.id)} strategy={rectSortingStrategy}>
const name = cipher.decName || cipher.name || t('txt_no_name'); {totpItems.map((cipher) => (
const username = cipher.login?.decUsername || ''; <SortableTotpRow
return ( key={cipher.id}
<div key={cipher.id} className="totp-code-row"> cipher={cipher}
<div className="totp-code-info"> live={totpMap[cipher.id] || null}
<div className="list-icon-wrap"> onCopy={(value) => void copyToClipboard(value)}
<TotpListIcon cipher={cipher} /> />
</div> ))}
<div className="totp-code-meta"> </SortableContext>
<div className="totp-code-name" title={name}>{name}</div> </DndContext>
<div className="totp-code-username" title={username}>{username || t('txt_no_username')}</div>
</div>
</div>
<div className="totp-code-main">
<strong>{live ? formatTotp(live.code) : t('txt_text_3')}</strong>
<div
className="totp-timer"
title={t('txt_refresh_in_seconds_s', { seconds: live ? live.remain : 0 })}
aria-label={t('txt_refresh_in_seconds_s', { seconds: live ? live.remain : 0 })}
>
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
<circle
className="totp-ring-progress"
cx="18"
cy="18"
r={TOTP_RING_RADIUS}
style={{
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE -
TOTP_RING_CIRCUMFERENCE *
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
),
}}
/>
</svg>
<span className="totp-timer-value">{live ? live.remain : 0}</span>
</div>
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => void copyToClipboard(live?.code || '')} aria-label={t('txt_copy')}>
<Clipboard size={14} className="btn-icon" />
</button>
</div>
</div>
);
})}
</div> </div>
</div> </div>
</div> </div>
+173 -56
View File
@@ -17,6 +17,9 @@ import {
buildCipherDuplicateSignature, buildCipherDuplicateSignature,
firstCipherUri, firstCipherUri,
firstPasskeyCreationTime, firstPasskeyCreationTime,
isCipherVisibleInArchive,
isCipherVisibleInNormalVault,
isCipherVisibleInTrash,
sortTimeValue, sortTimeValue,
type SidebarFilter, type SidebarFilter,
type VaultSortMode, type VaultSortMode,
@@ -36,9 +39,13 @@ interface VaultPageProps {
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>; onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>; onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
onDelete: (cipher: Cipher) => Promise<void>; onDelete: (cipher: Cipher) => Promise<void>;
onArchive: (cipher: Cipher) => Promise<void>;
onUnarchive: (cipher: Cipher) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>; onBulkDelete: (ids: string[]) => Promise<void>;
onBulkPermanentDelete: (ids: string[]) => Promise<void>; onBulkPermanentDelete: (ids: string[]) => Promise<void>;
onBulkRestore: (ids: string[]) => Promise<void>; onBulkRestore: (ids: string[]) => Promise<void>;
onBulkArchive: (ids: string[]) => Promise<void>;
onBulkUnarchive: (ids: string[]) => Promise<void>;
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>; onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
onVerifyMasterPassword: (email: string, password: string) => Promise<void>; onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -54,6 +61,10 @@ interface VaultPageProps {
export default function VaultPage(props: VaultPageProps) { export default function VaultPage(props: VaultPageProps) {
const getInitialIsMobileLayout = () =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(MOBILE_LAYOUT_QUERY).matches
: false;
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchComposing, setSearchComposing] = useState(false); const [searchComposing, setSearchComposing] = useState(false);
@@ -72,7 +83,9 @@ export default function VaultPage(props: VaultPageProps) {
const [fieldLabel, setFieldLabel] = useState(''); const [fieldLabel, setFieldLabel] = useState('');
const [fieldValue, setFieldValue] = useState(''); const [fieldValue, setFieldValue] = useState('');
const [localError, setLocalError] = useState(''); const [localError, setLocalError] = useState('');
const [pendingArchive, setPendingArchive] = useState<Cipher | null>(null);
const [pendingDelete, setPendingDelete] = useState<Cipher | null>(null); const [pendingDelete, setPendingDelete] = useState<Cipher | null>(null);
const [bulkArchiveOpen, setBulkArchiveOpen] = useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [moveOpen, setMoveOpen] = useState(false); const [moveOpen, setMoveOpen] = useState(false);
const [moveFolderId, setMoveFolderId] = useState('__none__'); const [moveFolderId, setMoveFolderId] = useState('__none__');
@@ -88,7 +101,7 @@ export default function VaultPage(props: VaultPageProps) {
const [repromptOpen, setRepromptOpen] = useState(false); const [repromptOpen, setRepromptOpen] = useState(false);
const [repromptPassword, setRepromptPassword] = useState(''); const [repromptPassword, setRepromptPassword] = useState('');
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null); const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
const [isMobileLayout, setIsMobileLayout] = useState(false); 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 createMenuRef = useRef<HTMLDivElement | null>(null); const createMenuRef = useRef<HTMLDivElement | null>(null);
@@ -229,8 +242,7 @@ export default function VaultPage(props: VaultPageProps) {
const duplicateSignatureCounts = useMemo(() => { const duplicateSignatureCounts = useMemo(() => {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const cipher of props.ciphers) { for (const cipher of props.ciphers) {
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt); if (!isCipherVisibleInNormalVault(cipher)) continue;
if (isDeleted) continue;
const signature = buildCipherDuplicateSignature(cipher); const signature = buildCipherDuplicateSignature(cipher);
counts.set(signature, (counts.get(signature) || 0) + 1); counts.set(signature, (counts.get(signature) || 0) + 1);
} }
@@ -239,11 +251,12 @@ export default function VaultPage(props: VaultPageProps) {
const filteredCiphers = useMemo(() => { const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => { const next = props.ciphers.filter((cipher) => {
const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt);
if (sidebarFilter.kind === 'trash') { if (sidebarFilter.kind === 'trash') {
if (!isDeleted) return false; if (!isCipherVisibleInTrash(cipher)) return false;
} else if (sidebarFilter.kind === 'archive') {
if (!isCipherVisibleInArchive(cipher)) return false;
} else { } else {
if (isDeleted) return false; if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) { if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) {
return false; return false;
} }
@@ -494,7 +507,30 @@ function folderName(id: string | null | undefined): string {
setDraft((prev) => { setDraft((prev) => {
if (!prev) return prev; if (!prev) return prev;
const next = [...prev.loginUris]; const next = [...prev.loginUris];
next[index] = value; next[index] = { ...(next[index] || { uri: '', match: null }), uri: value };
return { ...prev, loginUris: next };
});
}
function updateDraftLoginUriMatch(index: number, value: number | null): void {
setDraft((prev) => {
if (!prev) return prev;
const next = [...prev.loginUris];
next[index] = { ...(next[index] || { uri: '', match: null }), match: value };
return { ...prev, loginUris: next };
});
}
function reorderDraftLoginUri(fromIndex: number, toIndex: number): void {
setDraft((prev) => {
if (!prev) return prev;
if (fromIndex < 0 || toIndex < 0 || fromIndex >= prev.loginUris.length || toIndex >= prev.loginUris.length || fromIndex === toIndex) {
return prev;
}
const next = [...prev.loginUris];
const [moved] = next.splice(fromIndex, 1);
if (!moved) return prev;
next.splice(toIndex, 0, moved);
return { ...prev, loginUris: next }; return { ...prev, loginUris: next };
}); });
} }
@@ -677,6 +713,63 @@ function folderName(id: string | null | undefined): string {
} }
} }
async function confirmArchiveSelected(): Promise<void> {
if (!pendingArchive) return;
setBusy(true);
try {
await props.onArchive(pendingArchive);
setPendingArchive(null);
if (isMobileLayout && selectedCipherId === pendingArchive.id) {
setMobilePanel('list');
}
} finally {
setBusy(false);
}
}
async function handleUnarchiveSelected(cipher: Cipher): Promise<void> {
setBusy(true);
try {
await props.onBulkUnarchive([cipher.id]);
setSelectedMap((prev) => {
const next = { ...prev };
delete next[cipher.id];
return next;
});
} finally {
setBusy(false);
}
}
async function confirmBulkArchive(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
setBusy(true);
try {
await props.onBulkArchive(ids);
setSelectedMap({});
setBulkArchiveOpen(false);
} finally {
setBusy(false);
}
}
async function confirmBulkUnarchive(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
setBusy(true);
try {
await props.onBulkUnarchive(ids);
setSelectedMap({});
} finally {
setBusy(false);
}
}
async function confirmDeleteAllFolders(): Promise<void> { async function confirmDeleteAllFolders(): Promise<void> {
if (!props.folders.length) return; if (!props.folders.length) return;
setBusy(true); setBusy(true);
@@ -694,7 +787,15 @@ function folderName(id: string | null | undefined): string {
return ( return (
<> <>
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}> <div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
{isMobileLayout && mobileSidebarOpen && <div className="mobile-sidebar-mask" onClick={() => setMobileSidebarOpen(false)} />} {isMobileLayout && (
<div
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
onClick={() => {
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}}
/>
)}
<VaultSidebar <VaultSidebar
folders={props.folders} folders={props.folders}
sidebarFilter={sidebarFilter} sidebarFilter={sidebarFilter}
@@ -760,6 +861,8 @@ function folderName(id: string | null | undefined): string {
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)} onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
onStartCreate={startCreate} onStartCreate={startCreate}
onBulkRestore={() => void confirmBulkRestore()} onBulkRestore={() => void confirmBulkRestore()}
onBulkArchive={() => setBulkArchiveOpen(true)}
onBulkUnarchive={() => void confirmBulkUnarchive()}
onOpenMove={() => { onOpenMove={() => {
setMoveFolderId('__none__'); setMoveFolderId('__none__');
setMoveOpen(true); setMoveOpen(true);
@@ -801,57 +904,65 @@ function folderName(id: string | null | undefined): string {
</div> </div>
)} )}
{isEditing && draft && ( {isEditing && draft && (
<VaultEditor <div key={`editor-${draft.id || selectedCipher?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
draft={draft} <VaultEditor
isCreating={isCreating} draft={draft}
busy={busy} isCreating={isCreating}
folders={props.folders} busy={busy}
selectedCipher={selectedCipher} folders={props.folders}
editExistingAttachments={editExistingAttachments} selectedCipher={selectedCipher}
removedAttachmentIds={removedAttachmentIds} editExistingAttachments={editExistingAttachments}
removedAttachmentCount={removedAttachmentCount} removedAttachmentIds={removedAttachmentIds}
attachmentQueue={attachmentQueue} removedAttachmentCount={removedAttachmentCount}
attachmentInputRef={attachmentInputRef} attachmentQueue={attachmentQueue}
localError={localError} attachmentInputRef={attachmentInputRef}
onUpdateDraft={updateDraft} localError={localError}
onSeedSshDefaults={(force) => void seedSshDefaults(force)} onUpdateDraft={updateDraft}
onUpdateSshPublicKey={updateSshPublicKey} onSeedSshDefaults={(force) => void seedSshDefaults(force)}
onUpdateDraftLoginUri={updateDraftLoginUri} onUpdateSshPublicKey={updateSshPublicKey}
onQueueAttachmentFiles={queueAttachmentFiles} onUpdateDraftLoginUri={updateDraftLoginUri}
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval} onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
onRemoveQueuedAttachment={removeQueuedAttachment} onReorderDraftLoginUri={reorderDraftLoginUri}
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)} onQueueAttachmentFiles={queueAttachmentFiles}
downloadingAttachmentKey={props.downloadingAttachmentKey} onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
attachmentDownloadPercent={props.attachmentDownloadPercent} onRemoveQueuedAttachment={removeQueuedAttachment}
uploadingAttachmentName={props.uploadingAttachmentName} onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
attachmentUploadPercent={props.attachmentUploadPercent} downloadingAttachmentKey={props.downloadingAttachmentKey}
onPatchDraftCustomField={patchDraftCustomField} attachmentDownloadPercent={props.attachmentDownloadPercent}
onUpdateDraftCustomFields={updateDraftCustomFields} uploadingAttachmentName={props.uploadingAttachmentName}
onOpenFieldModal={() => setFieldModalOpen(true)} attachmentUploadPercent={props.attachmentUploadPercent}
onSave={() => void saveDraft()} onPatchDraftCustomField={patchDraftCustomField}
onCancel={cancelEdit} onUpdateDraftCustomFields={updateDraftCustomFields}
onDeleteSelected={() => selectedCipher && setPendingDelete(selectedCipher)} onOpenFieldModal={() => setFieldModalOpen(true)}
/> onSave={() => void saveDraft()}
onCancel={cancelEdit}
onDeleteSelected={() => selectedCipher && setPendingDelete(selectedCipher)}
/>
</div>
)} )}
{!isEditing && selectedCipher && ( {!isEditing && selectedCipher && (
<VaultDetailView <div key={`detail-${selectedCipher.id}`} className="detail-switch-stage">
selectedCipher={selectedCipher} <VaultDetailView
repromptApprovedCipherId={repromptApprovedCipherId} selectedCipher={selectedCipher}
showPassword={showPassword} repromptApprovedCipherId={repromptApprovedCipherId}
totpLive={totpLive} showPassword={showPassword}
passkeyCreatedAt={passkeyCreatedAt} totpLive={totpLive}
hiddenFieldVisibleMap={hiddenFieldVisibleMap} passkeyCreatedAt={passkeyCreatedAt}
folderName={folderName} hiddenFieldVisibleMap={hiddenFieldVisibleMap}
onOpenReprompt={() => setRepromptOpen(true)} folderName={folderName}
onToggleShowPassword={() => setShowPassword((value) => !value)} onOpenReprompt={() => setRepromptOpen(true)}
onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))} onToggleShowPassword={() => setShowPassword((value) => !value)}
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)} onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
downloadingAttachmentKey={props.downloadingAttachmentKey} onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
attachmentDownloadPercent={props.attachmentDownloadPercent} downloadingAttachmentKey={props.downloadingAttachmentKey}
onStartEdit={startEdit} attachmentDownloadPercent={props.attachmentDownloadPercent}
onDelete={setPendingDelete} onStartEdit={startEdit}
/> onDelete={setPendingDelete}
onArchive={(cipher) => setPendingArchive(cipher)}
onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)}
/>
</div>
)} )}
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>} {!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>}
@@ -863,6 +974,8 @@ function folderName(id: string | null | undefined): string {
fieldType={fieldType} fieldType={fieldType}
fieldLabel={fieldLabel} fieldLabel={fieldLabel}
fieldValue={fieldValue} fieldValue={fieldValue}
archiveConfirmOpen={!!pendingArchive}
bulkArchiveOpen={bulkArchiveOpen}
pendingDeleteOpen={!!pendingDelete} pendingDeleteOpen={!!pendingDelete}
bulkDeleteOpen={bulkDeleteOpen} bulkDeleteOpen={bulkDeleteOpen}
sidebarTrashMode={sidebarFilter.kind === 'trash'} sidebarTrashMode={sidebarFilter.kind === 'trash'}
@@ -905,6 +1018,10 @@ function folderName(id: string | null | undefined): string {
onFieldTypeChange={setFieldType} onFieldTypeChange={setFieldType}
onFieldLabelChange={setFieldLabel} onFieldLabelChange={setFieldLabel}
onFieldValueChange={setFieldValue} onFieldValueChange={setFieldValue}
onConfirmArchive={() => void confirmArchiveSelected()}
onCancelArchive={() => setPendingArchive(null)}
onConfirmBulkArchive={() => void confirmBulkArchive()}
onCancelBulkArchive={() => setBulkArchiveOpen(false)}
onConfirmDelete={() => void deleteSelected()} onConfirmDelete={() => void deleteSelected()}
onCancelDelete={() => setPendingDelete(null)} onCancelDelete={() => setPendingDelete(null)}
onConfirmBulkDelete={() => void confirmBulkDelete()} onConfirmBulkDelete={() => void confirmBulkDelete()}
@@ -256,6 +256,23 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
</div> </div>
</div> </div>
</label> </label>
<label className="field">
<span>{t('txt_backup_start_time')}</span>
<input
className="input"
type="time"
step={300}
value={props.selectedDestination.schedule.startTime || '03:00'}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => props.onUpdateDestination((destination) => ({
...destination,
schedule: {
...destination.schedule,
startTime: (event.currentTarget as HTMLInputElement).value || '03:00',
},
}))}
/>
</label>
<label className="field"> <label className="field">
<span>{t('txt_backup_timezone')}</span> <span>{t('txt_backup_timezone')}</span>
<select <select
+40 -19
View File
@@ -1,5 +1,5 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, Trash2 } from 'lucide-preact'; import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
@@ -31,11 +31,14 @@ interface VaultDetailViewProps {
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void; onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
onStartEdit: () => void; onStartEdit: () => void;
onDelete: (cipher: Cipher) => void; onDelete: (cipher: Cipher) => void;
onArchive: (cipher: Cipher) => void | Promise<void>;
onUnarchive: (cipher: Cipher) => void | Promise<void>;
} }
export default function VaultDetailView(props: VaultDetailViewProps) { export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : []; const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
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');
@@ -62,6 +65,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<div className="card"> <div className="card">
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3> <h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div> <div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
{isArchived && <div className="list-badge" style={{ marginTop: '8px', width: 'fit-content' }}>{t('txt_archived')}</div>}
</div> </div>
{props.selectedCipher.login && ( {props.selectedCipher.login && (
@@ -265,29 +269,36 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
if (fieldType === 2) { if (fieldType === 2) {
const checked = toBooleanFieldValue(rawValue); const checked = toBooleanFieldValue(rawValue);
return ( return (
<div key={`view-field-${index}`} className="kv-row custom-field-row"> <div key={`view-field-${index}`} className="custom-field-card">
<span className="kv-label" title={fieldName}>{fieldName}</span> <div className="custom-field-label">{fieldName}</div>
<div className="kv-main boolean-main"> <div className="custom-field-body">
<label className="check-line cf-check view"> <div className="custom-field-value">
<input type="checkbox" checked={checked} disabled /> <label className="check-line cf-check view custom-field-check">
</label> <input type="checkbox" checked={checked} disabled />
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}> <span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
{checked ? t('txt_checked') : t('txt_unchecked')} {checked ? t('txt_checked') : t('txt_unchecked')}
</span> </span>
</label>
</div>
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div> </div>
<div className="kv-actions" />
</div> </div>
); );
} }
return ( return (
<div key={`view-field-${index}`} className="kv-row custom-field-row"> <div key={`view-field-${index}`} className="custom-field-card">
<span className="kv-label" title={fieldName}>{fieldName}</span> <div className="custom-field-label" title={fieldName}>{fieldName}</div>
<div className="kv-main"> <div className="custom-field-body">
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}> <div className="custom-field-value">
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue} <strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
</strong> {fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
</div> </strong>
<div className="kv-actions"> </div>
<div className="kv-actions">
{fieldType === 1 && ( {fieldType === 1 && (
<button type="button" className="btn btn-secondary small" onClick={() => props.onToggleHiddenField(index)}> <button type="button" className="btn btn-secondary small" onClick={() => props.onToggleHiddenField(index)}>
{isHiddenVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />} {isHiddenVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
@@ -297,6 +308,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}> <button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')} <Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button> </button>
</div>
</div> </div>
</div> </div>
); );
@@ -351,6 +363,15 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}> <button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
<Pencil size={14} className="btn-icon" /> {t('txt_edit')} <Pencil size={14} className="btn-icon" /> {t('txt_edit')}
</button> </button>
{isArchived ? (
<button type="button" className="btn btn-secondary" onClick={() => void props.onUnarchive(props.selectedCipher)}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
) : (
<button type="button" className="btn btn-secondary" onClick={() => void props.onArchive(props.selectedCipher)}>
<Archive size={14} className="btn-icon" /> {t('txt_archive')}
</button>
)}
</div> </div>
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}> <button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')} <Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
@@ -8,6 +8,8 @@ interface VaultDialogsProps {
fieldType: CustomFieldType; fieldType: CustomFieldType;
fieldLabel: string; fieldLabel: string;
fieldValue: string; fieldValue: string;
archiveConfirmOpen: boolean;
bulkArchiveOpen: boolean;
pendingDeleteOpen: boolean; pendingDeleteOpen: boolean;
bulkDeleteOpen: boolean; bulkDeleteOpen: boolean;
sidebarTrashMode: boolean; sidebarTrashMode: boolean;
@@ -26,6 +28,10 @@ interface VaultDialogsProps {
onFieldTypeChange: (value: CustomFieldType) => void; onFieldTypeChange: (value: CustomFieldType) => void;
onFieldLabelChange: (value: string) => void; onFieldLabelChange: (value: string) => void;
onFieldValueChange: (value: string) => void; onFieldValueChange: (value: string) => void;
onConfirmArchive: () => void;
onCancelArchive: () => void;
onConfirmBulkArchive: () => void;
onCancelBulkArchive: () => void;
onConfirmDelete: () => void; onConfirmDelete: () => void;
onCancelDelete: () => void; onCancelDelete: () => void;
onConfirmBulkDelete: () => void; onConfirmBulkDelete: () => void;
@@ -88,6 +94,26 @@ export default function VaultDialogs(props: VaultDialogsProps) {
)} )}
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog
open={props.archiveConfirmOpen}
title={t('txt_archive_item')}
message={t('txt_archive_item_message')}
confirmText={t('txt_archive')}
cancelText={t('txt_cancel')}
onConfirm={props.onConfirmArchive}
onCancel={props.onCancelArchive}
/>
<ConfirmDialog
open={props.bulkArchiveOpen}
title={t('txt_archive_selected_items')}
message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
confirmText={t('txt_archive')}
cancelText={t('txt_cancel')}
onConfirm={props.onConfirmBulkArchive}
onCancel={props.onCancelBulkArchive}
/>
<ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} /> <ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} />
<ConfirmDialog <ConfirmDialog
+192 -31
View File
@@ -1,8 +1,26 @@
import type { RefObject } from 'preact'; import type { RefObject } from 'preact';
import { CheckCheck, Download, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact'; import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import {
closestCenter,
DndContext,
type DragEndEvent,
type DragStartEvent,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
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 { CREATE_TYPE_OPTIONS, cipherTypeLabel, formatAttachmentSize, toBooleanFieldValue } from '@/components/vault/vault-page-helpers'; import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers';
interface VaultEditorProps { interface VaultEditorProps {
draft: VaultDraft; draft: VaultDraft;
@@ -24,6 +42,8 @@ interface VaultEditorProps {
onSeedSshDefaults: (force?: boolean) => void; onSeedSshDefaults: (force?: boolean) => void;
onUpdateSshPublicKey: (value: string) => void; onUpdateSshPublicKey: (value: string) => void;
onUpdateDraftLoginUri: (index: number, value: string) => void; onUpdateDraftLoginUri: (index: number, value: string) => void;
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
onQueueAttachmentFiles: (list: FileList | null) => void; onQueueAttachmentFiles: (list: FileList | null) => void;
onToggleExistingAttachmentRemoval: (attachmentId: string) => void; onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
onRemoveQueuedAttachment: (index: number) => void; onRemoveQueuedAttachment: (index: number) => void;
@@ -36,7 +56,108 @@ interface VaultEditorProps {
onDeleteSelected: () => void; onDeleteSelected: () => void;
} }
interface SortableWebsiteRowProps {
id: string;
uriEntry: VaultDraft['loginUris'][number];
index: number;
canRemove: boolean;
isDragging: boolean;
onUpdateUri: (index: number, value: string) => void;
onUpdateMatch: (index: number, value: number | null) => void;
onRemove: (index: number) => void;
}
function SortableWebsiteRow(props: SortableWebsiteRowProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`website-row${isDragging || props.isDragging ? ' is-dragging' : ''}`}
>
<button
type="button"
ref={setActivatorNodeRef}
className="btn btn-secondary small website-drag-btn"
title={t('txt_drag_to_reorder')}
aria-label={t('txt_drag_to_reorder')}
{...attributes}
{...listeners}
>
<GripVertical size={14} className="btn-icon" />
</button>
<input
className="input"
value={props.uriEntry.uri}
onInput={(e) => props.onUpdateUri(props.index, (e.currentTarget as HTMLInputElement).value)}
/>
<select
className="input website-match-select"
value={props.uriEntry.match == null ? '' : String(props.uriEntry.match)}
onInput={(e) => {
const raw = (e.currentTarget as HTMLSelectElement).value;
props.onUpdateMatch(props.index, raw === '' ? null : Number(raw));
}}
>
{WEBSITE_MATCH_OPTIONS.map((option) => (
<option key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}>
{option.label}
</option>
))}
</select>
{props.canRemove && (
<button type="button" className="btn btn-secondary small" onClick={() => props.onRemove(props.index)}>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
)}
</div>
);
}
export default function VaultEditor(props: VaultEditorProps) { export default function VaultEditor(props: VaultEditorProps) {
const uriIdSeedRef = useRef(0);
const [uriItemIds, setUriItemIds] = useState<string[]>([]);
const [activeUriId, setActiveUriId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 6,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 120,
tolerance: 8,
},
}),
);
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`;
useEffect(() => {
setUriItemIds((prev) => {
if (prev.length === props.draft.loginUris.length) return prev;
if (prev.length < props.draft.loginUris.length) {
return [...prev, ...Array.from({ length: props.draft.loginUris.length - prev.length }, () => createUriId())];
}
return prev.slice(0, props.draft.loginUris.length);
});
}, [props.draft.loginUris.length]);
useEffect(() => {
setUriItemIds(props.draft.loginUris.map(() => createUriId()));
setActiveUriId(null);
}, [props.draft.id, props.isCreating]);
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');
@@ -52,6 +173,32 @@ export default function VaultEditor(props: VaultEditorProps) {
percent: props.attachmentUploadPercent, percent: props.attachmentUploadPercent,
}); });
const addLoginUri = () => {
setUriItemIds((prev) => [...prev, createUriId()]);
props.onUpdateDraft({ loginUris: [...props.draft.loginUris, createEmptyLoginUri()] });
};
const removeLoginUri = (index: number) => {
setUriItemIds((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, itemIndex) => itemIndex !== index) });
};
const handleWebsiteDragStart = (event: DragStartEvent) => {
setActiveUriId(String(event.active.id));
};
const handleWebsiteDragEnd = (event: DragEndEvent) => {
const activeId = String(event.active.id);
const overId = event.over ? String(event.over.id) : null;
setActiveUriId(null);
if (!overId || activeId === overId) return;
const fromIndex = uriItemIds.indexOf(activeId);
const toIndex = uriItemIds.indexOf(overId);
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
setUriItemIds((prev) => arrayMove(prev, fromIndex, toIndex));
props.onReorderDraftLoginUri(fromIndex, toIndex);
};
return ( return (
<> <>
<div className="card"> <div className="card">
@@ -119,21 +266,27 @@ export default function VaultEditor(props: VaultEditorProps) {
</label> </label>
<div className="section-head"> <div className="section-head">
<h4>{t('txt_websites')}</h4> <h4>{t('txt_websites')}</h4>
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: [...props.draft.loginUris, ''] })}> <button type="button" className="btn btn-secondary small" onClick={addLoginUri}>
<Plus size={14} className="btn-icon" /> {t('txt_add_website')} <Plus size={14} className="btn-icon" /> {t('txt_add_website')}
</button> </button>
</div> </div>
{props.draft.loginUris.map((uri, index) => ( <DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleWebsiteDragStart} onDragEnd={handleWebsiteDragEnd}>
<div key={`uri-${index}`} className="website-row"> <SortableContext items={uriItemIds} strategy={verticalListSortingStrategy}>
<input className="input" value={uri} onInput={(e) => props.onUpdateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} /> {props.draft.loginUris.map((uriEntry, index) => (
{props.draft.loginUris.length > 1 && ( <SortableWebsiteRow
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, i) => i !== index) })}> key={uriItemIds[index] ?? `uri-${index}`}
<X size={14} className="btn-icon" /> id={uriItemIds[index] ?? `uri-fallback-${index}`}
{t('txt_remove')} uriEntry={uriEntry}
</button> index={index}
)} canRemove={props.draft.loginUris.length > 1}
</div> isDragging={activeUriId === uriItemIds[index]}
))} onUpdateUri={props.onUpdateDraftLoginUri}
onUpdateMatch={props.onUpdateDraftLoginUriMatch}
onRemove={removeLoginUri}
/>
))}
</SortableContext>
</DndContext>
</div> </div>
)} )}
@@ -322,23 +475,31 @@ export default function VaultEditor(props: VaultEditorProps) {
.map((field, originalIndex) => ({ field, originalIndex })) .map((field, originalIndex) => ({ field, originalIndex }))
.filter((entry) => entry.field.type !== 3) .filter((entry) => entry.field.type !== 3)
.map(({ field, originalIndex }) => ( .map(({ field, originalIndex }) => (
<div key={`field-${originalIndex}`} className="uri-row"> <div key={`field-${originalIndex}`} className="custom-field-card">
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} /> <label className="field custom-field-label">
{field.type === 2 ? ( <span>{t('txt_field_label')}</span>
<label className="check-line cf-check"> <input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
<input </label>
type="checkbox" <div className="custom-field-body">
checked={toBooleanFieldValue(field.value)} <div className="custom-field-value">
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })} {field.type === 2 ? (
/> <label className="check-line cf-check custom-field-check">
</label> <input
) : ( type="checkbox"
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} /> checked={toBooleanFieldValue(field.value)}
)} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}> />
<X size={14} className="btn-icon" /> <span>{toBooleanFieldValue(field.value) ? t('txt_checked') : t('txt_unchecked')}</span>
{t('txt_remove')} </label>
</button> ) : (
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
)}
</div>
<button type="button" className="btn btn-secondary small custom-field-remove" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
</div>
</div> </div>
))} ))}
</div> </div>
+43 -21
View File
@@ -1,5 +1,5 @@
import type { RefObject } from 'preact'; import type { RefObject } from 'preact';
import { ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, Trash2, X } from 'lucide-preact'; import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
@@ -48,6 +48,8 @@ interface VaultListPanelProps {
onToggleCreateMenu: () => void; onToggleCreateMenu: () => void;
onStartCreate: (type: number) => void; onStartCreate: (type: number) => void;
onBulkRestore: () => void; onBulkRestore: () => void;
onBulkArchive: () => void;
onBulkUnarchive: () => void;
onOpenMove: () => void; onOpenMove: () => void;
onClearSelection: () => void; onClearSelection: () => void;
onScroll: (top: number) => void; onScroll: (top: number) => void;
@@ -102,14 +104,39 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</button> </button>
</div> </div>
<div className="toolbar actions"> <div className="toolbar actions">
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button>
{props.sidebarFilter.kind === 'duplicates' && ( {props.sidebarFilter.kind === 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}> <button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')} <Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button> </button>
)} )}
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
</button>
)}
{props.selectedCount > 0 && (
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
)}
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button>
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}> <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>
@@ -134,32 +161,27 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</div> </div>
)} )}
</div> </div>
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
</button>
)}
{props.selectedCount > 0 && (
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
)}
</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.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, index) => (
<div key={cipher.id} className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}> <div
key={cipher.id}
className={`list-item stagger-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
props.onSelectCipher(cipher.id);
}}
>
<input <input
type="checkbox" type="checkbox"
className="row-check" className="row-check"
checked={!!props.selectedMap[cipher.id]} checked={!!props.selectedMap[cipher.id]}
onClick={(event) => event.stopPropagation()}
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)} onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
/> />
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}> <button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
@@ -1,4 +1,5 @@
import { import {
Archive,
Copy, Copy,
CreditCard, CreditCard,
Folder as FolderIcon, Folder as FolderIcon,
@@ -48,6 +49,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'favorite' })}> <button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'favorite' })}>
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span> <Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
</button> </button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'archive' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'archive' })}>
<Archive size={14} className="tree-icon" /> <span className="tree-label">{t('txt_archive')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'trash' })}> <button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'trash' })}>
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span> <Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
</button> </button>
@@ -9,13 +9,14 @@ import {
} from 'lucide-preact'; } from 'lucide-preact';
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 } from '@/lib/types'; import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
export type VaultSortMode = 'edited' | 'created' | 'name'; export type VaultSortMode = 'edited' | 'created' | 'name';
export type SidebarFilter = export type SidebarFilter =
| { kind: 'all' } | { kind: 'all' }
| { kind: 'favorite' } | { kind: 'favorite' }
| { kind: 'archive' }
| { kind: 'trash' } | { kind: 'trash' }
| { kind: 'duplicates' } | { kind: 'duplicates' }
| { kind: 'type'; value: TypeFilter } | { kind: 'type'; value: TypeFilter }
@@ -50,6 +51,16 @@ export const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }
{ value: 2, label: t('txt_boolean') }, { value: 2, label: t('txt_boolean') },
]; ];
export const WEBSITE_MATCH_OPTIONS: Array<{ value: number | null; label: string }> = [
{ value: null, label: t('txt_uri_match_default_base_domain') },
{ value: 0, label: t('txt_uri_match_base_domain') },
{ value: 1, label: t('txt_uri_match_host') },
{ value: 3, label: t('txt_uri_match_exact') },
{ value: 5, label: t('txt_uri_match_never') },
{ value: 2, label: t('txt_uri_match_starts_with') },
{ value: 4, label: t('txt_uri_match_regular_expression') },
];
export const TOTP_PERIOD_SECONDS = 30; export const TOTP_PERIOD_SECONDS = 30;
export const TOTP_RING_RADIUS = 14; export const TOTP_RING_RADIUS = 14;
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
@@ -71,6 +82,34 @@ export function cipherTypeKey(type: number): TypeFilter {
return 'ssh'; return 'ssh';
} }
function cipherDeletedValue(cipher: Cipher): boolean {
return !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
}
function cipherArchivedValue(cipher: Cipher): boolean {
return !!(cipher.archivedDate || (cipher as { archivedAt?: string | null }).archivedAt);
}
export function isCipherDeleted(cipher: Cipher): boolean {
return cipherDeletedValue(cipher);
}
export function isCipherArchived(cipher: Cipher): boolean {
return cipherArchivedValue(cipher) && !cipherDeletedValue(cipher);
}
export function isCipherVisibleInNormalVault(cipher: Cipher): boolean {
return !cipherDeletedValue(cipher) && !cipherArchivedValue(cipher);
}
export function isCipherVisibleInArchive(cipher: Cipher): boolean {
return !cipherDeletedValue(cipher) && cipherArchivedValue(cipher);
}
export function isCipherVisibleInTrash(cipher: Cipher): boolean {
return cipherDeletedValue(cipher);
}
export function cipherTypeLabel(type: number): string { export function cipherTypeLabel(type: number): string {
if (type === 1) return t('txt_login'); if (type === 1) return t('txt_login');
if (type === 3) return t('txt_card'); if (type === 3) return t('txt_card');
@@ -125,6 +164,15 @@ export function websiteIconUrl(host: string): string {
return `/icons/${encodeURIComponent(host)}/icon.png`; return `/icons/${encodeURIComponent(host)}/icon.png`;
} }
export function createEmptyLoginUri(): VaultDraftLoginUri {
return { uri: '', match: null };
}
export function websiteMatchLabel(value: number | null | undefined): string {
const normalized = typeof value === 'number' && Number.isFinite(value) ? value : null;
return WEBSITE_MATCH_OPTIONS.find((option) => option.value === normalized)?.label || t('txt_uri_match_default_base_domain');
}
function valueOrFallback(value: string | null | undefined): string { function valueOrFallback(value: string | null | undefined): string {
return String(value || ''); return String(value || '');
} }
@@ -216,7 +264,7 @@ export function createEmptyDraft(type: number): VaultDraft {
loginUsername: '', loginUsername: '',
loginPassword: '', loginPassword: '',
loginTotp: '', loginTotp: '',
loginUris: [''], loginUris: [createEmptyLoginUri()],
loginFido2Credentials: [], loginFido2Credentials: [],
cardholderName: '', cardholderName: '',
cardNumber: '', cardNumber: '',
@@ -262,11 +310,14 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
draft.loginUsername = cipher.login.decUsername || ''; draft.loginUsername = cipher.login.decUsername || '';
draft.loginPassword = cipher.login.decPassword || ''; draft.loginPassword = cipher.login.decPassword || '';
draft.loginTotp = cipher.login.decTotp || ''; draft.loginTotp = cipher.login.decTotp || '';
draft.loginUris = (cipher.login.uris || []).map((x) => x.decUri || x.uri || ''); draft.loginUris = (cipher.login.uris || []).map((x) => ({
uri: x.decUri || x.uri || '',
match: x.match ?? null,
}));
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials) draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
? cipher.login.fido2Credentials.map((credential) => ({ ...credential })) ? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
: []; : [];
if (!draft.loginUris.length) draft.loginUris = ['']; if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
} }
if (cipher.card) { if (cipher.card) {
draft.cardholderName = cipher.card.decCardholderName || ''; draft.cardholderName = cipher.card.decCardholderName || '';
+48
View File
@@ -22,12 +22,15 @@ import {
} from '@/lib/app-support'; } from '@/lib/app-support';
import { buildSendShareKey, bulkDeleteSends, createSend, deleteSend, updateSend } from '@/lib/api/send'; import { buildSendShareKey, bulkDeleteSends, createSend, deleteSend, updateSend } from '@/lib/api/send';
import { import {
archiveCipher,
buildCipherImportPayload, buildCipherImportPayload,
bulkArchiveCiphers,
bulkDeleteCiphers, bulkDeleteCiphers,
bulkDeleteFolders, bulkDeleteFolders,
bulkMoveCiphers, bulkMoveCiphers,
bulkPermanentDeleteCiphers, bulkPermanentDeleteCiphers,
bulkRestoreCiphers, bulkRestoreCiphers,
bulkUnarchiveCiphers,
createCipher, createCipher,
createFolder, createFolder,
deleteCipher, deleteCipher,
@@ -40,6 +43,7 @@ import {
type CiphersImportPayload, type CiphersImportPayload,
type ImportedCipherMapEntry, type ImportedCipherMapEntry,
updateCipher, updateCipher,
unarchiveCipher,
uploadCipherAttachment, uploadCipherAttachment,
} from '@/lib/api/vault'; } from '@/lib/api/vault';
import { deriveLoginHash, getPreloginKdfConfig, verifyMasterPassword } from '@/lib/api/auth'; import { deriveLoginHash, getPreloginKdfConfig, verifyMasterPassword } from '@/lib/api/auth';
@@ -237,6 +241,28 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
} }
}, },
async archiveVaultItem(cipher: Cipher) {
try {
await archiveCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]);
onNotify('success', t('txt_item_archived'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed'));
throw error;
}
},
async unarchiveVaultItem(cipher: Cipher) {
try {
await unarchiveCipher(authedFetch, cipher.id);
await Promise.all([refetchCiphers(), refetchFolders()]);
onNotify('success', t('txt_item_unarchived'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed'));
throw error;
}
},
async bulkDeleteVaultItems(ids: string[]) { async bulkDeleteVaultItems(ids: string[]) {
try { try {
await bulkDeleteCiphers(authedFetch, ids); await bulkDeleteCiphers(authedFetch, ids);
@@ -248,6 +274,28 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
} }
}, },
async bulkArchiveVaultItems(ids: string[]) {
try {
await bulkArchiveCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]);
onNotify('success', t('txt_archived_selected_items'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed'));
throw error;
}
},
async bulkUnarchiveVaultItems(ids: string[]) {
try {
await bulkUnarchiveCiphers(authedFetch, ids);
await Promise.all([refetchCiphers(), refetchFolders()]);
onNotify('success', t('txt_unarchived_selected_items'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed'));
throw error;
}
},
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);
+50 -5
View File
@@ -367,12 +367,19 @@ async function encryptCustomFields(
return out; return out;
} }
async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Promise<Array<{ uri: string | null; match: null }>> { async function encryptUris(
const out: Array<{ uri: string | null; match: null }> = []; uris: VaultDraft['loginUris'],
for (const uri of uris || []) { enc: Uint8Array,
const trimmed = String(uri || '').trim(); mac: Uint8Array
): Promise<Array<{ uri: string | null; match: number | null }>> {
const out: Array<{ uri: string | null; match: number | null }> = [];
for (const entry of uris || []) {
const trimmed = String(entry?.uri || '').trim();
if (!trimmed) continue; if (!trimmed) continue;
out.push({ uri: await encryptTextValue(trimmed, enc, mac), match: null }); out.push({
uri: await encryptTextValue(trimmed, enc, mac),
match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
});
} }
return out; return out;
} }
@@ -582,6 +589,20 @@ export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string):
if (!resp.ok) throw new Error('Delete item failed'); if (!resp.ok) throw new Error('Delete item failed');
} }
export async function archiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/archive`, { method: 'PUT' });
if (!resp.ok) throw new Error('Archive item failed');
}
export async function unarchiveCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/unarchive`, { method: 'PUT' });
if (!resp.ok) throw new Error('Unarchive item failed');
}
export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> { export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
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)));
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
@@ -594,6 +615,18 @@ export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[])
} }
} }
export async function bulkArchiveCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
const resp = await authedFetch('/api/ciphers/archive', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: chunk }),
});
if (!resp.ok) throw new Error('Bulk archive failed');
}
}
export async function bulkPermanentDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> { export async function bulkPermanentDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
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)));
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) { for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
@@ -618,6 +651,18 @@ export async function bulkRestoreCiphers(authedFetch: AuthedFetch, ids: string[]
} }
} }
export async function bulkUnarchiveCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
const resp = await authedFetch('/api/ciphers/unarchive', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: chunk }),
});
if (!resp.ok) throw new Error('Bulk unarchive failed');
}
}
export async function bulkMoveCiphers( export async function bulkMoveCiphers(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
ids: string[], ids: string[],
+12 -4
View File
@@ -97,7 +97,7 @@ export function buildEmptyImportDraft(type: number): VaultDraft {
loginUsername: '', loginUsername: '',
loginPassword: '', loginPassword: '',
loginTotp: '', loginTotp: '',
loginUris: [''], loginUris: [{ uri: '', match: null }],
loginFido2Credentials: [], loginFido2Credentials: [],
cardholderName: '', cardholderName: '',
cardNumber: '', cardNumber: '',
@@ -167,9 +167,17 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
: []; : [];
const urisRaw = Array.isArray(login.uris) ? login.uris : []; const urisRaw = Array.isArray(login.uris) ? login.uris : [];
const uris = urisRaw const uris = urisRaw
.map((u) => asText((u as Record<string, unknown>)?.uri).trim()) .map((u) => {
.filter((u) => !!u); const row = (u || {}) as Record<string, unknown>;
draft.loginUris = uris.length ? uris : ['']; const uri = asText(row.uri).trim();
const matchRaw = row.match;
return {
uri,
match: typeof matchRaw === 'number' && Number.isFinite(matchRaw) ? matchRaw : null,
};
})
.filter((u) => !!u.uri);
draft.loginUris = uris.length ? uris : [{ uri: '', match: null }];
} else if (type === 3) { } else if (type === 3) {
const card = (cipher.card || {}) as Record<string, unknown>; const card = (cipher.card || {}) as Record<string, unknown>;
draft.cardholderName = asText(card.cardholderName); draft.cardholderName = asText(card.cardholderName);
+60 -6
View File
@@ -140,6 +140,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_type: "Backup Type", txt_backup_type: "Backup Type",
txt_backup_destination_reserved: "Reserved Slot", txt_backup_destination_reserved: "Reserved Slot",
txt_backup_time: "Backup Time", txt_backup_time: "Backup Time",
txt_backup_start_time: "Start Time",
txt_backup_timezone: "Timezone", txt_backup_timezone: "Timezone",
txt_backup_interval_hours: "Every", txt_backup_interval_hours: "Every",
txt_backup_interval_hours_suffix: "hours", txt_backup_interval_hours_suffix: "hours",
@@ -164,10 +165,10 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_include_attachments_help_button: "Attachment backup help", 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_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_enable_schedule: "Enable automatic daily backup",
txt_backup_schedule_note: "The worker checks the schedule every 5 minutes and runs the backup as soon as the selected time window is reached.", 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_disabled: "Disabled",
txt_backup_schedule_status: "Schedule", txt_backup_schedule_status: "Schedule",
txt_backup_schedule_summary: "Daily at {time} ({timezone})", txt_backup_schedule_summary: "Start at {time}, every {interval} hours ({timezone})",
txt_backup_schedule_empty: "No automatic backup plans are enabled yet.", txt_backup_schedule_empty: "No automatic backup plans are enabled yet.",
txt_backup_last_success: "Last Success", txt_backup_last_success: "Last Success",
txt_backup_last_target: "Last Target", txt_backup_last_target: "Last Target",
@@ -280,7 +281,23 @@ const messages: Record<Locale, Record<string, string>> = {
txt_delete_item: "Delete Item", txt_delete_item: "Delete Item",
txt_delete_item_failed: "Delete item failed", txt_delete_item_failed: "Delete item failed",
txt_delete_permanently: "Delete Permanently", txt_delete_permanently: "Delete Permanently",
txt_delete_selected: "Delete Selected", 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: "Delete Selected Items",
txt_delete_selected_items_permanently: "Delete Selected Items Permanently", txt_delete_selected_items_permanently: "Delete Selected Items Permanently",
txt_delete_send_failed: "Delete send failed", txt_delete_send_failed: "Delete send failed",
@@ -434,6 +451,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_master_password_reprompt_2: "Master Password Reprompt", txt_master_password_reprompt_2: "Master Password Reprompt",
txt_max_access_count: "Max Access Count", txt_max_access_count: "Max Access Count",
txt_middle_name: "Middle Name", txt_middle_name: "Middle Name",
txt_drag_to_reorder: "Drag to reorder",
txt_move: "Move", txt_move: "Move",
txt_move_selected_items: "Move Selected Items", txt_move_selected_items: "Move Selected Items",
txt_moved_selected_items: "Moved selected items", txt_moved_selected_items: "Moved selected items",
@@ -556,6 +574,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_submit: "Submit", txt_submit: "Submit",
txt_sync: "Sync", txt_sync: "Sync",
txt_sync_vault: "Sync Vault", 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_dash: "-",
txt_text: "Text", txt_text: "Text",
txt_text_2fa_recovered: "2FA recovered", txt_text_2fa_recovered: "2FA recovered",
@@ -610,6 +630,13 @@ const messages: Record<Locale, Record<string, string>> = {
txt_user_deleted: "User deleted", txt_user_deleted: "User deleted",
txt_user_status_updated: "User status updated", txt_user_status_updated: "User status updated",
txt_username: "Username", 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_users: "Users",
txt_vault_synced: "Vault synced", txt_vault_synced: "Vault synced",
txt_verification_code: "Verification Code", txt_verification_code: "Verification Code",
@@ -761,6 +788,7 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_type: '备份类型', txt_backup_type: '备份类型',
txt_backup_destination_reserved: '预留位置', txt_backup_destination_reserved: '预留位置',
txt_backup_time: '备份时间', txt_backup_time: '备份时间',
txt_backup_start_time: '开始时间',
txt_backup_timezone: '时区', txt_backup_timezone: '时区',
txt_backup_interval_hours: '每隔', txt_backup_interval_hours: '每隔',
txt_backup_interval_hours_suffix: '小时', txt_backup_interval_hours_suffix: '小时',
@@ -785,10 +813,10 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_include_attachments_help_button: '附件备份说明', txt_backup_include_attachments_help_button: '附件备份说明',
txt_backup_include_attachments_help: '附件会以增量方式保存在远端的 attachments 文件夹中,后续备份通常只上传新增文件。你在本地删除附件时,已经备份到远端的旧文件不会自动删除。恢复时会按需从 attachments 文件夹读取对应附件,找不到的附件会自动跳过。', txt_backup_include_attachments_help: '附件会以增量方式保存在远端的 attachments 文件夹中,后续备份通常只上传新增文件。你在本地删除附件时,已经备份到远端的旧文件不会自动删除。恢复时会按需从 attachments 文件夹读取对应附件,找不到的附件会自动跳过。',
txt_backup_enable_schedule: '启用每日自动备份', txt_backup_enable_schedule: '启用每日自动备份',
txt_backup_schedule_note: 'Worker 每 5 分钟检查一次计划,到达你设定的时间窗口后会尽快执行备份。', txt_backup_schedule_note: 'Worker 每 5 分钟检查一次计划。会先按你选择的时区和开始时间起跑,再按小时间隔继续执行;到了下一天,会重新从开始时间开始。',
txt_backup_schedule_disabled: '未启用', txt_backup_schedule_disabled: '未启用',
txt_backup_schedule_status: '计划状态', txt_backup_schedule_status: '计划状态',
txt_backup_schedule_summary: '每天 {time}{timezone}', txt_backup_schedule_summary: ' {time} 开始,每隔 {interval} 小时{timezone}',
txt_backup_schedule_empty: '还没有启用任何自动备份计划', txt_backup_schedule_empty: '还没有启用任何自动备份计划',
txt_backup_last_success: '上次成功时间', txt_backup_last_success: '上次成功时间',
txt_backup_last_target: '上次备份位置', txt_backup_last_target: '上次备份位置',
@@ -852,13 +880,14 @@ const zhCNOverrides: Record<string, string> = {
txt_delete: '删除', txt_delete: '删除',
txt_save: '保存', txt_save: '保存',
txt_confirm: '确认', txt_confirm: '确认',
txt_drag_to_reorder: '拖动调整顺序',
txt_move: '移动', txt_move: '移动',
txt_copy: '复制', txt_copy: '复制',
txt_code_copied: '验证码已复制', txt_code_copied: '验证码已复制',
txt_copy_link: '复制链接', txt_copy_link: '复制链接',
txt_select_all: '全选', txt_select_all: '全选',
txt_select_duplicate_items: '选择重复项', txt_select_duplicate_items: '选择重复项',
txt_delete_selected: '删除所选', txt_delete_selected: '删除',
txt_all_items: '所有项目', txt_all_items: '所有项目',
txt_favorites: '收藏', txt_favorites: '收藏',
txt_duplicates: '重复项', txt_duplicates: '重复项',
@@ -888,6 +917,13 @@ const zhCNOverrides: Record<string, string> = {
txt_last_edited_value: '最后编辑:{value}', txt_last_edited_value: '最后编辑:{value}',
txt_created_value: '创建于:{value}', txt_created_value: '创建于:{value}',
txt_username: '用户名', 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_website: '网站', txt_website: '网站',
txt_websites: '网站', txt_websites: '网站',
txt_open: '打开', txt_open: '打开',
@@ -1185,6 +1221,8 @@ const zhCNOverrides: Record<string, string> = {
txt_totp_is_enabled_for_this_account: '此账户已启用 TOTP。', txt_totp_is_enabled_for_this_account: '此账户已启用 TOTP。',
txt_total_items_count: '共 {count} 项', txt_total_items_count: '共 {count} 项',
txt_totp_verify_failed: 'TOTP 验证失败', txt_totp_verify_failed: 'TOTP 验证失败',
txt_switch_to_dark_mode: '切换到暗黑模式',
txt_switch_to_light_mode: '切换到明亮模式',
txt_trust_this_device_for_30_days: '信任此设备 30 天', txt_trust_this_device_for_30_days: '信任此设备 30 天',
txt_type_type: '类型 {type}', txt_type_type: '类型 {type}',
txt_unlock_details: '解锁详情', txt_unlock_details: '解锁详情',
@@ -1363,6 +1401,22 @@ zhCNOverrides.txt_import_encrypted_zip_message = '该 ZIP 压缩包已加密,
zhCNOverrides.txt_import_export_title = '导入导出'; zhCNOverrides.txt_import_export_title = '导入导出';
zhCNOverrides.txt_new_type_header = '新建{type}'; zhCNOverrides.txt_new_type_header = '新建{type}';
zhCNOverrides.txt_edit_type_header = '编辑{type}'; zhCNOverrides.txt_edit_type_header = '编辑{type}';
zhCNOverrides.txt_archive = '归档';
zhCNOverrides.txt_archive_item = '归档项目';
zhCNOverrides.txt_archive_item_message = '归档后,此项目将被排除在一般搜索结果和自动填充建议之外。';
zhCNOverrides.txt_archive_selected_items = '归档项目';
zhCNOverrides.txt_archive_selected_items_message = '归档后,所选的 {count} 个项目将被排除在一般搜索结果和自动填充建议之外。';
zhCNOverrides.txt_archived = '已归档';
zhCNOverrides.txt_archive_selected = '归档';
zhCNOverrides.txt_item_archived = '项目已归档';
zhCNOverrides.txt_item_unarchived = '项目已取消归档';
zhCNOverrides.txt_archived_selected_items = '已归档所选项目';
zhCNOverrides.txt_unarchived_selected_items = '已取消归档所选项目';
zhCNOverrides.txt_archive_item_failed = '归档项目失败';
zhCNOverrides.txt_unarchive_item_failed = '取消归档项目失败';
zhCNOverrides.txt_bulk_archive_failed = '批量归档失败';
zhCNOverrides.txt_bulk_unarchive_failed = '批量取消归档失败';
zhCNOverrides.txt_unarchive = '取消归档';
zhCNOverrides.txt_delete_folder = '删除文件夹'; zhCNOverrides.txt_delete_folder = '删除文件夹';
zhCNOverrides.txt_delete_folder_message = '删除文件夹「{name}」?其中的项目将移至无文件夹。'; zhCNOverrides.txt_delete_folder_message = '删除文件夹「{name}」?其中的项目将移至无文件夹。';
zhCNOverrides.txt_delete_all_folders = '删除全部文件夹'; zhCNOverrides.txt_delete_all_folders = '删除全部文件夹';
+8 -1
View File
@@ -28,9 +28,15 @@ export interface Folder {
export interface CipherLoginUri { export interface CipherLoginUri {
uri?: string | null; uri?: string | null;
match?: number | null;
decUri?: string; decUri?: string;
} }
export interface VaultDraftLoginUri {
uri: string;
match: number | null;
}
export interface CipherAttachment { export interface CipherAttachment {
id?: string; id?: string;
url?: string | null; url?: string | null;
@@ -143,6 +149,7 @@ export interface Cipher {
creationDate?: string; creationDate?: string;
revisionDate?: string; revisionDate?: string;
deletedDate?: string | null; deletedDate?: string | null;
archivedDate?: string | null;
attachments?: CipherAttachment[] | null; attachments?: CipherAttachment[] | null;
login?: CipherLogin | null; login?: CipherLogin | null;
card?: CipherCard | null; card?: CipherCard | null;
@@ -220,7 +227,7 @@ export interface VaultDraft {
loginUsername: string; loginUsername: string;
loginPassword: string; loginPassword: string;
loginTotp: string; loginTotp: string;
loginUris: string[]; loginUris: VaultDraftLoginUri[];
loginFido2Credentials: Array<Record<string, unknown>>; loginFido2Credentials: Array<Record<string, unknown>>;
cardholderName: string; cardholderName: string;
cardNumber: string; cardNumber: string;
+1543 -135
View File
File diff suppressed because it is too large Load Diff