mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e892e85a2 | |||
| 3e5a80e498 | |||
| 89308fc8a6 | |||
| fe0bd80f43 | |||
| 0062fd6c48 | |||
| 7373eeb501 | |||
| 8b07cd4409 | |||
| 0fc7bd7985 | |||
| 58c029beba | |||
| ac79cbd8bd | |||
| 96fc3ae485 | |||
| cb4632cd04 | |||
| f7b5534cd0 | |||
| b50673f7d9 | |||
| 98e94e766f |
+2
-1
@@ -38,4 +38,5 @@ npm-debug.log*
|
||||
# Package lock (optional - remove if you want to commit it)
|
||||
# package-lock.json
|
||||
|
||||
tmp/
|
||||
tmp/
|
||||
.tmp/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
运行在 Cloudflare Workers 的 Bitwarden 第三方服务端,兼容官方客户端
|
||||
运行在 Cloudflare Workers 上的第三方 Bitwarden 兼容服务端。
|
||||
</p>
|
||||
|
||||
[](https://workers.cloudflare.com/)
|
||||
@@ -11,51 +11,50 @@
|
||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||
[](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)
|
||||
|
||||
English:[`README_EN.md`](./README_EN.md)
|
||||
[更新日志](./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)
|
||||
|
||||
> **免责声明**
|
||||
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
|
||||
> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。
|
||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||
|
||||
---
|
||||
|
||||
## 与 Bitwarden 官方服务端能力对比
|
||||
|
||||
| 能力项 | Bitwarden | NodeWarden | 说明 |
|
||||
| 能力 | Bitwarden | NodeWarden | 说明 |
|
||||
|---|---|---|---|
|
||||
| Web Vault(登录/笔记/卡片/身份) | ✅ | ✅ | 网页端密码库管理页面 |
|
||||
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
|
||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
|
||||
| 附件上传/下载 | ✅ | ✅ | Cloudflare R2 和 KV 二选一 |
|
||||
| 导入导出功能 | ✅ | ✅ | 完整实现,含 Bitwarden 密码库+附件 ZIP 导入 |
|
||||
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
||||
| passkey、TOTP 字段 | ✅ | ✅ | 完全支持,无需高级版 |
|
||||
| Send | ✅ | ✅ | Cloudflare R2 和 KV 二选一 |
|
||||
| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 |
|
||||
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
|
||||
| 登录 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ 部分支持 | 仅支持用户级 TOTP |
|
||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
|
||||
| 紧急访问 | ✅ | ❌ | 没必要实现 |
|
||||
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
|
||||
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
|
||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
|
||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / E3 定时备份** |
|
||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
||||
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
||||
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
|
||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||
|
||||
## 测试情况:
|
||||
|
||||
- ✅ Windows 客户端(v2026.1.0)
|
||||
- ✅ 手机 App(v2026.1.0)
|
||||
- ✅ 浏览器扩展(v2026.1.0)
|
||||
- ✅ Linux 客户端(v2026.1.0)
|
||||
- ⬜ macOS 客户端(未测试)
|
||||
---
|
||||
|
||||
## 已测试客户端
|
||||
|
||||
- ✅ Windows 桌面端
|
||||
- ✅ 手机 App
|
||||
- ✅ 浏览器扩展
|
||||
- ✅ Linux 桌面端
|
||||
- ⚠️ macOS 桌面端尚未完整验证
|
||||
|
||||
---
|
||||
|
||||
## 网页部署
|
||||
|
||||
|
||||
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文件上限 | 免费额度 |
|
||||
|---|---|---|---|
|
||||
@@ -72,51 +71,60 @@ English:[`README_EN.md`](./README_EN.md)
|
||||
## CLI 部署
|
||||
|
||||
```powershell
|
||||
# 先把仓库拉到本地
|
||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||
cd NodeWarden
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# Cloudflare CLI 登录
|
||||
npx wrangler login
|
||||
|
||||
# 部署到 Cloudflare
|
||||
npm run deploy
|
||||
# 默认:R2 模式
|
||||
npm run deploy
|
||||
|
||||
# (可选)KV 模式(无 R2 / 无信用卡)
|
||||
# 可选:KV 模式
|
||||
npm run deploy:kv
|
||||
|
||||
# 本地开发
|
||||
npm run dev
|
||||
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: 忘记主密码怎么办?**
|
||||
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
|
||||
- Bitwarden JSON
|
||||
- Bitwarden CSV
|
||||
- Bitwarden 密码库 + 附件 ZIP
|
||||
- NodeWarden JSON
|
||||
- 网页导入器里可见的多种浏览器 / 密码管理器格式
|
||||
|
||||
**Q: 可以多人使用吗?**
|
||||
A: 支持。第一个注册的用户会自动成为管理员;管理员可在管理页面生成邀请码,其他用户凭邀请码注册。
|
||||
当前支持的导出方式包括:
|
||||
|
||||
- Bitwarden JSON
|
||||
- Bitwarden 加密 JSON
|
||||
- 带附件的 ZIP 导出
|
||||
- NodeWarden JSON 系列
|
||||
- 备份中心中的实例级完整手动导出
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 开源协议
|
||||
|
||||
LGPL-3.0 License
|
||||
@@ -125,11 +133,12 @@ LGPL-3.0 License
|
||||
|
||||
## 致谢
|
||||
|
||||
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
|
||||
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||
|
||||
+72
-64
@@ -3,7 +3,7 @@
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
[](https://workers.cloudflare.com/)
|
||||
@@ -11,105 +11,114 @@
|
||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||
[](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**
|
||||
> 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 not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team.
|
||||
> 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 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 |
|
||||
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
|
||||
| Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized |
|
||||
| Attachment upload/download | ✅ | ✅ | Choose either Cloudflare R2 or KV |
|
||||
| Import / export | ✅ | ✅ | Fully implemented, including Bitwarden vault + attachments ZIP import |
|
||||
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
||||
| passkey / TOTP fields | ✅ | ✅ | Fully supported, no premium required |
|
||||
| Send | ✅ | ✅ | Choose either Cloudflare R2 or KV |
|
||||
| Multi-user | ✅ | ✅ | Full user management with invitation mechanism |
|
||||
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
|
||||
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | User-level TOTP only |
|
||||
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
|
||||
| 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 Vault | ✅ | ✅ | **Original Web Vault interface** |
|
||||
| Full sync `/api/sync` | ✅ | ✅ | Optimized for official clients |
|
||||
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
||||
| Send | ✅ | ✅ | Supports both text and file Sends |
|
||||
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
||||
| **Cloud Backup Center** | ❌ | ✅ | **Supports scheduled backups with WebDAV / E3** |
|
||||
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
|
||||
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
||||
| Multi-user | ✅ | ✅ | Invite-based registration |
|
||||
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
|
||||
| Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP |
|
||||
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
|
||||
|
||||
---
|
||||
|
||||
## Web deploy
|
||||
## Tested Clients
|
||||
|
||||
1. Fork this repository. If you find this project helpful, 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` -> (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.
|
||||
- ✅ Windows desktop client
|
||||
- ✅ 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 |
|
||||
|---|---|---|---|
|
||||
| R2 | Yes | 100 MB (soft limit, can be changed) | 10 GB |
|
||||
| KV | No | 25 MiB (Cloudflare limit, cannot be changed) | 1 GB |
|
||||
| R2 | Yes | 100 MB (soft limit, can be adjusted) | 10 GB |
|
||||
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||
|
||||
> [!TIP]
|
||||
> Sync upstream (keep your fork updated):
|
||||
>- Manual: open your fork on GitHub, click `Sync fork`, then click `Update branch`.
|
||||
>- Automatic: in your fork, go to `Actions` -> `Sync upstream` -> `Enable workflow`. It will automatically sync from upstream every day at 3 AM.
|
||||
> [!TIP]
|
||||
> How to keep your fork updated:
|
||||
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
|
||||
> - 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
|
||||
# Clone repository
|
||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||
cd NodeWarden
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Cloudflare CLI login
|
||||
npx wrangler login
|
||||
|
||||
# Deploy to Cloudflare
|
||||
npm run deploy
|
||||
# Default: R2 mode
|
||||
npm run deploy
|
||||
|
||||
# (Optional) KV mode (no R2 / no credit card)
|
||||
# Optional: KV mode
|
||||
npm run deploy:kv
|
||||
|
||||
# Local development
|
||||
npm run dev
|
||||
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?**
|
||||
A: Use **Export vault** in your client and save the JSON file.
|
||||
- Remote backup supports **WebDAV** and **E3**
|
||||
- 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?**
|
||||
A: It can’t be recovered (end-to-end encryption). Keep it safe.
|
||||
## Import / Export
|
||||
|
||||
**Q: Can multiple people use it?**
|
||||
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.
|
||||
Current supported import sources include:
|
||||
|
||||
- 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
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||
|
||||
@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
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,
|
||||
@@ -51,10 +52,12 @@ CREATE TABLE IF NOT EXISTS ciphers (
|
||||
key 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
|
||||
);
|
||||
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 TABLE IF NOT EXISTS folders (
|
||||
@@ -144,6 +147,10 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
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,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, device_identifier),
|
||||
|
||||
Generated
+79
-5
@@ -1,14 +1,17 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nodewarden",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"license": "LGPL-3.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@zip.js/zip.js": "^2.8.22",
|
||||
@@ -507,6 +510,60 @@
|
||||
"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": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||
@@ -2746,6 +2803,19 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-3.0.0.tgz",
|
||||
@@ -2811,6 +2881,12 @@
|
||||
"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": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
|
||||
@@ -2943,9 +3019,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
@@ -46,6 +46,9 @@
|
||||
"wrangler": "^4.71.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@zip.js/zip.js": "^2.8.22",
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.4.0';
|
||||
export const APP_VERSION = '1.4.1';
|
||||
|
||||
@@ -3,6 +3,7 @@ export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||
export const BACKUP_DEFAULT_E3_REGION = 'auto';
|
||||
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
||||
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||
|
||||
export type BackupDestinationType = 'e3' | 'webdav';
|
||||
|
||||
@@ -40,6 +41,7 @@ export interface BackupRuntimeState {
|
||||
export interface BackupScheduleConfig {
|
||||
enabled: boolean;
|
||||
intervalHours: number;
|
||||
startTime: string;
|
||||
timezone: string;
|
||||
retentionCount: number | null;
|
||||
}
|
||||
@@ -82,6 +84,7 @@ export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFA
|
||||
return {
|
||||
enabled: false,
|
||||
intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
|
||||
startTime: BACKUP_DEFAULT_START_TIME,
|
||||
timezone,
|
||||
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
|
||||
};
|
||||
|
||||
@@ -75,6 +75,16 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
|
||||
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 {
|
||||
void env;
|
||||
return {
|
||||
@@ -98,6 +108,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
||||
forcePasswordReset: false,
|
||||
avatarColor: null,
|
||||
creationDate: user.createdAt,
|
||||
verifyDevices: user.verifyDevices,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
object: 'profile',
|
||||
@@ -194,6 +205,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
securityStamp: generateUUID(),
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
verifyDevices: true,
|
||||
totpSecret: null,
|
||||
totpRecoveryCode: null,
|
||||
createdAt: now,
|
||||
@@ -363,6 +375,40 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
|
||||
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
|
||||
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
+162
-6
@@ -26,6 +26,34 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
|
||||
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 {
|
||||
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 }
|
||||
): CipherResponse {
|
||||
// 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 normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||
|
||||
@@ -163,7 +191,7 @@ export function cipherToResponse(
|
||||
creationDate: createdAt,
|
||||
revisionDate: updatedAt,
|
||||
deletedDate: deletedAt,
|
||||
archivedDate: null,
|
||||
archivedDate: archivedAt ?? null,
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
permissions: {
|
||||
@@ -273,12 +301,12 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
reprompt: cipherData.reprompt || 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: readCipherArchivedAt(cipherData, null),
|
||||
deletedAt: null,
|
||||
};
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||
normalizeCipherForStorage(cipher);
|
||||
|
||||
// Prevent referencing a folder owned by another user.
|
||||
if (cipher.folderId) {
|
||||
@@ -331,10 +359,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||
createdAt: existingCipher.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||
deletedAt: existingCipher.deletedAt,
|
||||
};
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||
|
||||
// Custom fields deletion compatibility:
|
||||
// - 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') {
|
||||
cipher.fields = null;
|
||||
}
|
||||
normalizeCipherForStorage(cipher);
|
||||
|
||||
// Prevent referencing a folder owned by another user.
|
||||
if (cipher.folderId) {
|
||||
@@ -376,6 +404,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
||||
// Soft delete
|
||||
cipher.deletedAt = new Date().toISOString();
|
||||
cipher.updatedAt = cipher.deletedAt;
|
||||
syncCipherComputedAliases(cipher);
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
@@ -441,6 +470,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
||||
|
||||
cipher.deletedAt = null;
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
syncCipherComputedAliases(cipher);
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
@@ -479,6 +509,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
||||
cipher.favorite = body.favorite;
|
||||
}
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
syncCipherComputedAliases(cipher);
|
||||
|
||||
await storage.saveCipher(cipher);
|
||||
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 });
|
||||
}
|
||||
|
||||
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
|
||||
export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
+302
-20
@@ -1,3 +1,4 @@
|
||||
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
|
||||
import { Env } from '../types';
|
||||
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
||||
import { StorageService } from '../services/storage';
|
||||
@@ -5,6 +6,101 @@ import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { readKnownDeviceProbe } from '../utils/device';
|
||||
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
|
||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||
// - 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);
|
||||
|
||||
return jsonResponse({
|
||||
data: devices.map(device => ({
|
||||
id: device.deviceIdentifier,
|
||||
name: device.name,
|
||||
identifier: device.deviceIdentifier,
|
||||
type: device.type,
|
||||
creationDate: device.createdAt,
|
||||
revisionDate: device.updatedAt,
|
||||
object: 'device',
|
||||
})),
|
||||
data: devices.map((device) => buildDeviceResponse(device)),
|
||||
object: 'list',
|
||||
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
|
||||
// Returns known devices together with active 2FA remember-token expiry.
|
||||
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);
|
||||
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
|
||||
return {
|
||||
id: device.deviceIdentifier,
|
||||
name: device.name,
|
||||
identifier: device.deviceIdentifier,
|
||||
type: device.type,
|
||||
creationDate: device.createdAt,
|
||||
revisionDate: device.updatedAt,
|
||||
...buildDeviceResponse(device),
|
||||
online: onlineSet.has(device.deviceIdentifier),
|
||||
trusted: !!trustedInfo,
|
||||
trustedTokenCount: trustedInfo?.tokenCount || 0,
|
||||
@@ -80,13 +193,22 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
||||
|
||||
for (const row of trusted) {
|
||||
if (knownIdentifiers.has(row.deviceIdentifier)) continue;
|
||||
data.push({
|
||||
id: row.deviceIdentifier,
|
||||
const placeholderDevice: Device = {
|
||||
userId,
|
||||
deviceIdentifier: row.deviceIdentifier,
|
||||
name: 'Unknown device',
|
||||
identifier: row.deviceIdentifier,
|
||||
type: 14,
|
||||
creationDate: '',
|
||||
revisionDate: '',
|
||||
sessionStamp: '',
|
||||
encryptedUserKey: null,
|
||||
encryptedPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
devicePendingAuthRequest: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
data.push({
|
||||
...buildDeviceResponse(placeholderDevice),
|
||||
isTrusted: true,
|
||||
online: onlineSet.has(row.deviceIdentifier),
|
||||
trusted: true,
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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
|
||||
// Bitwarden mobile reports push token updates to this endpoint.
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,28 @@ function resolveTotpSecret(userSecret: string | null): string | 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 {
|
||||
const providers = includeRecoveryCode
|
||||
? [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 kdfParallelism = user?.kdfParallelism ?? null;
|
||||
|
||||
return jsonResponse({
|
||||
kdf: kdfType,
|
||||
kdfIterations: kdfIterations,
|
||||
kdfMemory: kdfMemory,
|
||||
kdfParallelism: kdfParallelism,
|
||||
});
|
||||
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
|
||||
}
|
||||
|
||||
// POST /identity/connect/revocation
|
||||
|
||||
@@ -232,6 +232,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
key: (c as any).key ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
};
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
@@ -245,10 +246,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
const data = JSON.stringify(cipher);
|
||||
return env.DB
|
||||
.prepare(
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'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(
|
||||
cipher.id,
|
||||
@@ -263,6 +264,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
bindNull(cipher.key),
|
||||
cipher.createdAt,
|
||||
cipher.updatedAt,
|
||||
bindNull(cipher.archivedAt),
|
||||
bindNull(cipher.deletedAt)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -148,6 +148,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
forcePasswordReset: false,
|
||||
avatarColor: null,
|
||||
creationDate: user.createdAt,
|
||||
verifyDevices: user.verifyDevices,
|
||||
object: 'profile',
|
||||
};
|
||||
|
||||
@@ -180,6 +181,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
},
|
||||
policies: [],
|
||||
sends: sends.map(sendToResponse),
|
||||
UserDecryption: {
|
||||
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
|
||||
TrustedDeviceOption: null,
|
||||
KeyConnectorOption: null,
|
||||
Object: 'userDecryption',
|
||||
},
|
||||
// PascalCase for desktop/browser clients
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
handleGetRevisionDate,
|
||||
handleVerifyPassword,
|
||||
handleChangePassword,
|
||||
handleSetVerifyDevices,
|
||||
handleGetTotpStatus,
|
||||
handleSetTotpStatus,
|
||||
handleGetTotpRecoveryCode,
|
||||
@@ -20,11 +21,15 @@ import {
|
||||
handleDeleteCipherCompat,
|
||||
handlePermanentDeleteCipher,
|
||||
handleRestoreCipher,
|
||||
handleBulkArchiveCiphers,
|
||||
handlePartialUpdateCipher,
|
||||
handleBulkUnarchiveCiphers,
|
||||
handleBulkMoveCiphers,
|
||||
handleBulkDeleteCiphers,
|
||||
handleBulkPermanentDeleteCiphers,
|
||||
handleBulkRestoreCiphers,
|
||||
handleArchiveCipher,
|
||||
handleUnarchiveCipher,
|
||||
} from './handlers/ciphers';
|
||||
import {
|
||||
handleGetFolders,
|
||||
@@ -110,6 +115,10 @@ export async function handleAuthenticatedRoute(
|
||||
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') {
|
||||
return handleSync(request, env, userId);
|
||||
}
|
||||
@@ -140,6 +149,14 @@ export async function handleAuthenticatedRoute(
|
||||
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')) {
|
||||
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 === 'DELETE') return handlePermanentDeleteCipher(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 === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||
|
||||
+60
-3
@@ -1,12 +1,21 @@
|
||||
import type { Env } from './types';
|
||||
import {
|
||||
handleGetAuthorizedDevices,
|
||||
handleGetDevice,
|
||||
handleGetDevices,
|
||||
handleGetDeviceByIdentifier,
|
||||
handleUpdateDeviceKeys,
|
||||
handleUpdateDeviceTrust,
|
||||
handleUntrustDevices,
|
||||
handleRetrieveDeviceKeys,
|
||||
handleDeactivateDevice,
|
||||
handleRevokeAllTrustedDevices,
|
||||
handleRevokeTrustedDevice,
|
||||
handleDeleteAllDevices,
|
||||
handleDeleteDevice,
|
||||
handleUpdateDeviceToken,
|
||||
handleUpdateDeviceWebPushAuth,
|
||||
handleClearDeviceToken,
|
||||
} from './handlers/devices';
|
||||
|
||||
export async function handleAuthenticatedDeviceRoute(
|
||||
@@ -35,16 +44,64 @@ export async function handleAuthenticatedDeviceRoute(
|
||||
}
|
||||
|
||||
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') {
|
||||
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const deviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
||||
if (deviceTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(deviceTokenMatch[1]);
|
||||
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
||||
if (identifierMatch && method === 'GET') {
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
+49
-22
@@ -78,6 +78,43 @@ function buildIconServiceCsp(origin: string): string {
|
||||
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 {
|
||||
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
|
||||
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
|
||||
@@ -243,6 +280,11 @@ export async function handlePublicRoute(
|
||||
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') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
@@ -255,6 +297,12 @@ export async function handlePublicRoute(
|
||||
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') {
|
||||
return handleRecoverTwoFactor(request, env);
|
||||
}
|
||||
@@ -275,28 +323,7 @@ export async function handlePublicRoute(
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const origin = new URL(request.url).origin;
|
||||
return jsonResponse({
|
||||
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',
|
||||
});
|
||||
return jsonResponse(buildConfigResponse(origin));
|
||||
}
|
||||
|
||||
if (path === '/api/version' && method === 'GET') {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from './backup-settings-crypto';
|
||||
import {
|
||||
BACKUP_DEFAULT_INTERVAL_HOURS,
|
||||
BACKUP_DEFAULT_START_TIME,
|
||||
BACKUP_DEFAULT_TIMEZONE,
|
||||
type BackupDestinationConfig,
|
||||
type BackupDestinationRecord,
|
||||
@@ -90,6 +91,20 @@ function normalizeIntervalHours(value: unknown, fallback: number = BACKUP_DEFAUL
|
||||
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 {
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
const endpoint = asTrimmedString(source.endpoint);
|
||||
@@ -219,6 +234,10 @@ function normalizeDestinationRecord(
|
||||
scheduleSource.intervalHours ?? previousSchedule.intervalHours,
|
||||
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),
|
||||
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
||||
};
|
||||
@@ -259,6 +278,7 @@ function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTi
|
||||
schedule: {
|
||||
enabled: !!rawValue.enabled,
|
||||
intervalHours,
|
||||
startTime: BACKUP_DEFAULT_START_TIME,
|
||||
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||
retentionCount: 30,
|
||||
},
|
||||
@@ -326,6 +346,7 @@ export function parseBackupSettings(raw: string | null, fallbackTimezone: string
|
||||
schedule: {
|
||||
enabled: scheduleEnabled,
|
||||
intervalHours: globalIntervalHours,
|
||||
startTime: BACKUP_DEFAULT_START_TIME,
|
||||
timezone: globalTimezone,
|
||||
retentionCount: 30,
|
||||
},
|
||||
@@ -495,15 +516,87 @@ export function getBackupLocalTime(date: Date, timezone: string): string {
|
||||
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(
|
||||
destination: BackupDestinationRecord,
|
||||
now: Date,
|
||||
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
|
||||
): boolean {
|
||||
if (!destination.schedule.enabled) return false;
|
||||
const intervalMs = destination.schedule.intervalHours * 60 * 60 * 1000;
|
||||
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||
if (!lastAttemptAt || !Number.isFinite(lastAttemptAt.getTime())) return true;
|
||||
return now.getTime() - lastAttemptAt.getTime() >= Math.max(0, intervalMs - toleranceMs);
|
||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||
? 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;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface CipherRow {
|
||||
key: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
archived_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,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
|
||||
deletedAt: row.deleted_at ?? null,
|
||||
};
|
||||
} catch {
|
||||
@@ -46,7 +48,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -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> {
|
||||
const data = JSON.stringify(cipher);
|
||||
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) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'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(
|
||||
stmt,
|
||||
@@ -79,10 +81,15 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
|
||||
cipher.key,
|
||||
cipher.createdAt,
|
||||
cipher.updatedAt,
|
||||
cipher.archivedAt ?? null,
|
||||
cipher.deletedAt
|
||||
).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> {
|
||||
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
|
||||
): Promise<string | 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;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -126,7 +133,7 @@ export async function bulkRestoreCiphers(
|
||||
userId: string
|
||||
): Promise<string | 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;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -157,7 +164,7 @@ export async function bulkDeleteCiphers(
|
||||
userId: string
|
||||
): Promise<string | 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;
|
||||
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
@@ -212,7 +219,7 @@ export async function getCiphersByIds(
|
||||
userId: string
|
||||
): Promise<Cipher[]> {
|
||||
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 [];
|
||||
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
@@ -242,7 +249,7 @@ export async function bulkMoveCiphers(
|
||||
): Promise<string | null> {
|
||||
if (ids.length === 0) return null;
|
||||
const now = new Date().toISOString();
|
||||
const uniqueIds = Array.from(new Set(ids));
|
||||
const uniqueIds = sanitizeIds(ids);
|
||||
const patch = JSON.stringify({ folderId, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(4);
|
||||
|
||||
@@ -261,3 +268,65 @@ export async function bulkMoveCiphers(
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ function mapDeviceRow(row: any): Device {
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
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,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
@@ -22,19 +25,92 @@ export async function upsertDevice(
|
||||
deviceIdentifier: string,
|
||||
name: string,
|
||||
type: number,
|
||||
sessionStamp?: string
|
||||
sessionStamp?: string,
|
||||
keys?: {
|
||||
encryptedUserKey?: string | null;
|
||||
encryptedPublicKey?: string | null;
|
||||
encryptedPrivateKey?: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, 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'
|
||||
'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, ' +
|
||||
'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();
|
||||
}
|
||||
|
||||
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> {
|
||||
const row = await db
|
||||
.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[]> {
|
||||
const res = await db
|
||||
.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'
|
||||
)
|
||||
.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> {
|
||||
const row = await db
|
||||
.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'
|
||||
)
|
||||
.bind(userId, deviceIdentifier)
|
||||
|
||||
@@ -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, ' +
|
||||
'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, ' +
|
||||
'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 role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'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_recovery_code TEXT',
|
||||
|
||||
@@ -20,9 +21,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'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, ' +
|
||||
'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)',
|
||||
'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_archived ON ciphers(user_id, archived_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||
|
||||
'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 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, ' +
|
||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||
'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)',
|
||||
'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_at TEXT',
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { User } from '../types';
|
||||
|
||||
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 {
|
||||
return {
|
||||
@@ -19,6 +23,7 @@ function mapUserRow(row: any): User {
|
||||
securityStamp: row.security_stamp,
|
||||
role: row.role === 'admin' ? 'admin' : 'user',
|
||||
status: row.status === 'banned' ? 'banned' : 'active',
|
||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
createdAt: row.created_at,
|
||||
@@ -28,9 +33,7 @@ function mapUserRow(row: any): User {
|
||||
|
||||
export async function getUser(db: D1Database, email: string): Promise<User | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'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 = ?'
|
||||
)
|
||||
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE email = ?`)
|
||||
.bind(email.toLowerCase())
|
||||
.first<any>();
|
||||
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> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'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 = ?'
|
||||
)
|
||||
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE id = ?`)
|
||||
.bind(id)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
@@ -55,9 +56,7 @@ export async function getUserCount(db: D1Database): Promise<number> {
|
||||
|
||||
export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'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'
|
||||
)
|
||||
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users ORDER BY created_at ASC`)
|
||||
.all<any>();
|
||||
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> {
|
||||
const email = user.email.toLowerCase();
|
||||
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) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'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, ' +
|
||||
'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(
|
||||
stmt,
|
||||
@@ -88,6 +87,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
||||
user.securityStamp,
|
||||
user.role,
|
||||
user.status,
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
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> {
|
||||
const email = user.email.toLowerCase();
|
||||
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) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'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 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
@@ -123,6 +123,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
||||
user.securityStamp,
|
||||
user.role,
|
||||
user.status,
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.createdAt,
|
||||
|
||||
+42
-3
@@ -36,10 +36,12 @@ import {
|
||||
saveFolder as saveStoredFolder,
|
||||
} from './storage-folder-repo';
|
||||
import {
|
||||
bulkArchiveCiphers as archiveStoredCiphers,
|
||||
bulkDeleteCiphers as deleteStoredCiphers,
|
||||
bulkMoveCiphers as moveStoredCiphers,
|
||||
bulkRestoreCiphers as restoreStoredCiphers,
|
||||
bulkSoftDeleteCiphers as softDeleteStoredCiphers,
|
||||
bulkUnarchiveCiphers as unarchiveStoredCiphers,
|
||||
getAllCiphers as listStoredCiphers,
|
||||
getCipher as findStoredCipher,
|
||||
getCiphersByIds as listStoredCiphersByIds,
|
||||
@@ -80,6 +82,7 @@ import {
|
||||
import {
|
||||
deleteDevice as deleteStoredDevice,
|
||||
deleteDevicesByUserId as deleteStoredDevicesByUserId,
|
||||
clearDeviceKeys as clearStoredDeviceKeys,
|
||||
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
|
||||
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
|
||||
getDevice as findStoredDevice,
|
||||
@@ -90,6 +93,7 @@ import {
|
||||
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
||||
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
||||
upsertDevice as saveStoredDevice,
|
||||
updateDeviceKeys as updateStoredDeviceKeys,
|
||||
} from './storage-device-repo';
|
||||
import {
|
||||
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
||||
@@ -102,7 +106,7 @@ import {
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-03-19.1';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-03-23.1';
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
@@ -286,6 +290,14 @@ export class StorageService {
|
||||
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> {
|
||||
return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
||||
}
|
||||
@@ -495,8 +507,19 @@ export class StorageService {
|
||||
|
||||
// --- Devices ---
|
||||
|
||||
async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number, sessionStamp?: string): Promise<void> {
|
||||
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp);
|
||||
async upsertDevice(
|
||||
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> {
|
||||
@@ -515,6 +538,22 @@ export class StorageService {
|
||||
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> {
|
||||
return deleteStoredDevice(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface User {
|
||||
securityStamp: string;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
verifyDevices?: boolean;
|
||||
totpSecret: string | null;
|
||||
totpRecoveryCode: string | null;
|
||||
createdAt: string;
|
||||
@@ -169,6 +170,7 @@ export interface Cipher {
|
||||
key: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt: string | null;
|
||||
deletedAt: string | null;
|
||||
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
|
||||
[key: string]: any;
|
||||
@@ -189,10 +191,47 @@ export interface Device {
|
||||
name: string;
|
||||
type: number;
|
||||
sessionStamp: string;
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
encryptedPrivateKey: string | null;
|
||||
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
||||
createdAt: 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 {
|
||||
userId: string;
|
||||
expiresAt: number;
|
||||
@@ -351,6 +390,7 @@ export interface ProfileResponse {
|
||||
forcePasswordReset: boolean;
|
||||
avatarColor: string | null;
|
||||
creationDate: string;
|
||||
verifyDevices?: boolean;
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
object: string;
|
||||
@@ -409,6 +449,13 @@ export interface SyncResponse {
|
||||
domains: any;
|
||||
policies: any[];
|
||||
sends: SendResponse[];
|
||||
UserDecryption?: {
|
||||
MasterPasswordUnlock: MasterPasswordUnlock | null;
|
||||
TrustedDeviceOption?: null;
|
||||
KeyConnectorOption?: null;
|
||||
WebAuthnPrfOption?: null;
|
||||
Object?: string;
|
||||
} | null;
|
||||
// PascalCase for desktop/browser clients
|
||||
UserDecryptionOptions: UserDecryptionOptions | null;
|
||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||
|
||||
@@ -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 SETTINGS_HOME_ROUTE = '/settings';
|
||||
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
||||
const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
|
||||
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||
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() {
|
||||
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
||||
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
||||
@@ -100,6 +157,8 @@ export default function App() {
|
||||
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||
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 [mobileLayout, setMobileLayout] = useState(false);
|
||||
@@ -175,6 +234,41 @@ export default function App() {
|
||||
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) {
|
||||
sessionRef.current = next;
|
||||
setSessionState(next);
|
||||
@@ -974,9 +1068,13 @@ export default function App() {
|
||||
onCreateVaultItem: vaultSendActions.createVaultItem,
|
||||
onUpdateVaultItem: vaultSendActions.updateVaultItem,
|
||||
onDeleteVaultItem: vaultSendActions.deleteVaultItem,
|
||||
onArchiveVaultItem: vaultSendActions.archiveVaultItem,
|
||||
onUnarchiveVaultItem: vaultSendActions.unarchiveVaultItem,
|
||||
onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems,
|
||||
onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems,
|
||||
onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems,
|
||||
onBulkArchiveVaultItems: vaultSendActions.bulkArchiveVaultItems,
|
||||
onBulkUnarchiveVaultItems: vaultSendActions.bulkUnarchiveVaultItems,
|
||||
onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems,
|
||||
onVerifyMasterPassword: vaultSendActions.verifyMasterPassword,
|
||||
onCreateFolder: vaultSendActions.createFolder,
|
||||
@@ -1131,8 +1229,11 @@ export default function App() {
|
||||
settingsAccountRoute={SETTINGS_ACCOUNT_ROUTE}
|
||||
importRoute={IMPORT_ROUTE}
|
||||
isImportRoute={isImportRoute}
|
||||
darkMode={resolvedTheme === 'dark'}
|
||||
themeToggleTitle={resolvedTheme === 'dark' ? t('txt_switch_to_light_mode') : t('txt_switch_to_dark_mode')}
|
||||
onLock={handleLock}
|
||||
onLogout={handleLogout}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
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 { Link } from 'wouter';
|
||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Profile } from '@/lib/types';
|
||||
@@ -15,12 +16,17 @@ interface AppAuthenticatedShellProps {
|
||||
settingsAccountRoute: string;
|
||||
importRoute: string;
|
||||
isImportRoute: boolean;
|
||||
darkMode: boolean;
|
||||
themeToggleTitle: string;
|
||||
onLock: () => void;
|
||||
onLogout: () => void;
|
||||
onToggleTheme: () => void;
|
||||
mainRoutesProps: AppMainRoutesProps;
|
||||
}
|
||||
|
||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<div className="app-shell">
|
||||
@@ -35,6 +41,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<ShieldUser size={16} />
|
||||
<span>{props.profile?.email}</span>
|
||||
</div>
|
||||
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onLock}>
|
||||
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
||||
</button>
|
||||
@@ -49,6 +56,9 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<FolderIcon size={16} className="btn-icon" />
|
||||
</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}>
|
||||
<Lock size={14} className="btn-icon" />
|
||||
</button>
|
||||
@@ -98,7 +108,9 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
</Link>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<AppMainRoutes {...props.mainRoutesProps} />
|
||||
<div key={routeAnimationKey} className="route-stage">
|
||||
<AppMainRoutes {...props.mainRoutesProps} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -64,9 +64,13 @@ export interface AppMainRoutesProps {
|
||||
onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||
onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
||||
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
|
||||
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||
onBulkPermanentDeleteVaultItems: (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>;
|
||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||
onCreateFolder: (name: string) => Promise<void>;
|
||||
@@ -174,9 +178,13 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onCreate={props.onCreateVaultItem}
|
||||
onUpdate={props.onUpdateVaultItem}
|
||||
onDelete={props.onDeleteVaultItem}
|
||||
onArchive={props.onArchiveVaultItem}
|
||||
onUnarchive={props.onUnarchiveVaultItem}
|
||||
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||
onBulkArchive={props.onBulkArchiveVaultItems}
|
||||
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
|
||||
onBulkMove={props.onBulkMoveVaultItems}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onNotify={props.onNotify}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { Check, X } from 'lucide-preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
@@ -20,14 +20,32 @@ interface 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 (
|
||||
<div className="dialog-mask">
|
||||
<div className={`dialog-mask ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}>
|
||||
<form
|
||||
className="dialog-card"
|
||||
className={`dialog-card ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (props.confirmDisabled) return;
|
||||
if (props.confirmDisabled || closing) return;
|
||||
props.onConfirm();
|
||||
}}
|
||||
>
|
||||
@@ -39,7 +57,6 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
disabled={props.confirmDisabled}
|
||||
>
|
||||
<Check size={14} className="btn-icon" />
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
{!props.hideCancel && (
|
||||
@@ -52,7 +69,6 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
props.onCancel();
|
||||
}}
|
||||
>
|
||||
<X size={14} className="btn-icon" />
|
||||
{props.cancelText || t('txt_no')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -62,6 +62,10 @@ function draftFromSend(send: Send): SendDraft {
|
||||
}
|
||||
|
||||
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 [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
|
||||
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 [showPassword, setShowPassword] = useState(false);
|
||||
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 [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
||||
@@ -226,7 +230,15 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
|
||||
return (
|
||||
<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' : ''}`}>
|
||||
{isMobileLayout && (
|
||||
<div className="mobile-sidebar-head">
|
||||
@@ -310,12 +322,27 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="list-panel">
|
||||
{filteredSends.map((send) => (
|
||||
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}>
|
||||
{filteredSends.map((send, index) => (
|
||||
<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
|
||||
type="checkbox"
|
||||
className="row-check"
|
||||
checked={!!selectedMap[send.id]}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onInput={(e) =>
|
||||
setSelectedMap((prev) => ({
|
||||
...prev,
|
||||
@@ -377,10 +404,11 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</div>
|
||||
)}
|
||||
{isEditing && draft && (
|
||||
<div className="card">
|
||||
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
||||
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
|
||||
<div className="field-grid">
|
||||
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
|
||||
<div className="card stagger-item" style={{ animationDelay: '0ms' }}>
|
||||
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
||||
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_name')}</span>
|
||||
<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>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="detail-actions">
|
||||
</div>
|
||||
<div className="detail-actions">
|
||||
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
|
||||
<Save size={14} className="btn-icon" /> {t('txt_save')}
|
||||
</button>
|
||||
@@ -470,18 +498,19 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
>
|
||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && selectedSend && (
|
||||
<>
|
||||
<div className="card">
|
||||
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
|
||||
<div className="card stagger-item" style={{ animationDelay: '36ms' }}>
|
||||
<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>
|
||||
|
||||
<div className="card">
|
||||
<div className="card stagger-item" style={{ animationDelay: '72ms' }}>
|
||||
<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_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
||||
@@ -504,7 +533,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
</div>
|
||||
|
||||
{!!(selectedSend.decNotes || '').trim() && (
|
||||
<div className="card">
|
||||
<div className="card stagger-item" style={{ animationDelay: '108ms' }}>
|
||||
<h4>{t('txt_notes')}</h4>
|
||||
<div className="notes">{selectedSend.decNotes || ''}</div>
|
||||
</div>
|
||||
@@ -523,7 +552,7 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,26 @@
|
||||
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 { calcTotpNow } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
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 {
|
||||
ciphers: Cipher[];
|
||||
@@ -15,6 +31,7 @@ interface TotpCodesPageProps {
|
||||
const TOTP_PERIOD_SECONDS = 30;
|
||||
const TOTP_RING_RADIUS = 14;
|
||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
|
||||
const failedIconHosts = new Set<string>();
|
||||
|
||||
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) {
|
||||
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
|
||||
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 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> {
|
||||
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
|
||||
}
|
||||
|
||||
const totpItems = useMemo(
|
||||
const baseTotpItems = useMemo(
|
||||
() =>
|
||||
props.ciphers
|
||||
.filter((cipher) => {
|
||||
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
|
||||
return !isDeleted && !!cipher.login?.decTotp;
|
||||
})
|
||||
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
|
||||
.sort((a, b) => {
|
||||
const nameA = (a.decName || a.name || '').trim().toLowerCase();
|
||||
const nameB = (b.decName || b.name || '').trim().toLowerCase();
|
||||
@@ -93,6 +205,44 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
[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(() => {
|
||||
if (!totpItems.length) {
|
||||
setTotpMap({});
|
||||
@@ -142,6 +292,16 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
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 (
|
||||
<div className="totp-codes-page">
|
||||
<div className="card">
|
||||
@@ -154,54 +314,18 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
|
||||
>
|
||||
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
||||
{totpItems.map((cipher) => {
|
||||
const live = totpMap[cipher.id] || null;
|
||||
const name = cipher.decName || cipher.name || t('txt_no_name');
|
||||
const username = cipher.login?.decUsername || '';
|
||||
return (
|
||||
<div key={cipher.id} className="totp-code-row">
|
||||
<div className="totp-code-info">
|
||||
<div className="list-icon-wrap">
|
||||
<TotpListIcon cipher={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>{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>
|
||||
);
|
||||
})}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={totpItems.map((cipher) => cipher.id)} strategy={rectSortingStrategy}>
|
||||
{totpItems.map((cipher) => (
|
||||
<SortableTotpRow
|
||||
key={cipher.id}
|
||||
cipher={cipher}
|
||||
live={totpMap[cipher.id] || null}
|
||||
onCopy={(value) => void copyToClipboard(value)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
buildCipherDuplicateSignature,
|
||||
firstCipherUri,
|
||||
firstPasskeyCreationTime,
|
||||
isCipherVisibleInArchive,
|
||||
isCipherVisibleInNormalVault,
|
||||
isCipherVisibleInTrash,
|
||||
sortTimeValue,
|
||||
type SidebarFilter,
|
||||
type VaultSortMode,
|
||||
@@ -36,9 +39,13 @@ interface VaultPageProps {
|
||||
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
||||
onDelete: (cipher: Cipher) => Promise<void>;
|
||||
onArchive: (cipher: Cipher) => Promise<void>;
|
||||
onUnarchive: (cipher: Cipher) => Promise<void>;
|
||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||
onBulkPermanentDelete: (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>;
|
||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
@@ -54,6 +61,10 @@ interface 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 [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchComposing, setSearchComposing] = useState(false);
|
||||
@@ -72,7 +83,9 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [fieldLabel, setFieldLabel] = useState('');
|
||||
const [fieldValue, setFieldValue] = useState('');
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [pendingArchive, setPendingArchive] = useState<Cipher | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<Cipher | null>(null);
|
||||
const [bulkArchiveOpen, setBulkArchiveOpen] = useState(false);
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||
const [moveOpen, setMoveOpen] = useState(false);
|
||||
const [moveFolderId, setMoveFolderId] = useState('__none__');
|
||||
@@ -88,7 +101,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||
const [repromptPassword, setRepromptPassword] = useState('');
|
||||
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 [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -229,8 +242,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const duplicateSignatureCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const cipher of props.ciphers) {
|
||||
const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
|
||||
if (isDeleted) continue;
|
||||
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
||||
const signature = buildCipherDuplicateSignature(cipher);
|
||||
counts.set(signature, (counts.get(signature) || 0) + 1);
|
||||
}
|
||||
@@ -239,11 +251,12 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
|
||||
const filteredCiphers = useMemo(() => {
|
||||
const next = props.ciphers.filter((cipher) => {
|
||||
const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt);
|
||||
if (sidebarFilter.kind === 'trash') {
|
||||
if (!isDeleted) return false;
|
||||
if (!isCipherVisibleInTrash(cipher)) return false;
|
||||
} else if (sidebarFilter.kind === 'archive') {
|
||||
if (!isCipherVisibleInArchive(cipher)) return false;
|
||||
} else {
|
||||
if (isDeleted) return false;
|
||||
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
||||
if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) {
|
||||
return false;
|
||||
}
|
||||
@@ -494,7 +507,30 @@ function folderName(id: string | null | undefined): string {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -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> {
|
||||
if (!props.folders.length) return;
|
||||
setBusy(true);
|
||||
@@ -694,7 +787,15 @@ function folderName(id: string | null | undefined): string {
|
||||
return (
|
||||
<>
|
||||
<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
|
||||
folders={props.folders}
|
||||
sidebarFilter={sidebarFilter}
|
||||
@@ -760,6 +861,8 @@ function folderName(id: string | null | undefined): string {
|
||||
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
|
||||
onStartCreate={startCreate}
|
||||
onBulkRestore={() => void confirmBulkRestore()}
|
||||
onBulkArchive={() => setBulkArchiveOpen(true)}
|
||||
onBulkUnarchive={() => void confirmBulkUnarchive()}
|
||||
onOpenMove={() => {
|
||||
setMoveFolderId('__none__');
|
||||
setMoveOpen(true);
|
||||
@@ -801,57 +904,65 @@ function folderName(id: string | null | undefined): string {
|
||||
</div>
|
||||
)}
|
||||
{isEditing && draft && (
|
||||
<VaultEditor
|
||||
draft={draft}
|
||||
isCreating={isCreating}
|
||||
busy={busy}
|
||||
folders={props.folders}
|
||||
selectedCipher={selectedCipher}
|
||||
editExistingAttachments={editExistingAttachments}
|
||||
removedAttachmentIds={removedAttachmentIds}
|
||||
removedAttachmentCount={removedAttachmentCount}
|
||||
attachmentQueue={attachmentQueue}
|
||||
attachmentInputRef={attachmentInputRef}
|
||||
localError={localError}
|
||||
onUpdateDraft={updateDraft}
|
||||
onSeedSshDefaults={(force) => void seedSshDefaults(force)}
|
||||
onUpdateSshPublicKey={updateSshPublicKey}
|
||||
onUpdateDraftLoginUri={updateDraftLoginUri}
|
||||
onQueueAttachmentFiles={queueAttachmentFiles}
|
||||
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
||||
onRemoveQueuedAttachment={removeQueuedAttachment}
|
||||
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
||||
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||
uploadingAttachmentName={props.uploadingAttachmentName}
|
||||
attachmentUploadPercent={props.attachmentUploadPercent}
|
||||
onPatchDraftCustomField={patchDraftCustomField}
|
||||
onUpdateDraftCustomFields={updateDraftCustomFields}
|
||||
onOpenFieldModal={() => setFieldModalOpen(true)}
|
||||
onSave={() => void saveDraft()}
|
||||
onCancel={cancelEdit}
|
||||
onDeleteSelected={() => selectedCipher && setPendingDelete(selectedCipher)}
|
||||
/>
|
||||
<div key={`editor-${draft.id || selectedCipher?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
|
||||
<VaultEditor
|
||||
draft={draft}
|
||||
isCreating={isCreating}
|
||||
busy={busy}
|
||||
folders={props.folders}
|
||||
selectedCipher={selectedCipher}
|
||||
editExistingAttachments={editExistingAttachments}
|
||||
removedAttachmentIds={removedAttachmentIds}
|
||||
removedAttachmentCount={removedAttachmentCount}
|
||||
attachmentQueue={attachmentQueue}
|
||||
attachmentInputRef={attachmentInputRef}
|
||||
localError={localError}
|
||||
onUpdateDraft={updateDraft}
|
||||
onSeedSshDefaults={(force) => void seedSshDefaults(force)}
|
||||
onUpdateSshPublicKey={updateSshPublicKey}
|
||||
onUpdateDraftLoginUri={updateDraftLoginUri}
|
||||
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
||||
onReorderDraftLoginUri={reorderDraftLoginUri}
|
||||
onQueueAttachmentFiles={queueAttachmentFiles}
|
||||
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
||||
onRemoveQueuedAttachment={removeQueuedAttachment}
|
||||
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
||||
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||
uploadingAttachmentName={props.uploadingAttachmentName}
|
||||
attachmentUploadPercent={props.attachmentUploadPercent}
|
||||
onPatchDraftCustomField={patchDraftCustomField}
|
||||
onUpdateDraftCustomFields={updateDraftCustomFields}
|
||||
onOpenFieldModal={() => setFieldModalOpen(true)}
|
||||
onSave={() => void saveDraft()}
|
||||
onCancel={cancelEdit}
|
||||
onDeleteSelected={() => selectedCipher && setPendingDelete(selectedCipher)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && selectedCipher && (
|
||||
<VaultDetailView
|
||||
selectedCipher={selectedCipher}
|
||||
repromptApprovedCipherId={repromptApprovedCipherId}
|
||||
showPassword={showPassword}
|
||||
totpLive={totpLive}
|
||||
passkeyCreatedAt={passkeyCreatedAt}
|
||||
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
||||
folderName={folderName}
|
||||
onOpenReprompt={() => setRepromptOpen(true)}
|
||||
onToggleShowPassword={() => setShowPassword((value) => !value)}
|
||||
onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
|
||||
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
||||
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||
onStartEdit={startEdit}
|
||||
onDelete={setPendingDelete}
|
||||
/>
|
||||
<div key={`detail-${selectedCipher.id}`} className="detail-switch-stage">
|
||||
<VaultDetailView
|
||||
selectedCipher={selectedCipher}
|
||||
repromptApprovedCipherId={repromptApprovedCipherId}
|
||||
showPassword={showPassword}
|
||||
totpLive={totpLive}
|
||||
passkeyCreatedAt={passkeyCreatedAt}
|
||||
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
||||
folderName={folderName}
|
||||
onOpenReprompt={() => setRepromptOpen(true)}
|
||||
onToggleShowPassword={() => setShowPassword((value) => !value)}
|
||||
onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
|
||||
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
||||
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||
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>}
|
||||
@@ -863,6 +974,8 @@ function folderName(id: string | null | undefined): string {
|
||||
fieldType={fieldType}
|
||||
fieldLabel={fieldLabel}
|
||||
fieldValue={fieldValue}
|
||||
archiveConfirmOpen={!!pendingArchive}
|
||||
bulkArchiveOpen={bulkArchiveOpen}
|
||||
pendingDeleteOpen={!!pendingDelete}
|
||||
bulkDeleteOpen={bulkDeleteOpen}
|
||||
sidebarTrashMode={sidebarFilter.kind === 'trash'}
|
||||
@@ -905,6 +1018,10 @@ function folderName(id: string | null | undefined): string {
|
||||
onFieldTypeChange={setFieldType}
|
||||
onFieldLabelChange={setFieldLabel}
|
||||
onFieldValueChange={setFieldValue}
|
||||
onConfirmArchive={() => void confirmArchiveSelected()}
|
||||
onCancelArchive={() => setPendingArchive(null)}
|
||||
onConfirmBulkArchive={() => void confirmBulkArchive()}
|
||||
onCancelBulkArchive={() => setBulkArchiveOpen(false)}
|
||||
onConfirmDelete={() => void deleteSelected()}
|
||||
onCancelDelete={() => setPendingDelete(null)}
|
||||
onConfirmBulkDelete={() => void confirmBulkDelete()}
|
||||
|
||||
@@ -256,6 +256,23 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<span>{t('txt_backup_timezone')}</span>
|
||||
<select
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { t } from '@/lib/i18n';
|
||||
import {
|
||||
@@ -31,11 +31,14 @@ interface VaultDetailViewProps {
|
||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onDelete: (cipher: Cipher) => void;
|
||||
onArchive: (cipher: Cipher) => void | Promise<void>;
|
||||
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
||||
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
||||
const formatDownloadLabel = (attachmentId: string) => {
|
||||
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||
@@ -62,6 +65,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
<div className="card">
|
||||
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
|
||||
<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>
|
||||
|
||||
{props.selectedCipher.login && (
|
||||
@@ -265,29 +269,36 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
if (fieldType === 2) {
|
||||
const checked = toBooleanFieldValue(rawValue);
|
||||
return (
|
||||
<div key={`view-field-${index}`} className="kv-row custom-field-row">
|
||||
<span className="kv-label" title={fieldName}>{fieldName}</span>
|
||||
<div className="kv-main boolean-main">
|
||||
<label className="check-line cf-check view">
|
||||
<input type="checkbox" checked={checked} disabled />
|
||||
</label>
|
||||
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
|
||||
{checked ? t('txt_checked') : t('txt_unchecked')}
|
||||
</span>
|
||||
<div key={`view-field-${index}`} className="custom-field-card">
|
||||
<div className="custom-field-label">{fieldName}</div>
|
||||
<div className="custom-field-body">
|
||||
<div className="custom-field-value">
|
||||
<label className="check-line cf-check view custom-field-check">
|
||||
<input type="checkbox" checked={checked} disabled />
|
||||
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
|
||||
{checked ? t('txt_checked') : t('txt_unchecked')}
|
||||
</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 className="kv-actions" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={`view-field-${index}`} className="kv-row custom-field-row">
|
||||
<span className="kv-label" title={fieldName}>{fieldName}</span>
|
||||
<div className="kv-main">
|
||||
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
|
||||
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<div key={`view-field-${index}`} className="custom-field-card">
|
||||
<div className="custom-field-label" title={fieldName}>{fieldName}</div>
|
||||
<div className="custom-field-body">
|
||||
<div className="custom-field-value">
|
||||
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
|
||||
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
{fieldType === 1 && (
|
||||
<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" />}
|
||||
@@ -297,6 +308,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
|
||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -351,6 +363,15 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
|
||||
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
|
||||
</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>
|
||||
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
|
||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||
|
||||
@@ -8,6 +8,8 @@ interface VaultDialogsProps {
|
||||
fieldType: CustomFieldType;
|
||||
fieldLabel: string;
|
||||
fieldValue: string;
|
||||
archiveConfirmOpen: boolean;
|
||||
bulkArchiveOpen: boolean;
|
||||
pendingDeleteOpen: boolean;
|
||||
bulkDeleteOpen: boolean;
|
||||
sidebarTrashMode: boolean;
|
||||
@@ -26,6 +28,10 @@ interface VaultDialogsProps {
|
||||
onFieldTypeChange: (value: CustomFieldType) => void;
|
||||
onFieldLabelChange: (value: string) => void;
|
||||
onFieldValueChange: (value: string) => void;
|
||||
onConfirmArchive: () => void;
|
||||
onCancelArchive: () => void;
|
||||
onConfirmBulkArchive: () => void;
|
||||
onCancelBulkArchive: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onConfirmBulkDelete: () => void;
|
||||
@@ -88,6 +94,26 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
)}
|
||||
</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
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
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 { 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 {
|
||||
draft: VaultDraft;
|
||||
@@ -24,6 +42,8 @@ interface VaultEditorProps {
|
||||
onSeedSshDefaults: (force?: boolean) => void;
|
||||
onUpdateSshPublicKey: (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;
|
||||
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
||||
onRemoveQueuedAttachment: (index: number) => void;
|
||||
@@ -36,7 +56,108 @@ interface VaultEditorProps {
|
||||
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) {
|
||||
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 downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
|
||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||
@@ -52,6 +173,32 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
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 (
|
||||
<>
|
||||
<div className="card">
|
||||
@@ -119,21 +266,27 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
</label>
|
||||
<div className="section-head">
|
||||
<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')}
|
||||
</button>
|
||||
</div>
|
||||
{props.draft.loginUris.map((uri, index) => (
|
||||
<div key={`uri-${index}`} className="website-row">
|
||||
<input className="input" value={uri} onInput={(e) => props.onUpdateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} />
|
||||
{props.draft.loginUris.length > 1 && (
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, i) => i !== index) })}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleWebsiteDragStart} onDragEnd={handleWebsiteDragEnd}>
|
||||
<SortableContext items={uriItemIds} strategy={verticalListSortingStrategy}>
|
||||
{props.draft.loginUris.map((uriEntry, index) => (
|
||||
<SortableWebsiteRow
|
||||
key={uriItemIds[index] ?? `uri-${index}`}
|
||||
id={uriItemIds[index] ?? `uri-fallback-${index}`}
|
||||
uriEntry={uriEntry}
|
||||
index={index}
|
||||
canRemove={props.draft.loginUris.length > 1}
|
||||
isDragging={activeUriId === uriItemIds[index]}
|
||||
onUpdateUri={props.onUpdateDraftLoginUri}
|
||||
onUpdateMatch={props.onUpdateDraftLoginUriMatch}
|
||||
onRemove={removeLoginUri}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -322,23 +475,31 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
.map((field, originalIndex) => ({ field, originalIndex }))
|
||||
.filter((entry) => entry.field.type !== 3)
|
||||
.map(({ field, originalIndex }) => (
|
||||
<div key={`field-${originalIndex}`} className="uri-row">
|
||||
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
|
||||
{field.type === 2 ? (
|
||||
<label className="check-line cf-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toBooleanFieldValue(field.value)}
|
||||
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_remove')}
|
||||
</button>
|
||||
<div key={`field-${originalIndex}`} className="custom-field-card">
|
||||
<label className="field custom-field-label">
|
||||
<span>{t('txt_field_label')}</span>
|
||||
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</label>
|
||||
<div className="custom-field-body">
|
||||
<div className="custom-field-value">
|
||||
{field.type === 2 ? (
|
||||
<label className="check-line cf-check custom-field-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toBooleanFieldValue(field.value)}
|
||||
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
|
||||
/>
|
||||
<span>{toBooleanFieldValue(field.value) ? t('txt_checked') : t('txt_unchecked')}</span>
|
||||
</label>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { t } from '@/lib/i18n';
|
||||
import {
|
||||
@@ -48,6 +48,8 @@ interface VaultListPanelProps {
|
||||
onToggleCreateMenu: () => void;
|
||||
onStartCreate: (type: number) => void;
|
||||
onBulkRestore: () => void;
|
||||
onBulkArchive: () => void;
|
||||
onBulkUnarchive: () => void;
|
||||
onOpenMove: () => void;
|
||||
onClearSelection: () => void;
|
||||
onScroll: (top: number) => void;
|
||||
@@ -102,14 +104,39 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
</button>
|
||||
</div>
|
||||
<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' && (
|
||||
<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')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
||||
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
||||
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
||||
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && (
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
||||
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||
</button>
|
||||
@@ -134,32 +161,27 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
</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 className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||
{!!props.filteredCiphers.length && (
|
||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||
{props.visibleCiphers.map((cipher) => (
|
||||
<div key={cipher.id} className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}>
|
||||
{props.visibleCiphers.map((cipher, index) => (
|
||||
<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
|
||||
type="checkbox"
|
||||
className="row-check"
|
||||
checked={!!props.selectedMap[cipher.id]}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Archive,
|
||||
Copy,
|
||||
CreditCard,
|
||||
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' })}>
|
||||
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
|
||||
</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' })}>
|
||||
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
|
||||
</button>
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
} from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
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 VaultSortMode = 'edited' | 'created' | 'name';
|
||||
export type SidebarFilter =
|
||||
| { kind: 'all' }
|
||||
| { kind: 'favorite' }
|
||||
| { kind: 'archive' }
|
||||
| { kind: 'trash' }
|
||||
| { kind: 'duplicates' }
|
||||
| { kind: 'type'; value: TypeFilter }
|
||||
@@ -50,6 +51,16 @@ export const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }
|
||||
{ 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_RING_RADIUS = 14;
|
||||
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||
@@ -71,6 +82,34 @@ export function cipherTypeKey(type: number): TypeFilter {
|
||||
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 {
|
||||
if (type === 1) return t('txt_login');
|
||||
if (type === 3) return t('txt_card');
|
||||
@@ -125,6 +164,15 @@ export function websiteIconUrl(host: string): string {
|
||||
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 {
|
||||
return String(value || '');
|
||||
}
|
||||
@@ -216,7 +264,7 @@ export function createEmptyDraft(type: number): VaultDraft {
|
||||
loginUsername: '',
|
||||
loginPassword: '',
|
||||
loginTotp: '',
|
||||
loginUris: [''],
|
||||
loginUris: [createEmptyLoginUri()],
|
||||
loginFido2Credentials: [],
|
||||
cardholderName: '',
|
||||
cardNumber: '',
|
||||
@@ -262,11 +310,14 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
||||
draft.loginUsername = cipher.login.decUsername || '';
|
||||
draft.loginPassword = cipher.login.decPassword || '';
|
||||
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)
|
||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||
: [];
|
||||
if (!draft.loginUris.length) draft.loginUris = [''];
|
||||
if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
|
||||
}
|
||||
if (cipher.card) {
|
||||
draft.cardholderName = cipher.card.decCardholderName || '';
|
||||
|
||||
@@ -22,12 +22,15 @@ import {
|
||||
} from '@/lib/app-support';
|
||||
import { buildSendShareKey, bulkDeleteSends, createSend, deleteSend, updateSend } from '@/lib/api/send';
|
||||
import {
|
||||
archiveCipher,
|
||||
buildCipherImportPayload,
|
||||
bulkArchiveCiphers,
|
||||
bulkDeleteCiphers,
|
||||
bulkDeleteFolders,
|
||||
bulkMoveCiphers,
|
||||
bulkPermanentDeleteCiphers,
|
||||
bulkRestoreCiphers,
|
||||
bulkUnarchiveCiphers,
|
||||
createCipher,
|
||||
createFolder,
|
||||
deleteCipher,
|
||||
@@ -40,6 +43,7 @@ import {
|
||||
type CiphersImportPayload,
|
||||
type ImportedCipherMapEntry,
|
||||
updateCipher,
|
||||
unarchiveCipher,
|
||||
uploadCipherAttachment,
|
||||
} from '@/lib/api/vault';
|
||||
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[]) {
|
||||
try {
|
||||
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) {
|
||||
try {
|
||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
||||
|
||||
@@ -367,12 +367,19 @@ async function encryptCustomFields(
|
||||
return out;
|
||||
}
|
||||
|
||||
async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Promise<Array<{ uri: string | null; match: null }>> {
|
||||
const out: Array<{ uri: string | null; match: null }> = [];
|
||||
for (const uri of uris || []) {
|
||||
const trimmed = String(uri || '').trim();
|
||||
async function encryptUris(
|
||||
uris: VaultDraft['loginUris'],
|
||||
enc: Uint8Array,
|
||||
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;
|
||||
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;
|
||||
}
|
||||
@@ -582,6 +589,20 @@ export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string):
|
||||
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> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
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> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
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(
|
||||
authedFetch: AuthedFetch,
|
||||
ids: string[],
|
||||
|
||||
@@ -97,7 +97,7 @@ export function buildEmptyImportDraft(type: number): VaultDraft {
|
||||
loginUsername: '',
|
||||
loginPassword: '',
|
||||
loginTotp: '',
|
||||
loginUris: [''],
|
||||
loginUris: [{ uri: '', match: null }],
|
||||
loginFido2Credentials: [],
|
||||
cardholderName: '',
|
||||
cardNumber: '',
|
||||
@@ -167,9 +167,17 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
||||
: [];
|
||||
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
||||
const uris = urisRaw
|
||||
.map((u) => asText((u as Record<string, unknown>)?.uri).trim())
|
||||
.filter((u) => !!u);
|
||||
draft.loginUris = uris.length ? uris : [''];
|
||||
.map((u) => {
|
||||
const row = (u || {}) as Record<string, unknown>;
|
||||
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) {
|
||||
const card = (cipher.card || {}) as Record<string, unknown>;
|
||||
draft.cardholderName = asText(card.cardholderName);
|
||||
|
||||
+60
-6
@@ -140,6 +140,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_backup_type: "Backup Type",
|
||||
txt_backup_destination_reserved: "Reserved Slot",
|
||||
txt_backup_time: "Backup Time",
|
||||
txt_backup_start_time: "Start Time",
|
||||
txt_backup_timezone: "Timezone",
|
||||
txt_backup_interval_hours: "Every",
|
||||
txt_backup_interval_hours_suffix: "hours",
|
||||
@@ -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: "Attachments are stored incrementally in the remote attachments folder, so later backups usually only upload new files. Deleting an attachment locally does not remove earlier remote copies. During restore, NodeWarden reads the required files from the attachments folder and skips any attachment that is no longer available.",
|
||||
txt_backup_enable_schedule: "Enable automatic daily backup",
|
||||
txt_backup_schedule_note: "The worker checks the schedule every 5 minutes 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_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_last_success: "Last Success",
|
||||
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_failed: "Delete item failed",
|
||||
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_permanently: "Delete Selected Items Permanently",
|
||||
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_max_access_count: "Max Access Count",
|
||||
txt_middle_name: "Middle Name",
|
||||
txt_drag_to_reorder: "Drag to reorder",
|
||||
txt_move: "Move",
|
||||
txt_move_selected_items: "Move Selected Items",
|
||||
txt_moved_selected_items: "Moved selected items",
|
||||
@@ -556,6 +574,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_submit: "Submit",
|
||||
txt_sync: "Sync",
|
||||
txt_sync_vault: "Sync Vault",
|
||||
txt_switch_to_dark_mode: "Switch to dark mode",
|
||||
txt_switch_to_light_mode: "Switch to light mode",
|
||||
txt_dash: "-",
|
||||
txt_text: "Text",
|
||||
txt_text_2fa_recovered: "2FA recovered",
|
||||
@@ -610,6 +630,13 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_user_deleted: "User deleted",
|
||||
txt_user_status_updated: "User status updated",
|
||||
txt_username: "Username",
|
||||
txt_uri_match_default_base_domain: "Default (Base Domain)",
|
||||
txt_uri_match_base_domain: "Base Domain",
|
||||
txt_uri_match_host: "Host",
|
||||
txt_uri_match_exact: "Exact",
|
||||
txt_uri_match_never: "Never",
|
||||
txt_uri_match_starts_with: "Starts With",
|
||||
txt_uri_match_regular_expression: "Regular Expression",
|
||||
txt_users: "Users",
|
||||
txt_vault_synced: "Vault synced",
|
||||
txt_verification_code: "Verification Code",
|
||||
@@ -761,6 +788,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_backup_type: '备份类型',
|
||||
txt_backup_destination_reserved: '预留位置',
|
||||
txt_backup_time: '备份时间',
|
||||
txt_backup_start_time: '开始时间',
|
||||
txt_backup_timezone: '时区',
|
||||
txt_backup_interval_hours: '每隔',
|
||||
txt_backup_interval_hours_suffix: '小时',
|
||||
@@ -785,10 +813,10 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_backup_include_attachments_help_button: '附件备份说明',
|
||||
txt_backup_include_attachments_help: '附件会以增量方式保存在远端的 attachments 文件夹中,后续备份通常只上传新增文件。你在本地删除附件时,已经备份到远端的旧文件不会自动删除。恢复时会按需从 attachments 文件夹读取对应附件,找不到的附件会自动跳过。',
|
||||
txt_backup_enable_schedule: '启用每日自动备份',
|
||||
txt_backup_schedule_note: 'Worker 每 5 分钟检查一次计划,到达你设定的时间窗口后会尽快执行备份。',
|
||||
txt_backup_schedule_note: 'Worker 每 5 分钟检查一次计划。会先按你选择的时区和开始时间起跑,再按小时间隔继续执行;到了下一天,会重新从开始时间开始。',
|
||||
txt_backup_schedule_disabled: '未启用',
|
||||
txt_backup_schedule_status: '计划状态',
|
||||
txt_backup_schedule_summary: '每天 {time}({timezone})',
|
||||
txt_backup_schedule_summary: '从 {time} 开始,每隔 {interval} 小时({timezone})',
|
||||
txt_backup_schedule_empty: '还没有启用任何自动备份计划',
|
||||
txt_backup_last_success: '上次成功时间',
|
||||
txt_backup_last_target: '上次备份位置',
|
||||
@@ -852,13 +880,14 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_delete: '删除',
|
||||
txt_save: '保存',
|
||||
txt_confirm: '确认',
|
||||
txt_drag_to_reorder: '拖动调整顺序',
|
||||
txt_move: '移动',
|
||||
txt_copy: '复制',
|
||||
txt_code_copied: '验证码已复制',
|
||||
txt_copy_link: '复制链接',
|
||||
txt_select_all: '全选',
|
||||
txt_select_duplicate_items: '选择重复项',
|
||||
txt_delete_selected: '删除所选',
|
||||
txt_delete_selected: '删除',
|
||||
txt_all_items: '所有项目',
|
||||
txt_favorites: '收藏',
|
||||
txt_duplicates: '重复项',
|
||||
@@ -888,6 +917,13 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_last_edited_value: '最后编辑:{value}',
|
||||
txt_created_value: '创建于:{value}',
|
||||
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_websites: '网站',
|
||||
txt_open: '打开',
|
||||
@@ -1185,6 +1221,8 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_totp_is_enabled_for_this_account: '此账户已启用 TOTP。',
|
||||
txt_total_items_count: '共 {count} 项',
|
||||
txt_totp_verify_failed: 'TOTP 验证失败',
|
||||
txt_switch_to_dark_mode: '切换到暗黑模式',
|
||||
txt_switch_to_light_mode: '切换到明亮模式',
|
||||
txt_trust_this_device_for_30_days: '信任此设备 30 天',
|
||||
txt_type_type: '类型 {type}',
|
||||
txt_unlock_details: '解锁详情',
|
||||
@@ -1363,6 +1401,22 @@ zhCNOverrides.txt_import_encrypted_zip_message = '该 ZIP 压缩包已加密,
|
||||
zhCNOverrides.txt_import_export_title = '导入导出';
|
||||
zhCNOverrides.txt_new_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_message = '删除文件夹「{name}」?其中的项目将移至无文件夹。';
|
||||
zhCNOverrides.txt_delete_all_folders = '删除全部文件夹';
|
||||
|
||||
@@ -28,9 +28,15 @@ export interface Folder {
|
||||
|
||||
export interface CipherLoginUri {
|
||||
uri?: string | null;
|
||||
match?: number | null;
|
||||
decUri?: string;
|
||||
}
|
||||
|
||||
export interface VaultDraftLoginUri {
|
||||
uri: string;
|
||||
match: number | null;
|
||||
}
|
||||
|
||||
export interface CipherAttachment {
|
||||
id?: string;
|
||||
url?: string | null;
|
||||
@@ -143,6 +149,7 @@ export interface Cipher {
|
||||
creationDate?: string;
|
||||
revisionDate?: string;
|
||||
deletedDate?: string | null;
|
||||
archivedDate?: string | null;
|
||||
attachments?: CipherAttachment[] | null;
|
||||
login?: CipherLogin | null;
|
||||
card?: CipherCard | null;
|
||||
@@ -220,7 +227,7 @@ export interface VaultDraft {
|
||||
loginUsername: string;
|
||||
loginPassword: string;
|
||||
loginTotp: string;
|
||||
loginUris: string[];
|
||||
loginUris: VaultDraftLoginUri[];
|
||||
loginFido2Credentials: Array<Record<string, unknown>>;
|
||||
cardholderName: string;
|
||||
cardNumber: string;
|
||||
|
||||
+1543
-135
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user