mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 14:50:11 +00:00
Compare commits
24
Commits
v1.7.3
..
fb376797d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb376797d2 | ||
|
|
99b50275a6 | ||
|
|
dfc98008cb | ||
|
|
b472121f43 | ||
|
|
9caa064488 | ||
|
|
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,85 +105,46 @@ 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
-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)
|
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
<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/) - 无服务器平台
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 贡献者
|
||||||
|
|
||||||
|
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
@@ -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,11 @@ 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'],
|
||||||
|
['de', 'de.ts', 'de', 'German'],
|
||||||
|
['fr', 'fr.ts', 'fr', 'French'],
|
||||||
|
['it', 'it.ts', 'it', 'Italian'],
|
||||||
|
['sv', 'sv.ts', 'sv', 'Swedish'],
|
||||||
];
|
];
|
||||||
|
|
||||||
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
-1
@@ -9,7 +9,7 @@
|
|||||||
script-src 'self' 'unsafe-inline';
|
script-src 'self' 'unsafe-inline';
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
img-src 'self' data:;
|
img-src 'self' data:;
|
||||||
connect-src 'self';
|
connect-src 'self' https://api.pwnedpasswords.com;
|
||||||
font-src 'self';
|
font-src 'self';
|
||||||
form-action 'self';
|
form-action 'self';
|
||||||
base-uri 'self';
|
base-uri 'self';
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import { t } from '@/lib/i18n';
|
|||||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||||
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
||||||
|
import { clearPasswordSecurityCache } from '@/lib/password-security-cache';
|
||||||
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
||||||
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
||||||
import {
|
import {
|
||||||
@@ -111,6 +112,8 @@ const APP_ROUTE_PATHS = [
|
|||||||
'/',
|
'/',
|
||||||
'/vault',
|
'/vault',
|
||||||
'/vault/totp',
|
'/vault/totp',
|
||||||
|
'/security/password-health',
|
||||||
|
'/generator',
|
||||||
'/sends',
|
'/sends',
|
||||||
'/admin',
|
'/admin',
|
||||||
'/logs',
|
'/logs',
|
||||||
@@ -385,6 +388,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [phase, profile, session]);
|
}, [phase, profile, session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'app') clearPasswordSecurityCache();
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
|
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
|
||||||
@@ -871,6 +878,7 @@ export default function App() {
|
|||||||
setDecryptedFolders([]);
|
setDecryptedFolders([]);
|
||||||
setDecryptedCiphers([]);
|
setDecryptedCiphers([]);
|
||||||
setDecryptedSends([]);
|
setDecryptedSends([]);
|
||||||
|
clearPasswordSecurityCache();
|
||||||
setUnlockPassword('');
|
setUnlockPassword('');
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setPendingTotpMode(null);
|
setPendingTotpMode(null);
|
||||||
@@ -892,6 +900,7 @@ export default function App() {
|
|||||||
setSession(null);
|
setSession(null);
|
||||||
clearProfileSnapshot();
|
clearProfileSnapshot();
|
||||||
clearOfflineUnlockRecord();
|
clearOfflineUnlockRecord();
|
||||||
|
clearPasswordSecurityCache();
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
@@ -1901,13 +1910,17 @@ export default function App() {
|
|||||||
const mobilePrimaryRoute =
|
const mobilePrimaryRoute =
|
||||||
location === '/sends'
|
location === '/sends'
|
||||||
? '/sends'
|
? '/sends'
|
||||||
|
: location === '/generator'
|
||||||
|
? '/generator'
|
||||||
: location === '/vault/totp'
|
: location === '/vault/totp'
|
||||||
? '/vault/totp'
|
? '/vault/totp'
|
||||||
: location === '/vault'
|
: location === '/vault'
|
||||||
? '/vault'
|
? '/vault'
|
||||||
: '/settings';
|
: '/settings';
|
||||||
const currentPageTitle = (() => {
|
const currentPageTitle = (() => {
|
||||||
|
if (location === '/security/password-health') return t('txt_password_security');
|
||||||
if (location === '/vault/totp') return t('txt_verification_code');
|
if (location === '/vault/totp') return t('txt_verification_code');
|
||||||
|
if (location === '/generator') return t('txt_password_generator');
|
||||||
if (location === '/sends') return t('nav_sends');
|
if (location === '/sends') return t('nav_sends');
|
||||||
if (location === '/admin') return t('nav_admin_panel');
|
if (location === '/admin') return t('nav_admin_panel');
|
||||||
if (location === '/logs') return t('nav_log_center');
|
if (location === '/logs') return t('nav_log_center');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact';
|
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, SlidersHorizontal, Sparkles, Users } from 'lucide-preact';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { Link } from 'wouter';
|
import { Link } from 'wouter';
|
||||||
@@ -55,7 +55,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
||||||
const isLogRoute = props.location === '/logs';
|
const isLogRoute = props.location === '/logs';
|
||||||
const isAdmin = isAdminProfile(props.profile);
|
const isAdmin = isAdminProfile(props.profile);
|
||||||
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
|
const vaultActive = props.location === '/vault' || props.location === '/vault/totp' || props.location === '/security/password-health';
|
||||||
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
|
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
|
||||||
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive;
|
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive;
|
||||||
const flatSettingsActive = settingsActive && !deviceManagementActive;
|
const flatSettingsActive = settingsActive && !deviceManagementActive;
|
||||||
@@ -175,6 +175,8 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<>
|
<>
|
||||||
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
|
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
|
||||||
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
|
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
|
||||||
|
{renderSideLink('/security/password-health', props.location === '/security/password-health', <ShieldCheck size={16} />, t('nav_password_security'))}
|
||||||
|
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
|
||||||
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||||
{renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
|
{renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
|
||||||
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
||||||
@@ -195,8 +197,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<>
|
<>
|
||||||
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
|
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
|
||||||
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
|
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
|
||||||
|
{renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
|
||||||
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||||
{renderNavGroup(
|
{renderNavGroup(
|
||||||
'settings',
|
'settings',
|
||||||
@@ -327,6 +331,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<Clock3 size={18} />
|
<Clock3 size={18} />
|
||||||
<span>{t('txt_verification_code')}</span>
|
<span>{t('txt_verification_code')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/generator" className={`mobile-tab ${props.mobilePrimaryRoute === '/generator' ? 'active' : ''}`}>
|
||||||
|
<Sparkles size={18} />
|
||||||
|
<span>{t('nav_generator')}</span>
|
||||||
|
</Link>
|
||||||
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
||||||
<SendIcon size={18} />
|
<SendIcon size={18} />
|
||||||
<span>{t('nav_sends')}</span>
|
<span>{t('nav_sends')}</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { lazy, Suspense } from 'preact/compat';
|
import { lazy, Suspense } from 'preact/compat';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
import { Link, Route, Switch } from 'wouter';
|
import { Link, Route, Switch } from 'wouter';
|
||||||
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldCheck, ShieldUser } from 'lucide-preact';
|
||||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
@@ -13,6 +13,8 @@ import type { ExportRequest } from '@/lib/export-formats';
|
|||||||
|
|
||||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||||
|
const PasswordGeneratorPage = lazy(() => import('@/components/PasswordGeneratorPage'));
|
||||||
|
const PasswordSecurityPage = lazy(() => import('@/components/PasswordSecurityPage'));
|
||||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||||
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
||||||
@@ -207,6 +209,16 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Route path="/security/password-health">
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} />
|
||||||
|
</Suspense>
|
||||||
|
</Route>
|
||||||
|
<Route path="/generator">
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<PasswordGeneratorPage />
|
||||||
|
</Suspense>
|
||||||
|
</Route>
|
||||||
<Route path="/sends">
|
<Route path="/sends">
|
||||||
<Suspense fallback={<RouteContentFallback />}>
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
<SendsPage
|
<SendsPage
|
||||||
@@ -328,6 +340,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<SettingsIcon size={18} />
|
<SettingsIcon size={18} />
|
||||||
<span>{t('nav_account_settings')}</span>
|
<span>{t('nav_account_settings')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/security/password-health" className="mobile-settings-link">
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
<span>{t('nav_password_security')}</span>
|
||||||
|
</Link>
|
||||||
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
||||||
<Shield size={18} />
|
<Shield size={18} />
|
||||||
<span>{t('nav_device_management')}</span>
|
<span>{t('nav_device_management')}</span>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import { EFFLongWordList } from '@/lib/eff-word-list';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
type GeneratorMode = 'password' | 'passphrase';
|
||||||
|
|
||||||
|
interface PasswordOptions {
|
||||||
|
length: number;
|
||||||
|
uppercase: boolean;
|
||||||
|
lowercase: boolean;
|
||||||
|
numbers: boolean;
|
||||||
|
special: boolean;
|
||||||
|
minNumbers: number;
|
||||||
|
minSpecial: number;
|
||||||
|
avoidAmbiguous: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PassphraseOptions {
|
||||||
|
words: number;
|
||||||
|
separator: string;
|
||||||
|
capitalize: boolean;
|
||||||
|
includeNumber: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v1';
|
||||||
|
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
const DIGITS = '0123456789';
|
||||||
|
const SPECIAL = '!@#$%^&*';
|
||||||
|
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1']);
|
||||||
|
|
||||||
|
const defaultPasswordOptions: PasswordOptions = {
|
||||||
|
length: 14,
|
||||||
|
uppercase: true,
|
||||||
|
lowercase: true,
|
||||||
|
numbers: true,
|
||||||
|
special: false,
|
||||||
|
minNumbers: 1,
|
||||||
|
minSpecial: 1,
|
||||||
|
avoidAmbiguous: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPassphraseOptions: PassphraseOptions = {
|
||||||
|
words: 6,
|
||||||
|
separator: '-',
|
||||||
|
capitalize: false,
|
||||||
|
includeNumber: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp(value: unknown, minimum: number, maximum: number, fallback: number): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettings(): { mode: GeneratorMode; password: PasswordOptions; passphrase: PassphraseOptions } {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') as Partial<{ mode: GeneratorMode; password: Partial<PasswordOptions>; passphrase: Partial<PassphraseOptions> }>;
|
||||||
|
return {
|
||||||
|
mode: stored.mode === 'passphrase' ? 'passphrase' : 'password',
|
||||||
|
password: {
|
||||||
|
...defaultPasswordOptions,
|
||||||
|
...stored.password,
|
||||||
|
length: clamp(stored.password?.length, 5, 128, defaultPasswordOptions.length),
|
||||||
|
minNumbers: clamp(stored.password?.minNumbers, 0, 9, defaultPasswordOptions.minNumbers),
|
||||||
|
minSpecial: clamp(stored.password?.minSpecial, 0, 9, defaultPasswordOptions.minSpecial),
|
||||||
|
},
|
||||||
|
passphrase: {
|
||||||
|
...defaultPassphraseOptions,
|
||||||
|
...stored.passphrase,
|
||||||
|
words: clamp(stored.passphrase?.words, 3, 20, defaultPassphraseOptions.words),
|
||||||
|
separator: String(stored.passphrase?.separator ?? defaultPassphraseOptions.separator).slice(0, 1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomIndex(length: number): number {
|
||||||
|
const range = 0x1_0000_0000;
|
||||||
|
const upperBound = Math.floor(range / length) * length;
|
||||||
|
const buffer = new Uint32Array(1);
|
||||||
|
do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound);
|
||||||
|
return buffer[0] % length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(characters: string): string {
|
||||||
|
return characters[randomIndex(characters.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle(value: string[]): string[] {
|
||||||
|
for (let index = value.length - 1; index > 0; index -= 1) {
|
||||||
|
const next = randomIndex(index + 1);
|
||||||
|
[value[index], value[next]] = [value[next], value[index]];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtered(characters: string, avoidAmbiguous: boolean): string {
|
||||||
|
return avoidAmbiguous ? characters.split('').filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePassword(options: PasswordOptions): string {
|
||||||
|
const sets: Array<{ chars: string; minimum: number }> = [];
|
||||||
|
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||||
|
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||||
|
if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers });
|
||||||
|
if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial });
|
||||||
|
if (!sets.length) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||||
|
|
||||||
|
const minimumLength = sets.reduce((total, set) => total + set.minimum, 0);
|
||||||
|
const length = Math.max(options.length, minimumLength, 5);
|
||||||
|
const allCharacters = sets.map((set) => set.chars).join('');
|
||||||
|
const characters = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
|
||||||
|
while (characters.length < length) characters.push(pick(allCharacters));
|
||||||
|
return shuffle(characters).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePassphrase(options: PassphraseOptions): string {
|
||||||
|
const words = Array.from({ length: options.words }, () => EFFLongWordList[randomIndex(EFFLongWordList.length)]);
|
||||||
|
if (options.capitalize) {
|
||||||
|
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
|
||||||
|
}
|
||||||
|
if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10));
|
||||||
|
return words.join(options.separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
function strengthLabel(mode: GeneratorMode, value: string): { label: string; score: number } {
|
||||||
|
const score = mode === 'password' ? Math.min(4, Math.max(1, Math.floor(value.length / 5))) : Math.min(4, Math.max(1, Math.floor(value.split(/[-_. ]/).filter(Boolean).length / 2)));
|
||||||
|
return { score, label: t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][score - 1]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasswordGeneratorPage() {
|
||||||
|
const initial = useMemo(readSettings, []);
|
||||||
|
const [mode, setMode] = useState<GeneratorMode>(initial.mode);
|
||||||
|
const [passwordOptions, setPasswordOptions] = useState<PasswordOptions>(initial.password);
|
||||||
|
const [passphraseOptions, setPassphraseOptions] = useState<PassphraseOptions>(initial.passphrase);
|
||||||
|
const [seed, setSeed] = useState(0);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const generated = useMemo(
|
||||||
|
() => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)),
|
||||||
|
[mode, passwordOptions, passphraseOptions, seed]
|
||||||
|
);
|
||||||
|
const strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions }));
|
||||||
|
} catch {
|
||||||
|
// The generator remains fully usable when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
}, [mode, passwordOptions, passphraseOptions]);
|
||||||
|
|
||||||
|
const regenerate = () => {
|
||||||
|
setCopied(false);
|
||||||
|
setSeed((value) => value + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
|
||||||
|
window.setTimeout(() => setCopied(false), 1600);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
|
||||||
|
setPasswordOptions((current) => ({ ...current, [key]: value }));
|
||||||
|
setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePassphraseOption = <K extends keyof PassphraseOptions>(key: K, value: PassphraseOptions[K]) => {
|
||||||
|
setPassphraseOptions((current) => ({ ...current, [key]: value }));
|
||||||
|
setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="generator-page" aria-label={t('txt_password_generator')}>
|
||||||
|
<div className="generator-layout">
|
||||||
|
<section className="generator-output-card" aria-live="polite">
|
||||||
|
<div className="settings-category-tabs" role="tablist" aria-label={t('txt_generator_type')}>
|
||||||
|
<button type="button" role="tab" aria-selected={mode === 'password'} className={`settings-category-tab ${mode === 'password' ? 'active' : ''}`} onClick={() => setMode('password')}>{t('txt_password')}</button>
|
||||||
|
<button type="button" role="tab" aria-selected={mode === 'passphrase'} className={`settings-category-tab ${mode === 'passphrase' ? 'active' : ''}`} onClick={() => setMode('passphrase')}>{t('txt_passphrase')}</button>
|
||||||
|
</div>
|
||||||
|
<output className="generator-value" aria-label={t('txt_generated_password')}>{generated}</output>
|
||||||
|
<div className="generator-strength-row">
|
||||||
|
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strength.label}`}>
|
||||||
|
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength.score ? `active level-${strength.score}` : ''} />)}
|
||||||
|
</div>
|
||||||
|
<span><ShieldCheck size={15} /> {strength.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="actions generator-actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={regenerate}><RefreshCw size={16} className="btn-icon" />{t('txt_regenerate')}</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : t('txt_copy')}</button>
|
||||||
|
</div>
|
||||||
|
<p className="generator-security-note"><Check size={15} />{t('txt_generator_security_note')}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="generator-options-card" aria-labelledby="generator-options-title">
|
||||||
|
<h2 id="generator-options-title">{t('txt_options')}</h2>
|
||||||
|
{mode === 'password' ? (
|
||||||
|
<>
|
||||||
|
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={passwordOptions.length} minimum={5} maximum={128} fallback={14} onChange={(value) => changePasswordOption('length', value)} />
|
||||||
|
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
|
||||||
|
<GeneratorToggle checked={passwordOptions.uppercase} onChange={(checked) => changePasswordOption('uppercase', checked)} label={t('txt_generator_uppercase')} />
|
||||||
|
<GeneratorToggle checked={passwordOptions.lowercase} onChange={(checked) => changePasswordOption('lowercase', checked)} label={t('txt_generator_lowercase')} />
|
||||||
|
<GeneratorToggle checked={passwordOptions.numbers} onChange={(checked) => changePasswordOption('numbers', checked)} label={t('txt_generator_numbers')} />
|
||||||
|
{passwordOptions.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={passwordOptions.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minNumbers', value)} />}
|
||||||
|
<GeneratorToggle checked={passwordOptions.special} onChange={(checked) => changePasswordOption('special', checked)} label={t('txt_generator_special')} />
|
||||||
|
{passwordOptions.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={passwordOptions.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minSpecial', value)} />}
|
||||||
|
</fieldset>
|
||||||
|
<GeneratorToggle checked={passwordOptions.avoidAmbiguous} onChange={(checked) => changePasswordOption('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={passphraseOptions.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
|
||||||
|
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={passphraseOptions.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
|
||||||
|
<div className="generator-option-group">
|
||||||
|
<GeneratorToggle checked={passphraseOptions.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
||||||
|
<GeneratorToggle checked={passphraseOptions.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeneratorToggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||||
|
return <label className="generator-toggle"><input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.currentTarget.checked)} /><span aria-hidden="true" /><strong>{props.label}</strong></label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeneratorNumberStepper(props: { id: string; label: string; value: number; minimum: number; maximum: number; fallback: number; compact?: boolean; onChange: (value: number) => void }) {
|
||||||
|
const id = `generator-stepper-${props.id}`;
|
||||||
|
const setValue = (value: number) => props.onChange(clamp(value, props.minimum, props.maximum, props.fallback));
|
||||||
|
return (
|
||||||
|
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
|
||||||
|
<label htmlFor={id}>{props.label}</label>
|
||||||
|
<div className="generator-stepper">
|
||||||
|
<button type="button" aria-label={`${props.label} -`} disabled={props.value <= props.minimum} onClick={() => setValue(props.value - 1)}><Minus size={15} /></button>
|
||||||
|
<input id={id} className="input" type="text" inputMode="numeric" pattern="[0-9]*" value={props.value} onInput={(event) => setValue(Number(event.currentTarget.value))} />
|
||||||
|
<button type="button" aria-label={`${props.label} +`} disabled={props.value >= props.maximum} onClick={() => setValue(props.value + 1)}><Plus size={15} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
|
import { AlertTriangle, CheckCircle2, ExternalLink, Eye, EyeOff, RefreshCw, ScanSearch, ShieldAlert, ShieldCheck, Unplug } from 'lucide-preact';
|
||||||
|
import { Link } from 'wouter';
|
||||||
|
import { maskSecret } from '@/components/vault/vault-page-helpers';
|
||||||
|
import { getPasswordSecurityState, readPasswordSecurityState, startPasswordSecurityScan, subscribePasswordSecurityState } from '@/lib/password-security-cache';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { Cipher } from '@/lib/types';
|
||||||
|
|
||||||
|
interface PasswordSecurityPageProps {
|
||||||
|
ciphers: Cipher[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordSecurityFilter = 'exposed' | 'reused' | 'weak' | 'all';
|
||||||
|
|
||||||
|
function vaultFingerprint(ciphers: Cipher[]): string {
|
||||||
|
return JSON.stringify(ciphers.map((cipher) => ({
|
||||||
|
id: cipher.id,
|
||||||
|
type: cipher.type,
|
||||||
|
revisionDate: cipher.revisionDate || '',
|
||||||
|
deletedDate: cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt || '',
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCheckedAt(value: number): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasswordSecurityPage(props: PasswordSecurityPageProps) {
|
||||||
|
const fingerprint = vaultFingerprint(props.ciphers);
|
||||||
|
const [securityState, setSecurityState] = useState(() => getPasswordSecurityState(fingerprint));
|
||||||
|
const [filter, setFilter] = useState<PasswordSecurityFilter>('all');
|
||||||
|
const [revealedPasswordIds, setRevealedPasswordIds] = useState<Set<string>>(() => new Set());
|
||||||
|
useEffect(() => {
|
||||||
|
setSecurityState(getPasswordSecurityState(fingerprint));
|
||||||
|
setFilter('all');
|
||||||
|
setRevealedPasswordIds(new Set());
|
||||||
|
return subscribePasswordSecurityState(() => {
|
||||||
|
const next = readPasswordSecurityState(fingerprint);
|
||||||
|
if (next) setSecurityState(next);
|
||||||
|
});
|
||||||
|
}, [fingerprint]);
|
||||||
|
|
||||||
|
const { report, scannedAt, scanning, progress, scanError } = securityState;
|
||||||
|
|
||||||
|
const eligibleCount = useMemo(
|
||||||
|
() => props.ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length,
|
||||||
|
[props.ciphers],
|
||||||
|
);
|
||||||
|
const ciphersById = useMemo(() => new Map(props.ciphers.map((cipher) => [cipher.id, cipher])), [props.ciphers]);
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!report || filter === 'all') return report?.items || [];
|
||||||
|
if (filter === 'exposed') return report.items.filter((item) => (item.exposedCount || 0) > 0);
|
||||||
|
if (filter === 'reused') return report.items.filter((item) => item.reusedCount > 1);
|
||||||
|
return report.items.filter((item) => item.weak);
|
||||||
|
}, [filter, report]);
|
||||||
|
const allPasswordsVisible = !!report?.items.length && report.items.every((item) => revealedPasswordIds.has(item.cipherId));
|
||||||
|
|
||||||
|
const togglePasswordVisibility = (cipherId: string) => {
|
||||||
|
setRevealedPasswordIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(cipherId)) next.delete(cipherId);
|
||||||
|
else next.add(cipherId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAllPasswordVisibility = () => {
|
||||||
|
if (!report) return;
|
||||||
|
setRevealedPasswordIds(allPasswordsVisible ? new Set() : new Set(report.items.map((item) => item.cipherId)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const scan = () => {
|
||||||
|
setRevealedPasswordIds(new Set());
|
||||||
|
setFilter('all');
|
||||||
|
startPasswordSecurityScan(fingerprint, props.ciphers);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="password-security-page" aria-label={t('txt_password_security')}>
|
||||||
|
<div className="password-security-intro card">
|
||||||
|
<div className="password-security-intro-icon"><ShieldCheck size={22} /></div>
|
||||||
|
<div>
|
||||||
|
<h2>{t('txt_password_security')}</h2>
|
||||||
|
<p>{t('txt_password_security_privacy')}</p>
|
||||||
|
{scannedAt && <p className="password-security-checked-at">{t('txt_password_security_last_checked', { value: formatCheckedAt(scannedAt) })}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="password-security-intro-actions">
|
||||||
|
{report && <button type="button" className="btn btn-secondary password-security-toggle-all" onClick={toggleAllPasswordVisibility}>
|
||||||
|
{allPasswordsVisible ? <EyeOff size={16} className="btn-icon" /> : <Eye size={16} className="btn-icon" />}
|
||||||
|
{allPasswordsVisible ? t('txt_password_security_hide_all') : t('txt_password_security_show_all')}
|
||||||
|
</button>}
|
||||||
|
<button type="button" className="btn btn-primary password-security-scan" disabled={props.loading || scanning || eligibleCount === 0} onClick={scan}>
|
||||||
|
{scanning ? <RefreshCw size={16} className="btn-icon spin" /> : <ScanSearch size={16} className="btn-icon" />}
|
||||||
|
{scanning ? t('txt_checking_password_security') : report ? t('txt_recheck_password_security') : t('txt_check_password_security')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!report && !scanning && !props.loading && (
|
||||||
|
<div className="password-security-empty card">
|
||||||
|
<ShieldCheck size={26} aria-hidden="true" />
|
||||||
|
<strong>{eligibleCount ? t('txt_password_security_ready') : t('txt_password_security_no_login')}</strong>
|
||||||
|
<span>{eligibleCount ? t('txt_password_security_manual') : t('txt_password_security_no_login_help')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(scanning || report) && (
|
||||||
|
<div className="password-security-summary" aria-live="polite">
|
||||||
|
<SecurityMetric icon={<ShieldAlert size={18} />} tone="danger" label={t('txt_exposed_passwords')} value={report?.exposedCount ?? 0} active={filter === 'exposed'} disabled={!report} onClick={() => setFilter('exposed')} />
|
||||||
|
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_reused_passwords')} value={report?.reusedCount ?? 0} active={filter === 'reused'} disabled={!report} onClick={() => setFilter('reused')} />
|
||||||
|
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_weak_passwords')} value={report?.weakCount ?? 0} active={filter === 'weak'} disabled={!report} onClick={() => setFilter('weak')} />
|
||||||
|
<SecurityMetric icon={<CheckCircle2 size={18} />} tone="primary" label={t('txt_passwords_checked')} value={`${scanning ? progress.checked : report?.checkedCount || 0} / ${scanning ? progress.total : report?.eligibleCount || 0}`} active={filter === 'all'} disabled={!report} onClick={() => setFilter('all')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scanError && <div className="password-security-notice warning card" role="alert"><Unplug size={16} />{t('txt_password_security_check_failed')}</div>}
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<section className="password-security-results card">
|
||||||
|
{report.unavailableCount > 0 && (
|
||||||
|
<div className="password-security-notice warning"><Unplug size={16} />{t('txt_password_security_unavailable', { count: report.unavailableCount })}</div>
|
||||||
|
)}
|
||||||
|
{!report.items.length ? (
|
||||||
|
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks')}</strong></div>
|
||||||
|
) : !filteredItems.length ? (
|
||||||
|
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks_in_filter')}</strong></div>
|
||||||
|
) : (
|
||||||
|
<div className="password-security-list">
|
||||||
|
{filteredItems.map((item) => {
|
||||||
|
const cipher = ciphersById.get(item.cipherId);
|
||||||
|
const name = String(cipher?.decName || cipher?.name || '');
|
||||||
|
const password = String(cipher?.login?.decPassword || '');
|
||||||
|
const passwordVisible = revealedPasswordIds.has(item.cipherId);
|
||||||
|
return <article className="password-security-item" key={item.cipherId}>
|
||||||
|
<div className="password-security-item-main">
|
||||||
|
<div className="password-security-item-header">
|
||||||
|
<strong>{name || t('txt_no_name')}</strong>
|
||||||
|
<div className="password-security-badges">
|
||||||
|
{item.exposedCount === null && <span className="risk-badge muted">{t('txt_password_security_not_checked')}</span>}
|
||||||
|
{(item.exposedCount || 0) > 0 && <span className="risk-badge danger">{t('txt_password_security_exposed_short', { count: item.exposedCount || 0 })}</span>}
|
||||||
|
{item.weak && <span className="risk-badge weak">{t('txt_password_security_weak_short')}</span>}
|
||||||
|
{item.reusedCount > 1 && <span className="risk-badge reused">{t('txt_password_security_reused_short')}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="password-security-password">{passwordVisible ? password : maskSecret(password)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="password-security-item-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => togglePasswordVisibility(item.cipherId)}>
|
||||||
|
{passwordVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||||
|
{passwordVisible ? t('txt_hide') : t('txt_reveal')}
|
||||||
|
</button>
|
||||||
|
<Link href={`/vault?cipher=${encodeURIComponent(item.cipherId)}`} className="btn btn-secondary small password-security-open">
|
||||||
|
<ExternalLink size={14} className="btn-icon" />{t('txt_password_security_jump')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecurityMetric(props: { icon: preact.ComponentChildren; tone: 'danger' | 'warning' | 'primary'; label: string; value: string | number; active: boolean; disabled: boolean; onClick: () => void }) {
|
||||||
|
return <button type="button" className={`password-security-metric ${props.tone}`} aria-pressed={props.active} disabled={props.disabled} onClick={props.onClick}><span>{props.icon}</span><div><strong>{props.value}</strong><small>{props.label}</small></div></button>;
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
|
const pendingFocusCipherIdRef = useRef<string | null>(null);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -419,7 +420,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') {
|
||||||
@@ -468,8 +498,59 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
|
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
|
||||||
}, [sidebarFilter.kind, duplicateMode]);
|
}, [sidebarFilter.kind, duplicateMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const focusId = String(new URLSearchParams(window.location.search || '').get('cipher') || '').trim();
|
||||||
|
if (!focusId) return;
|
||||||
|
pendingFocusCipherIdRef.current = focusId;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const focusId = pendingFocusCipherIdRef.current;
|
||||||
|
if (!focusId) return;
|
||||||
|
const cipher = cipherById.get(focusId);
|
||||||
|
if (!cipher) {
|
||||||
|
if (!props.loading && props.ciphers.length > 0) pendingFocusCipherIdRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFilter: SidebarFilter = isCipherVisibleInTrash(cipher)
|
||||||
|
? { kind: 'trash' }
|
||||||
|
: isCipherVisibleInArchive(cipher)
|
||||||
|
? { kind: 'archive' }
|
||||||
|
: { kind: 'all' };
|
||||||
|
setSidebarFilter((prev) => (prev.kind === nextFilter.kind ? prev : nextFilter));
|
||||||
|
setSearchInput('');
|
||||||
|
setSearchQuery('');
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
}, [cipherById, props.ciphers.length, props.loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreating) return;
|
if (isCreating) return;
|
||||||
|
|
||||||
|
const focusId = pendingFocusCipherIdRef.current;
|
||||||
|
if (focusId) {
|
||||||
|
if (!filteredCipherIds.has(focusId)) return;
|
||||||
|
setSelectedCipherId(focusId);
|
||||||
|
setRepromptApprovedCipherId(null);
|
||||||
|
setShowPassword(false);
|
||||||
|
setHiddenFieldVisibleMap({});
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
pendingFocusCipherIdRef.current = null;
|
||||||
|
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (url.searchParams.has('cipher')) {
|
||||||
|
url.searchParams.delete('cipher');
|
||||||
|
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
window.history.replaceState(null, '', next || '/vault');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!filteredCiphers.length) {
|
if (!filteredCiphers.length) {
|
||||||
if (selectedCipherId) setSelectedCipherId('');
|
if (selectedCipherId) setSelectedCipherId('');
|
||||||
return;
|
return;
|
||||||
@@ -477,7 +558,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
|
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
|
||||||
setSelectedCipherId(filteredCiphers[0].id);
|
setSelectedCipherId(filteredCiphers[0].id);
|
||||||
}
|
}
|
||||||
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]);
|
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating, isMobileLayout]);
|
||||||
|
|
||||||
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
|
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
|
||||||
const virtualRange = useMemo(() => {
|
const virtualRange = useMemo(() => {
|
||||||
@@ -1049,6 +1130,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 +1258,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}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
import { AlertTriangle, Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RefreshCw, RotateCcw, ShieldCheck, ShieldAlert, Trash2, X } from 'lucide-preact';
|
||||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
import type { TotpCodeResult } from '@/lib/crypto';
|
import type { TotpCodeResult } from '@/lib/crypto';
|
||||||
|
import { checkPasswordLeaked, type PasswordBreachResult } from '@/lib/password-security';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,10 @@ import {
|
|||||||
toBooleanFieldValue,
|
toBooleanFieldValue,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError';
|
||||||
|
}
|
||||||
|
|
||||||
interface VaultDetailViewProps {
|
interface VaultDetailViewProps {
|
||||||
selectedCipher: Cipher;
|
selectedCipher: Cipher;
|
||||||
repromptApprovedCipherId: string | null;
|
repromptApprovedCipherId: string | null;
|
||||||
@@ -90,6 +95,9 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||||
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
||||||
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
|
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
|
||||||
|
const [breachResult, setBreachResult] = useState<PasswordBreachResult | null>(null);
|
||||||
|
const [checkingBreach, setCheckingBreach] = useState(false);
|
||||||
|
const breachControllerRef = useRef<AbortController | null>(null);
|
||||||
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
||||||
const isDeleted = isCipherDeleted(props.selectedCipher);
|
const isDeleted = isCipherDeleted(props.selectedCipher);
|
||||||
const passwordHistoryEntries = useMemo(
|
const passwordHistoryEntries = useMemo(
|
||||||
@@ -103,9 +111,39 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
[props.selectedCipher.passwordHistory]
|
[props.selectedCipher.passwordHistory]
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
breachControllerRef.current?.abort();
|
||||||
|
breachControllerRef.current = null;
|
||||||
setShowSshPrivateKey(false);
|
setShowSshPrivateKey(false);
|
||||||
setPasswordHistoryOpen(false);
|
setPasswordHistoryOpen(false);
|
||||||
}, [props.selectedCipher.id]);
|
setBreachResult(null);
|
||||||
|
setCheckingBreach(false);
|
||||||
|
return () => {
|
||||||
|
breachControllerRef.current?.abort();
|
||||||
|
breachControllerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [props.selectedCipher.id, props.selectedCipher.login?.decPassword]);
|
||||||
|
const checkBreach = async () => {
|
||||||
|
const password = String(props.selectedCipher.login?.decPassword || '');
|
||||||
|
if (!password) return;
|
||||||
|
breachControllerRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
breachControllerRef.current = controller;
|
||||||
|
setCheckingBreach(true);
|
||||||
|
setBreachResult(null);
|
||||||
|
try {
|
||||||
|
const result = await checkPasswordLeaked(password, fetch, controller.signal);
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setBreachResult(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted || isAbortError(error)) return;
|
||||||
|
setBreachResult({ count: null, available: false });
|
||||||
|
} finally {
|
||||||
|
if (breachControllerRef.current === controller) {
|
||||||
|
breachControllerRef.current = null;
|
||||||
|
setCheckingBreach(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
const formatDownloadLabel = (attachmentId: string) => {
|
const formatDownloadLabel = (attachmentId: string) => {
|
||||||
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||||
@@ -172,8 +210,18 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
|
||||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={checkingBreach || !props.selectedCipher.login?.decPassword} onClick={() => void checkBreach()}>
|
||||||
|
{checkingBreach ? <RefreshCw size={14} className="btn-icon spin" /> : <ShieldCheck size={14} className="btn-icon" />}
|
||||||
|
{checkingBreach ? t('txt_checking_password_security') : t('txt_check_password_breach')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{breachResult && (
|
||||||
|
<div className={`password-breach-inline ${breachResult.available ? (breachResult.count ? 'danger' : 'safe') : 'warning'}`} role="status">
|
||||||
|
{breachResult.available ? (breachResult.count ? <ShieldAlert size={15} /> : <ShieldCheck size={15} />) : <AlertTriangle size={15} />}
|
||||||
|
<span>{breachResult.available ? (breachResult.count ? t('txt_password_exposed_count', { count: breachResult.count }) : t('txt_password_not_found_in_breaches')) : t('txt_password_security_check_failed')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!!props.selectedCipher.login.decTotp && (
|
{!!props.selectedCipher.login.decTotp && (
|
||||||
<div className="kv-row">
|
<div className="kv-row">
|
||||||
<span className="kv-label">{t('txt_totp')}</span>
|
<span className="kv-label">{t('txt_totp')}</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import {
|
|||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Pencil,
|
Pencil,
|
||||||
ShieldUser,
|
ShieldUser,
|
||||||
|
ShieldCheck,
|
||||||
Star,
|
Star,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-preact';
|
} from 'lucide-preact';
|
||||||
|
import { Link } from 'wouter';
|
||||||
import type { Folder } from '@/lib/types';
|
import type { Folder } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
|
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
|
||||||
@@ -95,6 +97,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="sidebar-block">
|
<div className="sidebar-block">
|
||||||
|
<Link href="/security/password-health" className="tree-btn">
|
||||||
|
<ShieldCheck size={14} className="tree-icon" /> <span className="tree-label">{t('nav_password_security')}</span>
|
||||||
|
</Link>
|
||||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}>
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}>
|
||||||
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
|
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+21
-4
@@ -10,10 +10,14 @@ export type Locale =
|
|||||||
| 'zh-CN'
|
| 'zh-CN'
|
||||||
| 'zh-TW'
|
| 'zh-TW'
|
||||||
| 'ru'
|
| 'ru'
|
||||||
| 'es';
|
| 'es'
|
||||||
|
| 'fi'
|
||||||
|
| 'de'
|
||||||
|
| 'fr'
|
||||||
|
| 'it'
|
||||||
|
| 'sv';
|
||||||
|
|
||||||
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 +28,11 @@ 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' },
|
||||||
|
{ value: 'de', label: 'Deutsch' },
|
||||||
|
{ value: 'fr', label: 'Français' },
|
||||||
|
{ value: 'it', label: 'Italiano' },
|
||||||
|
{ value: 'sv', label: 'Svenska' },
|
||||||
];
|
];
|
||||||
|
|
||||||
let locale: Locale = resolveInitialLocale();
|
let locale: Locale = resolveInitialLocale();
|
||||||
@@ -49,6 +58,11 @@ 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';
|
||||||
|
if (normalized.startsWith('de')) return 'de';
|
||||||
|
if (normalized.startsWith('fr')) return 'fr';
|
||||||
|
if (normalized.startsWith('it')) return 'it';
|
||||||
|
if (normalized.startsWith('sv')) return 'sv';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'en';
|
return 'en';
|
||||||
@@ -60,6 +74,11 @@ 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'),
|
||||||
|
de: () => import('./i18n/locales/de'),
|
||||||
|
fr: () => import('./i18n/locales/fr'),
|
||||||
|
it: () => import('./i18n/locales/it'),
|
||||||
|
sv: () => import('./i18n/locales/sv'),
|
||||||
};
|
};
|
||||||
|
|
||||||
function localeToHtmlLang(value: Locale): string {
|
function localeToHtmlLang(value: Locale): string {
|
||||||
@@ -74,7 +93,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 +241,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,30 @@ const en: Record<string, string> = {
|
|||||||
"nav_my_vault": "My Vault",
|
"nav_my_vault": "My Vault",
|
||||||
"nav_vault_items": "Vault",
|
"nav_vault_items": "Vault",
|
||||||
"nav_sends": "Sends",
|
"nav_sends": "Sends",
|
||||||
|
"nav_generator": "Generator",
|
||||||
|
"txt_password_generator": "Password Generator",
|
||||||
|
"txt_password_generator_description": "Create a strong, unique password locally on this device.",
|
||||||
|
"txt_generator_type": "Generator type",
|
||||||
|
"txt_passphrase": "Passphrase",
|
||||||
|
"txt_generated_password": "Generated password",
|
||||||
|
"txt_password_strength": "Strength",
|
||||||
|
"txt_password_strength_weak": "Weak",
|
||||||
|
"txt_password_strength_fair": "Fair",
|
||||||
|
"txt_password_strength_good": "Good",
|
||||||
|
"txt_password_strength_strong": "Strong",
|
||||||
|
"txt_generator_security_note": "Generation happens locally. Your generated password is never sent to the server.",
|
||||||
|
"txt_generator_length": "Length",
|
||||||
|
"txt_generator_character_types": "Character types",
|
||||||
|
"txt_generator_uppercase": "Uppercase (A-Z)",
|
||||||
|
"txt_generator_lowercase": "Lowercase (a-z)",
|
||||||
|
"txt_generator_numbers": "Numbers (0-9)",
|
||||||
|
"txt_generator_special": "Special characters (!@#$%^&*)",
|
||||||
|
"txt_generator_minimum": "Minimum",
|
||||||
|
"txt_generator_avoid_ambiguous": "Avoid ambiguous characters",
|
||||||
|
"txt_generator_words": "Number of words",
|
||||||
|
"txt_generator_separator": "Word separator",
|
||||||
|
"txt_generator_capitalize": "Capitalize",
|
||||||
|
"txt_generator_include_number": "Include a number",
|
||||||
"nav_backup_strategy": "Cloud Backup",
|
"nav_backup_strategy": "Cloud Backup",
|
||||||
"nav_import_export": "Import & Export",
|
"nav_import_export": "Import & Export",
|
||||||
"nav_group_data_backup": "Data & Backup",
|
"nav_group_data_backup": "Data & Backup",
|
||||||
@@ -1012,6 +1036,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",
|
||||||
@@ -1444,4 +1471,39 @@ const en: Record<string, string> = {
|
|||||||
"txt_ip_address": "IP address"
|
"txt_ip_address": "IP address"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Object.assign(en, {
|
||||||
|
"nav_password_security": "Password Security",
|
||||||
|
"txt_password_security": "Password Security Check",
|
||||||
|
"txt_password_security_privacy": "Passwords are checked locally in your browser. Only an anonymous hash prefix is sent to the breach database.",
|
||||||
|
"txt_check_password_security": "Start check",
|
||||||
|
"txt_checking_password_security": "Checking",
|
||||||
|
"txt_recheck_password_security": "Check again",
|
||||||
|
"txt_password_security_ready": "Your vault is ready for a security check.",
|
||||||
|
"txt_password_security_no_login": "There are no login passwords to check.",
|
||||||
|
"txt_password_security_manual": "The check only starts when you choose it. Results are kept until you refresh or your vault changes.",
|
||||||
|
"txt_password_security_no_login_help": "Add a login item with a password, then return here to check it.",
|
||||||
|
"txt_exposed_passwords": "Exposed",
|
||||||
|
"txt_reused_passwords": "Reused",
|
||||||
|
"txt_weak_passwords": "Weak",
|
||||||
|
"txt_passwords_checked": "Checked",
|
||||||
|
"txt_password_security_last_checked": "Last checked: {value}",
|
||||||
|
"txt_password_security_show_all": "Show all",
|
||||||
|
"txt_password_security_hide_all": "Hide all",
|
||||||
|
"txt_password_security_jump": "Go to item",
|
||||||
|
"txt_password_security_exposed_short": "Exposed {count} times",
|
||||||
|
"txt_password_security_weak_short": "Weak password",
|
||||||
|
"txt_password_security_reused_short": "Reused",
|
||||||
|
"txt_password_security_unavailable": "{count} password checks could not reach the breach database. They are not marked safe.",
|
||||||
|
"txt_password_security_not_checked": "Not checked",
|
||||||
|
"txt_password_exposed_count": "Found in {count} breaches",
|
||||||
|
"txt_password_reused_count": "Used {count} times",
|
||||||
|
"txt_weak_password": "Weak password",
|
||||||
|
"txt_no_password_risks": "No password risks found",
|
||||||
|
"txt_no_password_risks_in_filter": "No password risks in this category",
|
||||||
|
"txt_open_vault": "Open vault",
|
||||||
|
"txt_check_password_breach": "Check breach",
|
||||||
|
"txt_password_not_found_in_breaches": "Not found in the breach database",
|
||||||
|
"txt_password_security_check_failed": "The breach check could not be completed."
|
||||||
|
});
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const es: Record<string, string> = {
|
|||||||
"nav_my_vault": "Mi bóveda",
|
"nav_my_vault": "Mi bóveda",
|
||||||
"nav_vault_items": "Bóveda",
|
"nav_vault_items": "Bóveda",
|
||||||
"nav_sends": "Envíos",
|
"nav_sends": "Envíos",
|
||||||
|
"nav_generator": "Generador", "txt_password_generator": "Generador de contraseñas", "txt_password_generator_description": "Crea una contraseña única y segura localmente en este dispositivo.", "txt_generator_type": "Tipo de generador", "txt_passphrase": "Frase de contraseña", "txt_generated_password": "Contraseña generada", "txt_password_strength": "Seguridad", "txt_password_strength_weak": "Débil", "txt_password_strength_fair": "Regular", "txt_password_strength_good": "Buena", "txt_password_strength_strong": "Fuerte", "txt_generator_security_note": "La generación se realiza localmente. Tu contraseña nunca se envía al servidor.", "txt_generator_length": "Longitud", "txt_generator_character_types": "Tipos de caracteres", "txt_generator_uppercase": "Mayúsculas (A-Z)", "txt_generator_lowercase": "Minúsculas (a-z)", "txt_generator_numbers": "Números (0-9)", "txt_generator_special": "Caracteres especiales (!@#$%^&*)", "txt_generator_minimum": "Mínimo", "txt_generator_avoid_ambiguous": "Evitar caracteres ambiguos", "txt_generator_words": "Número de palabras", "txt_generator_separator": "Separador de palabras", "txt_generator_capitalize": "Usar mayúsculas", "txt_generator_include_number": "Incluir un número",
|
||||||
"nav_backup_strategy": "Copia de seguridad en la nube",
|
"nav_backup_strategy": "Copia de seguridad en la nube",
|
||||||
"nav_import_export": "Importar y exportar",
|
"nav_import_export": "Importar y exportar",
|
||||||
"nav_group_data_backup": "Datos y copias",
|
"nav_group_data_backup": "Datos y copias",
|
||||||
@@ -1012,6 +1013,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",
|
||||||
@@ -1444,4 +1448,12 @@ const es: Record<string, string> = {
|
|||||||
"txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública"
|
"txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Object.assign(es, {
|
||||||
|
"nav_password_security": "Seguridad de contraseñas", "txt_password_security": "Comprobación de seguridad", "txt_password_security_privacy": "Las contraseñas se comprueban localmente. Solo se envía un prefijo de hash anónimo a la base de filtraciones al iniciar la comprobación.", "txt_check_password_security": "Iniciar comprobación", "txt_checking_password_security": "Comprobando", "txt_recheck_password_security": "Comprobar de nuevo", "txt_password_security_ready": "Tu bóveda está lista para una comprobación de seguridad.", "txt_password_security_no_login": "No hay contraseñas de inicio de sesión para comprobar.", "txt_password_security_manual": "La comprobación solo empieza cuando la eliges. Los resultados se conservan solo en esta página.", "txt_password_security_no_login_help": "Añade un inicio de sesión con contraseña y vuelve aquí para comprobarlo.", "txt_exposed_passwords": "Filtradas", "txt_reused_passwords": "Reutilizadas", "txt_weak_passwords": "Débiles", "txt_passwords_checked": "Comprobadas", "txt_password_security_unavailable": "{count} comprobaciones no pudieron acceder a la base de filtraciones. No se marcan como seguras.", "txt_password_security_not_checked": "Sin comprobar", "txt_password_exposed_count": "Encontrada en {count} filtraciones", "txt_password_reused_count": "Usada {count} veces", "txt_weak_password": "Contraseña débil", "txt_no_password_risks": "No se encontraron riesgos de contraseña", "txt_open_vault": "Abrir bóveda", "txt_check_password_breach": "Comprobar filtración", "txt_password_not_found_in_breaches": "No encontrada en la base de filtraciones", "txt_password_security_check_failed": "No se pudo completar la comprobación de filtraciones."
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(es, { "txt_password_security_last_checked": "Última comprobación: {value}" });
|
||||||
|
Object.assign(es, { "txt_no_password_risks_in_filter": "No hay riesgos de contraseña en esta categoría" });
|
||||||
|
Object.assign(es, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
|
||||||
|
|
||||||
export default es;
|
export default es;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ const ru: Record<string, string> = {
|
|||||||
"nav_my_vault": "Мое хранилище",
|
"nav_my_vault": "Мое хранилище",
|
||||||
"nav_vault_items": "Хранилище",
|
"nav_vault_items": "Хранилище",
|
||||||
"nav_sends": "Отправляет",
|
"nav_sends": "Отправляет",
|
||||||
|
"nav_generator": "Генератор", "txt_password_generator": "Генератор паролей", "txt_password_generator_description": "Создайте надежный уникальный пароль локально на этом устройстве.", "txt_generator_type": "Тип генератора", "txt_passphrase": "Парольная фраза", "txt_generated_password": "Созданный пароль", "txt_password_strength": "Надежность", "txt_password_strength_weak": "Слабый", "txt_password_strength_fair": "Средний", "txt_password_strength_good": "Хороший", "txt_password_strength_strong": "Надежный", "txt_generator_security_note": "Генерация выполняется локально. Пароль никогда не отправляется на сервер.", "txt_generator_length": "Длина", "txt_generator_character_types": "Типы символов", "txt_generator_uppercase": "Заглавные буквы (A-Z)", "txt_generator_lowercase": "Строчные буквы (a-z)", "txt_generator_numbers": "Цифры (0-9)", "txt_generator_special": "Специальные символы (!@#$%^&*)", "txt_generator_minimum": "Минимум", "txt_generator_avoid_ambiguous": "Исключить похожие символы", "txt_generator_words": "Количество слов", "txt_generator_separator": "Разделитель слов", "txt_generator_capitalize": "С заглавной буквы", "txt_generator_include_number": "Добавить число",
|
||||||
"nav_backup_strategy": "Облачное резервное копирование",
|
"nav_backup_strategy": "Облачное резервное копирование",
|
||||||
"nav_import_export": "Импорт и экспорт",
|
"nav_import_export": "Импорт и экспорт",
|
||||||
"nav_group_data_backup": "Данные и резервные копии",
|
"nav_group_data_backup": "Данные и резервные копии",
|
||||||
@@ -1012,6 +1013,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": "Синхронизировать хранилище",
|
||||||
@@ -1444,4 +1448,12 @@ const ru: Record<string, string> = {
|
|||||||
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
|
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Object.assign(ru, {
|
||||||
|
"nav_password_security": "Безопасность паролей", "txt_password_security": "Проверка безопасности паролей", "txt_password_security_privacy": "Пароли проверяются локально. После запуска в базу утечек передаётся только анонимный префикс хеша.", "txt_check_password_security": "Начать проверку", "txt_checking_password_security": "Проверка", "txt_recheck_password_security": "Проверить снова", "txt_password_security_ready": "Ваше хранилище готово к проверке безопасности.", "txt_password_security_no_login": "Нет паролей для входа, доступных для проверки.", "txt_password_security_manual": "Проверка запускается только по вашему выбору. Результаты остаются только на этой странице.", "txt_password_security_no_login_help": "Добавьте запись входа с паролем и вернитесь сюда для проверки.", "txt_exposed_passwords": "Скомпрометированы", "txt_reused_passwords": "Повторно используются", "txt_weak_passwords": "Слабые", "txt_passwords_checked": "Проверено", "txt_password_security_unavailable": "{count} проверок не смогли обратиться к базе утечек. Они не помечены безопасными.", "txt_password_security_not_checked": "Не проверено", "txt_password_exposed_count": "Найдено в {count} утечках", "txt_password_reused_count": "Используется {count} раз", "txt_weak_password": "Слабый пароль", "txt_no_password_risks": "Рисков паролей не найдено", "txt_open_vault": "Открыть хранилище", "txt_check_password_breach": "Проверить утечку", "txt_password_not_found_in_breaches": "Не найден в базе утечек", "txt_password_security_check_failed": "Не удалось завершить проверку утечки."
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(ru, { "txt_password_security_last_checked": "Последняя проверка: {value}" });
|
||||||
|
Object.assign(ru, { "txt_no_password_risks_in_filter": "В этой категории нет рисков для паролей" });
|
||||||
|
Object.assign(ru, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
|
||||||
|
|
||||||
export default ru;
|
export default ru;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,10 @@ const zhCN: Record<string, string> = {
|
|||||||
"nav_my_vault": "我的密码库",
|
"nav_my_vault": "我的密码库",
|
||||||
"nav_vault_items": "密码库",
|
"nav_vault_items": "密码库",
|
||||||
"nav_sends": "Send",
|
"nav_sends": "Send",
|
||||||
|
"nav_generator": "密码生成器",
|
||||||
|
"txt_password_generator": "密码生成器",
|
||||||
|
"txt_password_generator_description": "在此设备本地生成强且唯一的密码。",
|
||||||
|
"txt_generator_type": "生成类型", "txt_passphrase": "密码短语", "txt_generated_password": "已生成密码", "txt_password_strength": "强度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "一般", "txt_password_strength_good": "良好", "txt_password_strength_strong": "强", "txt_generator_security_note": "生成过程仅在本地进行,密码不会发送到服务器。", "txt_generator_length": "长度", "txt_generator_character_types": "字符类型", "txt_generator_uppercase": "大写字母 (A-Z)", "txt_generator_lowercase": "小写字母 (a-z)", "txt_generator_numbers": "数字 (0-9)", "txt_generator_special": "特殊字符 (!@#$%^&*)", "txt_generator_minimum": "最少数量", "txt_generator_avoid_ambiguous": "避免易混淆字符", "txt_generator_words": "单词数量", "txt_generator_separator": "单词分隔符", "txt_generator_capitalize": "首字母大写", "txt_generator_include_number": "包含数字",
|
||||||
"nav_backup_strategy": "云端备份",
|
"nav_backup_strategy": "云端备份",
|
||||||
"nav_import_export": "导入导出",
|
"nav_import_export": "导入导出",
|
||||||
"nav_group_data_backup": "数据与备份",
|
"nav_group_data_backup": "数据与备份",
|
||||||
@@ -1012,6 +1016,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": "同步",
|
||||||
@@ -1444,4 +1451,39 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_ip_address": "IP 地址"
|
"txt_ip_address": "IP 地址"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Object.assign(zhCN, {
|
||||||
|
"nav_password_security": "安全检测",
|
||||||
|
"txt_password_security": "安全检测",
|
||||||
|
"txt_password_security_privacy": "密码仅在本地前端检查;只有匿名哈希前缀会发送到泄露密码库。",
|
||||||
|
"txt_check_password_security": "开始检查",
|
||||||
|
"txt_checking_password_security": "检查中",
|
||||||
|
"txt_recheck_password_security": "重新检查",
|
||||||
|
"txt_password_security_ready": "密码库已准备好进行安全检查。",
|
||||||
|
"txt_password_security_no_login": "没有可检查的登录密码。",
|
||||||
|
"txt_password_security_manual": "仅在您主动开始后才会联网检查;结果会保留到刷新页面或密码库内容变更前。",
|
||||||
|
"txt_password_security_no_login_help": "添加一个包含密码的登录项目后,再回到此处检查。",
|
||||||
|
"txt_exposed_passwords": "已泄露",
|
||||||
|
"txt_reused_passwords": "重复使用",
|
||||||
|
"txt_weak_passwords": "较弱",
|
||||||
|
"txt_passwords_checked": "已检查",
|
||||||
|
"txt_password_security_last_checked": "上次检测:{value}",
|
||||||
|
"txt_password_security_show_all": "显示全部",
|
||||||
|
"txt_password_security_hide_all": "隐藏全部",
|
||||||
|
"txt_password_security_jump": "跳转",
|
||||||
|
"txt_password_security_exposed_short": "泄露 {count} 次",
|
||||||
|
"txt_password_security_weak_short": "弱密码",
|
||||||
|
"txt_password_security_reused_short": "重复",
|
||||||
|
"txt_password_security_unavailable": "有 {count} 个密码无法连接泄露库,未被标记为安全。",
|
||||||
|
"txt_password_security_not_checked": "未检查",
|
||||||
|
"txt_password_exposed_count": "已在 {count} 次泄露中出现",
|
||||||
|
"txt_password_reused_count": "使用了 {count} 次",
|
||||||
|
"txt_weak_password": "较弱密码",
|
||||||
|
"txt_no_password_risks": "未发现密码风险",
|
||||||
|
"txt_no_password_risks_in_filter": "此类别中没有密码风险",
|
||||||
|
"txt_open_vault": "打开密码库",
|
||||||
|
"txt_check_password_breach": "检查泄露",
|
||||||
|
"txt_password_not_found_in_breaches": "未在泄露密码库中发现",
|
||||||
|
"txt_password_security_check_failed": "无法完成泄露检查。"
|
||||||
|
});
|
||||||
|
|
||||||
export default zhCN;
|
export default zhCN;
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ const zhTW: Record<string, string> = {
|
|||||||
"nav_my_vault": "我的密碼庫",
|
"nav_my_vault": "我的密碼庫",
|
||||||
"nav_vault_items": "密碼庫",
|
"nav_vault_items": "密碼庫",
|
||||||
"nav_sends": "Send",
|
"nav_sends": "Send",
|
||||||
|
"nav_generator": "密碼產生器",
|
||||||
|
"txt_password_generator": "密碼產生器",
|
||||||
|
"txt_password_generator_description": "在此裝置本機建立強而唯一的密碼。",
|
||||||
|
"txt_generator_type": "產生類型", "txt_passphrase": "密碼片語", "txt_generated_password": "已產生密碼", "txt_password_strength": "強度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "普通", "txt_password_strength_good": "良好", "txt_password_strength_strong": "強", "txt_generator_security_note": "產生程序僅在本機進行,密碼不會傳送到伺服器。", "txt_generator_length": "長度", "txt_generator_character_types": "字元類型", "txt_generator_uppercase": "大寫字母 (A-Z)", "txt_generator_lowercase": "小寫字母 (a-z)", "txt_generator_numbers": "數字 (0-9)", "txt_generator_special": "特殊字元 (!@#$%^&*)", "txt_generator_minimum": "最少數量", "txt_generator_avoid_ambiguous": "避免易混淆字元", "txt_generator_words": "單字數量", "txt_generator_separator": "單字分隔符號", "txt_generator_capitalize": "首字母大寫", "txt_generator_include_number": "包含數字",
|
||||||
"nav_backup_strategy": "雲端備份",
|
"nav_backup_strategy": "雲端備份",
|
||||||
"nav_import_export": "導入導出",
|
"nav_import_export": "導入導出",
|
||||||
"nav_group_data_backup": "資料與備份",
|
"nav_group_data_backup": "資料與備份",
|
||||||
@@ -1012,6 +1016,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": "同步",
|
||||||
@@ -1444,4 +1451,39 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰"
|
"txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Object.assign(zhTW, {
|
||||||
|
"nav_password_security": "密碼安全",
|
||||||
|
"txt_password_security": "密碼安全檢查",
|
||||||
|
"txt_password_security_privacy": "密碼僅在本機前端檢查;只有匿名雜湊前綴會傳送到外洩密碼庫。",
|
||||||
|
"txt_check_password_security": "開始檢查",
|
||||||
|
"txt_checking_password_security": "檢查中",
|
||||||
|
"txt_recheck_password_security": "重新檢查",
|
||||||
|
"txt_password_security_ready": "密碼庫已準備好進行安全檢查。",
|
||||||
|
"txt_password_security_no_login": "沒有可檢查的登入密碼。",
|
||||||
|
"txt_password_security_manual": "僅在您主動開始後才會連線檢查;結果會保留到重新整理頁面或密碼庫內容變更前。",
|
||||||
|
"txt_password_security_no_login_help": "新增一個含有密碼的登入項目後,再回到此處檢查。",
|
||||||
|
"txt_exposed_passwords": "已外洩",
|
||||||
|
"txt_reused_passwords": "重複使用",
|
||||||
|
"txt_weak_passwords": "較弱",
|
||||||
|
"txt_passwords_checked": "已檢查",
|
||||||
|
"txt_password_security_last_checked": "上次檢查:{value}",
|
||||||
|
"txt_password_security_show_all": "顯示全部",
|
||||||
|
"txt_password_security_hide_all": "隱藏全部",
|
||||||
|
"txt_password_security_jump": "跳轉",
|
||||||
|
"txt_password_security_exposed_short": "外洩 {count} 次",
|
||||||
|
"txt_password_security_weak_short": "弱密碼",
|
||||||
|
"txt_password_security_reused_short": "重複",
|
||||||
|
"txt_password_security_unavailable": "有 {count} 個密碼無法連線至外洩資料庫,未被標記為安全。",
|
||||||
|
"txt_password_security_not_checked": "未檢查",
|
||||||
|
"txt_password_exposed_count": "已在 {count} 次外洩中出現",
|
||||||
|
"txt_password_reused_count": "使用了 {count} 次",
|
||||||
|
"txt_weak_password": "較弱密碼",
|
||||||
|
"txt_no_password_risks": "未發現密碼風險",
|
||||||
|
"txt_no_password_risks_in_filter": "此類別中沒有密碼風險",
|
||||||
|
"txt_open_vault": "開啟密碼庫",
|
||||||
|
"txt_check_password_breach": "檢查外洩",
|
||||||
|
"txt_password_not_found_in_breaches": "未在外洩密碼庫中發現",
|
||||||
|
"txt_password_security_check_failed": "無法完成外洩檢查。"
|
||||||
|
});
|
||||||
|
|
||||||
export default zhTW;
|
export default zhTW;
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { inspectVaultPasswordSecurity, type PasswordSecurityReport } from '@/lib/password-security';
|
||||||
|
import type { Cipher } from '@/lib/types';
|
||||||
|
|
||||||
|
export interface PasswordSecurityState {
|
||||||
|
fingerprint: string;
|
||||||
|
report: PasswordSecurityReport | null;
|
||||||
|
scannedAt: number | null;
|
||||||
|
scanning: boolean;
|
||||||
|
progress: { checked: number; total: number };
|
||||||
|
scanError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InternalPasswordSecurityState = PasswordSecurityState & { controller: AbortController | null };
|
||||||
|
|
||||||
|
let state: InternalPasswordSecurityState | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function notify(): void {
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
function createState(fingerprint: string): InternalPasswordSecurityState {
|
||||||
|
return { fingerprint, report: null, scannedAt: null, scanning: false, progress: { checked: 0, total: 0 }, scanError: false, controller: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
|
||||||
|
if (state?.fingerprint !== fingerprint) {
|
||||||
|
state?.controller?.abort();
|
||||||
|
state = createState(fingerprint);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPasswordSecurityState(fingerprint: string): PasswordSecurityState | null {
|
||||||
|
return state?.fingerprint === fingerprint ? state : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribePasswordSecurityState(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startPasswordSecurityScan(fingerprint: string, ciphers: Cipher[]): void {
|
||||||
|
const current = getPasswordSecurityState(fingerprint);
|
||||||
|
current.controller?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const total = ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length;
|
||||||
|
state = { ...current, report: null, scannedAt: null, scanning: true, progress: { checked: 0, total }, scanError: false, controller };
|
||||||
|
notify();
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const report = await inspectVaultPasswordSecurity(ciphers, (checked, total) => {
|
||||||
|
if (controller.signal.aborted || state?.controller !== controller) return;
|
||||||
|
state = { ...state, progress: { checked, total } };
|
||||||
|
notify();
|
||||||
|
}, fetch, controller.signal);
|
||||||
|
if (controller.signal.aborted || state?.controller !== controller) return;
|
||||||
|
state = { ...state, report, scannedAt: Date.now() };
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted || (error as { name?: string } | null)?.name === 'AbortError') return;
|
||||||
|
if (state?.controller === controller) state = { ...state, scanError: true };
|
||||||
|
} finally {
|
||||||
|
if (state?.controller === controller) state = { ...state, controller: null, scanning: false };
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPasswordSecurityCache(): void {
|
||||||
|
state?.controller?.abort();
|
||||||
|
state = null;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import type { Cipher } from '@/lib/types';
|
||||||
|
|
||||||
|
const PWNED_PASSWORDS_RANGE_URL = 'https://api.pwnedpasswords.com/range/';
|
||||||
|
const MAX_CONCURRENT_BREACH_CHECKS = 5;
|
||||||
|
const COMMON_PASSWORDS = new Set([
|
||||||
|
'password', 'password1', '123456', '12345678', '123456789', 'qwerty', 'abc123', 'letmein', 'welcome', 'iloveyou', 'admin', 'changeme',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface PasswordBreachResult {
|
||||||
|
count: number | null;
|
||||||
|
available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordSecurityItem {
|
||||||
|
cipherId: string;
|
||||||
|
exposedCount: number | null;
|
||||||
|
reusedCount: number;
|
||||||
|
weak: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordSecurityReport {
|
||||||
|
eligibleCount: number;
|
||||||
|
checkedCount: number;
|
||||||
|
exposedCount: number;
|
||||||
|
reusedCount: number;
|
||||||
|
weakCount: number;
|
||||||
|
unavailableCount: number;
|
||||||
|
items: PasswordSecurityItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Candidate = {
|
||||||
|
cipherId: string;
|
||||||
|
name: string;
|
||||||
|
hash: string;
|
||||||
|
weak: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError';
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfAborted(signal?: AbortSignal): void {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
const error = new Error('The operation was aborted.');
|
||||||
|
error.name = 'AbortError';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sha1Password(password: string): Promise<string> {
|
||||||
|
const input = new TextEncoder().encode(password);
|
||||||
|
return bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-1', input)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRangeResponse(text: string, suffix: string): number {
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const separator = line.indexOf(':');
|
||||||
|
if (separator !== 35) continue;
|
||||||
|
if (line.slice(0, separator).toUpperCase() !== suffix) continue;
|
||||||
|
const count = Number.parseInt(line.slice(separator + 1), 10);
|
||||||
|
return Number.isSafeInteger(count) && count > 0 ? count : 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkPasswordHashLeaked(
|
||||||
|
hash: string,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<number> {
|
||||||
|
if (!/^[A-F0-9]{40}$/.test(hash)) throw new Error('Password hash is invalid.');
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = globalThis.setTimeout(() => controller.abort(), 12_000);
|
||||||
|
const onExternalAbort = () => controller.abort();
|
||||||
|
signal?.addEventListener('abort', onExternalAbort, { once: true });
|
||||||
|
if (signal?.aborted) controller.abort();
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(`${PWNED_PASSWORDS_RANGE_URL}${hash.slice(0, 5)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
mode: 'cors',
|
||||||
|
credentials: 'omit',
|
||||||
|
cache: 'no-store',
|
||||||
|
referrerPolicy: 'no-referrer',
|
||||||
|
headers: { 'Add-Padding': 'true' },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Pwned Passwords returned ${response.status}.`);
|
||||||
|
return parseRangeResponse(await response.text(), hash.slice(5));
|
||||||
|
} catch (error) {
|
||||||
|
// External cancel (leave page / re-scan) must stay distinguishable from timeout/network failures.
|
||||||
|
if (signal?.aborted) {
|
||||||
|
const abortError = new Error('The operation was aborted.');
|
||||||
|
abortError.name = 'AbortError';
|
||||||
|
throw abortError;
|
||||||
|
}
|
||||||
|
if (isAbortError(error)) throw new Error('Pwned Passwords request timed out.');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
globalThis.clearTimeout(timeout);
|
||||||
|
signal?.removeEventListener('abort', onExternalAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkPasswordLeaked(
|
||||||
|
password: string,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<PasswordBreachResult> {
|
||||||
|
if (!password) return { count: 0, available: true };
|
||||||
|
try {
|
||||||
|
return { count: await checkPasswordHashLeaked(await sha1Password(password), fetchImpl, signal), available: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error) || signal?.aborted) throw error;
|
||||||
|
return { count: null, available: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSimpleSequence(value: string): boolean {
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
return ['0123456789', '9876543210', 'abcdefghijklmnopqrstuvwxyz', 'zyxwvutsrqponmlkjihgfedcba', 'qwertyuiop', 'poiuytrewq']
|
||||||
|
.some((sequence) => sequence.includes(normalized) || normalized.includes(sequence.slice(0, 5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeakPassword(password: string, username: string = ''): boolean {
|
||||||
|
const normalized = password.toLowerCase();
|
||||||
|
const compactUsername = username.split('@')[0]?.trim().toLowerCase() || '';
|
||||||
|
if (COMMON_PASSWORDS.has(normalized) || password.length < 10) return true;
|
||||||
|
if (/^(.)\1+$/.test(password) || hasSimpleSequence(password)) return true;
|
||||||
|
if (compactUsername.length >= 3 && normalized.includes(compactUsername)) return true;
|
||||||
|
const classes = [/[a-z]/.test(password), /[A-Z]/.test(password), /\d/.test(password), /[^A-Za-z0-9]/.test(password)].filter(Boolean).length;
|
||||||
|
return password.length < 14 && classes < 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEligibleCipher(cipher: Cipher): boolean {
|
||||||
|
return Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapWithConcurrency<T, R>(
|
||||||
|
values: T[],
|
||||||
|
limit: number,
|
||||||
|
worker: (value: T) => Promise<R>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results = new Array<R>(values.length);
|
||||||
|
let nextIndex = 0;
|
||||||
|
const run = async () => {
|
||||||
|
while (true) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const index = nextIndex;
|
||||||
|
nextIndex += 1;
|
||||||
|
if (index >= values.length) return;
|
||||||
|
results[index] = await worker(values[index]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, run));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectVaultPasswordSecurity(
|
||||||
|
ciphers: Cipher[],
|
||||||
|
onProgress?: (checked: number, total: number) => void,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<PasswordSecurityReport> {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const eligible = ciphers.filter(isEligibleCipher);
|
||||||
|
const candidates: Candidate[] = await Promise.all(eligible.map(async (cipher) => {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const password = String(cipher.login?.decPassword || '');
|
||||||
|
const username = String(cipher.login?.decUsername || '');
|
||||||
|
return {
|
||||||
|
cipherId: cipher.id,
|
||||||
|
name: String(cipher.decName || cipher.name || ''),
|
||||||
|
hash: await sha1Password(password),
|
||||||
|
weak: isWeakPassword(password, username),
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
const candidatesByHash = new Map<string, Candidate[]>();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const group = candidatesByHash.get(candidate.hash) || [];
|
||||||
|
group.push(candidate);
|
||||||
|
candidatesByHash.set(candidate.hash, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exposureByHash = new Map<string, PasswordBreachResult>();
|
||||||
|
let checked = 0;
|
||||||
|
await mapWithConcurrency([...candidatesByHash.keys()], MAX_CONCURRENT_BREACH_CHECKS, async (hash) => {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
let result: PasswordBreachResult;
|
||||||
|
try {
|
||||||
|
result = { count: await checkPasswordHashLeaked(hash, fetchImpl, signal), available: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbortError(error) || signal?.aborted) throw error;
|
||||||
|
result = { count: null, available: false };
|
||||||
|
}
|
||||||
|
exposureByHash.set(hash, result);
|
||||||
|
checked += candidatesByHash.get(hash)?.length || 0;
|
||||||
|
onProgress?.(Math.min(checked, candidates.length), candidates.length);
|
||||||
|
return result;
|
||||||
|
}, signal);
|
||||||
|
|
||||||
|
throwIfAborted(signal);
|
||||||
|
|
||||||
|
const items = candidates.map((candidate) => {
|
||||||
|
const exposure = exposureByHash.get(candidate.hash) || { count: null, available: false };
|
||||||
|
return {
|
||||||
|
cipherId: candidate.cipherId,
|
||||||
|
exposedCount: exposure.count,
|
||||||
|
reusedCount: candidatesByHash.get(candidate.hash)?.length || 1,
|
||||||
|
weak: candidate.weak,
|
||||||
|
};
|
||||||
|
}).filter((item) => item.exposedCount === null || (item.exposedCount || 0) > 0 || item.reusedCount > 1 || item.weak)
|
||||||
|
.sort((a, b) => (Number(b.exposedCount || 0) - Number(a.exposedCount || 0)) || (b.reusedCount - a.reusedCount) || Number(b.weak) - Number(a.weak) || a.cipherId.localeCompare(b.cipherId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
eligibleCount: candidates.length,
|
||||||
|
checkedCount: checked,
|
||||||
|
exposedCount: candidates.filter((candidate) => (exposureByHash.get(candidate.hash)?.count || 0) > 0).length,
|
||||||
|
reusedCount: candidates.filter((candidate) => (candidatesByHash.get(candidate.hash)?.length || 0) > 1).length,
|
||||||
|
weakCount: candidates.filter((candidate) => candidate.weak).length,
|
||||||
|
unavailableCount: candidates.filter((candidate) => exposureByHash.get(candidate.hash)?.count === null).length,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
@import './styles/base.css';
|
@import './styles/base.css';
|
||||||
@import './styles/auth.css';
|
@import './styles/auth.css';
|
||||||
@import './styles/forms.css';
|
@import './styles/forms.css';
|
||||||
|
@import './styles/generator.css';
|
||||||
|
@import './styles/password-security.css';
|
||||||
@import './styles/shell.css';
|
@import './styles/shell.css';
|
||||||
@import './styles/vault.css';
|
@import './styles/vault.css';
|
||||||
@import './styles/management.css';
|
@import './styles/management.css';
|
||||||
@@ -428,7 +430,7 @@ h4 {
|
|||||||
min-height: min(640px, calc(100dvh - 180px));
|
min-height: min(640px, calc(100dvh - 180px));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-home-section {
|
.settings-home-section {
|
||||||
@@ -675,7 +677,7 @@ h4 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 0px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.generator-page {
|
||||||
|
width: min(100%, 1180px);
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generator-layout { display: grid; grid-template-columns: minmax(300px, .82fr) minmax(0, 1.18fr); grid-template-areas: 'options output'; gap: 16px; align-items: start; }
|
||||||
|
.generator-output-card, .generator-options-card { border: 1px solid var(--line); border-radius: 20px; background: var(--panel); box-shadow: var(--shadow-sm); }
|
||||||
|
.generator-output-card { grid-area: output; padding: 20px; }
|
||||||
|
.generator-options-card { grid-area: options; padding: 19px; }
|
||||||
|
.generator-options-card h2 { margin: 0 0 18px; font-size: 17px; }
|
||||||
|
.generator-value { display: block; min-height: 110px; margin: 18px 0 10px; padding: 18px; border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--line)); border-radius: 16px; background: color-mix(in srgb, var(--primary) 5%, var(--panel)); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: clamp(19px, 2.2vw, 27px); font-weight: 700; line-height: 1.45; overflow-wrap: anywhere; user-select: all; }
|
||||||
|
.generator-strength-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted-strong); font-size: 13px; font-weight: 700; }
|
||||||
|
.generator-strength-row > span, .generator-security-note { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.generator-strength { display: flex; flex: 1; gap: 4px; }
|
||||||
|
.generator-strength span { height: 5px; flex: 1; border-radius: 999px; background: var(--line); }
|
||||||
|
.generator-strength span.active.level-1 { background: #e87171; }.generator-strength span.active.level-2 { background: #db9b38; }.generator-strength span.active.level-3 { background: #46936c; }.generator-strength span.active.level-4 { background: var(--primary); }
|
||||||
|
.generator-actions { margin-top: 22px; }.generator-actions .btn { flex: 1; }
|
||||||
|
.generator-security-note { margin: 18px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; }.generator-security-note svg { color: var(--success); flex: 0 0 auto; }
|
||||||
|
.generator-number-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 15px; color: var(--text); font-size: 14px; font-weight: 700; }.generator-number-field > label { min-width: 0; }.generator-stepper { display: grid; grid-template-columns: 38px 64px 38px; align-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }.generator-stepper button { display: grid; width: 38px; height: 40px; place-items: center; border: 0; background: transparent; color: var(--primary-strong); cursor: pointer; transition: background-color 160ms ease, color 160ms ease; }.generator-stepper button:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 10%, var(--panel)); }.generator-stepper button:active:not(:disabled) { background: color-mix(in srgb, var(--primary) 17%, var(--panel)); }.generator-stepper button:focus-visible { position: relative; z-index: 1; outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); outline-offset: -3px; }.generator-stepper button:disabled { color: var(--muted); cursor: not-allowed; }.generator-stepper .input { width: 64px; height: 40px; min-width: 0; border: 0; border-radius: 0; padding: 0; background: transparent; text-align: center; font-variant-numeric: tabular-nums; }.generator-stepper .input:focus { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary) 36%, transparent); }.generator-number-field.compact { grid-template-columns: minmax(0, 1fr) auto; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 32px 46px 32px; border-radius: 9px; }.generator-number-field.compact .generator-stepper button { width: 32px; height: 34px; }.generator-number-field.compact .generator-stepper .input { width: 46px; height: 34px; font-size: 13px; }
|
||||||
|
.generator-option-group { display: grid; gap: 9px; margin: 18px 0; padding: 0; border: 0; }.generator-option-group legend { margin-bottom: 10px; padding: 0; font-size: 14px; font-weight: 700; }
|
||||||
|
.generator-toggle { display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 32px; cursor: pointer; }.generator-toggle input { position: absolute; opacity: 0; }.generator-toggle > span { position: relative; width: 38px; height: 22px; border-radius: 999px; background: #cbd5e1; transition: background 180ms ease; }.generator-toggle > span::after { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.25); content: ''; transition: transform 180ms ease; }.generator-toggle input:checked + span { background: var(--primary); }.generator-toggle input:checked + span::after { transform: translateX(16px); }.generator-toggle input:focus-visible + span { outline: 3px solid color-mix(in srgb, var(--primary) 30%, transparent); outline-offset: 2px; }.generator-toggle strong { font-size: 14px; font-weight: 600; }
|
||||||
|
.generator-inline-number { display: grid; grid-template-columns: minmax(0, 1fr) 72px; align-items: center; gap: 14px; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-inline-number .input { height: 34px; text-align: center; }
|
||||||
|
@media (max-width: 760px) { .generator-page { width: 100%; padding: 0 0 18px; }.generator-layout { grid-template-columns: 1fr; grid-template-areas: 'output' 'options'; gap: 10px; }.generator-output-card, .generator-options-card { padding: 15px; border-radius: 16px; }.generator-value { min-height: 94px; margin: 14px 0 10px; padding: 14px; font-size: 19px; }.generator-actions .btn { justify-content: center; padding-inline: 10px; }.generator-option-group { margin: 15px 0; }.generator-toggle { min-height: 44px; }.generator-number-field.compact { margin-left: 50px; }.generator-stepper { grid-template-columns: 40px 64px 40px; }.generator-stepper button { width: 40px; min-height: 44px; }.generator-stepper .input { height: 44px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 36px 46px 36px; }.generator-number-field.compact .generator-stepper button { width: 36px; height: 40px; min-height: 40px; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .generator-toggle > span, .generator-toggle > span::after { transition: none; } }
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
.password-security-page { width: min(100%, 1180px); margin: 0; display: grid; gap: 10px; padding: 4px 0 24px; }
|
||||||
|
.password-security-intro { display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
|
||||||
|
.password-security-intro-icon { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 14px; color: var(--primary-strong); background: color-mix(in srgb, var(--primary) 12%, var(--panel)); }
|
||||||
|
.password-security-intro h2 { margin: 0 0 3px; font-size: 18px; }
|
||||||
|
.password-security-intro p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
|
||||||
|
.password-security-intro .password-security-checked-at { margin-top: 4px; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||||
|
.password-security-intro-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||||||
|
.password-security-scan, .password-security-toggle-all { min-height: 40px; }
|
||||||
|
.password-security-empty { min-height: 190px; display: grid; place-items: center; align-content: center; gap: 9px; text-align: center; color: var(--muted); padding: 28px; }
|
||||||
|
.password-security-empty > svg { color: var(--primary); }
|
||||||
|
.password-security-empty strong { color: var(--ink); }
|
||||||
|
.password-security-empty span { font-size: 14px; max-width: 520px; line-height: 1.5; }
|
||||||
|
.password-security-empty.compact { min-height: 150px; }
|
||||||
|
.password-security-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
||||||
|
.password-security-metric { display: flex; align-items: center; gap: 10px; min-height: 72px; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--panel); box-shadow: var(--shadow-sm); color: inherit; font: inherit; text-align: left; cursor: pointer; transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; }
|
||||||
|
.password-security-metric:hover:not(:disabled), .password-security-metric[aria-pressed='true'] { border-color: var(--primary); box-shadow: var(--shadow-md); }
|
||||||
|
.password-security-metric:active:not(:disabled) { transform: scale(.99); }
|
||||||
|
.password-security-metric:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 45%, transparent); outline-offset: 2px; }
|
||||||
|
.password-security-metric:disabled { cursor: default; }
|
||||||
|
.password-security-metric > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; }
|
||||||
|
.password-security-metric.danger > span { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, var(--panel)); }
|
||||||
|
.password-security-metric.warning > span { color: #b45309; background: #fff7e6; }
|
||||||
|
.password-security-metric.primary > span { color: var(--primary-strong); background: color-mix(in srgb, var(--primary) 12%, var(--panel)); }
|
||||||
|
.password-security-metric div { display: grid; gap: 1px; min-width: 0; }
|
||||||
|
.password-security-metric strong { font-size: 20px; line-height: 1.15; font-variant-numeric: tabular-nums; }
|
||||||
|
.password-security-metric small { color: var(--muted); font-size: 12px; }
|
||||||
|
.password-security-results { padding: 8px; }
|
||||||
|
.password-security-notice { display: flex; align-items: center; gap: 8px; padding: 9px 10px; margin-bottom: 8px; border-radius: var(--radius-md); font-size: 13px; }
|
||||||
|
.password-security-notice.warning { color: #92400e; background: #fff7e6; border: 1px solid #fcd8a3; }
|
||||||
|
.password-security-list { display: grid; }
|
||||||
|
.password-security-item { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 64px; padding: 10px; border-bottom: 1px solid var(--line-soft); }
|
||||||
|
.password-security-item:last-child { border-bottom: 0; }
|
||||||
|
.password-security-item-main { min-width: 0; display: grid; gap: 5px; }
|
||||||
|
.password-security-item-header { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.password-security-item-header > strong { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.password-security-password { min-width: 0; color: var(--muted); font-size: 13px; font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.password-security-item-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; }
|
||||||
|
.password-security-badges { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.risk-badge { display: inline-flex; align-items: center; min-height: 22px; padding: 2px 7px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||||
|
.risk-badge.danger { color: #b42318; background: #fef0ef; }
|
||||||
|
.risk-badge.reused { color: #92400e; background: #fff7e6; }
|
||||||
|
.risk-badge.weak { color: #5b21b6; background: #f3e8ff; }
|
||||||
|
.risk-badge.muted { color: var(--muted); background: var(--panel-soft); }
|
||||||
|
.password-security-open { flex: 0 0 auto; }
|
||||||
|
.password-breach-inline { display: flex; align-items: center; gap: 7px; margin-top: 9px; padding: 9px 10px; border-radius: var(--radius-md); font-size: 13px; line-height: 1.35; }
|
||||||
|
.password-breach-inline.safe { color: #16704d; background: #ecfdf3; border: 1px solid #b7ebcd; }
|
||||||
|
.password-breach-inline.danger { color: #b42318; background: #fef0ef; border: 1px solid #fecdc9; }
|
||||||
|
.password-breach-inline.warning { color: #92400e; background: #fff7e6; border: 1px solid #fcd8a3; }
|
||||||
|
.spin { animation: password-security-spin 900ms linear infinite; }
|
||||||
|
@keyframes password-security-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.password-security-page { width: 100%; padding: 0 0 18px; gap: 10px; }
|
||||||
|
.password-security-intro { align-items: flex-start; padding: 14px; }
|
||||||
|
.password-security-intro-icon { width: 38px; height: 38px; border-radius: 12px; }
|
||||||
|
.password-security-intro h2 { font-size: 16px; }
|
||||||
|
.password-security-intro p { font-size: 13px; }
|
||||||
|
.password-security-intro-actions { width: 100%; margin: 8px 0 0; grid-column: 1 / -1; }
|
||||||
|
.password-security-scan, .password-security-toggle-all { flex: 1 1 0; }
|
||||||
|
.password-security-intro { display: grid; grid-template-columns: auto minmax(0, 1fr); }
|
||||||
|
.password-security-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||||
|
.password-security-metric { min-height: 74px; padding: 12px; gap: 8px; }
|
||||||
|
.password-security-metric > span { width: 32px; height: 32px; border-radius: 10px; }
|
||||||
|
.password-security-metric strong { font-size: 18px; }
|
||||||
|
.password-security-item { align-items: stretch; flex-direction: column; gap: 8px; }
|
||||||
|
.password-security-item-actions { width: 100%; }
|
||||||
|
.password-security-item-actions > * { flex: 1 1 0; min-height: 40px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .password-security-metric.warning > span,
|
||||||
|
:root[data-theme='dark'] .password-security-notice.warning,
|
||||||
|
:root[data-theme='dark'] .risk-badge.reused,
|
||||||
|
:root[data-theme='dark'] .password-breach-inline.warning { color: #fbbf24; background: rgba(180, 83, 9, .18); border-color: rgba(251, 191, 36, .25); }
|
||||||
|
:root[data-theme='dark'] .risk-badge.danger { color: #fca5a5; background: rgba(180, 35, 24, .2); }
|
||||||
|
:root[data-theme='dark'] .risk-badge.weak { color: #d8b4fe; background: rgba(91, 33, 182, .22); }
|
||||||
|
:root[data-theme='dark'] .password-breach-inline.safe { color: #6ee7b7; background: rgba(22, 112, 77, .2); border-color: rgba(110, 231, 183, .25); }
|
||||||
|
:root[data-theme='dark'] .password-breach-inline.danger { color: #fca5a5; background: rgba(180, 35, 24, .2); border-color: rgba(252, 165, 165, .25); }
|
||||||
@@ -234,7 +234,7 @@
|
|||||||
|
|
||||||
.mobile-tabbar {
|
.mobile-tabbar {
|
||||||
@apply grid items-center gap-1.5;
|
@apply grid items-center gap-1.5;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
min-height: var(--mobile-tabbar-height);
|
min-height: var(--mobile-tabbar-height);
|
||||||
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
@@ -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