mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 14:50:11 +00:00
Compare commits
19
Commits
v1.7.3
..
aae614a079
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aae614a079 | ||
|
|
0e46cd371f | ||
|
|
db31792cef | ||
|
|
8c65cb2e80 | ||
|
|
14dff8ee6a | ||
|
|
8d399f431b | ||
|
|
bb3f866220 | ||
|
|
39d9df78ea | ||
|
|
a1b12fc447 | ||
|
|
099217062a | ||
|
|
dd90d7b8b8 | ||
|
|
525b773cf4 | ||
|
|
04cb475935 | ||
|
|
e063f45cd9 | ||
|
|
e10920d142 | ||
|
|
b07edb0850 | ||
|
|
58a86ae8fd | ||
|
|
b986af86dc | ||
|
|
8e33f92b33 |
@@ -0,0 +1,2 @@
|
|||||||
|
custom:
|
||||||
|
- https://nodewarden.app/sponsor
|
||||||
+1
-1
@@ -43,7 +43,7 @@ tmp/
|
|||||||
.tmp/
|
.tmp/
|
||||||
.tmp-bitwarden-clients/
|
.tmp-bitwarden-clients/
|
||||||
|
|
||||||
nodewarden.wiki/
|
nodewarden-wiki/
|
||||||
wiki/
|
wiki/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
settings.json
|
settings.json
|
||||||
|
|||||||
@@ -3,95 +3,100 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
Bitwarden-compatible server running on Cloudflare Workers
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="./README_EN.md">English</a> |
|
<a href="./README_ZH.md">中文</a> |
|
||||||
<a href="./CONTRIBUTING.md">贡献指南</a>
|
<a href="./CONTRIBUTING.md">Contributing</a> |
|
||||||
|
<a href="https://nodewarden.app">Official wiki</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> **免责声明**
|
> **Disclaimer**
|
||||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
> This project is for learning and discussion purposes only. Please back up your vault regularly.
|
||||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 与 Bitwarden 官方服务端能力对比
|
## Feature comparison with the official Bitwarden server
|
||||||
|
|
||||||
| 能力 | Bitwarden | NodeWarden | 说明 |
|
| Feature | Bitwarden Free | NodeWarden | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
| Web vault | ✅ | ✅ | **Original Web Vault UI** |
|
||||||
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
|
| TOTP | ❌ | ✅ | Includes `steam://` support |
|
||||||
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
|
| **PWA / offline** | ❌ | ✅ | **Installable, offline** |
|
||||||
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
| **Passkey login** | ✅ | ✅ | **passwordless auth** |
|
||||||
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
| API keys | ✅ | ✅ | CLI keys; create and rotate |
|
||||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
| Login 2FA | ✅ | ✅ | TOTP, YubiKey, Passkey |
|
||||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
| 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
|
||||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
| Real-time push sync | ✅ | ✅ | All device sync |
|
||||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** |
|
| Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
|
||||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
| Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
|
||||||
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
| **Cloud backup center** | ❌ | ✅ | **Scheduled WebDAV / S3 incrementals** |
|
||||||
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
| Device management | ✅ | ✅ | **Remove devices; trust controls** |
|
||||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
| Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
|
||||||
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
|
| **Multi-user** | ✅ | ✅ | Invite-code registration |
|
||||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
| Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
|
||||||
|
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
|
||||||
|
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
|
||||||
|
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 已测试客户端
|
## Tested clients
|
||||||
|
|
||||||
- ✅ Windows 桌面端
|
- ✅ Windows desktop
|
||||||
- ✅ 手机 App
|
- ✅ Mobile app
|
||||||
- ✅ 浏览器扩展
|
- ✅ Browser extension
|
||||||
- ✅ Linux 桌面端
|
- ✅ Linux desktop
|
||||||
- ⚠️ macOS 桌面端尚未完整验证
|
- ⚠️ macOS desktop not fully verified yet
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 可视化快速部署
|
## Visual quick deploy
|
||||||
|
|
||||||
1. Fork NodeWarden 仓库到自己的 GitHub 账号
|
1. Fork the NodeWarden repository to your GitHub account
|
||||||
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||||
3. 选择 Continue with GitHub 并选择你的仓库
|
3. Choose **Continue with GitHub** and select your fork
|
||||||
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
|
||||||
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
- For KV mode, change the deploy command to `npm run deploy:kv`
|
||||||
5. 等部署完成后,打开生成的 Workers 域名
|
5. After deployment finishes, open the generated Workers URL
|
||||||
|
|
||||||
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
|
- The default Workers hostname may be unreachable on some networks. To use a custom domain, add it in [Workers settings](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings).
|
||||||
|
|
||||||
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
|
- If the site reports a missing `JWT_SECRET`, add it as a **Secret** in Workers settings. In production use a random string of at least 32 characters; do not use temporary or example values.
|
||||||
|
|
||||||
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
- In this flow you hand code to Cloudflare to build and deploy. `wrangler.toml` or `wrangler.kv.toml` in the repo defines binding names; the Worker initializes the D1 schema on first request—no manual SQL upload.
|
||||||
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 默认R2与可选KV的区别:
|
> Default R2 vs optional KV:
|
||||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
> | Storage | Card required | Max single attachment / Send file | Free tier |
|
||||||
> |---|---|---|---|
|
> |---|---|---|---|
|
||||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
> | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||||
|
|
||||||
|
|
||||||
## 更新方法:
|
## How to update
|
||||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
|
||||||
- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork** → **Update branch**
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## CLI 部署
|
|
||||||
|
## CLI deploy
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
@@ -100,82 +105,31 @@ cd NodeWarden
|
|||||||
npm install
|
npm install
|
||||||
npx wrangler login
|
npx wrangler login
|
||||||
|
|
||||||
# 默认:R2 模式
|
# Default: R2 mode
|
||||||
npm run deploy
|
npm run deploy
|
||||||
|
|
||||||
# 可选:KV 模式
|
# Optional: KV mode
|
||||||
npm run deploy:kv
|
npm run deploy:kv
|
||||||
|
|
||||||
# 本地开发
|
# Local development
|
||||||
npm run dev
|
npm run dev
|
||||||
npm run dev:kv
|
npm run dev:kv
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 主要特性
|
|
||||||
|
|
||||||
### PWA 渐进式 Web 应用
|
## License
|
||||||
|
|
||||||
- ✅ **可安装到桌面** - 像原生应用一样运行
|
|
||||||
- ✅ **离线使用** - Service Worker 缓存,离线也能查看密码
|
|
||||||
- ✅ **App 快捷方式** - 快速启动保险库、TOTP代码
|
|
||||||
- ✅ **后台解密** - Web Worker 处理解密,不阻塞UI
|
|
||||||
|
|
||||||
### Passkey 无密码登录
|
|
||||||
|
|
||||||
- ✅ **WebAuthn/FIDO2 支持** - 使用指纹、Face ID等登录
|
|
||||||
- ✅ **PRF 密钥解锁** - Passkey 可直接解锁保险库
|
|
||||||
- ✅ **官方客户端兼容** - Chromium系浏览器扩展可用Passkey登录
|
|
||||||
- ✅ **多设备同步** - 支持iCloud、Google Password Manager等
|
|
||||||
|
|
||||||
### 云端备份说明
|
|
||||||
|
|
||||||
- 远程备份支持 **WebDAV** 与 **S3**
|
|
||||||
- 支持 **OneDrive**(通过Koofr)、**Google Drive**(通过Koofr)、**Cloudflare R2**、**Backblaze B2** 等
|
|
||||||
- 勾选”包含附件”后:
|
|
||||||
- ZIP 内仍只包含 `db.json` 与 `manifest.json`
|
|
||||||
- 真实附件单独存放在 `attachments/`
|
|
||||||
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
|
|
||||||
- 远程还原时:
|
|
||||||
- 会从 `attachments/` 目录按需读取附件
|
|
||||||
- 缺失的附件会被安全跳过
|
|
||||||
- 被跳过的附件不会在恢复后的数据库中留下脏记录
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 导入 / 导出
|
|
||||||
|
|
||||||
当前支持的导入来源包括:
|
|
||||||
|
|
||||||
- Bitwarden JSON
|
|
||||||
- Bitwarden CSV
|
|
||||||
- Bitwarden 密码库 + 附件 ZIP
|
|
||||||
- NodeWarden JSON
|
|
||||||
- 网页导入器里可见的多种浏览器 / 密码管理器格式
|
|
||||||
|
|
||||||
当前支持的导出方式包括:
|
|
||||||
|
|
||||||
- Bitwarden JSON
|
|
||||||
- Bitwarden 加密 JSON
|
|
||||||
- 带附件的 ZIP 导出
|
|
||||||
- NodeWarden JSON 系列
|
|
||||||
- 备份中心中的实例级完整手动导出
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## 开源协议
|
|
||||||
|
|
||||||
LGPL-3.0 License
|
LGPL-3.0 License
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 致谢
|
## Credits
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
- [Bitwarden](https://bitwarden.com/) - Original design and clients
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
-172
@@ -1,172 +0,0 @@
|
|||||||
<p align="center">
|
|
||||||
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
Bitwarden-compatible server running on Cloudflare Workers
|
|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
|
||||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
|
||||||
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
|
||||||
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="./README.md">中文说明</a> |
|
|
||||||
<a href="./CONTRIBUTING.md">Contributing</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
> **Disclaimer**
|
|
||||||
>
|
|
||||||
> This project is for learning and discussion 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 with the Official Bitwarden Server
|
|
||||||
|
|
||||||
| Capability | Bitwarden | NodeWarden | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
|
||||||
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
|
|
||||||
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
|
|
||||||
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
|
|
||||||
| Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time |
|
|
||||||
| 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** | ❌ | ✅ | **WebDAV / S3 scheduled backup (OneDrive/Google Drive etc.)** |
|
|
||||||
| 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 | TOTP and Passkey (as second factor) |
|
|
||||||
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tested Clients
|
|
||||||
|
|
||||||
- ✅ Windows desktop client
|
|
||||||
- ✅ Mobile app
|
|
||||||
- ✅ Browser extension
|
|
||||||
- ✅ Linux desktop client
|
|
||||||
- ⚠️ macOS desktop client has not been fully verified yet
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Web Deploy
|
|
||||||
|
|
||||||
1. Fork this repository. If this project helps you, 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`) -> continue.
|
|
||||||
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
|
|
||||||
4. Deploy and open the generated URL.
|
|
||||||
|
|
||||||
| Storage | Card required | Single attachment / Send file limit | Free tier |
|
|
||||||
|---|---|---|---|
|
|
||||||
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
|
||||||
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
|
||||||
|
|
||||||
> [!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
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
|
||||||
cd NodeWarden
|
|
||||||
npm install
|
|
||||||
npx wrangler login
|
|
||||||
|
|
||||||
# Default: R2 mode
|
|
||||||
npm run deploy
|
|
||||||
|
|
||||||
# Optional: KV mode
|
|
||||||
npm run deploy:kv
|
|
||||||
|
|
||||||
# Local development
|
|
||||||
npm run dev
|
|
||||||
npm run dev:kv
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
### PWA Progressive Web App
|
|
||||||
|
|
||||||
- ✅ **Install to desktop** - Runs like a native app
|
|
||||||
- ✅ **Offline usage** - Service Worker caching, view passwords offline
|
|
||||||
- ✅ **App shortcuts** - Quick launch vault, TOTP codes
|
|
||||||
- ✅ **Background decryption** - Web Worker handles decryption without blocking UI
|
|
||||||
|
|
||||||
### Passkey Passwordless Login
|
|
||||||
|
|
||||||
- ✅ **WebAuthn/FIDO2 support** - Login with fingerprint, Face ID, etc.
|
|
||||||
- ✅ **PRF key unlock** - Passkey can unlock vault directly
|
|
||||||
- ✅ **Official client compatibility** - Chromium browser extension supports Passkey login
|
|
||||||
- ✅ **Multi-device sync** - Supports iCloud, Google Password Manager, etc.
|
|
||||||
|
|
||||||
### Cloud Backup Notes
|
|
||||||
|
|
||||||
- Remote backup supports **WebDAV** and **S3**
|
|
||||||
- Supports **OneDrive** (via Koofr), **Google Drive** (via Koofr), **Cloudflare R2**, **Backblaze B2**, etc.
|
|
||||||
- When `Include attachments` is enabled:
|
|
||||||
- the ZIP still contains only `db.json` and `manifest.json`
|
|
||||||
- actual attachment files are stored separately under `attachments/`
|
|
||||||
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
|
|
||||||
- During remote restore:
|
|
||||||
- required attachment files are loaded from `attachments/` on demand
|
|
||||||
- missing attachments are skipped safely
|
|
||||||
- skipped attachments do not leave broken rows in the restored database
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Import / Export
|
|
||||||
|
|
||||||
Current supported import sources include:
|
|
||||||
|
|
||||||
- Bitwarden JSON
|
|
||||||
- Bitwarden CSV
|
|
||||||
- Bitwarden vault + attachments ZIP
|
|
||||||
- NodeWarden JSON
|
|
||||||
- Multiple browser / password-manager formats available 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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
LGPL-3.0 License
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Credits
|
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - Original design and clients
|
|
||||||
- [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)
|
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
|
||||||
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
|
||||||
|
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
||||||
|
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="./README.md">English</a> |
|
||||||
|
<a href="./CONTRIBUTING.md">贡献指南</a> |
|
||||||
|
<a href="https://nodewarden.app">官方wiki</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> **免责声明**
|
||||||
|
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||||
|
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与 Bitwarden 官方服务端能力对比
|
||||||
|
|
||||||
|
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||||
|
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
|
||||||
|
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
|
||||||
|
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
||||||
|
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
|
||||||
|
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
|
||||||
|
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
|
||||||
|
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
||||||
|
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
|
||||||
|
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||||
|
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
|
||||||
|
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
|
||||||
|
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
|
||||||
|
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
|
||||||
|
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
|
||||||
|
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
|
||||||
|
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||||
|
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已测试客户端
|
||||||
|
|
||||||
|
- ✅ Windows 桌面端
|
||||||
|
- ✅ 手机 App
|
||||||
|
- ✅ 浏览器扩展
|
||||||
|
- ✅ Linux 桌面端
|
||||||
|
- ⚠️ macOS 桌面端尚未完整验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 可视化快速部署
|
||||||
|
|
||||||
|
1. Fork NodeWarden 仓库到自己的 GitHub 账号
|
||||||
|
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||||
|
3. 选择 Continue with GitHub 并选择你的仓库
|
||||||
|
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
||||||
|
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||||
|
5. 等部署完成后,打开生成的 Workers 域名
|
||||||
|
|
||||||
|
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
|
||||||
|
|
||||||
|
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
|
||||||
|
|
||||||
|
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` 或 `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
|
||||||
|
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 默认R2与可选KV的区别:
|
||||||
|
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||||
|
> |---|---|---|---|
|
||||||
|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||||
|
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||||
|
|
||||||
|
|
||||||
|
## 更新方法:
|
||||||
|
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## CLI 部署
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
|
cd NodeWarden
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npx wrangler login
|
||||||
|
|
||||||
|
# 默认:R2 模式
|
||||||
|
npm run deploy
|
||||||
|
|
||||||
|
# 可选:KV 模式
|
||||||
|
npm run deploy:kv
|
||||||
|
|
||||||
|
# 本地开发
|
||||||
|
npm run dev
|
||||||
|
npm run dev:kv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 开源协议
|
||||||
|
|
||||||
|
LGPL-3.0 License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
- [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)
|
||||||
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
security_stamp TEXT NOT NULL,
|
security_stamp TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'user',
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
verify_devices INTEGER NOT NULL DEFAULT 1,
|
verify_devices INTEGER NOT NULL DEFAULT 0,
|
||||||
totp_secret TEXT,
|
totp_secret TEXT,
|
||||||
totp_recovery_code TEXT,
|
totp_recovery_code TEXT,
|
||||||
api_key TEXT,
|
api_key TEXT,
|
||||||
|
|||||||
Generated
+670
-410
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -58,7 +58,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tsx": "^4.22.4",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^8.1.3",
|
||||||
"wrangler": "^4.105.0"
|
"wrangler": "^4.105.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const localeFiles = [
|
|||||||
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
|
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
|
||||||
['ru', 'ru.ts', 'ru', 'Russian'],
|
['ru', 'ru.ts', 'ru', 'Russian'],
|
||||||
['es', 'es.ts', 'es', 'Spanish'],
|
['es', 'es.ts', 'es', 'Spanish'],
|
||||||
|
['fi', 'fi.ts', 'fi', 'Finnish'],
|
||||||
];
|
];
|
||||||
|
|
||||||
function readLocale(fileName, variableName) {
|
function readLocale(fileName, variableName) {
|
||||||
|
|||||||
+31
-50
@@ -352,7 +352,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
securityStamp: generateUUID(),
|
securityStamp: generateUUID(),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
verifyDevices: true,
|
verifyDevices: false, // new-device verification requires email delivery (not available)
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
totpRecoveryCode: null,
|
totpRecoveryCode: null,
|
||||||
yubikeyKey1: null,
|
yubikeyKey1: null,
|
||||||
@@ -553,51 +553,31 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PUT/POST /api/accounts/verify-devices
|
// PUT/POST /api/accounts/verify-devices
|
||||||
|
// New-device verification requires an email delivery channel which NodeWarden
|
||||||
|
// does not provide. This endpoint always rejects the request so clients receive
|
||||||
|
// clear feedback that the feature is unavailable rather than silently ignoring
|
||||||
|
// the user's preference.
|
||||||
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const auth = new AuthService(env);
|
const auth = new AuthService(env);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
let body: {
|
// Log the attempt for audit purposes, but do not change state.
|
||||||
secret?: string;
|
|
||||||
masterPasswordHash?: string;
|
|
||||||
verifyDevices?: boolean;
|
|
||||||
VerifyDevices?: boolean;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
body = await request.json();
|
|
||||||
} catch {
|
|
||||||
return errorResponse('Invalid JSON', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
|
|
||||||
if (typeof 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 = verifyDevices;
|
|
||||||
user.updatedAt = new Date().toISOString();
|
|
||||||
await storage.saveUser(user);
|
|
||||||
await writeAuditEvent(storage, {
|
await writeAuditEvent(storage, {
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
action: 'account.verify_devices.update',
|
action: 'account.verify_devices.update.rejected',
|
||||||
category: 'security',
|
category: 'security',
|
||||||
level: 'security',
|
level: 'info',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
metadata: {
|
metadata: {
|
||||||
verifyDevices: user.verifyDevices,
|
reason: 'new-device verification is not supported (no email delivery channel)',
|
||||||
...auditRequestMetadata(request),
|
...auditRequestMetadata(request),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/accounts/keys
|
// GET /api/accounts/keys
|
||||||
@@ -819,13 +799,16 @@ function yubiKeyResponse(user: User): Record<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function deviceVerificationSettingsResponse(user: User): Record<string, unknown> {
|
// New-device verification requires an email delivery channel to send OTP
|
||||||
const enabled = user.verifyDevices !== false;
|
// challenges to unknown devices. NodeWarden does not integrate with an email
|
||||||
|
// provider, so this feature is intentionally unavailable. The settings
|
||||||
|
// response always reports disabled regardless of any legacy DB value.
|
||||||
|
function deviceVerificationSettingsResponse(_user: User): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
Enabled: enabled,
|
Enabled: false,
|
||||||
enabled,
|
enabled: false,
|
||||||
VerifyDevices: enabled,
|
VerifyDevices: false,
|
||||||
verifyDevices: enabled,
|
verifyDevices: false,
|
||||||
Object: 'deviceVerificationSettings',
|
Object: 'deviceVerificationSettings',
|
||||||
object: 'deviceVerificationSettings',
|
object: 'deviceVerificationSettings',
|
||||||
};
|
};
|
||||||
@@ -915,9 +898,10 @@ export async function handleGetDeviceVerificationSettings(request: Request, env:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PUT/POST /api/two-factor/device-verification-settings
|
// PUT/POST /api/two-factor/device-verification-settings
|
||||||
|
// New-device verification is not supported (no email delivery channel).
|
||||||
|
// Reject any attempt to enable it; always return disabled state.
|
||||||
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const auth = new AuthService(env);
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
@@ -929,31 +913,28 @@ export async function handlePutDeviceVerificationSettings(request: Request, env:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices;
|
const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices;
|
||||||
if (typeof rawEnabled !== 'boolean') {
|
|
||||||
return errorResponse('enabled must be true or false', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']);
|
// Log the attempt for audit purposes — never change state.
|
||||||
const verified = await verifyUserSecret(auth, user, secret);
|
|
||||||
if (!verified) return errorResponse('User verification failed.', 400);
|
|
||||||
|
|
||||||
user.verifyDevices = rawEnabled;
|
|
||||||
user.updatedAt = new Date().toISOString();
|
|
||||||
await storage.saveUser(user);
|
|
||||||
await writeAuditEvent(storage, {
|
await writeAuditEvent(storage, {
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
action: 'account.verify_devices.update',
|
action: 'account.verify_devices.update.rejected',
|
||||||
category: 'security',
|
category: 'security',
|
||||||
level: 'security',
|
level: 'info',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
metadata: {
|
metadata: {
|
||||||
verifyDevices: user.verifyDevices,
|
requested: rawEnabled,
|
||||||
|
reason: 'new-device verification is not supported (no email delivery channel)',
|
||||||
source: 'two-factor.device-verification-settings',
|
source: 'two-factor.device-verification-settings',
|
||||||
...auditRequestMetadata(request),
|
...auditRequestMetadata(request),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (rawEnabled === true) {
|
||||||
|
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setting to false is the only supported state — return it.
|
||||||
return jsonResponse(deviceVerificationSettingsResponse(user));
|
return jsonResponse(deviceVerificationSettingsResponse(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,15 @@ export function formatSize(bytes: number): string {
|
|||||||
|
|
||||||
export function parseDate(raw: unknown): Date | null {
|
export function parseDate(raw: unknown): Date | null {
|
||||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||||
const date = new Date(raw);
|
let value = raw.trim();
|
||||||
|
if (!/[zZ]$/.test(value) && !/[+\-]\d{2}:?\d{2}$/.test(value)) {
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
|
||||||
|
value += 'Z';
|
||||||
|
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value)) {
|
||||||
|
value = value.replace(' ', 'T') + 'Z';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return null;
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ function isWorkerHandledPath(path: string): boolean {
|
|||||||
path.startsWith('/api/') ||
|
path.startsWith('/api/') ||
|
||||||
path.startsWith('/identity/') ||
|
path.startsWith('/identity/') ||
|
||||||
path.startsWith('/icons/') ||
|
path.startsWith('/icons/') ||
|
||||||
|
path.startsWith('/fill-assist/') ||
|
||||||
path.startsWith('/notifications/') ||
|
path.startsWith('/notifications/') ||
|
||||||
path.startsWith('/.well-known/') ||
|
path.startsWith('/.well-known/') ||
|
||||||
|
path === '/v1/assetlinks:check' ||
|
||||||
|
path === '/web-bootstrap' ||
|
||||||
path === '/config' ||
|
path === '/config' ||
|
||||||
path === '/api/config' ||
|
path === '/api/config' ||
|
||||||
path === '/api/version'
|
path === '/api/version'
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
|||||||
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
||||||
users: cloneRows(payload.users || []).map((row) => ({
|
users: cloneRows(payload.users || []).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
verify_devices: row.verify_devices ?? 1,
|
verify_devices: row.verify_devices ?? 0,
|
||||||
yubikey_nfc: row.yubikey_nfc ?? 0,
|
yubikey_nfc: row.yubikey_nfc ?? 0,
|
||||||
})),
|
})),
|
||||||
domain_settings: cloneRows(payload.domain_settings || []),
|
domain_settings: cloneRows(payload.domain_settings || []),
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key 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 0, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 0',
|
||||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
|
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function mapUserRow(row: any): User {
|
|||||||
securityStamp: row.security_stamp,
|
securityStamp: row.security_stamp,
|
||||||
role: row.role === 'admin' ? 'admin' : 'user',
|
role: row.role === 'admin' ? 'admin' : 'user',
|
||||||
status: row.status === 'banned' ? 'banned' : 'active',
|
status: row.status === 'banned' ? 'banned' : 'active',
|
||||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
verifyDevices: row.verify_devices == null ? false : !!row.verify_devices,
|
||||||
totpSecret: row.totp_secret ?? null,
|
totpSecret: row.totp_secret ?? null,
|
||||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||||
yubikeyKey1: row.yubikey_key1 ?? null,
|
yubikeyKey1: row.yubikey_key1 ?? null,
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
|
|
||||||
|
// Keep this list aligned with Bitwarden server's default FIDO2 origins.
|
||||||
|
// These are the stable store IDs for the official Chromium-based extensions.
|
||||||
|
export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
|
||||||
|
'chrome-extension://nngceckbapebfimnlniiiahkandclblb',
|
||||||
|
'chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh',
|
||||||
|
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function normalizeOrigin(value: unknown): string | null {
|
export function normalizeOrigin(value: unknown): string | null {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -25,7 +33,7 @@ export function isBrowserExtensionOrigin(origin: unknown): boolean {
|
|||||||
export function getConfiguredWebAuthnAllowedOrigins(
|
export function getConfiguredWebAuthnAllowedOrigins(
|
||||||
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
|
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
|
||||||
): string[] {
|
): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS);
|
||||||
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
|
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
|
||||||
const origin = normalizeOrigin(item);
|
const origin = normalizeOrigin(item);
|
||||||
if (origin) seen.add(origin);
|
if (origin) seen.add(origin);
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
|||||||
forcePasswordReset: false,
|
forcePasswordReset: false,
|
||||||
avatarColor: null,
|
avatarColor: null,
|
||||||
creationDate: user.createdAt,
|
creationDate: user.createdAt,
|
||||||
verifyDevices: user.verifyDevices !== false,
|
// New-device verification is not supported without an email delivery channel.
|
||||||
|
// Always report disabled so clients do not present a false security posture.
|
||||||
|
verifyDevices: false,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
status: user.status,
|
status: user.status,
|
||||||
object: 'profile',
|
object: 'profile',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
import { AlertTriangle, ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
import { getCurrentNetworkStatus, subscribeNetworkStatus, type NetworkStatus } from '@/lib/network-status';
|
||||||
|
|
||||||
interface LoginValues {
|
interface LoginValues {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -81,6 +82,36 @@ function PasswordField(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OfflineModeNotice() {
|
||||||
|
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
|
||||||
|
|
||||||
|
useEffect(() => subscribeNetworkStatus(setStatus), []);
|
||||||
|
|
||||||
|
if (status !== 'offline') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="offline-mode-notice" role="alert" aria-live="assertive">
|
||||||
|
<div>
|
||||||
|
<strong>{t('txt_offline_mode_notice_title')}</strong>
|
||||||
|
<div className="offline-shortcut-list">
|
||||||
|
<div className="offline-shortcut-row">
|
||||||
|
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_windows')}</span>
|
||||||
|
<span className="offline-shortcut-value">
|
||||||
|
<span className="offline-shortcut-chord"><kbd>Ctrl</kbd><span>+</span><kbd>F5</kbd></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="offline-shortcut-row">
|
||||||
|
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_macos')}</span>
|
||||||
|
<span className="offline-shortcut-value">
|
||||||
|
<span className="offline-shortcut-chord"><kbd>Command</kbd><span>+</span><kbd>Shift</kbd><span>+</span><kbd>R</kbd></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuthViews(props: AuthViewsProps) {
|
export default function AuthViews(props: AuthViewsProps) {
|
||||||
const loginBusy = props.pendingAction === 'login';
|
const loginBusy = props.pendingAction === 'login';
|
||||||
const passkeyBusy = props.pendingAction === 'passkey';
|
const passkeyBusy = props.pendingAction === 'passkey';
|
||||||
@@ -99,6 +130,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
props.onSubmitUnlock();
|
props.onSubmitUnlock();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<OfflineModeNotice />
|
||||||
<p className="muted standalone-muted">{props.emailForLock}</p>
|
<p className="muted standalone-muted">{props.emailForLock}</p>
|
||||||
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
||||||
<PasswordField
|
<PasswordField
|
||||||
@@ -245,6 +277,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
props.onSubmitLogin();
|
props.onSubmitLogin();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<OfflineModeNotice />
|
||||||
{passkeyPasswordPending ? (
|
{passkeyPasswordPending ? (
|
||||||
<>
|
<>
|
||||||
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
|
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
const cardRef = useRef<HTMLFormElement | null>(null);
|
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||||
const maskPointerStartedRef = useRef(false);
|
const maskPointerStartedRef = useRef(false);
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
const lastTitleRef = useRef<ComponentChildren>(props.title);
|
||||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||||
const titleId = `${dialogId}-title`;
|
const titleId = `${dialogId}-title`;
|
||||||
const messageId = `${dialogId}-message`;
|
const messageId = `${dialogId}-message`;
|
||||||
@@ -95,6 +96,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.open) {
|
if (props.open) {
|
||||||
|
lastTitleRef.current = props.title;
|
||||||
setPresent(true);
|
setPresent(true);
|
||||||
setClosing(false);
|
setClosing(false);
|
||||||
return;
|
return;
|
||||||
@@ -228,7 +230,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h3 id={titleId} className="dialog-title">{props.title}</h3>
|
<h3 id={titleId} className="dialog-title">{props.open ? props.title : lastTitleRef.current}</h3>
|
||||||
{hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
|
{hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
|
||||||
{props.children}
|
{props.children}
|
||||||
{!props.hideConfirm && (
|
{!props.hideConfirm && (
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
|
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSendDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return '';
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return '';
|
||||||
|
return parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
function optionalString(value: unknown): string | null {
|
function optionalString(value: unknown): string | null {
|
||||||
return typeof value === 'string' ? value : null;
|
return typeof value === 'string' ? value : null;
|
||||||
}
|
}
|
||||||
@@ -283,7 +290,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: sendData.expirationDate })}</p>}
|
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: formatSendDate(sendData.expirationDate) })}</p>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ function daysFromNow(iso: string | null | undefined, fallback: number): string {
|
|||||||
return String(Math.max(days, 0));
|
return String(Math.max(days, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSendDate(value: string | null | undefined): string {
|
||||||
|
if (!value) return t('txt_dash');
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return t('txt_dash');
|
||||||
|
return parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
function buildDefaultDraft(): SendDraft {
|
function buildDefaultDraft(): SendDraft {
|
||||||
return {
|
return {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -537,8 +544,8 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<div className="card stagger-item stagger-delay-2">
|
<div className="card stagger-item stagger-delay-2">
|
||||||
<h4>{t('txt_send_details')}</h4>
|
<h4>{t('txt_send_details')}</h4>
|
||||||
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
||||||
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{formatSendDate(selectedSend.deletionDate)}</strong></div>
|
||||||
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{selectedSend.expirationDate || t('txt_dash')}</strong></div>
|
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{formatSendDate(selectedSend.expirationDate)}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]);
|
const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]);
|
||||||
const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false);
|
const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false);
|
||||||
const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey'));
|
const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey'));
|
||||||
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(false);
|
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(true);
|
||||||
const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null);
|
const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null);
|
||||||
const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false);
|
const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false);
|
||||||
const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState('');
|
const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState('');
|
||||||
@@ -509,7 +509,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
setCreatePasskeyDialogOpen(false);
|
setCreatePasskeyDialogOpen(false);
|
||||||
setCreatePasskeyMasterPassword('');
|
setCreatePasskeyMasterPassword('');
|
||||||
setAccountPasskeyName(t('txt_account_passkey'));
|
setAccountPasskeyName(t('txt_account_passkey'));
|
||||||
setAccountPasskeyDirectUnlock(false);
|
setAccountPasskeyDirectUnlock(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCreatePasskeyDialog(): Promise<void> {
|
async function submitCreatePasskeyDialog(): Promise<void> {
|
||||||
|
|||||||
@@ -419,7 +419,36 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
return !!meta?.searchText.includes(searchQuery);
|
return !!meta?.searchText.includes(searchQuery);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pre-compute group min name for duplicates group ordering
|
||||||
|
const groupMinName = new Map<string, string>();
|
||||||
|
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
|
||||||
|
for (const cipher of next) {
|
||||||
|
const gk = (duplicateSignatureInfo.byId.get(cipher.id) || [])
|
||||||
|
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
|
||||||
|
.sort()[0] || '';
|
||||||
|
if (!gk) continue;
|
||||||
|
const name = cipherMetaById.get(cipher.id)?.name || '';
|
||||||
|
const cur = groupMinName.get(gk);
|
||||||
|
if (!cur || nameCollator.compare(name, cur) < 0) groupMinName.set(gk, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
next.sort((a, b) => {
|
next.sort((a, b) => {
|
||||||
|
// Duplicates view: group by color, sort A-Z within each group
|
||||||
|
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
|
||||||
|
const gk = (id: string) => (duplicateSignatureInfo.byId.get(id) || [])
|
||||||
|
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
|
||||||
|
.sort()[0] || '';
|
||||||
|
const gA = gk(a.id), gB = gk(b.id);
|
||||||
|
if (gA !== gB) return !gA ? 1 : !gB ? -1 : nameCollator.compare(
|
||||||
|
groupMinName.get(gA) || '', groupMinName.get(gB) || ''
|
||||||
|
) || (gA < gB ? -1 : 1);
|
||||||
|
return nameCollator.compare(
|
||||||
|
cipherMetaById.get(a.id)?.name || '',
|
||||||
|
cipherMetaById.get(b.id)?.name || ''
|
||||||
|
) || String(a.id || '').localeCompare(String(b.id || ''));
|
||||||
|
}
|
||||||
|
|
||||||
const metaA = cipherMetaById.get(a.id);
|
const metaA = cipherMetaById.get(a.id);
|
||||||
const metaB = cipherMetaById.get(b.id);
|
const metaB = cipherMetaById.get(b.id);
|
||||||
if (sortMode === 'edited') {
|
if (sortMode === 'edited') {
|
||||||
@@ -1049,6 +1078,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
}
|
}
|
||||||
setSelectedMap(map);
|
setSelectedMap(map);
|
||||||
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
|
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
|
||||||
|
const handleSelectUniqueFromDuplicates = useCallback(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
const seen = new Set<number>();
|
||||||
|
for (const cipher of filteredCiphers) {
|
||||||
|
const groupIndex = duplicateGroupIndexById.get(cipher.id);
|
||||||
|
if (groupIndex === undefined) continue;
|
||||||
|
if (seen.has(groupIndex)) {
|
||||||
|
map[cipher.id] = true;
|
||||||
|
} else {
|
||||||
|
seen.add(groupIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedMap(map);
|
||||||
|
}, [filteredCiphers, duplicateGroupIndexById]);
|
||||||
const handleSelectAll = useCallback(() => {
|
const handleSelectAll = useCallback(() => {
|
||||||
const map: Record<string, boolean> = {};
|
const map: Record<string, boolean> = {};
|
||||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||||
@@ -1163,6 +1206,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
onSyncVault={handleSyncVault}
|
onSyncVault={handleSyncVault}
|
||||||
onOpenBulkDelete={handleOpenBulkDelete}
|
onOpenBulkDelete={handleOpenBulkDelete}
|
||||||
onSelectDuplicates={handleSelectDuplicates}
|
onSelectDuplicates={handleSelectDuplicates}
|
||||||
|
onSelectUniqueFromDuplicates={handleSelectUniqueFromDuplicates}
|
||||||
onSelectAll={handleSelectAll}
|
onSelectAll={handleSelectAll}
|
||||||
onToggleCreateMenu={handleToggleCreateMenu}
|
onToggleCreateMenu={handleToggleCreateMenu}
|
||||||
onStartCreate={startCreate}
|
onStartCreate={startCreate}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ interface VaultListPanelProps {
|
|||||||
onSyncVault: () => void;
|
onSyncVault: () => void;
|
||||||
onOpenBulkDelete: () => void;
|
onOpenBulkDelete: () => void;
|
||||||
onSelectDuplicates: () => void;
|
onSelectDuplicates: () => void;
|
||||||
|
onSelectUniqueFromDuplicates: () => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
onToggleCreateMenu: () => void;
|
onToggleCreateMenu: () => void;
|
||||||
onStartCreate: (type: number) => void;
|
onStartCreate: (type: number) => void;
|
||||||
@@ -319,40 +320,43 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="search-input-wrap">
|
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||||
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
<div className="duplicate-mode-head-menu mobile-duplicate-toolbar">
|
||||||
<div className="duplicate-mode-head-menu">
|
<div className="mobile-duplicate-mode-select-wrap">
|
||||||
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
|
||||||
<>
|
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||||
<input
|
</button>
|
||||||
className="search-input"
|
</div>
|
||||||
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
) : (
|
||||||
value={props.searchInput}
|
<div className="search-input-wrap">
|
||||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
<input
|
||||||
onCompositionStart={props.onSearchCompositionStart}
|
className="search-input"
|
||||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
||||||
onKeyDown={(e) => {
|
value={props.searchInput}
|
||||||
if (e.key !== 'Escape' || !props.searchInput) return;
|
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
e.preventDefault();
|
onCompositionStart={props.onSearchCompositionStart}
|
||||||
props.onClearSearch();
|
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
/>
|
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||||
{!!props.searchInput && (
|
e.preventDefault();
|
||||||
<button
|
props.onClearSearch();
|
||||||
type="button"
|
}}
|
||||||
className="search-clear-btn"
|
/>
|
||||||
aria-label={t('txt_clear_search')}
|
{!!props.searchInput && (
|
||||||
title={t('txt_clear_search_esc')}
|
<button
|
||||||
onClick={props.onClearSearch}
|
type="button"
|
||||||
>
|
className="search-clear-btn"
|
||||||
<X size={14} />
|
aria-label={t('txt_clear_search')}
|
||||||
</button>
|
title={t('txt_clear_search_esc')}
|
||||||
)}
|
onClick={props.onClearSearch}
|
||||||
</>
|
>
|
||||||
)}
|
<X size={14} />
|
||||||
</div>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
||||||
<div className="duplicate-mode-head-menu">
|
<div className="duplicate-mode-head-menu">
|
||||||
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||||
@@ -387,7 +391,13 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||||
</button>
|
</button>
|
||||||
{!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu}
|
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout ? (
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
|
||||||
|
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface PendingAccountPasskeyCredential {
|
|||||||
deviceResponse: PublicKeyCredential;
|
deviceResponse: PublicKeyCredential;
|
||||||
request: Record<string, unknown>;
|
request: Record<string, unknown>;
|
||||||
supportsPrf: boolean;
|
supportsPrf: boolean;
|
||||||
|
prfKey?: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountPasskeyPrfKeySet {
|
export interface AccountPasskeyPrfKeySet {
|
||||||
@@ -82,20 +83,9 @@ async function getLoginWithPrfSalt(): Promise<Uint8Array> {
|
|||||||
return new Uint8Array(hash);
|
return new Uint8Array(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
function credentialIdToBase64Url(id: BufferSource): string | null {
|
|
||||||
try {
|
|
||||||
const bytes = id instanceof ArrayBuffer
|
|
||||||
? new Uint8Array(id)
|
|
||||||
: new Uint8Array(id.buffer, id.byteOffset, id.byteLength);
|
|
||||||
return bytesToBase64Url(bytes);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type PrfEvalInput = { first: Uint8Array };
|
type PrfEvalInput = { first: Uint8Array };
|
||||||
|
|
||||||
function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
|
function buildPrfExtension(salt: Uint8Array): Record<string, unknown> {
|
||||||
const evalInput: PrfEvalInput = { first: salt };
|
const evalInput: PrfEvalInput = { first: salt };
|
||||||
return {
|
return {
|
||||||
prf: {
|
prf: {
|
||||||
@@ -104,34 +94,23 @@ function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCredentialPrfExtension(
|
function withPrfExtension(
|
||||||
salt: Uint8Array,
|
options: PublicKeyCredentialCreationOptions,
|
||||||
credentialIds: Array<string | null | undefined>
|
salt: Uint8Array
|
||||||
): Record<string, unknown> {
|
): PublicKeyCredentialCreationOptions;
|
||||||
const evalInput = { first: salt };
|
|
||||||
const evalByCredential = credentialIds
|
|
||||||
.filter((id): id is string => !!id)
|
|
||||||
.reduce<Record<string, PrfEvalInput>>((out, id) => {
|
|
||||||
out[id] = evalInput;
|
|
||||||
return out;
|
|
||||||
}, {});
|
|
||||||
if (!Object.keys(evalByCredential).length) return buildLegacyPrfExtension(salt);
|
|
||||||
return {
|
|
||||||
prf: {
|
|
||||||
evalByCredential,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function withPrfExtension(
|
function withPrfExtension(
|
||||||
options: PublicKeyCredentialRequestOptions,
|
options: PublicKeyCredentialRequestOptions,
|
||||||
extension: Record<string, unknown>
|
salt: Uint8Array
|
||||||
): PublicKeyCredentialRequestOptions {
|
): PublicKeyCredentialRequestOptions;
|
||||||
|
function withPrfExtension(
|
||||||
|
options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions,
|
||||||
|
salt: Uint8Array
|
||||||
|
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
extensions: {
|
extensions: {
|
||||||
...((options as any).extensions || {}),
|
...((options as any).extensions || {}),
|
||||||
...extension,
|
...buildPrfExtension(salt),
|
||||||
} as any,
|
} as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -154,70 +133,17 @@ function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | unde
|
|||||||
return result instanceof ArrayBuffer ? result : undefined;
|
return result instanceof ArrayBuffer ? result : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPrfExtensionResult(credential: PublicKeyCredential): boolean {
|
|
||||||
return Object.prototype.hasOwnProperty.call(credential.getClientExtensionResults() as any, 'prf');
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRetryWithLegacyPrf(error: unknown): boolean {
|
|
||||||
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
|
|
||||||
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRetryCreateWithoutPrf(error: unknown): boolean {
|
|
||||||
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
|
|
||||||
const message = error instanceof DOMException || error instanceof Error ? error.message : '';
|
|
||||||
return (
|
|
||||||
name === 'NotSupportedError' ||
|
|
||||||
name === 'SyntaxError' ||
|
|
||||||
name === 'TypeError' ||
|
|
||||||
(name === 'UnknownError' && /transient/i.test(message))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function canRequestPrfExtension(): Promise<boolean> {
|
|
||||||
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPublicKeyCredentialWithPrf(
|
async function getPublicKeyCredentialWithPrf(
|
||||||
options: PublicKeyCredentialRequestOptions,
|
options: PublicKeyCredentialRequestOptions,
|
||||||
salt: Uint8Array,
|
salt: Uint8Array
|
||||||
credentialIds: string[] = []
|
|
||||||
): Promise<PublicKeyCredential> {
|
): Promise<PublicKeyCredential> {
|
||||||
const attempts = credentialIds.length
|
const credential = await navigator.credentials.get({
|
||||||
? [
|
publicKey: withPrfExtension(options, salt),
|
||||||
buildCredentialPrfExtension(salt, credentialIds),
|
});
|
||||||
buildLegacyPrfExtension(salt),
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
]
|
throw new Error(t('txt_no_passkey_selected'));
|
||||||
: [buildLegacyPrfExtension(salt)];
|
|
||||||
let lastCredential: PublicKeyCredential | null = null;
|
|
||||||
for (let index = 0; index < attempts.length; index += 1) {
|
|
||||||
try {
|
|
||||||
const credential = await navigator.credentials.get({
|
|
||||||
publicKey: withPrfExtension(options, attempts[index]),
|
|
||||||
});
|
|
||||||
if (!(credential instanceof PublicKeyCredential)) {
|
|
||||||
throw new Error(t('txt_no_passkey_selected'));
|
|
||||||
}
|
|
||||||
lastCredential = credential;
|
|
||||||
if (readPrfFirstResult(credential) || hasPrfExtensionResult(credential) || index === attempts.length - 1) {
|
|
||||||
return credential;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (index === attempts.length - 1 || !shouldRetryWithLegacyPrf(error)) {
|
|
||||||
if (lastCredential) return lastCredential;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (lastCredential) return lastCredential;
|
return credential;
|
||||||
throw new Error(t('txt_no_passkey_selected'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] {
|
|
||||||
return (options.allowCredentials || [])
|
|
||||||
.map((credential) => credentialIdToBase64Url(credential.id))
|
|
||||||
.filter((id): id is string => !!id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
|
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
|
||||||
@@ -282,8 +208,7 @@ export async function assertAccountPasskey(
|
|||||||
const nativeOptions = cloneRequestOptions(response.options);
|
const nativeOptions = cloneRequestOptions(response.options);
|
||||||
const credential = await getPublicKeyCredentialWithPrf(
|
const credential = await getPublicKeyCredentialWithPrf(
|
||||||
nativeOptions,
|
nativeOptions,
|
||||||
await getLoginWithPrfSalt(),
|
await getLoginWithPrfSalt()
|
||||||
prfCredentialIdsFromAllowCredentials(nativeOptions)
|
|
||||||
);
|
);
|
||||||
const prfResult = readPrfFirstResult(credential);
|
const prfResult = readPrfFirstResult(credential);
|
||||||
return {
|
return {
|
||||||
@@ -309,34 +234,22 @@ export async function createAccountPasskeyCredential(
|
|||||||
}
|
}
|
||||||
return credential;
|
return credential;
|
||||||
};
|
};
|
||||||
let credential: PublicKeyCredential;
|
const prfSalt = requestPrf ? await getLoginWithPrfSalt() : null;
|
||||||
if (requestPrf && await canRequestPrfExtension()) {
|
const credential = await createWithOptions(
|
||||||
const prfOptions: PublicKeyCredentialCreationOptions = {
|
prfSalt ? withPrfExtension(noPrfOptions, prfSalt) : noPrfOptions
|
||||||
...noPrfOptions,
|
);
|
||||||
extensions: {
|
|
||||||
...((noPrfOptions as any).extensions || {}),
|
|
||||||
prf: {},
|
|
||||||
} as any,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
credential = await createWithOptions(prfOptions);
|
|
||||||
} catch (error) {
|
|
||||||
if (!shouldRetryCreateWithoutPrf(error)) throw error;
|
|
||||||
credential = await createWithOptions(noPrfOptions);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
credential = await createWithOptions(noPrfOptions);
|
|
||||||
}
|
|
||||||
if (!(credential instanceof PublicKeyCredential)) {
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
throw new Error(t('txt_no_passkey_created'));
|
throw new Error(t('txt_no_passkey_created'));
|
||||||
}
|
}
|
||||||
const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled;
|
const prfResult = readPrfFirstResult(credential);
|
||||||
|
const supportsPrf = !!prfResult || (credential.getClientExtensionResults() as any).prf?.enabled === true;
|
||||||
return {
|
return {
|
||||||
token: response.token,
|
token: response.token,
|
||||||
createOptions: nativeOptions,
|
createOptions: nativeOptions,
|
||||||
deviceResponse: credential,
|
deviceResponse: credential,
|
||||||
request: attestationRequest(credential),
|
request: attestationRequest(credential),
|
||||||
supportsPrf,
|
supportsPrf,
|
||||||
|
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,8 +286,10 @@ export async function buildAccountPasskeyPrfKeySet(
|
|||||||
pending: PendingAccountPasskeyCredential,
|
pending: PendingAccountPasskeyCredential,
|
||||||
userKey: { symEncKey: string; symMacKey: string }
|
userKey: { symEncKey: string; symMacKey: string }
|
||||||
): Promise<AccountPasskeyPrfKeySet> {
|
): Promise<AccountPasskeyPrfKeySet> {
|
||||||
|
if (pending.prfKey) {
|
||||||
|
return buildAccountPasskeyPrfKeySetFromPrfKey(pending.prfKey, userKey);
|
||||||
|
}
|
||||||
const rawId = new Uint8Array(pending.deviceResponse.rawId);
|
const rawId = new Uint8Array(pending.deviceResponse.rawId);
|
||||||
const credentialId = bytesToBase64Url(rawId);
|
|
||||||
const assertionOptions: PublicKeyCredentialRequestOptions = {
|
const assertionOptions: PublicKeyCredentialRequestOptions = {
|
||||||
challenge: pending.createOptions?.challenge!,
|
challenge: pending.createOptions?.challenge!,
|
||||||
rpId: pending.createOptions?.rp?.id,
|
rpId: pending.createOptions?.rp?.id,
|
||||||
@@ -384,8 +299,7 @@ export async function buildAccountPasskeyPrfKeySet(
|
|||||||
};
|
};
|
||||||
const assertion = await getPublicKeyCredentialWithPrf(
|
const assertion = await getPublicKeyCredentialWithPrf(
|
||||||
assertionOptions,
|
assertionOptions,
|
||||||
await getLoginWithPrfSalt(),
|
await getLoginWithPrfSalt()
|
||||||
[credentialId]
|
|
||||||
);
|
);
|
||||||
const prfResult = readPrfFirstResult(assertion);
|
const prfResult = readPrfFirstResult(assertion);
|
||||||
if (!prfResult) {
|
if (!prfResult) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
AdminInvite,
|
AdminInvite,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
|
AuditLogEntry,
|
||||||
AuthorizedDevice,
|
AuthorizedDevice,
|
||||||
Cipher,
|
Cipher,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -383,6 +384,143 @@ export const DEMO_CIPHERS: Cipher[] = [
|
|||||||
decFingerprint: 'SHA256:demoNodeWardenFingerprint',
|
decFingerprint: 'SHA256:demoNodeWardenFingerprint',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// --- Duplicate detection demo pairs (exact, login-site, login-credentials, password) ---
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-exact-a',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-work',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Internal VPN',
|
||||||
|
decName: 'Internal VPN',
|
||||||
|
creationDate: '2026-04-10T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-04-28T10:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'vpn-user',
|
||||||
|
password: 'vpn-secret-2026', // gitguardian:ignore
|
||||||
|
decUsername: 'vpn-user',
|
||||||
|
decPassword: 'vpn-secret-2026', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://vpn.internal.example.com', decUri: 'https://vpn.internal.example.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-exact-b',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-work',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Internal VPN',
|
||||||
|
decName: 'Internal VPN',
|
||||||
|
creationDate: '2026-03-15T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-04-30T10:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'vpn-user',
|
||||||
|
password: 'vpn-secret-2026', // gitguardian:ignore
|
||||||
|
decUsername: 'vpn-user',
|
||||||
|
decPassword: 'vpn-secret-2026', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://vpn.internal.example.com', decUri: 'https://vpn.internal.example.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-site-a',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-devops',
|
||||||
|
favorite: false,
|
||||||
|
name: 'AWS Console',
|
||||||
|
decName: 'AWS Console',
|
||||||
|
creationDate: '2026-03-01T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-04-25T09:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'aws-admin',
|
||||||
|
password: 'aws-secure-password', // gitguardian:ignore
|
||||||
|
decUsername: 'aws-admin',
|
||||||
|
decPassword: 'aws-secure-password', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://console.aws.amazon.com', decUri: 'https://console.aws.amazon.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-site-b',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-devops',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Amazon Web Services',
|
||||||
|
decName: 'Amazon Web Services',
|
||||||
|
creationDate: '2026-02-20T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-04-20T09:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'aws-admin',
|
||||||
|
password: 'aws-secure-password', // gitguardian:ignore
|
||||||
|
decUsername: 'aws-admin',
|
||||||
|
decPassword: 'aws-secure-password', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://console.aws.amazon.com', decUri: 'https://console.aws.amazon.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-cred-a',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-personal',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Personal Blog',
|
||||||
|
decName: 'Personal Blog',
|
||||||
|
creationDate: '2026-01-10T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-04-15T10:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'my-account@example.com',
|
||||||
|
password: 'shared-credential', // gitguardian:ignore
|
||||||
|
decUsername: 'my-account@example.com',
|
||||||
|
decPassword: 'shared-credential', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://blog.example.com', decUri: 'https://blog.example.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-cred-b',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-personal',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Forum Account',
|
||||||
|
decName: 'Forum Account',
|
||||||
|
creationDate: '2026-01-15T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-04-18T10:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'my-account@example.com',
|
||||||
|
password: 'shared-credential', // gitguardian:ignore
|
||||||
|
decUsername: 'my-account@example.com',
|
||||||
|
decPassword: 'shared-credential', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://forum.example.com', decUri: 'https://forum.example.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-pw-a',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-personal',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Old Forum',
|
||||||
|
decName: 'Old Forum',
|
||||||
|
creationDate: '2025-06-01T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-03-01T10:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'legacy-user',
|
||||||
|
password: 'reused-password-2020', // gitguardian:ignore
|
||||||
|
decUsername: 'legacy-user',
|
||||||
|
decPassword: 'reused-password-2020', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://old-forum.example.com', decUri: 'https://old-forum.example.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-pw-b',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-personal',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Legacy CMS',
|
||||||
|
decName: 'Legacy CMS',
|
||||||
|
creationDate: '2025-05-10T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-02-15T10:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'cms-admin',
|
||||||
|
password: 'reused-password-2020', // gitguardian:ignore
|
||||||
|
decUsername: 'cms-admin',
|
||||||
|
decPassword: 'reused-password-2020', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://cms.example.com', decUri: 'https://cms.example.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'cipher-archived',
|
id: 'cipher-archived',
|
||||||
type: 1,
|
type: 1,
|
||||||
@@ -575,6 +713,233 @@ export const DEMO_BACKUP_SETTINGS: AdminBackupSettings = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DEMO_AUDIT_LOGS: AuditLogEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'demo-log-auth-login',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'auth.login.success',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'info',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ ip: '203.0.113.42', device: 'Chrome 125 on Windows', location: 'San Francisco, US' }),
|
||||||
|
createdAt: '2026-07-08T14:32:10.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-auth-failed',
|
||||||
|
actorUserId: null,
|
||||||
|
actorEmail: 'unknown@example.com',
|
||||||
|
action: 'auth.login.failed',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ ip: '198.51.100.7', reason: 'invalid_password', attemptCount: 3 }),
|
||||||
|
createdAt: '2026-07-08T13:15:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-auth-2fa',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'auth.totp.enabled',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'security',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'user_initiated' }),
|
||||||
|
createdAt: '2026-07-07T09:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-auth-refresh-failed',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'auth.refresh.failed.token_expired',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'error',
|
||||||
|
targetType: null,
|
||||||
|
targetId: 'demo-device-browser',
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ ip: '203.0.113.42', device: 'Chrome 125 on Windows' }),
|
||||||
|
createdAt: '2026-07-06T18:45:30.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-security-password',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'user.password.changed',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: DEMO_USER_ID,
|
||||||
|
targetUserEmail: DEMO_PROFILE.email,
|
||||||
|
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'user_initiated' }),
|
||||||
|
createdAt: '2026-07-05T10:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-security-user-banned',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.user.banned',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: 'demo-user-003',
|
||||||
|
targetUserEmail: 'suspended@example.com',
|
||||||
|
metadata: JSON.stringify({ ip: '203.0.113.42', reason: 'violation_of_tos' }),
|
||||||
|
createdAt: '2026-07-04T16:20:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-security-user-register',
|
||||||
|
actorUserId: null,
|
||||||
|
actorEmail: 'newuser@example.com',
|
||||||
|
action: 'user.register.completed',
|
||||||
|
category: 'security',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: 'demo-user-004',
|
||||||
|
targetUserEmail: 'newuser@example.com',
|
||||||
|
metadata: JSON.stringify({ ip: '192.0.2.55', invite: 'DEMO-INVITE-2026' }),
|
||||||
|
createdAt: '2026-07-03T08:30:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-device-trusted',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'device.trusted.added',
|
||||||
|
category: 'device',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: 'demo-device-mobile',
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ deviceName: 'iPhone', os: 'iOS 18', ip: '203.0.113.42' }),
|
||||||
|
createdAt: '2026-07-02T12:15:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-device-removed',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'device.removed',
|
||||||
|
category: 'device',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: 'demo-device-old',
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ deviceName: 'Firefox on Linux', ip: '198.51.100.20', trigger: 'user_initiated' }),
|
||||||
|
createdAt: '2026-07-01T09:45:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-device-all-revoked',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'device.all_trust_revoked',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'password_change' }),
|
||||||
|
createdAt: '2026-07-01T09:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-data-backup',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.backup.run.completed',
|
||||||
|
category: 'data',
|
||||||
|
level: 'info',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ fileName: 'nodewarden_backup_20260701_030000.zip', size: '1.2 MB', destination: 'Demo WebDAV' }),
|
||||||
|
createdAt: '2026-07-01T03:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-data-restore',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.backup.restore.completed',
|
||||||
|
category: 'data',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ fileName: 'nodewarden_backup_20260628_030000.zip', checksum: 'verified' }),
|
||||||
|
createdAt: '2026-06-30T14:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-data-export',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.export.completed',
|
||||||
|
category: 'data',
|
||||||
|
level: 'info',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ format: 'encrypted_json', totalItems: 24 }),
|
||||||
|
createdAt: '2026-06-28T11:30:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-system-settings',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.settings.updated',
|
||||||
|
category: 'system',
|
||||||
|
level: 'info',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ changedKeys: ['signupsAllowed', 'kdfIterations'], ip: '203.0.113.42' }),
|
||||||
|
createdAt: '2026-06-25T08:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-system-invite',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.invite.created',
|
||||||
|
category: 'system',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'invite',
|
||||||
|
targetId: 'DEMO-INVITE-2026',
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ expiresIn: '168h', ip: '203.0.113.42' }),
|
||||||
|
createdAt: '2026-06-20T10:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-system-config',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.config.updated',
|
||||||
|
category: 'system',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ changedKeys: ['smtp.host', 'smtp.port'], ip: '203.0.113.42' }),
|
||||||
|
createdAt: '2026-06-18T15:30:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function cloneJson<T>(value: T): T {
|
function cloneJson<T>(value: T): T {
|
||||||
return JSON.parse(JSON.stringify(value)) as T;
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
}
|
}
|
||||||
@@ -1075,6 +1440,38 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
onGetRecoveryCode: readonlyString,
|
onGetRecoveryCode: readonlyString,
|
||||||
onGetApiKey: readonlyString,
|
onGetApiKey: readonlyString,
|
||||||
onRotateApiKey: readonlyString,
|
onRotateApiKey: readonlyString,
|
||||||
|
onListAccountPasskeys: async () => [],
|
||||||
|
onCreateAccountPasskey: async () => {
|
||||||
|
await readonly();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onEnableAccountPasskeyDirectUnlock: readonly,
|
||||||
|
onDeleteAccountPasskey: readonly,
|
||||||
|
onLoadAuditLogs: async (filters) => {
|
||||||
|
const limit = Number(filters.limit || 50) || 50;
|
||||||
|
const offset = Number(filters.offset || 0) || 0;
|
||||||
|
let filtered = DEMO_AUDIT_LOGS.filter((log) => {
|
||||||
|
if (filters.category && filters.category !== 'all' && log.category !== filters.category) return false;
|
||||||
|
if (filters.level && filters.level !== 'all' && log.level !== filters.level) return false;
|
||||||
|
if (filters.q) {
|
||||||
|
const q = filters.q.toLowerCase();
|
||||||
|
if (!log.action.toLowerCase().includes(q) && !(log.actorEmail || '').toLowerCase().includes(q)) return false;
|
||||||
|
}
|
||||||
|
if (filters.from && new Date(log.createdAt).getTime() < new Date(filters.from).getTime()) return false;
|
||||||
|
if (filters.to && new Date(log.createdAt).getTime() > new Date(filters.to).getTime()) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
const total = filtered.length;
|
||||||
|
const sliced = filtered.slice(offset, offset + limit);
|
||||||
|
return {
|
||||||
|
logs: sliced,
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset: offset + sliced.length,
|
||||||
|
hasMore: offset + sliced.length < total,
|
||||||
|
};
|
||||||
|
},
|
||||||
onLockTimeoutChange: readonlyVoid,
|
onLockTimeoutChange: readonlyVoid,
|
||||||
onSessionTimeoutActionChange: readonlyVoid,
|
onSessionTimeoutActionChange: readonlyVoid,
|
||||||
onRefreshAuthorizedDevices: async () => {
|
onRefreshAuthorizedDevices: async () => {
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ export type Locale =
|
|||||||
| 'zh-CN'
|
| 'zh-CN'
|
||||||
| 'zh-TW'
|
| 'zh-TW'
|
||||||
| 'ru'
|
| 'ru'
|
||||||
| 'es';
|
| 'es'
|
||||||
|
| 'fi';
|
||||||
|
|
||||||
import enMessages from './i18n/locales/en';
|
import enMessages from './i18n/locales/en';
|
||||||
|
|
||||||
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
|
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
|
||||||
|
|
||||||
type MessageTable = Record<string, string>;
|
type MessageTable = Record<string, string>;
|
||||||
@@ -24,6 +24,7 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [
|
|||||||
{ value: 'zh-TW', label: '繁體中文' },
|
{ value: 'zh-TW', label: '繁體中文' },
|
||||||
{ value: 'ru', label: 'Русский' },
|
{ value: 'ru', label: 'Русский' },
|
||||||
{ value: 'es', label: 'Español' },
|
{ value: 'es', label: 'Español' },
|
||||||
|
{ value: 'fi', label: 'Suomi' },
|
||||||
];
|
];
|
||||||
|
|
||||||
let locale: Locale = resolveInitialLocale();
|
let locale: Locale = resolveInitialLocale();
|
||||||
@@ -49,6 +50,7 @@ function resolveInitialLocale(): Locale {
|
|||||||
if (normalized.startsWith('zh')) return 'zh-CN';
|
if (normalized.startsWith('zh')) return 'zh-CN';
|
||||||
if (normalized.startsWith('ru')) return 'ru';
|
if (normalized.startsWith('ru')) return 'ru';
|
||||||
if (normalized.startsWith('es')) return 'es';
|
if (normalized.startsWith('es')) return 'es';
|
||||||
|
if (normalized.startsWith('fi')) return 'fi';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'en';
|
return 'en';
|
||||||
@@ -60,6 +62,7 @@ const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> =
|
|||||||
'zh-TW': () => import('./i18n/locales/zh-TW'),
|
'zh-TW': () => import('./i18n/locales/zh-TW'),
|
||||||
ru: () => import('./i18n/locales/ru'),
|
ru: () => import('./i18n/locales/ru'),
|
||||||
es: () => import('./i18n/locales/es'),
|
es: () => import('./i18n/locales/es'),
|
||||||
|
fi: () => import('./i18n/locales/fi'),
|
||||||
};
|
};
|
||||||
|
|
||||||
function localeToHtmlLang(value: Locale): string {
|
function localeToHtmlLang(value: Locale): string {
|
||||||
@@ -74,7 +77,6 @@ function syncDocumentLanguage(): void {
|
|||||||
async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
|
async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
|
||||||
const cached = loadedMessages.get(next);
|
const cached = loadedMessages.get(next);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const mod = await localeLoaders[next]();
|
const mod = await localeLoaders[next]();
|
||||||
loadedMessages.set(next, mod.default);
|
loadedMessages.set(next, mod.default);
|
||||||
return mod.default;
|
return mod.default;
|
||||||
@@ -223,7 +225,6 @@ export function translateServerError(message: string | null | undefined, fallbac
|
|||||||
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
|
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
|
||||||
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
|
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
|
||||||
}[normalized];
|
}[normalized];
|
||||||
|
|
||||||
return key ? t(key) : normalized;
|
return key ? t(key) : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1012,6 +1012,9 @@ const en: Record<string, string> = {
|
|||||||
"txt_online": "Online",
|
"txt_online": "Online",
|
||||||
"txt_offline": "Offline",
|
"txt_offline": "Offline",
|
||||||
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
|
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
|
||||||
|
"txt_offline_mode_notice_title": "You are in offline mode. If this looks wrong, force refresh.",
|
||||||
|
"txt_offline_mode_notice_windows": "Windows:",
|
||||||
|
"txt_offline_mode_notice_macos": "macOS:",
|
||||||
"txt_submit": "Submit",
|
"txt_submit": "Submit",
|
||||||
"txt_sync": "Sync",
|
"txt_sync": "Sync",
|
||||||
"txt_sync_vault": "Sync Vault",
|
"txt_sync_vault": "Sync Vault",
|
||||||
|
|||||||
@@ -1012,6 +1012,9 @@ const es: Record<string, string> = {
|
|||||||
"txt_online": "En línea",
|
"txt_online": "En línea",
|
||||||
"txt_offline": "Sin conexión",
|
"txt_offline": "Sin conexión",
|
||||||
"txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.",
|
"txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.",
|
||||||
|
"txt_offline_mode_notice_title": "Estás en modo sin conexión. Si parece un error, fuerza la recarga.",
|
||||||
|
"txt_offline_mode_notice_windows": "Windows:",
|
||||||
|
"txt_offline_mode_notice_macos": "macOS:",
|
||||||
"txt_submit": "Enviar",
|
"txt_submit": "Enviar",
|
||||||
"txt_sync": "Sincronizar",
|
"txt_sync": "Sincronizar",
|
||||||
"txt_sync_vault": "Sincronizar bóveda",
|
"txt_sync_vault": "Sincronizar bóveda",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1012,6 +1012,9 @@ const ru: Record<string, string> = {
|
|||||||
"txt_online": "Онлайн",
|
"txt_online": "Онлайн",
|
||||||
"txt_offline": "Офлайн",
|
"txt_offline": "Офлайн",
|
||||||
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
|
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
|
||||||
|
"txt_offline_mode_notice_title": "Вы в автономном режиме. Если это ошибка, выполните принудительное обновление.",
|
||||||
|
"txt_offline_mode_notice_windows": "Windows:",
|
||||||
|
"txt_offline_mode_notice_macos": "macOS:",
|
||||||
"txt_submit": "Отправить",
|
"txt_submit": "Отправить",
|
||||||
"txt_sync": "Синхронизировать",
|
"txt_sync": "Синхронизировать",
|
||||||
"txt_sync_vault": "Синхронизировать хранилище",
|
"txt_sync_vault": "Синхронизировать хранилище",
|
||||||
|
|||||||
@@ -1012,6 +1012,9 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_online": "在线",
|
"txt_online": "在线",
|
||||||
"txt_offline": "离线",
|
"txt_offline": "离线",
|
||||||
"txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。",
|
"txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。",
|
||||||
|
"txt_offline_mode_notice_title": "你正处于离线模式,若误判请强制刷新",
|
||||||
|
"txt_offline_mode_notice_windows": "Windows:",
|
||||||
|
"txt_offline_mode_notice_macos": "macOS:",
|
||||||
"txt_submit": "提交",
|
"txt_submit": "提交",
|
||||||
"txt_sync": "同步",
|
"txt_sync": "同步",
|
||||||
"txt_sync_vault": "同步",
|
"txt_sync_vault": "同步",
|
||||||
|
|||||||
@@ -1012,6 +1012,9 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_online": "在線",
|
"txt_online": "在線",
|
||||||
"txt_offline": "離線",
|
"txt_offline": "離線",
|
||||||
"txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。",
|
"txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。",
|
||||||
|
"txt_offline_mode_notice_title": "你正處於離線模式,若誤判請強制重新整理",
|
||||||
|
"txt_offline_mode_notice_windows": "Windows:",
|
||||||
|
"txt_offline_mode_notice_macos": "macOS:",
|
||||||
"txt_submit": "提交",
|
"txt_submit": "提交",
|
||||||
"txt_sync": "同步",
|
"txt_sync": "同步",
|
||||||
"txt_sync_vault": "同步",
|
"txt_sync_vault": "同步",
|
||||||
|
|||||||
@@ -492,6 +492,58 @@
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offline-mode-notice {
|
||||||
|
@apply mb-4 flex items-start gap-3 rounded-xl border px-3.5 py-3 text-left text-sm leading-relaxed;
|
||||||
|
background: color-mix(in srgb, var(--warning) 10%, var(--panel));
|
||||||
|
border-color: color-mix(in srgb, var(--warning) 42%, var(--line));
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-mode-notice svg {
|
||||||
|
@apply mt-0.5 shrink-0;
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-mode-notice strong {
|
||||||
|
@apply block text-center text-[13px] font-extrabold;
|
||||||
|
color: color-mix(in srgb, var(--warning) 82%, var(--text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-mode-notice > div {
|
||||||
|
@apply min-w-0 flex-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-shortcut-list {
|
||||||
|
@apply mt-2 grid gap-1.5;
|
||||||
|
grid-template-columns: max-content minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-shortcut-row {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-shortcut-label {
|
||||||
|
@apply text-xs font-extrabold;
|
||||||
|
color: color-mix(in srgb, var(--warning) 76%, var(--text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-shortcut-value {
|
||||||
|
@apply flex min-w-0 flex-wrap items-center gap-1.5;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-shortcut-chord {
|
||||||
|
@apply inline-flex items-center gap-1 whitespace-nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-shortcut-chord kbd {
|
||||||
|
@apply inline-flex min-h-6 items-center rounded-md border px-1.5 font-mono text-[12px] font-bold leading-none;
|
||||||
|
background: color-mix(in srgb, var(--panel) 88%, var(--warning));
|
||||||
|
border-color: color-mix(in srgb, var(--warning) 35%, var(--line));
|
||||||
|
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--warning) 26%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.standalone-muted {
|
.standalone-muted {
|
||||||
@apply text-left;
|
@apply text-left;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -427,6 +427,20 @@
|
|||||||
min-width: max(100%, 190px);
|
min-width: max(100%, 190px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-head .mobile-duplicate-toolbar {
|
||||||
|
@apply flex min-w-0 items-center gap-1.5;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .mobile-duplicate-mode-select-wrap {
|
||||||
|
max-width: 130px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .mobile-duplicate-mode-select-wrap .mobile-vault-filter-trigger {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar.actions {
|
.toolbar.actions {
|
||||||
@apply justify-end overflow-visible pb-0.5;
|
@apply justify-end overflow-visible pb-0.5;
|
||||||
flex-wrap: unset;
|
flex-wrap: unset;
|
||||||
|
|||||||
Reference in New Issue
Block a user