mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
Compare commits
14
Commits
v1.7.3
..
8d399f431b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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-bitwarden-clients/
|
||||
|
||||
nodewarden.wiki/
|
||||
nodewarden-wiki/
|
||||
wiki/
|
||||
AGENTS.md
|
||||
settings.json
|
||||
|
||||
@@ -3,95 +3,100 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
||||
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 频道</a> |
|
||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
||||
<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_EN.md">English</a> |
|
||||
<a href="./CONTRIBUTING.md">贡献指南</a>
|
||||
<a href="./README_ZH.md">中文</a> |
|
||||
<a href="./CONTRIBUTING.md">Contributing</a> |
|
||||
<a href="https://nodewarden.app">Official wiki</a>
|
||||
</p>
|
||||
|
||||
> **免责声明**
|
||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
## 与 Bitwarden 官方服务端能力对比
|
||||
## Feature comparison with the official Bitwarden server
|
||||
|
||||
| 能力 | Bitwarden | NodeWarden | 说明 |
|
||||
| Feature | Bitwarden Free | NodeWarden | Notes |
|
||||
|---|---|---|---|
|
||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
|
||||
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
|
||||
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
||||
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** |
|
||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
||||
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
||||
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
|
||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||
| Web vault | ✅ | ✅ | **Original Web Vault UI** |
|
||||
| TOTP | ❌ | ✅ | Includes `steam://` support |
|
||||
| **PWA / offline** | ❌ | ✅ | **Installable, offline** |
|
||||
| **Passkey login** | ✅ | ✅ | **passwordless auth** |
|
||||
| API keys | ✅ | ✅ | CLI keys; create and rotate |
|
||||
| Login 2FA | ✅ | ✅ | TOTP, YubiKey, Passkey |
|
||||
| 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
|
||||
| Real-time push sync | ✅ | ✅ | All device sync |
|
||||
| Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
|
||||
| Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
|
||||
| **Cloud backup center** | ❌ | ✅ | **Scheduled WebDAV / S3 incrementals** |
|
||||
| Device management | ✅ | ✅ | **Remove devices; trust controls** |
|
||||
| Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
|
||||
| **Multi-user** | ✅ | ✅ | Invite-code registration |
|
||||
| Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
|
||||
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
|
||||
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
|
||||
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
|
||||
|
||||
---
|
||||
|
||||
## 已测试客户端
|
||||
## Tested clients
|
||||
|
||||
- ✅ Windows 桌面端
|
||||
- ✅ 手机 App
|
||||
- ✅ 浏览器扩展
|
||||
- ✅ Linux 桌面端
|
||||
- ⚠️ macOS 桌面端尚未完整验证
|
||||
- ✅ Windows desktop
|
||||
- ✅ Mobile app
|
||||
- ✅ Browser extension
|
||||
- ✅ Linux desktop
|
||||
- ⚠️ macOS desktop not fully verified yet
|
||||
|
||||
---
|
||||
|
||||
## 可视化快速部署
|
||||
## Visual quick deploy
|
||||
|
||||
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 域名
|
||||
1. Fork the NodeWarden repository to your GitHub account
|
||||
2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. Choose **Continue with GitHub** and select your fork
|
||||
4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
|
||||
- For KV mode, change the deploy command to `npm run deploy:kv`
|
||||
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]
|
||||
> 默认R2与可选KV的区别:
|
||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||
> Default R2 vs optional KV:
|
||||
> | Storage | Card required | Max single attachment / Send file | Free tier |
|
||||
> |---|---|---|---|
|
||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
> | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||
|
||||
|
||||
## 更新方法:
|
||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
||||
## How to update
|
||||
|
||||
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork** → **Update branch**
|
||||
|
||||
|
||||
|
||||
## CLI 部署
|
||||
|
||||
## CLI deploy
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||
@@ -100,82 +105,31 @@ cd NodeWarden
|
||||
npm install
|
||||
npx wrangler login
|
||||
|
||||
# 默认:R2 模式
|
||||
# Default: R2 mode
|
||||
npm run deploy
|
||||
|
||||
# 可选:KV 模式
|
||||
# Optional: KV mode
|
||||
npm run deploy:kv
|
||||
|
||||
# 本地开发
|
||||
# Local development
|
||||
npm run dev
|
||||
npm run dev:kv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 主要特性
|
||||
|
||||
### PWA 渐进式 Web 应用
|
||||
|
||||
- ✅ **可安装到桌面** - 像原生应用一样运行
|
||||
- ✅ **离线使用** - 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 系列
|
||||
- 备份中心中的实例级完整手动导出
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 开源协议
|
||||
## License
|
||||
|
||||
LGPL-3.0 License
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
## Credits
|
||||
|
||||
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||
- [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
|
||||
|
||||
---
|
||||
|
||||
|
||||
-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)
|
||||
Generated
+670
-410
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -58,7 +58,7 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^8.1.3",
|
||||
"wrangler": "^4.105.0"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -13,6 +13,7 @@ const localeFiles = [
|
||||
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
|
||||
['ru', 'ru.ts', 'ru', 'Russian'],
|
||||
['es', 'es.ts', 'es', 'Spanish'],
|
||||
['fi', 'fi.ts', 'fi', 'Finnish'],
|
||||
];
|
||||
|
||||
function readLocale(fileName, variableName) {
|
||||
|
||||
@@ -155,7 +155,15 @@ export function formatSize(bytes: number): string {
|
||||
|
||||
export function parseDate(raw: unknown): Date | 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;
|
||||
return date;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||
const maskPointerStartedRef = useRef(false);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const lastTitleRef = useRef<ComponentChildren>(props.title);
|
||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||
const titleId = `${dialogId}-title`;
|
||||
const messageId = `${dialogId}-message`;
|
||||
@@ -95,6 +96,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
lastTitleRef.current = props.title;
|
||||
setPresent(true);
|
||||
setClosing(false);
|
||||
return;
|
||||
@@ -228,7 +230,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
<X size={18} />
|
||||
</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>}
|
||||
{props.children}
|
||||
{!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;
|
||||
}
|
||||
|
||||
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 {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
@@ -283,7 +290,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
</button>
|
||||
</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));
|
||||
}
|
||||
|
||||
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 {
|
||||
return {
|
||||
type: 'text',
|
||||
@@ -537,8 +544,8 @@ export default function SendsPage(props: SendsPageProps) {
|
||||
<div className="card stagger-item stagger-delay-2">
|
||||
<h4>{t('txt_send_details')}</h4>
|
||||
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
||||
<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_deletion_date')}</span><strong>{formatSendDate(selectedSend.deletionDate)}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{formatSendDate(selectedSend.expirationDate)}</strong></div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
|
||||
@@ -419,7 +419,36 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
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) => {
|
||||
// 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 metaB = cipherMetaById.get(b.id);
|
||||
if (sortMode === 'edited') {
|
||||
@@ -1049,6 +1078,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
}
|
||||
setSelectedMap(map);
|
||||
}, [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 map: Record<string, boolean> = {};
|
||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||
@@ -1163,6 +1206,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
onSyncVault={handleSyncVault}
|
||||
onOpenBulkDelete={handleOpenBulkDelete}
|
||||
onSelectDuplicates={handleSelectDuplicates}
|
||||
onSelectUniqueFromDuplicates={handleSelectUniqueFromDuplicates}
|
||||
onSelectAll={handleSelectAll}
|
||||
onToggleCreateMenu={handleToggleCreateMenu}
|
||||
onStartCreate={startCreate}
|
||||
|
||||
@@ -81,6 +81,7 @@ interface VaultListPanelProps {
|
||||
onSyncVault: () => void;
|
||||
onOpenBulkDelete: () => void;
|
||||
onSelectDuplicates: () => void;
|
||||
onSelectUniqueFromDuplicates: () => void;
|
||||
onSelectAll: () => void;
|
||||
onToggleCreateMenu: () => 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 ? (
|
||||
<div className="duplicate-mode-head-menu">
|
||||
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||
<div className="duplicate-mode-head-menu mobile-duplicate-toolbar">
|
||||
<div className="mobile-duplicate-mode-select-wrap">
|
||||
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
||||
value={props.searchInput}
|
||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||
onCompositionStart={props.onSearchCompositionStart}
|
||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||
e.preventDefault();
|
||||
props.onClearSearch();
|
||||
}}
|
||||
/>
|
||||
{!!props.searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
className="search-clear-btn"
|
||||
aria-label={t('txt_clear_search')}
|
||||
title={t('txt_clear_search_esc')}
|
||||
onClick={props.onClearSearch}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
|
||||
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="search-input-wrap">
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
||||
value={props.searchInput}
|
||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||
onCompositionStart={props.onSearchCompositionStart}
|
||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||
e.preventDefault();
|
||||
props.onClearSearch();
|
||||
}}
|
||||
/>
|
||||
{!!props.searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
className="search-clear-btn"
|
||||
aria-label={t('txt_clear_search')}
|
||||
title={t('txt_clear_search_esc')}
|
||||
onClick={props.onClearSearch}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
||||
<div className="duplicate-mode-head-menu">
|
||||
{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}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||
</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>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type {
|
||||
AdminInvite,
|
||||
AdminUser,
|
||||
AuditLogEntry,
|
||||
AuthorizedDevice,
|
||||
Cipher,
|
||||
Folder,
|
||||
@@ -383,6 +384,143 @@ export const DEMO_CIPHERS: Cipher[] = [
|
||||
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',
|
||||
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 {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
@@ -1075,6 +1440,38 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
||||
onGetRecoveryCode: readonlyString,
|
||||
onGetApiKey: 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,
|
||||
onSessionTimeoutActionChange: readonlyVoid,
|
||||
onRefreshAuthorizedDevices: async () => {
|
||||
|
||||
@@ -10,10 +10,10 @@ export type Locale =
|
||||
| 'zh-CN'
|
||||
| 'zh-TW'
|
||||
| 'ru'
|
||||
| 'es';
|
||||
| 'es'
|
||||
| 'fi';
|
||||
|
||||
import enMessages from './i18n/locales/en';
|
||||
|
||||
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
|
||||
|
||||
type MessageTable = Record<string, string>;
|
||||
@@ -24,6 +24,7 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [
|
||||
{ value: 'zh-TW', label: '繁體中文' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'fi', label: 'Suomi' },
|
||||
];
|
||||
|
||||
let locale: Locale = resolveInitialLocale();
|
||||
@@ -49,6 +50,7 @@ function resolveInitialLocale(): Locale {
|
||||
if (normalized.startsWith('zh')) return 'zh-CN';
|
||||
if (normalized.startsWith('ru')) return 'ru';
|
||||
if (normalized.startsWith('es')) return 'es';
|
||||
if (normalized.startsWith('fi')) return 'fi';
|
||||
}
|
||||
}
|
||||
return 'en';
|
||||
@@ -60,6 +62,7 @@ const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> =
|
||||
'zh-TW': () => import('./i18n/locales/zh-TW'),
|
||||
ru: () => import('./i18n/locales/ru'),
|
||||
es: () => import('./i18n/locales/es'),
|
||||
fi: () => import('./i18n/locales/fi'),
|
||||
};
|
||||
|
||||
function localeToHtmlLang(value: Locale): string {
|
||||
@@ -74,7 +77,6 @@ function syncDocumentLanguage(): void {
|
||||
async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
|
||||
const cached = loadedMessages.get(next);
|
||||
if (cached) return cached;
|
||||
|
||||
const mod = await localeLoaders[next]();
|
||||
loadedMessages.set(next, 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 or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
|
||||
}[normalized];
|
||||
|
||||
return key ? t(key) : normalized;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -427,6 +427,20 @@
|
||||
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 {
|
||||
@apply justify-end overflow-visible pb-0.5;
|
||||
flex-wrap: unset;
|
||||
|
||||
Reference in New Issue
Block a user