Compare commits

...

28 Commits

Author SHA1 Message Date
shuaiplus 5048cc0720 chore: bump version to 1.7.0 2026-06-23 01:36:38 +08:00
Shuai 3f785febc8 Add SECURITY.md
Add security reporting policy and vulnerability disclosure guidance.
2026-06-23 00:07:46 +08:00
shuaiplus 907126d152 fix: refine login success toast handling 2026-06-22 23:15:11 +08:00
shuaiplus c1f57957c0 Remove vault toolbar switch animation 2026-06-22 22:39:48 +08:00
shuaiplus cd2ec8240b Show mobile sort button label 2026-06-22 22:32:58 +08:00
shuaiplus 16bde22604 Unify mobile topbar controls 2026-06-22 22:30:29 +08:00
shuaiplus 4900de0444 Refresh auth requests from realtime notifications 2026-06-22 22:09:54 +08:00
shuaiplus 79ed7c9f85 Add Bitwarden push relay support 2026-06-22 22:09:38 +08:00
shuaiplus 9a21504f40 Fix realtime sync notifications 2026-06-22 16:46:55 +08:00
shuaiplus 045b23fc47 Align web vault updates with resource sync 2026-06-21 18:16:44 +08:00
shuaiplus 42b765b113 Use resource sync notifications in the web client 2026-06-21 16:14:20 +08:00
shuaiplus f9fe53285f Preserve stored cipher permission flags in responses 2026-06-21 15:46:37 +08:00
shuaiplus 46ba8b9950 Emit cipher update notifications for attachment changes 2026-06-21 15:42:09 +08:00
shuaiplus f096681a2b Align public send access notifications with Bitwarden 2026-06-21 15:38:51 +08:00
shuaiplus fe0c66c561 Add official Bitwarden resource sync notifications 2026-06-21 15:14:42 +08:00
shuaiplus add921b3b3 Improve Bitwarden compatibility across account, sync, attachment, and send flows 2026-06-21 15:02:41 +08:00
shuaiplus f1b716fb31 chore: update .gitignore file 2026-06-20 00:01:36 +08:00
shuaiplus 8f2704fd41 feat: update toast close button with SVG icon and improve styling 2026-06-16 21:48:48 +08:00
shuaiplus 7e0406f751 feat: enhance mobile vault filter UI and improve styling for better usability 2026-06-16 21:17:43 +08:00
shuaiplus d5c2ab2b0f refactor: remove unused TOTP styling for cleaner code 2026-06-16 19:26:21 +08:00
shuaiplus 9e0908f43c feat: enhance TOTP formatting and improve responsive styles for TOTP codes display 2026-06-16 19:17:05 +08:00
shuaiplus 7b3be2c819 feat: add duplicate detection modes and UI enhancements for managing duplicates 2026-06-15 20:48:57 +08:00
shuaiplus a8183166ac fix: add S3 addressing style option
Add a configurable S3 addressing style for remote backups while keeping path-style as the default for existing configurations. Use virtual-hosted-style to support providers such as Tencent COS buckets that reject path-style requests.
2026-06-15 16:53:28 +08:00
shuaiplus f6169b7610 fix: add support for trusted two-factor device tokens in backup import and export 2026-06-13 17:45:01 +08:00
shuaiplus 493f901ec1 fix: refine typography styles for improved readability and consistency 2026-06-13 17:20:25 +08:00
shuaiplus b4dfb0409b fix: improve network status handling and probe logic 2026-06-13 17:05:30 +08:00
shuaiplus a06cb0ed71 fix: serialize Bitwarden CSV login URIs 2026-06-13 16:38:25 +08:00
DiaMeoww b0242265f4 fix(webapp): add CSV export and stabilize dialog dismissal
fix(webapp): 添加 CSV 导出并稳定弹窗关闭行为
2026-06-13 16:38:25 +08:00
76 changed files with 3033 additions and 1256 deletions
+5 -17
View File
@@ -1,17 +1,5 @@
# CodeGraph data files # CodeGraph data files — local to each machine, not for committing.
# These are local to each machine and should not be committed # Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
# Database *
*.db !.gitignore
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
*.pid
+1
View File
@@ -18,6 +18,7 @@ build/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
docs/
# OS # OS
.DS_Store .DS_Store
+1 -1
View File
@@ -37,7 +37,7 @@
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** | | **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** | | **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** | | **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 | | 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV | | 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send | | Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** | | 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
+1 -1
View File
@@ -40,7 +40,7 @@
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** | | **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** | | **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** | | **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients | | Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV | | Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends | | Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** | | Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
+76
View File
@@ -0,0 +1,76 @@
# Security Policy
## Reporting a Vulnerability
Thank you for helping keep NodeWarden safe.
Please **do not report security vulnerabilities through public GitHub issues, discussions, pull requests, or chat groups**.
Use GitHub Private Vulnerability Reporting instead:
1. Open the NodeWarden repository on GitHub.
2. Go to **Security and quality**.
3. Click **Report a vulnerability**.
4. Submit the report privately.
NodeWarden is independent from Bitwarden. Please do not report NodeWarden-specific issues to the official Bitwarden team.
## What to Include
Please include as much detail as possible:
* A clear description of the vulnerability.
* Steps to reproduce.
* Affected version, commit, or deployment method.
* Affected area, such as login, sync, vault data, attachments, Send, import/export, backup/restore, Passkey, WebAuthn, or API routes.
* Expected behavior and actual behavior.
* Security impact, such as authentication bypass, authorization bypass, replay, cross-user access, token misuse, data leakage, or secret exposure.
* Proof of concept, logs, screenshots, or request examples, if safe to share privately.
Please redact real passwords, tokens, private keys, recovery keys, vault data, and other secrets before submitting.
## Scope
Security reports are welcome for issues affecting NodeWarden itself, including:
* Authentication and session handling.
* User authorization and cross-user access.
* Vault data, cipher sync, attachments, and Send.
* Import, export, backup, and restore.
* Passkey, WebAuthn, and two-factor authentication.
* Secret handling and provider credentials.
* Cloudflare Workers, D1, R2, KV, WebDAV, or S3 behavior caused by NodeWarden code or documentation.
## Out of Scope
The following are usually out of scope:
* Issues only affecting third-party services or user infrastructure.
* Misconfigured personal deployments not caused by NodeWarden defaults.
* Social engineering or phishing.
* Denial-of-service testing.
* Scanner-only reports without a practical exploit path.
* Reports that only mention outdated dependencies without showing real impact.
## Response
NodeWarden is maintained on a best-effort basis.
We aim to acknowledge valid private reports within 72 hours, investigate the issue, and release a fix or mitigation when appropriate.
Please do not publicly disclose vulnerability details before a fix or mitigation is available.
## Supported Versions
Security fixes are generally provided for the latest release and the latest code on the default branch.
| Version | Supported |
| -------------- | ---------------------- |
| Latest release | Yes |
| `main` branch | Yes |
| Older releases | Best effort |
| Modified forks | Not directly supported |
## Rewards
NodeWarden does not currently operate a paid bug bounty program.
-785
View File
@@ -1,785 +0,0 @@
# NodeWarden Passkey 登录研究记录
记录日期:2026-06-09
研究范围:NodeWarden 自己的 server、web 登录/注册链路,以及官方 Bitwarden server、web、browser extension 对账户 passkey 登录的实现方式。
## 结论先放前面
NodeWarden 现在已经有完整的主密码注册、主密码登录、刷新 token、2FA、设备记录、官方客户端兼容的 `UserDecryptionOptions`,也支持 vault item 里的 `login.fido2Credentials` 字段。但它还没有“账户 passkey 登录”。现有 `src/utils/passkey.ts` 只有 base64url、challenge、clientData 解析这类工具函数,不能完成 FIDO2/WebAuthn 服务端注册和认证验证。
要支持“自己的 web 用 passkey 登录”和“官方/自定义浏览器扩展也能 passkey 登录”,不能只加一个登录按钮。必须补齐四块:
1. Server 端新增账户 WebAuthn credential 表、challenge/token 防重放机制、FIDO2 attestation/assertion 验证、`grant_type=webauthn`
2. Server 响应里按 Bitwarden 形状返回 PRF 解密材料:登录 token 响应用单个 `UserDecryptionOptions.WebAuthnPrfOption`sync 响应用多个 `UserDecryption.WebAuthnPrfOptions`
3. NodeWarden web 新增 passkey 注册、管理、登录和 PRF 解锁 vault key 的客户端流程。
4. 扩展兼容要跟官方 Bitwarden endpoint 和 response shape 对齐。官方 browser extension 当前只在 Chromium 系浏览器开放 passkey 登录,因为 Firefox/Safari 扩展环境还不能按官方代码需要的方式覆盖 RP ID。
下面按代码链路展开。
## 术语边界
这里有三个容易混淆的东西,文档后面严格区分:
- 账户 passkey 登录:用户不用主密码,使用 WebAuthn/passkey 完成账号认证,并且用 PRF 解开 vault user key。官方 Bitwarden 叫 `WebAuthnLogin`
- Vault item 里的 passkey:某个登录条目保存网站 passkey/FIDO2 credential 数据,对应 NodeWarden 的 `cipher.login.fido2Credentials`。这是“保险库保存别的网站 passkey”,不是“登录 NodeWarden 账号”。
- WebAuthn 2FA:主密码登录之后用安全密钥做第二因素。官方旧 web repo 里主要是这一类,不等于 passkey 登录。
## NodeWarden 现状
### 路由和入口
NodeWarden 后端是 Cloudflare Workers + D1。主入口 `src/index.ts` 初始化存储后进入 router。认证边界在:
- `src/router-public.ts`:公开接口,包含 `/identity/connect/token``/identity/accounts/prelogin``/api/accounts/register`
- `src/router-authenticated.ts`:需要 access token 的接口,包含 profile、change password、TOTP、sync、vault、devices。
- `src/handlers/identity.ts`OAuth/token 兼容入口。
- `src/handlers/accounts.ts`:注册、profile、密码变更、TOTP、API key 等账户接口。
目前公开路由没有:
- `GET /identity/accounts/webauthn/assertion-options`
- `POST /identity/connect/token``grant_type=webauthn`
- `POST /api/webauthn/attestation-options`
- `POST /api/webauthn/assertion-options`
- `GET/POST/PUT /api/webauthn`
### 注册链路
NodeWarden 自己 web 的注册入口在 `webapp/src/lib/api/auth.ts``registerAccount()`
- 使用邮箱作为 salt,用 PBKDF2 派生 master key。
- 再用 PBKDF2(masterKey, password, 1) 得到 client master password hash。
- 随机生成 64 字节 vault symmetric key。
- 用 masterKey 经 HKDF 拆成 enc/mac,把 vault key 加密成 Bitwarden `Key`
- 生成 RSA-OAEP key pair,把 private key 用 vault symmetric key 加密。
- POST `/api/accounts/register`,提交 `email``name``masterPasswordHash``key`、KDF 参数、invite code、`keys.publicKey``keys.encryptedPrivateKey`
后端 `src/handlers/accounts.ts``handleRegister()`
- 第一个用户自动成为 admin,后续用户需要 invite。
- 校验 `JWT_SECRET`、邮箱、KDF 下限、加密字符串形状、公钥/私钥。
- 不直接保存 client hash,而是 `AuthService.hashPasswordServer(masterPasswordHash, email)` 后保存到 `users.master_password_hash`
- 保存 `users.key``users.private_key``users.public_key`、KDF 参数、`security_stamp`
结论:账户 passkey 注册不是替代账号注册,而是“用户已登录后在安全设置里新增一个可登录 credential”。仍然需要已有 vault user key 来生成 PRF keyset。
### 主密码登录链路
NodeWarden 自己 web 的登录入口是 `webapp/src/lib/app-auth.ts``performPasswordLogin()`
-`deriveLoginHashLocally()` 得到 masterKey 和 client hash。
-`loginWithPassword()` POST `/identity/connect/token`
- token 成功后 `completeLogin()``token.Key` 和本地 masterKey 解开 vault key。
- 保存离线解锁记录。
`webapp/src/lib/api/auth.ts` 也有 `deriveLoginHash()``getPreloginKdfConfig()` 会调用 `/identity/accounts/prelogin`,但当前 `performPasswordLogin()` 走的是本地 fallback iterations。passkey 登录不应复用这条 masterKey 路径,因为 passkey 登录没有主密码,拿不到 password-derived masterKey。
后端 `src/handlers/identity.ts``handleToken()` 当前支持:
- `grant_type=password`
- `grant_type=client_credentials`
- `grant_type=refresh_token`
密码登录成功后会:
- 验证 IP 登录频率和用户状态。
- `AuthService.verifyPassword()` 验证 client hash。
- 处理 TOTP 或 remember 2FA token。
- 记录/更新 device。
- 生成 access token 和 refresh token。
- 返回 `Key``PrivateKey``AccountKeys`、KDF 参数、`UserDecryptionOptions`
### UserDecryptionOptions 和 sync
NodeWarden 的 `src/utils/user-decryption.ts` 当前只构造主密码解锁:
- `HasMasterPassword: true`
- `MasterPasswordUnlock`
- `TrustedDeviceOption: null`
- `KeyConnectorOption: null`
`src/types/index.ts` 的 sync 类型里预留了 `UserDecryption.WebAuthnPrfOption?: null`,但当前 `src/handlers/sync.ts` 实际只返回 `MasterPasswordUnlock`,没有账户 passkey PRF 解密选项。
passkey 登录必须新增两类 shape
- 登录 token 响应:`UserDecryptionOptions.WebAuthnPrfOption`,只返回本次认证所用 credential 的 PRF 解密材料。
- sync 响应:`UserDecryption.WebAuthnPrfOptions`,返回该用户所有已启用 PRF keyset 的 passkey 解密材料,供官方客户端锁定/解锁和 key rotation 使用。
### 现有 passkey 相关代码
NodeWarden 已支持 vault item 里的 FIDO2/passkey 字段:
- `src/types/index.ts``CipherLogin.fido2Credentials`
- `src/handlers/ciphers.ts`:读写 cipher 时保留/规范化 `fido2Credentials`
- `webapp/src/lib/api/vault.ts`:加密/解密 vault item 内的 `fido2Credentials`
- `webapp/src/lib/types.ts``CipherLoginPasskey`
这部分是“保存网站 passkey”,不是账户登录。
`src/utils/passkey.ts` 只有:
- `bytesToBase64Url()`
- `base64UrlToBytes()`
- `randomChallenge()`
- `parseClientDataJSON()`
缺少的核心能力:
- attestation verification
- assertion verification
- authenticator public key 格式处理
- signature verification
- sign counter 更新
- userHandle 与 user id 绑定验证
- origin/RP ID 验证
- challenge 过期和防重放
### 数据库和备份影响
NodeWarden schema 在这些地方需要同步:
- `migrations/0001_init.sql`
- `src/services/storage-schema.ts`
- `wrangler.toml` migrations
- `src/services/backup-archive.ts`
- `src/services/backup-import.ts`
- `shared/backup-schema` 相关类型
当前表里没有账户 passkey credential,也没有 WebAuthn challenge 表。`devices` 表保存设备 trust/key 信息,不适合混入 passkey credential,因为 WebAuthn credential 需要自己的 public key、credential id、counter、AAGUID、PRF keyset 等字段。
## 官方 Bitwarden server 参考
上游代码位置:
- `.codex-upstream/bitwarden-server`
- 研究时 HEAD`574f3fd`
官方 server 里也有两个 WebAuthn 概念:
- 传统 WebAuthn 2FA`TwoFactorController``WebAuthnTokenProvider`
- 账户 passkey 登录:`WebAuthnLogin`
本项目要参考的是后者。
### 公开 passkey 登录入口
`src/Identity/Controllers/AccountsController.cs`
- `GET /accounts/webauthn/assertion-options`
- 返回 `WebAuthnLoginAssertionOptionsResponseModel`
- response 包含:
- `options`
- `token`
- token 使用 `WebAuthnLoginAssertionOptionsTokenable`
- scope 为 `Authentication`
- token 生命周期约 17 分钟
`src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs`
- 新增 OAuth extension grant`grant_type=webauthn`
- 从 form 读取:
- `token`
- `deviceResponse`
- 解开 token,校验 scope 必须是 `Authentication`
- 反序列化 `AuthenticatorAssertionRawResponse`
- 调用 `AssertWebAuthnLoginCredential`
- 把成功认证的 credential 传给 `UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential)`
- 之后走通用登录成功逻辑,返回 access/refresh token 和账号加密状态。
`src/Identity/IdentityServer/ApiClient.cs`
- official identity client 的 allowed grant types 包含 `WebAuthnGrantValidator.GrantType`
`TwoFactorAuthenticationValidator` 里有一个重要行为:FIDO2 user verification 已经被视为第二因素,所以 passkey 登录成功后官方不会再要求额外 2FA。NodeWarden 之后需要明确策略:要兼容官方客户端,应把 passkey 登录视作已满足 2FA,否则官方 `LoginViaWebAuthnComponent` 会显示“不支持 passkey 2FA”的错误。
### 账户 passkey 管理接口
`src/Api/Auth/Controllers/WebAuthnController.cs`
官方 authenticated API
- `GET /webauthn`:列出账户 passkey credentials。
- `POST /webauthn/attestation-options`:主密码/secret verification 后生成 credential create options 和 token。
- `POST /webauthn/assertion-options`:主密码/secret verification 后生成 assertion options 和 token,用于给已有 credential 启用/更新 PRF keyset。
- `POST /webauthn`:保存新 credential。
- `PUT /webauthn`:更新 credential 的 PRF encryption keyset。
- `POST /webauthn/{id}/delete`:删除 credential。
官方创建 credential 时保存:
- `name`
- `token`
- `deviceResponse`
- `supportsPrf`
- 可选 `encryptedUserKey`
- 可选 `encryptedPublicKey`
- 可选 `encryptedPrivateKey`
官方最多允许 5 个账户 passkey credentials。
### 官方 WebAuthnCredential 表
`src/Core/Auth/Entities/WebAuthnCredential.cs`
字段:
- `Id`
- `UserId`
- `Name`
- `PublicKey`
- `CredentialId`
- `Counter`
- `Type`
- `AaGuid`
- `EncryptedUserKey`
- `EncryptedPrivateKey`
- `EncryptedPublicKey`
- `SupportsPrf`
- `CreationDate`
- `RevisionDate`
SQLite migration`util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs`
表名是 `WebAuthnCredential`,对 `User` 做 cascade delete,并按 `UserId` 建索引。
`GetPrfStatus()`
- `Unsupported``SupportsPrf` 为 false。
- `Supported`credential 支持 PRF,但还没有完整 encrypted keyset。
- `Enabled``EncryptedUserKey``EncryptedPrivateKey``EncryptedPublicKey` 都存在。
### 官方创建和认证策略
`GetWebAuthnLoginCredentialCreateOptionsCommand.cs`
- 使用 Fido2NetLib。
- `user.id` 是用户 id bytes。
- `user.name/displayName` 使用用户邮箱。
- 排除当前用户已有 credential ids。
- `residentKey: required`
- `userVerification: required`
- `attestation: none`
`GetWebAuthnLoginCredentialAssertionOptionsCommand.cs`
- `allowCredentials` 传空数组。
- `userVerification: required`
- 空 allow list 代表使用 discoverable credentials,也就是 passkey 登录页可以不先输入邮箱。
`CreateWebAuthnLoginCredentialCommand.cs`
- 限制每用户最多 5 个。
- 检查 credential id 在该用户下不能重复。
- FIDO `MakeNewCredentialAsync` 验证 attestation。
- 保存 credential id/public key/counter/type/AAGUID/PRF keyset。
`AssertWebAuthnLoginCredentialCommand.cs`
- 先用 challenge cache 防重放。
- 从 assertion response 的 `userHandle` 解析出 user id。
- 加载该用户所有 WebAuthn credentials。
- 用 credential id 找到记录。
- FIDO `MakeAssertionAsync` 验证签名、challenge、origin、RP ID、user verification。
- 成功后更新 counter。
### 官方 PRF 解密协议
`src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs`
`WebAuthnPrfDecryptionOption` 字段:
- `EncryptedPrivateKey`
- `EncryptedUserKey`
- `CredentialId`
- `Transports`
`src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs`
- `WithWebAuthnLoginCredential()` 只在 credential 的 PRF status 是 `Enabled` 时加入 `WebAuthnPrfOption`
- 如果 credential 没有 PRF keysetpasskey 只能认证账号,不能解开 vault。
`src/Api/Vault/Models/Response/SyncResponseModel.cs`
- sync response 会把所有 enabled PRF credentials 放进 `UserDecryption.WebAuthnPrfOptions`
## 官方 Bitwarden web/browser client 参考
上游代码位置:
- `.codex-upstream/bitwarden-clients`
- `.codex-upstream/bitwarden-browser`
- 两者研究时 HEAD 都是 `825f9be`browser repo 内容和 clients monorepo 对应。
旧的 `.codex-upstream/bitwarden-web` 主要有 WebAuthn connector 和 2FA 设置页,没有现代账户 passkey 登录主流程。账户 passkey 登录应以 `bitwarden-clients` 为准。
### 登录按钮可见性
`libs/auth/src/angular/login/default-login-component.service.ts`
- 默认只对 `ClientType.Web` 开启 passkey 登录。
`apps/browser/src/auth/popup/login/extension-login-component.service.ts`
- browser extension 覆盖逻辑:只对 Chromium 开启。
- 注释说明 Firefox 和 Safari 不能在扩展里覆盖 relying party ID。
- 官方代码引用了 W3C webextensions issue 238、Mozilla bug 1956484、Apple forum thread 774351。
结论:NodeWarden 后端即使完全兼容官方 passkey API,官方扩展也只有 Chromium 系会显示 passkey 登录入口。
### Passkey 登录页
`libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts`
流程:
1. 进入 `/login-with-passkey` 后自动开始认证。
2.`webAuthnLoginService.getCredentialAssertionOptions()`
3.`webAuthnLoginService.assertCredential(options)` 触发 `navigator.credentials.get()`
4.`webAuthnLoginService.logIn(assertion)` 走 identity token grant。
5. 如果 `authResult.requiresTwoFactor` 为 true,显示“客户端不支持 passkey 2FA”错误。
6. 只有本地 `keyService.userKey$(authResult.userId)` 已经拿到 user key,才运行 login success handler。
7. 成功路由:
- Web`/vault`
- Browser`/tabs/vault`
- Desktop`/vault`
Browser popout 下还会在成功后重新打开普通 popup 并关闭 popout。
### 客户端 passkey 登录请求
`libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts`
- GET `${identityUrl}/accounts/webauthn/assertion-options`
- 如果 NodeWarden 的 identityUrl 是站点 origin + `/identity`,实际路径就是 `/identity/accounts/webauthn/assertion-options`
`libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts`
- `navigator.credentials.get({ publicKey: options })`
- 会主动加 PRF extension
- salt 是 `SHA-256("passwordless-login")`
- extension shape 是 `extensions.prf.eval.first`
-`credential.getClientExtensionResults().prf.results.first` 取 PRF 输出。
-`WebAuthnLoginPrfKeyService.createSymmetricKeyFromPrf()` 转成 PRF key。
- 构造 `WebAuthnLoginAssertionResponseRequest`
- 明确检查 `deviceResponse.extensions` 里不能含 `prf`,避免把 PRF 输出泄漏给服务端。
`libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts`
- salt 常量:`passwordless-login`
- 先 SHA-256。
- 再用 HKDF expand 拆成 64 字节:
- `"enc"` 32 bytes
- `"mac"` 32 bytes
`libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts`
form encoded token 请求字段:
- `grant_type=webauthn`
- `token=<server assertion options token>`
- `deviceResponse=<JSON string>`
- 还会带 common device request 字段。
`libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts`
`deviceResponse` shape
- `id`
- `rawId`
- `type`
- `extensions: {}`
- `response.authenticatorData`
- `response.signature`
- `response.clientDataJSON`
- `response.userHandle`
全部二进制字段使用 base64url。
### 客户端如何用 PRF 解 vault key
`libs/auth/src/common/login-strategies/webauthn-login.strategy.ts`
- `setMasterKey()` 是空实现,因为 passkey 登录没有主密码 masterKey。
- `setUserKey()`
- 如果 token response 有 `key`,保存为 master-key-encrypted user key,兼容主密码解锁。
- 如果 `userDecryptionOptions.webAuthnPrfOption` 存在,且本地 assertion 得到了 `prfKey`
1. 用 PRF key unwrap `encryptedPrivateKey`
2. 用 private key decapsulate `encryptedUserKey`
3. 得到 user key,写入 `keyService`
核心约束:服务端永远看不到 PRF 输出。服务端只保存和返回被 PRF 相关密钥加密后的 keyset。
### 官方 web 设置页注册 passkey
`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts`
调用的 API
- `POST /webauthn/attestation-options`
- `POST /webauthn/assertion-options`
- `POST /webauthn`
- `GET /webauthn`
- `POST /webauthn/{id}/delete`
- `PUT /webauthn`
`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts`
创建流程:
1. 用户做 secret verification。
2. 请求 attestation options。
3. `navigator.credentials.create({ publicKey: options })`,并带 `extensions.prf = {}`
4. 从 client extension results 判断 `supportsPrf`
5. 如果要用于 vault encryption,再立即做一次 `navigator.credentials.get()`
- `allowCredentials` 锁定刚创建的 credential。
- 使用同一个 challenge、rpId、timeout、userVerification。
- 带 PRF eval salt。
6. 用 PRF key 和当前 user key 创建 rotateable keyset。
7. 保存 credential,带上 `encryptedUserKey``encryptedPublicKey``encryptedPrivateKey`
删除流程需要 secret verification。启用 encryption 的流程是对已有 credential 做 assertion,再创建并 PUT keyset。
`apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts`
- `Enabled = 0`
- `Supported = 1`
- `Unsupported = 2`
## NodeWarden 应实现的协议形状
### 公开登录流程
目标兼容官方客户端和 NodeWarden 自己 web
1. `GET /identity/accounts/webauthn/assertion-options`
- 生成 discoverable credential assertion options。
- `allowCredentials: []`
- `userVerification: "required"`
- 返回 `{ options, token }`
- token 绑定 challenge、scope=`Authentication`、RP ID、origin/audience、过期时间。
2. Browser/web 调 `navigator.credentials.get()`
- NodeWarden 自己 web 也要使用 PRF extension。
- PRF salt 必须和官方一致:`SHA-256("passwordless-login")`
3. `POST /identity/connect/token`
- 支持 `grant_type=webauthn`
- 接收 `token``deviceResponse`、device fields。
- 解 token,校验 challenge/scope/过期。
- 验证 assertion。
-`userHandle` 找到 user id。
- 从 credential id 找到 passkey record。
- 更新 counter。
- 记录/更新 device。
- 返回 access/refresh token、`AccountKeys``UserDecryptionOptions.WebAuthnPrfOption`
如果用户启用了 TOTP,建议为了官方兼容先遵循 Bitwardenpasskey 的 user verification 视作已满足第二因素。否则官方 passkey 登录页会进入 unsupported 2FA 错误状态。
### 账户 passkey 管理流程
建议对齐官方 API,同时在 NodeWarden 内部可挂到 `/api/webauthn`
- `GET /api/webauthn`
- `POST /api/webauthn/attestation-options`
- `POST /api/webauthn/assertion-options`
- `POST /api/webauthn`
- `PUT /api/webauthn`
- `POST /api/webauthn/:id/delete`
为了官方客户端兼容,可能还需要接受无 `/api` 前缀的 aliases
- `/webauthn`
- `/webauthn/attestation-options`
- `/webauthn/assertion-options`
- `/webauthn/:id/delete`
NodeWarden 自己 web 可以直接用 `/api/webauthn`,官方 web/browser 客户端会按它自己的 API base 组装 `/webauthn`
### 建议新增表
按 NodeWarden 命名风格,建议用小写 snake_case
```sql
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
public_key TEXT NOT NULL,
credential_id TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
type TEXT,
aa_guid TEXT,
transports TEXT,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
supports_prf INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_user_credential
ON webauthn_credentials(user_id, credential_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
ON webauthn_credentials(user_id);
```
如果要更严格防止同一个 credential id 被跨用户重复注册,也可以加全局 unique index `credential_id`。官方代码至少检查同用户唯一;实际安全上更建议全局唯一,因为 credential id 本身应该唯一标识 authenticator credential。
PRF status 不必落库为枚举,可以由字段计算:
- `supports_prf = 0` => `Unsupported`
- `supports_prf = 1` 且三段 encrypted key 不全 => `Supported`
- `supports_prf = 1` 且三段 encrypted key 全存在 => `Enabled`
### Challenge/token 存储
官方 server 用 protected token 携带 options,再用 challenge cache 防重放。NodeWarden 在 Workers/D1 里建议组合:
- tokenHMAC/JWT 样式,绑定 `scope``challenge``userId?``rpId``createdAt``expiresAt`
- D1 表或 KV:记录 challenge 是否使用过,至少字段 `challenge_hash``scope``user_id``expires_at``used_at`
- 登录 assertion options 是公开接口,不绑定 user idcreate/update/delete 管理流程应绑定 user id。
- 验证成功后立即 mark used。
建议 scopes
- `Authentication`
- `CreateCredential`
- `UpdateKeySet`
官方还有 `PrfRegistration` 语义,NodeWarden 可以用 `CreateCredential` 覆盖,只要 token 逻辑严谨即可。
### 服务端 WebAuthn 验证库
NodeWarden 当前没有 FIDO2/WebAuthn 服务端验证依赖。不要手写签名和 attestation 解析。
候选:`@simplewebauthn/server`。官方文档当前说明它提供 `generateRegistrationOptions``verifyRegistrationResponse``generateAuthenticationOptions``verifyAuthenticationResponse`,并记录了 RP ID、origin、credential public key、counter、transports 等数据结构。文档地址:https://simplewebauthn.dev/docs/packages/server
注意:NodeWarden 跑在 Cloudflare Workers,不是普通 Node server。正式选库前需要做一次构建/runtime 验证,确认包不会依赖 Workers 不支持的 Node API。这个验证属于实现阶段,不在本研究文档里写测试程序。
## NodeWarden web 需要改的地方
### 登录页
当前登录 UI 在 `webapp/src/components/AuthViews.tsx`,状态和行为主要由 `webapp/src/App.tsx``webapp/src/lib/app-auth.ts` 管。
新增:
- 登录页增加“使用 passkey 登录”按钮。
- 新增 `performPasskeyLogin()`
1. GET `/identity/accounts/webauthn/assertion-options`
2. 转换 server options 里的 base64url challenge/user id/credential id 为 ArrayBuffer。
3. `navigator.credentials.get()`,带 PRF salt。
4. POST `/identity/connect/token``grant_type=webauthn`
5. 从 response 的 `UserDecryptionOptions.WebAuthnPrfOption` 取 encrypted keyset。
6. 用本地 PRF key 解出 user key。
7. 构造 `SessionState` 并进入 app。
不能复用 `completeLogin(token, email, masterKey, fallbackKdfIterations)`,因为它要求 masterKey。应新增 passkey 专用 complete 函数。
### 设置页
当前账户/安全相关 UI 在 `webapp/src/components/SettingsPage.tsx` 一带。
新增:
- Passkey 列表。
- 新建 passkey dialog。
- 删除 passkey。
- 对支持 PRF 但未启用 encryption 的 passkey,提供“启用用于登录解锁”的操作。
自己 web 的新建流程要和官方一致:
1. 已登录状态下先验证主密码或现有 session secret。
2. 请求 attestation options。
3. `navigator.credentials.create()``extensions.prf = {}`
4. 如果用户希望这个 passkey 可直接解锁 vault,再对刚创建 credential 做一次 `navigator.credentials.get()` 获取 PRF 输出。
5. 用 PRF key 加密/封装当前 user key,发送到 server 保存。
### 客户端加密能力
NodeWarden web 当前已经有:
- PBKDF2
- HKDF expand
- Bitwarden EncString 加解密
- RSA-OAEP private key 加密
但 passkey PRF keyset 需要和官方策略对齐:
- PRF key 是 64 字节 symmetric key,前 32 enc、后 32 mac。
- `encryptedPrivateKey` 用 PRF key wrap 一个 decapsulation private key。
- `encryptedUserKey` 用对应 public key encapsulate user key。
- `encryptedPublicKey` 用于 key rotation。
这里需要认真复用或补齐 NodeWarden 现有 crypto helper,避免做出和官方客户端无法互解的 keyset。
## 扩展兼容要求
### 官方 browser extension
官方 extension passkey 登录入口在:
- `apps/browser/src/auth/popup/login/extension-login-component.service.ts`
- 只在 Chromium 开启。
如果要官方/派生扩展能对 NodeWarden passkey 登录:
- identity URL 必须能访问 `/accounts/webauthn/assertion-options`
- token URL 必须支持 `grant_type=webauthn`
- API URL 必须能访问 `/webauthn` 管理接口。
- response 大小写和字段名要同时照顾 PascalCase/camelCaseNodeWarden 当前 token response 已经在一些字段上双写,这个风格应继续沿用。
- passkey 登录成功时必须返回可解开 vault 的 `webAuthnPrfOption`,否则官方组件虽然认证成功,也不会进入可用 vault。
### RP ID 和 origin
自己的 web
- RP ID 通常是站点 host,例如 `vault.example.com`
- origin 是 `https://vault.example.com`
官方 browser extension
- 扩展页面 origin 是 `chrome-extension://...`
- 官方之所以只开 Chromium,是因为 Chromium extension 具备它需要的 RP ID 覆盖能力。
- NodeWarden server 验证 assertion 时必须允许正确的 origin/RP ID 组合。这里不能简单只接受当前 request origin,否则扩展登录会失败。
建议配置化:
- `WEBAUTHN_RP_ID`
- `WEBAUTHN_RP_NAME`
- `WEBAUTHN_ALLOWED_ORIGINS`
默认可以从 request URL 推导 web origin,但生产建议显式配置。
## 安全约束
- 所有账户 passkey 必须 `userVerification: required`
- 登录 assertion 使用 discoverable credential`userHandle` 必须能解析成 user id 并和 credential 记录一致。
- challenge 必须有过期时间和一次性使用标记。
- PRF 输出绝不能传给 server,也不能写入日志。
- token 里要绑定 scope,防止 attestation token 被拿去 authentication 用。
- counter 要更新。遇到 counter 异常时至少记录 audit event,是否阻断要结合 multi-device passkey 现实处理。
- 每用户 credential 数量限制建议沿用官方 5 个。
- 删除/新增/启用 encryption 必须要求已登录用户二次验证。
- 密码变更、user key rotation 后,所有 enabled PRF credentials 的 keyset 也要 rotation,否则 passkey 登录会解不开新 vault key。
- 备份导出/导入必须包含账户 passkey 表,否则恢复后 passkey 登录会全部失效。
- 审计日志建议新增:
- `auth.passkey.login.success`
- `auth.passkey.login.failed`
- `account.passkey.create`
- `account.passkey.delete`
- `account.passkey.encryption.enable`
- `account.passkey.rotate`
## 建议实施顺序
### 第一阶段:后端基础
1. 新增 `webauthn_credentials` 和 challenge 表。
2. 新增 storage repo。
3. 接入 WebAuthn 服务端验证库。
4. 实现 assertion options 和 `grant_type=webauthn`
5. token response 加 `WebAuthnPrfOption` shape。
这阶段先能让“已有手工塞入的 enabled credential”完成登录验证,但还不做 UI。
### 第二阶段:账户 passkey 管理 API
1. 实现 `/api/webauthn``/webauthn` aliases。
2. 实现 attestation options、save credential、list、delete、enable/update encryption。
3. 加 audit event。
4. 接入 backup export/import。
5. sync response 加 `WebAuthnPrfOptions`
### 第三阶段:NodeWarden 自己 web
1. 登录页 passkey 按钮和 `performPasskeyLogin()`
2. Passkey 设置页。
3. PRF keyset 创建、保存、删除、启用 encryption。
4. 浏览器能力判断和错误提示。
### 第四阶段:扩展兼容
1. 用官方 browser extension 的 Chromium passkey 登录流程校对 endpoint。
2. 校对 `/config` 里 identity/api/web vault URL。
3. 校对 RP ID、allowed origins。
4. 必要时加兼容字段或 alias route。
按用户要求,本阶段只需要代码跑通不报错;不在这里写可视化测试或测试程序。
## 待实现清单
- [ ] 设计并落库 `webauthn_credentials`
- [ ] 设计并落库 WebAuthn challenge/replay cache。
- [ ] 选定并验证 Workers 可用的 WebAuthn server library。
- [ ] `GET /identity/accounts/webauthn/assertion-options`
- [ ] `POST /identity/connect/token` 支持 `grant_type=webauthn`
- [ ] `UserDecryptionOptions.WebAuthnPrfOption`
- [ ] `UserDecryption.WebAuthnPrfOptions`
- [ ] `/api/webauthn` 管理接口。
- [ ] `/webauthn` 官方客户端 alias。
- [ ] NodeWarden web passkey 登录入口。
- [ ] NodeWarden web passkey 管理页。
- [ ] key rotation 时同步 rotate PRF keysets。
- [ ] backup export/import 覆盖新表。
- [ ] audit logs 覆盖 passkey 管理和登录。
## 关键文件索引
NodeWarden
- `src/router-public.ts`
- `src/router-authenticated.ts`
- `src/handlers/accounts.ts`
- `src/handlers/identity.ts`
- `src/handlers/sync.ts`
- `src/services/auth.ts`
- `src/services/storage-schema.ts`
- `src/services/storage-user-repo.ts`
- `src/services/storage-device-repo.ts`
- `src/utils/passkey.ts`
- `src/utils/user-decryption.ts`
- `src/types/index.ts`
- `webapp/src/lib/api/auth.ts`
- `webapp/src/lib/app-auth.ts`
- `webapp/src/components/AuthViews.tsx`
- `webapp/src/components/SettingsPage.tsx`
Bitwarden server
- `.codex-upstream/bitwarden-server/src/Identity/Controllers/AccountsController.cs`
- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs`
- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/ApiClient.cs`
- `.codex-upstream/bitwarden-server/src/Api/Auth/Controllers/WebAuthnController.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/Entities/WebAuthnCredential.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialCreateOptionsCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialAssertionOptionsCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/AssertWebAuthnLoginCredentialCommand.cs`
- `.codex-upstream/bitwarden-server/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs`
- `.codex-upstream/bitwarden-server/util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs`
Bitwarden clients/browser
- `.codex-upstream/bitwarden-clients/libs/auth/src/angular/login/default-login-component.service.ts`
- `.codex-upstream/bitwarden-clients/apps/browser/src/auth/popup/login/extension-login-component.service.ts`
- `.codex-upstream/bitwarden-clients/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts`
- `.codex-upstream/bitwarden-clients/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/save-credential.request.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/enable-credential-encryption.request.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts`
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts`
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts`
- `.codex-upstream/bitwarden-clients/libs/auth/src/common/models/domain/user-decryption-options.ts`
+3
View File
@@ -176,6 +176,8 @@ CREATE TABLE IF NOT EXISTS devices (
encrypted_user_key TEXT, encrypted_user_key TEXT,
encrypted_public_key TEXT, encrypted_public_key TEXT,
encrypted_private_key TEXT, encrypted_private_key TEXT,
push_uuid TEXT,
push_token TEXT,
banned INTEGER NOT NULL DEFAULT 0, banned INTEGER NOT NULL DEFAULT 0,
banned_at TEXT, banned_at TEXT,
device_note TEXT, device_note TEXT,
@@ -187,6 +189,7 @@ CREATE TABLE IF NOT EXISTS devices (
); );
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at); CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token);
CREATE TABLE IF NOT EXISTS auth_requests ( CREATE TABLE IF NOT EXISTS auth_requests (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.5.2", "version": "1.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodewarden", "name": "nodewarden",
"version": "1.5.2", "version": "1.7.0",
"license": "LGPL-3.0", "license": "LGPL-3.0",
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.6.1", "version": "1.7.0",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.6.1'; export const APP_VERSION = '1.7.0';
+3
View File
@@ -14,10 +14,12 @@ export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export const BACKUP_DEFAULT_START_TIME = '03:00'; export const BACKUP_DEFAULT_START_TIME = '03:00';
export type BackupDestinationType = 's3' | 'webdav'; export type BackupDestinationType = 's3' | 'webdav';
export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style';
export interface S3BackupDestination { export interface S3BackupDestination {
endpoint: string; endpoint: string;
bucket: string; bucket: string;
addressingStyle: S3BackupAddressingStyle;
region: string; region: string;
accessKeyId: string; accessKeyId: string;
secretAccessKey: string; secretAccessKey: string;
@@ -103,6 +105,7 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
return { return {
endpoint: '', endpoint: '',
bucket: '', bucket: '',
addressingStyle: 'path-style',
region: BACKUP_DEFAULT_S3_REGION, region: BACKUP_DEFAULT_S3_REGION,
accessKeyId: '', accessKeyId: '',
secretAccessKey: '', secretAccessKey: '',
+264 -3
View File
@@ -1,13 +1,24 @@
import { DurableObject, waitUntil } from 'cloudflare:workers'; import { DurableObject, waitUntil } from 'cloudflare:workers';
import type { Env } from '../types'; import type { Env } from '../types';
import { notifyMobilePush } from '../services/push-relay';
const SIGNALR_RECORD_SEPARATOR = 0x1e; const SIGNALR_RECORD_SEPARATOR = 0x1e;
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]); const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE = 0;
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE = 1;
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE = 3;
const SIGNALR_UPDATE_TYPE_SYNC_CIPHERS = 4;
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE = 7;
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE = 8;
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE = 9;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12;
const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13;
const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15; const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16; const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
type HubProtocol = 'json' | 'messagepack'; type HubProtocol = 'json' | 'messagepack';
type HubKind = 'user' | 'anonymous-auth-request'; type HubKind = 'user' | 'anonymous-auth-request';
@@ -164,7 +175,7 @@ function buildSignalRMessagePackInvocation(
target: string = 'ReceiveMessage' target: string = 'ReceiveMessage'
): Uint8Array { ): Uint8Array {
// SignalR MessagePack hub protocol uses an array-based invocation shape: // SignalR MessagePack hub protocol uses an array-based invocation shape:
// [type, headers, invocationId, target, arguments] // [type, headers, invocationId, target, arguments, streamIds]
const encodedPayload = encodeMsgPack([ const encodedPayload = encodeMsgPack([
1, 1,
{}, {},
@@ -177,6 +188,7 @@ function buildSignalRMessagePackInvocation(
Payload: messagePayload, Payload: messagePayload,
}, },
], ],
[],
]); ]);
return frameSignalRBinary(encodedPayload); return frameSignalRBinary(encodedPayload);
} }
@@ -207,7 +219,9 @@ export class NotificationsHub extends DurableObject<Env> {
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString(); const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim(); const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
const contextId = String(body?.contextId || '').trim() || null; const contextId = String(body?.contextId || '').trim() || null;
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT; const rawUpdateType = body?.updateType;
const parsedUpdateType = typeof rawUpdateType === 'number' ? rawUpdateType : Number(rawUpdateType);
const updateType = Number.isFinite(parsedUpdateType) ? parsedUpdateType : SIGNALR_UPDATE_TYPE_SYNC_VAULT;
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null; const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
const payload = body?.payload && typeof body.payload === 'object' const payload = body?.payload && typeof body.payload === 'object'
? body.payload ? body.payload
@@ -422,6 +436,243 @@ export function notifyUserVaultSync(
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null)); waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
} }
export function notifyUserCiphersSync(
env: Env,
userId: string,
revisionDate: string,
contextId?: string | null
): void {
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_CIPHERS, revisionDate, contextId ?? null, null));
}
export function notifyUserCipherCreate(
env: Env,
payload: {
userId: string;
cipherId: string;
revisionDate: string;
organizationId?: string | null;
collectionIds?: string[] | null;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.cipherId,
OrganizationId: payload.organizationId ?? null,
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserCipherUpdate(
env: Env,
payload: {
userId: string;
cipherId: string;
revisionDate: string;
organizationId?: string | null;
collectionIds?: string[] | null;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.cipherId,
OrganizationId: payload.organizationId ?? null,
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserCipherDelete(
env: Env,
payload: {
userId: string;
cipherId: string;
revisionDate: string;
organizationId?: string | null;
collectionIds?: string[] | null;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.cipherId,
OrganizationId: payload.organizationId ?? null,
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserFolderCreate(
env: Env,
payload: {
userId: string;
folderId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.folderId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserFolderUpdate(
env: Env,
payload: {
userId: string;
folderId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.folderId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserFolderDelete(
env: Env,
payload: {
userId: string;
folderId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.folderId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserSendCreate(
env: Env,
payload: {
userId: string;
sendId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.sendId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserSendUpdate(
env: Env,
payload: {
userId: string;
sendId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.sendId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserSendDelete(
env: Env,
payload: {
userId: string;
sendId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.sendId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserLogout( export function notifyUserLogout(
env: Env, env: Env,
userId: string, userId: string,
@@ -517,6 +768,16 @@ async function notifyUserUpdate(
}, },
}), }),
}); });
await notifyMobilePush(env, {
userId,
updateType,
revisionDate,
contextId,
payload: payloadOverride || {
UserId: userId,
Date: revisionDate,
},
});
} catch (error) { } catch (error) {
console.error('Failed to broadcast realtime notification:', error); console.error('Failed to broadcast realtime notification:', error);
} }
+93 -42
View File
@@ -1,4 +1,4 @@
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types'; import { Env, User, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
@@ -9,6 +9,7 @@ import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption'; import { buildAccountKeys } from '../utils/user-decryption';
import { buildProfileResponse } from '../utils/profile-response';
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000; const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
@@ -174,6 +175,24 @@ function readBodyString(body: Record<string, unknown>, names: string[]): string
return ''; return '';
} }
function readNestedString(source: unknown, path: string[]): string {
let current = source;
for (const key of path) {
if (!current || typeof current !== 'object') return '';
current = (current as Record<string, unknown>)[key];
}
return typeof current === 'string' ? current : '';
}
function readNestedNumber(source: unknown, path: string[]): number | undefined {
let current = source;
for (const key of path) {
if (!current || typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[key];
}
return typeof current === 'number' ? current : undefined;
}
async function readRequestBody(request: Request): Promise<Record<string, unknown>> { async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) { if (contentType.includes('application/x-www-form-urlencoded')) {
@@ -183,34 +202,32 @@ async function readRequestBody(request: Request): Promise<Record<string, unknown
return await request.json(); return await request.json();
} }
function toProfile(user: User, env: Env): ProfileResponse { function masterPasswordPolicyResponse(): Record<string, unknown> {
void env; return {
minComplexity: 0,
minLength: 0,
requireUpper: false,
requireLower: false,
requireNumbers: false,
requireSpecial: false,
enforceOnLogin: false,
object: 'masterPasswordPolicy',
};
}
function keysResponse(user: User): Record<string, unknown> {
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
return { return {
id: user.id, Key: user.key,
name: user.name, PublicKey: user.publicKey ?? '',
email: user.email, PrivateKey: user.privateKey ?? '',
emailVerified: true, AccountKeys: accountKeys,
premium: true, Object: 'keys',
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key, key: user.key,
privateKey: user.privateKey, publicKey: user.publicKey ?? '',
privateKey: user.privateKey ?? '',
accountKeys, accountKeys,
securityStamp: user.securityStamp || user.id, object: 'keys',
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
role: user.role,
status: user.status,
object: 'profile',
}; };
} }
@@ -445,7 +462,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
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);
return jsonResponse(toProfile(user, env)); return jsonResponse(buildProfileResponse(user, env));
} }
// PUT /api/accounts/profile // PUT /api/accounts/profile
@@ -484,7 +501,7 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
}, },
}); });
return jsonResponse(toProfile(user, env)); return jsonResponse(buildProfileResponse(user, env));
} }
// PUT/POST /api/accounts/verify-devices // PUT/POST /api/accounts/verify-devices
@@ -498,6 +515,7 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
secret?: string; secret?: string;
masterPasswordHash?: string; masterPasswordHash?: string;
verifyDevices?: boolean; verifyDevices?: boolean;
VerifyDevices?: boolean;
}; };
try { try {
body = await request.json(); body = await request.json();
@@ -505,7 +523,8 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
return errorResponse('Invalid JSON', 400); return errorResponse('Invalid JSON', 400);
} }
if (typeof body.verifyDevices !== 'boolean') { const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
if (typeof verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400); return errorResponse('verifyDevices must be true or false', 400);
} }
@@ -514,7 +533,7 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
return errorResponse('User verification failed.', 400); return errorResponse('User verification failed.', 400);
} }
user.verifyDevices = body.verifyDevices; user.verifyDevices = verifyDevices;
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
@@ -533,6 +552,19 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
// GET /api/accounts/keys
export async function handleGetKeys(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) {
return errorResponse('User not found', 404);
}
return jsonResponse(keysResponse(user));
}
// POST /api/accounts/keys // POST /api/accounts/keys
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -593,7 +625,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
}, },
}); });
return handleGetProfile(request, env, userId); return jsonResponse(keysResponse(user));
} }
// POST/PUT /api/accounts/password // POST/PUT /api/accounts/password
@@ -607,6 +639,7 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
masterPasswordHash?: string; masterPasswordHash?: string;
currentPasswordHash?: string; currentPasswordHash?: string;
newMasterPasswordHash?: string; newMasterPasswordHash?: string;
masterPasswordHint?: string | null;
key?: string; key?: string;
newKey?: string; newKey?: string;
encryptedPrivateKey?: string; encryptedPrivateKey?: string;
@@ -617,6 +650,8 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
kdfIterations?: number; kdfIterations?: number;
kdfMemory?: number; kdfMemory?: number;
kdfParallelism?: number; kdfParallelism?: number;
authenticationData?: Record<string, unknown>;
unlockData?: Record<string, unknown>;
}; };
try { try {
body = await request.json(); body = await request.json();
@@ -629,10 +664,16 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400); if (!valid) return errorResponse('Invalid password', 400);
if (!body.newMasterPasswordHash) { const newMasterPasswordHash =
body.newMasterPasswordHash ||
readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']);
if (!newMasterPasswordHash) {
return errorResponse('newMasterPasswordHash is required', 400); return errorResponse('newMasterPasswordHash is required', 400);
} }
const nextKey = body.newKey || body.key; const nextKey =
body.newKey ||
body.key ||
readNestedString(body, ['unlockData', 'masterKeyWrappedUserKey']);
const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey; const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey;
const nextPublicKey = body.newPublicKey || body.publicKey; const nextPublicKey = body.newPublicKey || body.publicKey;
if (nextKey && !looksLikeEncString(nextKey)) { if (nextKey && !looksLikeEncString(nextKey)) {
@@ -642,17 +683,24 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400); return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400);
} }
const kdfErr = validateKdfParams(body.kdf ?? user.kdfType, body.kdfIterations, body.kdfMemory, body.kdfParallelism); const nextKdf = body.kdf ?? readNestedNumber(body, ['unlockData', 'kdf', 'kdfType']) ?? user.kdfType;
const nextKdfIterations = body.kdfIterations ?? readNestedNumber(body, ['unlockData', 'kdf', 'iterations']);
const nextKdfMemory = body.kdfMemory ?? readNestedNumber(body, ['unlockData', 'kdf', 'memory']);
const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400); if (kdfErr) return errorResponse(kdfErr, 400);
user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email); user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
if (nextKey) user.key = nextKey; if (nextKey) user.key = nextKey;
if (nextPrivateKey) user.privateKey = nextPrivateKey; if (nextPrivateKey) user.privateKey = nextPrivateKey;
if (nextPublicKey) user.publicKey = nextPublicKey; if (nextPublicKey) user.publicKey = nextPublicKey;
if (typeof body.kdf === 'number') user.kdfType = body.kdf; if (typeof nextKdf === 'number') user.kdfType = nextKdf;
if (typeof body.kdfIterations === 'number') user.kdfIterations = body.kdfIterations; if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
if (typeof body.kdfMemory === 'number') user.kdfMemory = body.kdfMemory; if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
if (typeof body.kdfParallelism === 'number') user.kdfParallelism = body.kdfParallelism; if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) {
user.masterPasswordHint = body.masterPasswordHint;
}
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
@@ -1061,23 +1109,26 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
return errorResponse('User not found', 404); return errorResponse('User not found', 404);
} }
let body: { masterPasswordHash?: string }; let body: { masterPasswordHash?: string; authenticationData?: Record<string, unknown> };
try { try {
body = await request.json(); body = await request.json();
} catch { } catch {
return errorResponse('Invalid JSON', 400); return errorResponse('Invalid JSON', 400);
} }
if (!body.masterPasswordHash) { const masterPasswordHash =
body.masterPasswordHash ||
readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']);
if (!masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400); return errorResponse('masterPasswordHash is required', 400);
} }
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!valid) { if (!valid) {
return errorResponse('Invalid password', 400); return errorResponse('Invalid password', 400);
} }
return new Response(null, { status: 200 }); return jsonResponse(masterPasswordPolicyResponse());
} }
// POST /api/accounts/api-key // POST /api/accounts/api-key
+48 -5
View File
@@ -1,5 +1,5 @@
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types'; import { Env, Attachment, Cipher, DEFAULT_DEV_SECRET } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload'; import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
@@ -31,6 +31,38 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
function notifyCipherUpdateForRequest(
request: Request,
env: Env,
cipher: Cipher,
revisionDate: string
): void {
notifyUserCipherUpdate(env, {
userId: cipher.userId,
cipherId: cipher.id,
revisionDate,
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
collectionIds: Array.isArray((cipher as any).collectionIds)
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
: null,
contextId: readActingDeviceIdentifier(request),
});
}
function contentDispositionAttachment(fileName: string | null | undefined): string {
const fallback = 'attachment';
const value = String(fileName || fallback)
.replace(/[\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
}
async function writeAttachmentAudit( async function writeAttachmentAudit(
storage: StorageService, storage: StorageService,
request: Request, request: Request,
@@ -75,6 +107,7 @@ async function runWithConcurrency<T>(
async function processAttachmentUpload( async function processAttachmentUpload(
request: Request, request: Request,
env: Env, env: Env,
cipher: Cipher,
attachment: Attachment, attachment: Attachment,
cipherId: string cipherId: string
): Promise<Response> { ): Promise<Response> {
@@ -116,6 +149,7 @@ async function processAttachmentUpload(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
} }
return new Response(null, { status: 201 }); return new Response(null, { status: 201 });
@@ -176,6 +210,7 @@ export async function handleCreateAttachment(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
} }
// Get updated cipher for response // Get updated cipher for response
@@ -219,7 +254,7 @@ export async function handleUploadAttachment(
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
return processAttachmentUpload(request, env, attachment, cipherId); return processAttachmentUpload(request, env, cipher, attachment, cipherId);
} }
export async function handlePublicUploadAttachment( export async function handlePublicUploadAttachment(
@@ -257,7 +292,7 @@ export async function handlePublicUploadAttachment(
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
return processAttachmentUpload(request, env, attachment, cipherId); return processAttachmentUpload(request, env, cipher, attachment, cipherId);
} }
// GET /api/ciphers/{cipherId}/attachment/{attachmentId} // GET /api/ciphers/{cipherId}/attachment/{attachmentId}
@@ -348,6 +383,7 @@ export async function handleUpdateAttachmentMetadata(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
} }
return jsonResponse({ return jsonResponse({
@@ -415,7 +451,9 @@ export async function handlePublicDownloadAttachment(
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': object.contentType || 'application/octet-stream',
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} }
@@ -453,6 +491,7 @@ export async function handleDeleteAttachment(
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) { if (revisionInfo) {
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate);
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', { await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
id: attachmentId, id: attachmentId,
cipherId, cipherId,
@@ -463,9 +502,13 @@ export async function handleDeleteAttachment(
// Get updated cipher for response // Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId); const updatedCipher = await storage.getCipher(cipherId);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
const cipherResponse = cipherToResponse(updatedCipher!, attachments);
return jsonResponse({ return jsonResponse({
cipher: cipherToResponse(updatedCipher!, attachments), Cipher: cipherResponse,
cipher: cipherResponse,
Object: 'deleteAttachment',
object: 'deleteAttachment',
}); });
} }
+94 -7
View File
@@ -11,7 +11,13 @@ import {
PasswordHistory, PasswordHistory,
} from '../types'; } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import {
notifyUserCipherCreate,
notifyUserCipherDelete,
notifyUserCipherUpdate,
notifyUserCiphersSync,
notifyUserVaultSync,
} from '../durable/notifications-hub';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments'; import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
@@ -42,6 +48,22 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function readBooleanOrFallback(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
function buildCipherPermissions(passthrough: Record<string, unknown>): { delete: boolean; restore: boolean } {
const raw = passthrough.permissions;
const source = raw && typeof raw === 'object' && !Array.isArray(raw)
? raw as Record<string, unknown>
: null;
return {
delete: readBooleanOrFallback(source?.delete, true),
restore: readBooleanOrFallback(source?.restore, true),
};
}
function notifyVaultSyncForRequest( function notifyVaultSyncForRequest(
request: Request, request: Request,
env: Env, env: Env,
@@ -51,6 +73,60 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
function notifyCipherCreateForRequest(
request: Request,
env: Env,
cipher: Cipher,
revisionDate: string
): void {
notifyUserCipherCreate(env, {
userId: cipher.userId,
cipherId: cipher.id,
revisionDate,
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
collectionIds: Array.isArray((cipher as any).collectionIds)
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
: null,
contextId: readActingDeviceIdentifier(request),
});
}
function notifyCipherUpdateForRequest(
request: Request,
env: Env,
cipher: Cipher,
revisionDate: string
): void {
notifyUserCipherUpdate(env, {
userId: cipher.userId,
cipherId: cipher.id,
revisionDate,
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
collectionIds: Array.isArray((cipher as any).collectionIds)
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
: null,
contextId: readActingDeviceIdentifier(request),
});
}
function notifyCipherDeleteForRequest(
request: Request,
env: Env,
cipher: Cipher,
revisionDate: string
): void {
notifyUserCipherDelete(env, {
userId: cipher.userId,
cipherId: cipher.id,
revisionDate,
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
collectionIds: Array.isArray((cipher as any).collectionIds)
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
: null,
contextId: readActingDeviceIdentifier(request),
});
}
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } { function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
if (!source || typeof source !== 'object') return { present: false, value: undefined }; if (!source || typeof source !== 'object') return { present: false, value: undefined };
for (const key of aliases) { for (const key of aliases) {
@@ -645,6 +721,7 @@ export function cipherToResponse(
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 } ? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
: null; : null;
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments); const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
const responsePermissions = buildCipherPermissions(passthrough);
return { return {
// Pass through ALL stored cipher fields (known + unknown) // Pass through ALL stored cipher fields (known + unknown)
@@ -658,12 +735,9 @@ export function cipherToResponse(
revisionDate: updatedAt, revisionDate: updatedAt,
deletedDate: deletedAt, deletedDate: deletedAt,
archivedDate: archivedAt ?? null, archivedDate: archivedAt ?? null,
edit: true, edit: readBooleanOrFallback((passthrough as any).edit, true),
viewPassword: true, viewPassword: readBooleanOrFallback((passthrough as any).viewPassword, true),
permissions: { permissions: responsePermissions,
delete: true,
restore: true,
},
object: 'cipherDetails', object: 'cipherDetails',
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [], collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
attachments: formatAttachments(responseAttachments), attachments: formatAttachments(responseAttachments),
@@ -815,6 +889,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherCreateForRequest(request, env, cipher, revisionDate);
const responseOptions = cipherResponseOptionsForRequest(request); const responseOptions = cipherResponseOptionsForRequest(request);
return jsonResponse( return jsonResponse(
@@ -925,6 +1000,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
const responseOptions = cipherResponseOptionsForRequest(request); const responseOptions = cipherResponseOptionsForRequest(request);
@@ -949,6 +1025,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', { await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', {
id: cipher.id, id: cipher.id,
type: cipher.type, type: cipher.type,
@@ -978,6 +1055,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
await storage.deleteCipher(id, userId); await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', { await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id, id,
type: cipher.type, type: cipher.type,
@@ -1005,6 +1083,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
await storage.deleteCipher(id, userId); await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', { await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id, id,
type: cipher.type, type: cipher.type,
@@ -1029,6 +1108,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request)) cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
@@ -1068,6 +1148,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request)) cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
@@ -1144,6 +1225,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
@@ -1192,6 +1274,7 @@ export async function handleBulkArchiveCiphers(request: Request, env: Env, userI
const revisionDate = await storage.bulkArchiveCiphers(ids, userId); const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
return buildCipherListResponse(request, storage, userId, ids); return buildCipherListResponse(request, storage, userId, ids);
@@ -1216,6 +1299,7 @@ export async function handleBulkUnarchiveCiphers(request: Request, env: Env, use
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId); const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
return buildCipherListResponse(request, storage, userId, ids); return buildCipherListResponse(request, storage, userId, ids);
@@ -1239,6 +1323,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId); const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', { await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', {
count: body.ids.length, count: body.ids.length,
}); });
@@ -1265,6 +1350,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId); const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
@@ -1301,6 +1387,7 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId); const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', { await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', {
count: ownedIds.length, count: ownedIds.length,
requestedCount: ids.length, requestedCount: ids.length,
+40 -9
View File
@@ -3,6 +3,7 @@ import { Env } from '../types';
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device'; import { readKnownDeviceProbe } from '../utils/device';
@@ -223,6 +224,8 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
encryptedUserKey: null, encryptedUserKey: null,
encryptedPublicKey: null, encryptedPublicKey: null,
encryptedPrivateKey: null, encryptedPrivateKey: null,
pushUuid: null,
pushToken: null,
devicePendingAuthRequest: null, devicePendingAuthRequest: null,
deviceNote: null, deviceNote: null,
lastSeenAt: null, lastSeenAt: null,
@@ -325,10 +328,12 @@ export async function handleDeleteDevice(
if (!normalized) return errorResponse('Invalid device identifier', 400); if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) { if (deleted) {
await unregisterMobilePushDevice(env, device?.pushUuid);
AuthService.invalidateDeviceCache(userId, normalized); AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
@@ -537,10 +542,12 @@ export async function handleDeactivateDevice(
if (!normalized) return errorResponse('Invalid device identifier', 400); if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
await storage.deleteRefreshTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized);
if (deleted) { if (deleted) {
await unregisterMobilePushDevice(env, device?.pushUuid);
AuthService.invalidateDeviceCache(userId, normalized); AuthService.invalidateDeviceCache(userId, normalized);
notifyUserLogout(env, userId, normalized); notifyUserLogout(env, userId, normalized);
} }
@@ -557,18 +564,36 @@ export async function handleDeactivateDevice(
} }
// PUT /api/devices/identifier/{deviceIdentifier}/token // PUT /api/devices/identifier/{deviceIdentifier}/token
// Bitwarden mobile reports push token updates to this endpoint. // Bitwarden mobile reports APNs/FCM push token updates to this endpoint.
// NodeWarden does not implement push notifications, so accept and no-op.
export async function handleUpdateDeviceToken( export async function handleUpdateDeviceToken(
request: Request, request: Request,
env: Env, env: Env,
userId: string, userId: string,
deviceIdentifier: string deviceIdentifier: string
): Promise<Response> { ): Promise<Response> {
void request; const normalized = normalizeIdentifier(deviceIdentifier);
void env; if (!normalized) return errorResponse('Invalid device identifier', 400);
void userId;
void deviceIdentifier; const body = await readJsonBody(request);
const pushToken = String(body?.pushToken ?? body?.PushToken ?? '').trim();
if (!pushToken) return errorResponse('Invalid push token', 400);
const storage = new StorageService(env.DB);
const device = await storage.getDevice(userId, normalized);
if (!device) return errorResponse('Device not found', 404);
const pushUuid = device.pushUuid || generateUUID();
const updated = await storage.updateDevicePushToken(userId, normalized, pushUuid, pushToken);
if (updated) {
await registerMobilePushDevice(env, {
userId,
deviceIdentifier: normalized,
type: device.type,
pushUuid,
pushToken,
});
}
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
@@ -594,9 +619,15 @@ export async function handleClearDeviceToken(
deviceIdentifier: string deviceIdentifier: string
): Promise<Response> { ): Promise<Response> {
void request; void request;
void env; const normalized = normalizeIdentifier(deviceIdentifier);
void userId; if (!normalized) return errorResponse('Invalid device identifier', 400);
void deviceIdentifier;
const storage = new StorageService(env.DB);
const cleared = await storage.clearDevicePushToken(userId, normalized);
if (cleared?.pushUuid) {
await unregisterMobilePushDevice(env, cleared.pushUuid);
}
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
+38 -1
View File
@@ -1,5 +1,10 @@
import { Env, Folder, FolderResponse } from '../types'; import { Env, Folder, FolderResponse } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import {
notifyUserFolderCreate,
notifyUserFolderDelete,
notifyUserFolderUpdate,
notifyUserVaultSync,
} from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
@@ -111,6 +116,12 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
await storage.saveFolder(folder); await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserFolderCreate(env, {
userId,
folderId: folder.id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
return jsonResponse(folderToResponse(folder), 200); return jsonResponse(folderToResponse(folder), 200);
} }
@@ -139,6 +150,12 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
await storage.saveFolder(folder); await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserFolderUpdate(env, {
userId,
folderId: folder.id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
return jsonResponse(folderToResponse(folder)); return jsonResponse(folderToResponse(folder));
} }
@@ -156,6 +173,12 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
await storage.deleteFolder(id, userId); await storage.deleteFolder(id, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserFolderDelete(env, {
userId,
folderId: id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
await writeFolderAudit(storage, request, userId, 'folder.delete', { await writeFolderAudit(storage, request, userId, 'folder.delete', {
id, id,
}); });
@@ -179,9 +202,23 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
return errorResponse('Folder ids are required', 400); return errorResponse('Folder ids are required', 400);
} }
const folders = (
await Promise.all(ids.map(async (id) => {
const folder = await storage.getFolder(id);
return folder && folder.userId === userId ? folder : null;
}))
).filter((folder): folder is Folder => !!folder);
const revisionDate = await storage.bulkDeleteFolders(ids, userId); const revisionDate = await storage.bulkDeleteFolders(ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
for (const folder of folders) {
notifyUserFolderDelete(env, {
userId,
folderId: folder.id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
}
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', { await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
count: ids.length, count: ids.length,
}); });
+61 -15
View File
@@ -10,6 +10,7 @@ import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { issueSendAccessToken } from './sends'; import { issueSendAccessToken } from './sends';
import { registerMobilePushDevice } from '../services/push-relay';
import { import {
buildAccountKeys, buildAccountKeys,
buildUserDecryptionOptions, buildUserDecryptionOptions,
@@ -50,6 +51,44 @@ async function resolveDeviceSession(
return { identifier: deviceInfo.deviceIdentifier, sessionStamp }; return { identifier: deviceInfo.deviceIdentifier, sessionStamp };
} }
function readDevicePushToken(body: Record<string, string>): string {
return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim();
}
async function persistIdentityDevicePushToken(
env: Env,
storage: StorageService,
userId: string,
deviceSession: { identifier: string; sessionStamp: string } | null,
deviceType: number,
body: Record<string, string>
): Promise<void> {
if (!deviceSession) return;
const pushToken = readDevicePushToken(body);
if (!pushToken) return;
const device = await storage.getDevice(userId, deviceSession.identifier);
if (!device) return;
const pushUuid = device.pushUuid || generateUUID();
await storage.updateDevicePushToken(userId, deviceSession.identifier, pushUuid, pushToken);
const registered = await registerMobilePushDevice(env, {
userId,
deviceIdentifier: deviceSession.identifier,
type: device.type || deviceType,
pushUuid,
pushToken,
});
console.info('Mobile push token updated from identity token request', {
userId,
deviceIdentifier: deviceSession.identifier,
deviceType: device.type || deviceType,
pushUuid,
pushTokenLength: pushToken.length,
relayRegistered: registered,
});
}
function shouldUseWebSession(request: Request): boolean { function shouldUseWebSession(request: Request): boolean {
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1'; return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
} }
@@ -140,6 +179,20 @@ function buildPreloginResponse(
}; };
} }
function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
return {
minComplexity: 0,
minLength: 0,
requireUpper: false,
requireLower: false,
requireNumbers: false,
requireSpecial: false,
enforceOnLogin: false,
Object: 'masterPasswordPolicy',
object: 'masterPasswordPolicy',
};
}
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response { function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only. // Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to // Clients expose recovery-code entry points themselves; Android 2026.4 fails to
@@ -151,9 +204,7 @@ function twoFactorRequiredResponse(message: string = 'Two factor required.'): Re
TwoFactorProviders: providers, TwoFactorProviders: providers,
TwoFactorProviders2: providers2, TwoFactorProviders2: providers2,
SsoEmail2faSessionToken: null, SsoEmail2faSessionToken: null,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
}; };
// Bitwarden clients rely on these fields to trigger the 2FA UI flow. // Bitwarden clients rely on these fields to trigger the 2FA UI flow.
@@ -402,6 +453,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
deviceInfo.deviceType, deviceInfo.deviceType,
deviceSession.sessionStamp deviceSession.sessionStamp
); );
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
// Successful login - clear failed attempts // Successful login - clear failed attempts
@@ -446,9 +498,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism, KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false, ForcePasswordReset: false,
ResetMasterPassword: false, ResetMasterPassword: false,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
@@ -526,6 +576,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
deviceInfo.deviceType, deviceInfo.deviceType,
deviceSession.sessionStamp deviceSession.sessionStamp
); );
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
@@ -566,9 +617,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism, KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false, ForcePasswordReset: false,
ResetMasterPassword: false, ResetMasterPassword: false,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
@@ -656,6 +705,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
deviceInfo.deviceType, deviceInfo.deviceType,
deviceSession.sessionStamp deviceSession.sessionStamp
); );
await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body);
} }
// Successful login - clear failed attempts // Successful login - clear failed attempts
@@ -696,9 +746,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism, KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false, ForcePasswordReset: false,
ResetMasterPassword: false, ResetMasterPassword: false,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
@@ -836,9 +884,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
KdfParallelism: user.kdfParallelism, KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false, ForcePasswordReset: false,
ResetMasterPassword: false, ResetMasterPassword: false,
MasterPasswordPolicy: { MasterPasswordPolicy: masterPasswordPolicyResponse(),
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
+13
View File
@@ -16,6 +16,9 @@ import {
formatSize, formatSize,
getAliasedProp, getAliasedProp,
normalizeEmails, normalizeEmails,
notifySendCreateForRequest,
notifySendDeleteForRequest,
notifySendUpdateForRequest,
notifyVaultSyncForRequest, notifyVaultSyncForRequest,
parseDate, parseDate,
parseFileLength, parseFileLength,
@@ -99,6 +102,7 @@ async function processSendFileUpload(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const revisionDate = await storage.updateRevisionDate(send.userId); const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate); notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
return new Response(null, { status: 201 }); return new Response(null, { status: 201 });
} }
@@ -249,6 +253,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
return jsonResponse(sendToResponse(send)); return jsonResponse(sendToResponse(send));
} }
@@ -372,6 +377,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
const jwtSecret = getSafeJwtSecret(env); const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) { if (!jwtSecret) {
return errorResponse('Server configuration error', 500); return errorResponse('Server configuration error', 500);
@@ -619,6 +625,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
return jsonResponse(sendToResponse(send)); return jsonResponse(sendToResponse(send));
} }
@@ -641,6 +648,7 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
await storage.deleteSend(sendId, userId); await storage.deleteSend(sendId, userId);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendDeleteForRequest(request, env, sendId, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.delete', { await writeSendAudit(storage, request, userId, 'send.delete', {
id: sendId, id: sendId,
type: send.type, type: send.type,
@@ -676,6 +684,9 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
const revisionDate = await storage.bulkDeleteSends(body.ids, userId); const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
if (revisionDate) { if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
for (const send of sends) {
notifySendDeleteForRequest(request, env, send.id, userId, revisionDate);
}
await writeSendAudit(storage, request, userId, 'send.delete.bulk', { await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
count: sends.length, count: sends.length,
requestedCount: body.ids.length, requestedCount: body.ids.length,
@@ -697,6 +708,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.password.remove', { await writeSendAudit(storage, request, userId, 'send.password.remove', {
id: send.id, id: send.id,
type: send.type, type: send.type,
@@ -718,6 +730,7 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
await storage.saveSend(send); await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.auth.remove', { await writeSendAudit(storage, request, userId, 'send.auth.remove', {
id: send.id, id: send.id,
type: send.type, type: send.type,
+18
View File
@@ -21,6 +21,7 @@ import {
getSafeJwtSecret, getSafeJwtSecret,
hasEmailAuth, hasEmailAuth,
isSendAvailable, isSendAvailable,
notifySendUpdateForRequest,
notifyVaultSyncForRequest, notifyVaultSyncForRequest,
parseStoredSendData, parseStoredSendData,
resolveSendFromIdOrAccessId, resolveSendFromIdOrAccessId,
@@ -33,6 +34,14 @@ import {
verifySendPasswordHashB64, verifySendPasswordHashB64,
} from './sends-shared'; } from './sends-shared';
function contentDispositionAttachment(fileName: string | null | undefined): string {
const fallback = 'send-file';
const value = String(fileName || fallback)
.replace(/[\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
}
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> { export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const sendId = fromAccessId(accessId); const sendId = fromAccessId(accessId);
@@ -90,6 +99,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
send.accessCount += 1; send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId); const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate); notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
} }
const creatorIdentifier = await getCreatorIdentifier(storage, send); const creatorIdentifier = await getCreatorIdentifier(storage, send);
@@ -163,6 +173,7 @@ export async function handleAccessSendFile(
send.accessCount += 1; send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId); const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate); notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
const token = await createSendFileDownloadToken(send.id, fileId, secret); const token = await createSendFileDownloadToken(send.id, fileId, secret);
const url = new URL(request.url); const url = new URL(request.url);
@@ -203,6 +214,7 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise<Re
send.accessCount += 1; send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId); const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate); notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
} }
const creatorIdentifier = await getCreatorIdentifier(storage, send); const creatorIdentifier = await getCreatorIdentifier(storage, send);
@@ -242,6 +254,7 @@ export async function handleAccessSendFileV2(request: Request, env: Env, fileId:
send.accessCount += 1; send.accessCount += 1;
const revisionDate = await storage.updateRevisionDate(send.userId); const revisionDate = await storage.updateRevisionDate(send.userId);
notifyVaultSyncForRequest(request, env, send.userId, revisionDate); notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate);
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret); const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
const url = new URL(request.url); const url = new URL(request.url);
@@ -282,6 +295,9 @@ export async function handleDownloadSendFile(
if (!object) { if (!object) {
return errorResponse('Send file not found', 404); return errorResponse('Send file not found', 404);
} }
const send = await storage.getSend(sendId);
const data = send ? parseStoredSendData(send) : {};
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp); const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
if (!firstUse) { if (!firstUse) {
@@ -292,7 +308,9 @@ export async function handleDownloadSendFile(
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': object.contentType || 'application/octet-stream',
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} }
+51 -1
View File
@@ -1,5 +1,10 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types'; import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import {
notifyUserSendCreate,
notifyUserSendDelete,
notifyUserSendUpdate,
notifyUserVaultSync,
} from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
@@ -18,6 +23,51 @@ export function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
} }
export function notifySendCreateForRequest(
request: Request,
env: Env,
sendId: string,
userId: string,
revisionDate: string
): void {
notifyUserSendCreate(env, {
userId,
sendId,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
}
export function notifySendUpdateForRequest(
request: Request,
env: Env,
sendId: string,
userId: string,
revisionDate: string
): void {
notifyUserSendUpdate(env, {
userId,
sendId,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
}
export function notifySendDeleteForRequest(
request: Request,
env: Env,
sendId: string,
userId: string,
revisionDate: string
): void {
notifyUserSendDelete(env, {
userId,
sendId,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
}
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } { export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
if (!source || typeof source !== 'object') return { present: false, value: undefined }; if (!source || typeof source !== 'object') return { present: false, value: undefined };
for (const key of aliases) { for (const key of aliases) {
+4 -26
View File
@@ -5,12 +5,12 @@ import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepaira
import { sendToResponse } from './sends'; import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { import {
buildAccountKeys,
buildUserDecryptionCompat, buildUserDecryptionCompat,
buildUserDecryptionOptions, buildUserDecryptionOptions,
} from '../utils/user-decryption'; } from '../utils/user-decryption';
import { buildDomainsResponse } from '../services/domain-rules'; import { buildDomainsResponse } from '../services/domain-rules';
import { buildWebAuthnPrfOption } from '../utils/account-passkeys'; import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
import { buildProfileResponse } from '../utils/profile-response';
// CONTRACT: // CONTRACT:
// /api/sync reuses cipherToResponse() as the single cipher response shaper. // /api/sync reuses cipherToResponse() as the single cipher response shaper.
@@ -84,36 +84,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
storage.getAttachmentsByUserId(userId), storage.getAttachmentsByUserId(userId),
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId), excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
]); ]);
const accountKeys = buildAccountKeys(user);
const webAuthnPrfOptions = accountPasskeys const webAuthnPrfOptions = accountPasskeys
.map(buildWebAuthnPrfOption) .map(buildWebAuthnPrfOption)
.filter((option): option is NonNullable<typeof option> => !!option); .filter((option): option is NonNullable<typeof option> => !!option);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
const profile: ProfileResponse = { const profile: ProfileResponse = buildProfileResponse(user, env);
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys,
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices,
object: 'profile',
};
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) { for (const cipher of ciphers) {
@@ -149,6 +125,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
{ omitExcludedGlobals: true } { omitExcludedGlobals: true }
), ),
policies: [], policies: [],
policiesNew: [],
sends: sendResponses, sends: sendResponses,
UserDecryption: { UserDecryption: {
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock, MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
@@ -156,6 +133,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
KeyConnectorOption: null, KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOptions[0] || null, WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
WebAuthnPrfOptions: webAuthnPrfOptions, WebAuthnPrfOptions: webAuthnPrfOptions,
V2UpgradeToken: null,
Object: 'userDecryption', Object: 'userDecryption',
}, },
UserDecryptionOptions: userDecryptionOptions, UserDecryptionOptions: userDecryptionOptions,
+5 -2
View File
@@ -3,6 +3,7 @@ import { errorResponse, jsonResponse } from './utils/response';
import { import {
handleGetProfile, handleGetProfile,
handleUpdateProfile, handleUpdateProfile,
handleGetKeys,
handleSetKeys, handleSetKeys,
handleGetRevisionDate, handleGetRevisionDate,
handleVerifyPassword, handleVerifyPassword,
@@ -115,8 +116,10 @@ export async function handleAuthenticatedRoute(
return handleChangePassword(request, env, userId); return handleChangePassword(request, env, userId);
} }
if (path === '/api/accounts/keys' && method === 'POST') { if (path === '/api/accounts/keys') {
return handleSetKeys(request, env, userId); if (method === 'GET') return handleGetKeys(request, env, userId);
if (method === 'POST') return handleSetKeys(request, env, userId);
return errorResponse('Method not allowed', 405);
} }
if (path === '/api/accounts/totp') { if (path === '/api/accounts/totp') {
+19 -15
View File
@@ -20,6 +20,10 @@ import {
handleClearDeviceToken, handleClearDeviceToken,
} from './handlers/devices'; } from './handlers/devices';
function devicesPath(pattern: string): RegExp {
return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i');
}
export async function handleAuthenticatedDeviceRoute( export async function handleAuthenticatedDeviceRoute(
request: Request, request: Request,
env: Env, env: Env,
@@ -27,31 +31,31 @@ export async function handleAuthenticatedDeviceRoute(
path: string, path: string,
method: string method: string
): Promise<Response | null> { ): Promise<Response | null> {
if (path === '/api/devices') { if (path === '/api/devices' || path === '/devices') {
if (method === 'GET') return handleGetDevices(request, env, userId); if (method === 'GET') return handleGetDevices(request, env, userId);
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId); if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
return null; return null;
} }
if (path === '/api/devices/authorized') { if (path === '/api/devices/authorized' || path === '/devices/authorized') {
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId); if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId); if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
return null; return null;
} }
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i); const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)'));
if (authorizedDeviceMatch && method === 'DELETE') { if (authorizedDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier); return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
} }
const permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/i); const permanentAuthorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)/permanent'));
if (permanentAuthorizedDeviceMatch && method === 'POST') { if (permanentAuthorizedDeviceMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]);
return handleTrustDevicePermanently(request, env, userId, deviceIdentifier); return handleTrustDevicePermanently(request, env, userId, deviceIdentifier);
} }
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i); const deleteDeviceMatch = path.match(devicesPath('/([^/]+)'));
if (deleteDeviceMatch && method === 'GET') { if (deleteDeviceMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]); const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleGetDevice(request, env, userId, deviceIdentifier); return handleGetDevice(request, env, userId, deviceIdentifier);
@@ -61,59 +65,59 @@ export async function handleAuthenticatedDeviceRoute(
return handleDeleteDevice(request, env, userId, deviceIdentifier); return handleDeleteDevice(request, env, userId, deviceIdentifier);
} }
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i); const updateDeviceNameMatch = path.match(devicesPath('/([^/]+)/name'));
if (updateDeviceNameMatch && method === 'PUT') { if (updateDeviceNameMatch && method === 'PUT') {
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]); const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
return handleUpdateDeviceName(request, env, userId, deviceIdentifier); return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
} }
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i); const identifierMatch = path.match(devicesPath('/identifier/([^/]+)'));
if (identifierMatch && method === 'GET') { if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]); const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier); return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
} }
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i); const deviceKeysMatch = path.match(devicesPath('/([^/]+)/keys')) || path.match(devicesPath('/identifier/([^/]+)/keys'));
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) { if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]); const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier); return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
} }
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i); const identifierTokenMatch = path.match(devicesPath('/identifier/([^/]+)/token'));
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) { if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]); const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier); return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
} }
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i); const identifierWebPushMatch = path.match(devicesPath('/identifier/([^/]+)/web-push-auth'));
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) { if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]); const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier); return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
} }
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i); const identifierClearTokenMatch = path.match(devicesPath('/identifier/([^/]+)/clear-token'));
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) { if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]); const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
return handleClearDeviceToken(request, env, userId, deviceIdentifier); return handleClearDeviceToken(request, env, userId, deviceIdentifier);
} }
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i); const identifierRetrieveKeysMatch = path.match(devicesPath('/([^/]+)/retrieve-keys'));
if (identifierRetrieveKeysMatch && method === 'POST') { if (identifierRetrieveKeysMatch && method === 'POST') {
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]); const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier); return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
} }
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i); const identifierDeactivateMatch = path.match(devicesPath('/([^/]+)/deactivate'));
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) { if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]); const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
return handleDeactivateDevice(request, env, userId, deviceIdentifier); return handleDeactivateDevice(request, env, userId, deviceIdentifier);
} }
if (path === '/api/devices/update-trust' && method === 'POST') { if ((path === '/api/devices/update-trust' || path === '/devices/update-trust') && method === 'POST') {
return handleUpdateDeviceTrust(request, env, userId); return handleUpdateDeviceTrust(request, env, userId);
} }
if (path === '/api/devices/untrust' && method === 'POST') { if ((path === '/api/devices/untrust' || path === '/devices/untrust') && method === 'POST') {
return handleUntrustDevices(request, env, userId); return handleUntrustDevices(request, env, userId);
} }
+21 -1
View File
@@ -68,6 +68,7 @@ export interface BackupPayload {
ciphers: SqlRow[]; ciphers: SqlRow[];
attachments: SqlRow[]; attachments: SqlRow[];
webauthn_credentials?: SqlRow[]; webauthn_credentials?: SqlRow[];
trusted_two_factor_device_tokens?: SqlRow[];
}; };
} }
@@ -302,6 +303,7 @@ export function validateBackupPayloadContents(
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers'); const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments'); const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials'); const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens');
const externalAttachmentKeys = new Set<string>( const externalAttachmentKeys = new Set<string>(
options.allowExternalAttachmentBlobs options.allowExternalAttachmentBlobs
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`) ? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
@@ -390,6 +392,21 @@ export function validateBackupPayloadContents(
accountPasskeyIds.add(id); accountPasskeyIds.add(id);
accountPasskeyCredentialIds.add(credentialId); accountPasskeyCredentialIds.add(credentialId);
} }
const trustedTwoFactorTokens = new Set<string>();
for (const row of trustedTwoFactorTokenRows) {
const token = String(row.token || '').trim();
const userId = String(row.user_id || '').trim();
const deviceIdentifier = String(row.device_identifier || '').trim();
const expiresAt = Number(row.expires_at || 0);
if (!token || !userIds.has(userId) || !deviceIdentifier || !Number.isFinite(expiresAt) || expiresAt <= 0) {
throw new Error('Backup archive contains an invalid trusted two-factor device token row');
}
if (trustedTwoFactorTokens.has(token)) {
throw new Error(`Backup archive contains duplicate trusted two-factor device token: ${token}`);
}
trustedTwoFactorTokens.add(token);
}
} }
export async function buildBackupArchive( export async function buildBackupArchive(
@@ -408,7 +425,7 @@ export async function buildBackupArchive(
includeAttachments, includeAttachments,
}); });
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([ const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
@@ -417,6 +434,7 @@ export async function buildBackupArchive(
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'), queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
]); ]);
const exportedConfigRows = sanitizeConfigRowsForExport(configRows); const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
const exportedAttachmentRows = includeAttachments ? attachmentRows : []; const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
@@ -445,6 +463,7 @@ export async function buildBackupArchive(
ciphers: cipherRows.length, ciphers: cipherRows.length,
attachments: exportedAttachmentRows.length, attachments: exportedAttachmentRows.length,
webauthn_credentials: accountPasskeyRows.length, webauthn_credentials: accountPasskeyRows.length,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
}, },
includes: { includes: {
attachments: includeAttachments, attachments: includeAttachments,
@@ -468,6 +487,7 @@ export async function buildBackupArchive(
ciphers: cipherRows, ciphers: cipherRows,
attachments: exportedAttachmentRows, attachments: exportedAttachmentRows,
webauthn_credentials: accountPasskeyRows, webauthn_credentials: accountPasskeyRows,
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
}, null, BACKUP_JSON_INDENT)), }, null, BACKUP_JSON_INDENT)),
}; };
+6
View File
@@ -16,6 +16,7 @@ import {
type BackupRuntimeState, type BackupRuntimeState,
type BackupScheduleConfig, type BackupScheduleConfig,
type BackupSettings, type BackupSettings,
type S3BackupAddressingStyle,
type S3BackupDestination, type S3BackupDestination,
type WebDavBackupDestination, type WebDavBackupDestination,
createBackupRandomId, createBackupRandomId,
@@ -35,6 +36,7 @@ export type {
BackupRuntimeState, BackupRuntimeState,
BackupScheduleConfig, BackupScheduleConfig,
BackupSettings, BackupSettings,
S3BackupAddressingStyle,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '../../shared/backup-schema'; } from '../../shared/backup-schema';
@@ -109,6 +111,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
const source = isPlainObject(value) ? value : {}; const source = isPlainObject(value) ? value : {};
const endpoint = asTrimmedString(source.endpoint); const endpoint = asTrimmedString(source.endpoint);
const bucket = asTrimmedString(source.bucket); const bucket = asTrimmedString(source.bucket);
const addressingStyleRaw = asTrimmedString(source.addressingStyle);
const addressingStyle: S3BackupAddressingStyle =
addressingStyleRaw === 'virtual-hosted-style' ? 'virtual-hosted-style' : 'path-style';
const accessKeyId = asTrimmedString(source.accessKeyId); const accessKeyId = asTrimmedString(source.accessKeyId);
const secretAccessKey = asTrimmedString(source.secretAccessKey); const secretAccessKey = asTrimmedString(source.secretAccessKey);
const region = asTrimmedString(source.region) || 'auto'; const region = asTrimmedString(source.region) || 'auto';
@@ -131,6 +136,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
return { return {
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '', endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
bucket, bucket,
addressingStyle,
region, region,
accessKeyId, accessKeyId,
secretAccessKey, secretAccessKey,
+21
View File
@@ -24,6 +24,7 @@ type BackupTableName =
| 'users' | 'users'
| 'domain_settings' | 'domain_settings'
| 'user_revisions' | 'user_revisions'
| 'trusted_two_factor_device_tokens'
| 'webauthn_credentials' | 'webauthn_credentials'
| 'folders' | 'folders'
| 'ciphers' | 'ciphers'
@@ -34,6 +35,7 @@ const BACKUP_TABLES: BackupTableName[] = [
'users', 'users',
'domain_settings', 'domain_settings',
'user_revisions', 'user_revisions',
'trusted_two_factor_device_tokens',
'webauthn_credentials', 'webauthn_credentials',
'folders', 'folders',
'ciphers', 'ciphers',
@@ -51,6 +53,7 @@ export interface BackupImportResultBody {
users: number; users: number;
domainSettings: number; domainSettings: number;
userRevisions: number; userRevisions: number;
trustedTwoFactorDeviceTokens: number;
webauthnCredentials: number; webauthnCredentials: number;
folders: number; folders: number;
ciphers: number; ciphers: number;
@@ -172,6 +175,7 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
'DELETE FROM ciphers', 'DELETE FROM ciphers',
'DELETE FROM folders', 'DELETE FROM folders',
'DELETE FROM webauthn_credentials', 'DELETE FROM webauthn_credentials',
'DELETE FROM trusted_two_factor_device_tokens',
'DELETE FROM domain_settings', 'DELETE FROM domain_settings',
'DELETE FROM user_revisions', 'DELETE FROM user_revisions',
'DELETE FROM users', 'DELETE FROM users',
@@ -296,6 +300,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []), user_revisions: cloneRows(payload.user_revisions || []),
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []), webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
folders: cloneRows(payload.folders || []), folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({ ciphers: cloneRows(payload.ciphers || []).map((row) => ({
@@ -634,6 +639,16 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
true true
) )
); );
await runInsertBatch(
db,
tableName('trusted_two_factor_device_tokens'),
buildInsertStatements(
db,
tableName('trusted_two_factor_device_tokens'),
['token', 'user_id', 'device_identifier', 'expires_at'],
payload.trusted_two_factor_device_tokens || []
)
);
await runInsertBatch( await runInsertBatch(
db, db,
tableName('webauthn_credentials'), tableName('webauthn_credentials'),
@@ -712,6 +727,7 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -735,6 +751,7 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -776,6 +793,7 @@ export async function importBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length, domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length, userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length, webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -853,6 +871,7 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -876,6 +895,7 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domain_settings: (db.domain_settings || []).length, domain_settings: (db.domain_settings || []).length,
user_revisions: (db.user_revisions || []).length, user_revisions: (db.user_revisions || []).length,
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
webauthn_credentials: (db.webauthn_credentials || []).length, webauthn_credentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
@@ -923,6 +943,7 @@ export async function importRemoteBackupArchiveBytes(
users: (db.users || []).length, users: (db.users || []).length,
domainSettings: (db.domain_settings || []).length, domainSettings: (db.domain_settings || []).length,
userRevisions: (db.user_revisions || []).length, userRevisions: (db.user_revisions || []).length,
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
webauthnCredentials: (db.webauthn_credentials || []).length, webauthnCredentials: (db.webauthn_credentials || []).length,
folders: (db.folders || []).length, folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length, ciphers: (db.ciphers || []).length,
+24 -5
View File
@@ -448,8 +448,27 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
return true; return true;
} }
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
const hostname = endpoint.hostname.toLowerCase();
const bucketName = bucket.trim().toLowerCase();
return !!bucketName && (hostname === bucketName || hostname.startsWith(`${bucketName}.`));
}
function s3BucketBaseUrl(config: S3BackupDestination): URL { function s3BucketBaseUrl(config: S3BackupDestination): URL {
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`); const endpoint = new URL(config.endpoint.replace(/\/+$/, ''));
const bucket = config.bucket.trim();
if (config.addressingStyle === 'virtual-hosted-style') {
if (isBucketHostedS3Endpoint(endpoint, bucket)) return endpoint;
endpoint.hostname = `${bucket}.${endpoint.hostname}`;
return endpoint;
}
return new URL(`${endpoint.toString().replace(/\/+$/, '')}/${encodeURIComponent(bucket)}`);
}
function s3ObjectUrl(config: S3BackupDestination, objectKey: string): URL {
return new URL(`${s3BucketBaseUrl(config).toString().replace(/\/+$/, '')}/${encodePathSegments(objectKey)}`);
} }
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string { function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
@@ -501,7 +520,7 @@ async function putToS3(
options: RemoteBackupFilePutOptions = {} options: RemoteBackupFilePutOptions = {}
): Promise<void> { ): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath); const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType); const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
if (!response.ok) { if (!response.ok) {
@@ -594,7 +613,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
throw new Error('Please select a backup file'); throw new Error('Please select a backup file');
} }
const objectKey = normalizeS3ObjectKey(config, normalized); const objectKey = normalizeS3ObjectKey(config, normalized);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'GET', url); const response = await signedS3Request(config, 'GET', url);
if (!response.ok) { if (!response.ok) {
throw new Error(`S3 download failed: ${response.status}`); throw new Error(`S3 download failed: ${response.status}`);
@@ -610,7 +629,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> { async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath); const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'DELETE', url); const response = await signedS3Request(config, 'DELETE', url);
if (!response.ok && response.status !== 404) { if (!response.ok && response.status !== 404) {
throw new Error(`S3 delete failed: ${response.status}`); throw new Error(`S3 delete failed: ${response.status}`);
@@ -619,7 +638,7 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> { async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
const objectKey = normalizeS3ObjectKey(config, relativePath); const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'HEAD', url); const response = await signedS3Request(config, 'HEAD', url);
if (response.status === 404) return false; if (response.status === 404) return false;
if (!response.ok) { if (!response.ok) {
+275
View File
@@ -0,0 +1,275 @@
import type { Env } from '../types';
import {
setConfigValue as saveConfigValue,
} from './storage-config-repo';
const PUSH_RELAY_URI = 'https://push.bitwarden.com';
const PUSH_IDENTITY_URI = 'https://identity.bitwarden.com';
const INSTALLATIONS_URI = 'https://api.bitwarden.com/installations';
const PUSH_INSTALLATION_ID_KEY = 'push.installation.id';
const PUSH_INSTALLATION_KEY_KEY = 'push.installation.key';
const PUSH_REQUEST_TIMEOUT_MS = 5000;
interface CachedPushAccessToken {
token: string;
expiresAt: number;
}
let cachedPushAccessToken: CachedPushAccessToken | null = null;
async function fetchPushEndpoint(url: string, init: RequestInit, errorMessage: string): Promise<Response | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PUSH_REQUEST_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch (error) {
console.error(errorMessage, error);
return null;
} finally {
clearTimeout(timeout);
}
}
function randomInstallationEmail(): string {
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
const localPart = Array.from(bytes, (byte) => (byte % 36).toString(36)).join('');
return `${localPart}@nodewarden.app`;
}
async function getConfigKeyPresence(db: D1Database, key: string): Promise<string | null> {
const row = await db.prepare('SELECT value FROM config WHERE key = ? LIMIT 1').bind(key).first<{ value: string }>();
return typeof row?.value === 'string' ? row.value : null;
}
async function getPushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
const [id, key] = await Promise.all([
getConfigKeyPresence(db, PUSH_INSTALLATION_ID_KEY),
getConfigKeyPresence(db, PUSH_INSTALLATION_KEY_KEY),
]);
const normalizedId = String(id || '').trim();
const normalizedKey = String(key || '').trim();
return normalizedId && normalizedKey ? { id: normalizedId, key: normalizedKey } : null;
}
export async function ensurePushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> {
const existing = await getPushInstallationCredentials(db);
if (existing) return existing;
const response = await fetchPushEndpoint(
INSTALLATIONS_URI,
{
method: 'POST',
headers: {
accept: 'application/json',
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
'cache-control': 'no-cache',
'content-type': 'application/json',
origin: 'https://bitwarden.com',
pragma: 'no-cache',
priority: 'u=1, i',
referer: 'https://bitwarden.com/host/',
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
},
body: JSON.stringify({
formName: 'request_host',
url: '/host/',
locale: 'zh-CN',
email: randomInstallationEmail(),
region: 'us',
}),
},
'Failed to request Bitwarden push installation:'
);
if (!response) return null;
if (!response.ok) {
console.error('Failed to request Bitwarden push installation:', response.status, await response.text().catch(() => ''));
return null;
}
const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | null;
const id = String(body?.id || '').trim();
const key = String(body?.key || '').trim();
if (!id || !key) {
console.error('Bitwarden push installation response did not include id/key');
return null;
}
await Promise.all([
saveConfigValue(db, PUSH_INSTALLATION_ID_KEY, id),
saveConfigValue(db, PUSH_INSTALLATION_KEY_KEY, key),
]);
return { id, key };
}
async function getPushAccessToken(env: Env): Promise<string | null> {
const credentials = await ensurePushInstallationCredentials(env.DB);
if (!credentials) return null;
const now = Date.now();
if (cachedPushAccessToken && cachedPushAccessToken.expiresAt > now + 30_000) {
return cachedPushAccessToken.token;
}
const params = new URLSearchParams({
grant_type: 'client_credentials',
scope: 'api.push',
client_id: `installation.${credentials.id}`,
client_secret: credentials.key,
});
const response = await fetchPushEndpoint(
`${PUSH_IDENTITY_URI}/connect/token`,
{
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
},
'Failed to get Bitwarden push relay token:'
);
if (!response) return null;
if (!response.ok) {
console.error('Failed to get Bitwarden push relay token:', response.status, await response.text().catch(() => ''));
return null;
}
const body = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null;
const token = String(body?.access_token || '').trim();
if (!token) {
console.error('Bitwarden push relay token response did not include an access_token');
return null;
}
const expiresInSeconds = Math.max(60, Number(body?.expires_in || 3600));
cachedPushAccessToken = {
token,
expiresAt: now + Math.floor(expiresInSeconds * 500),
};
return token;
}
async function postToPushRelay(env: Env, path: string, body?: unknown): Promise<boolean> {
const token = await getPushAccessToken(env);
if (!token) return false;
const response = await fetchPushEndpoint(
`${PUSH_RELAY_URI}${path}`,
{
method: 'POST',
headers: {
accept: 'application/json',
authorization: `Bearer ${token}`,
...(body === undefined ? {} : { 'content-type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
},
`Bitwarden push relay request failed: ${path}`
);
if (!response) return false;
if (!response.ok) {
console.error('Bitwarden push relay request failed:', path, response.status, await response.text().catch(() => ''));
return false;
}
return true;
}
function mobilePayloadFromSignalR(updateType: number, userId: string, revisionDate: string, payload: Record<string, unknown> | null | undefined): Record<string, unknown> {
const source = payload || {};
const id = source.Id ?? source.id;
const organizationId = source.OrganizationId ?? source.organizationId ?? null;
const collectionIds = source.CollectionIds ?? source.collectionIds ?? null;
if (id != null) {
return {
id,
userId: source.UserId ?? source.userId ?? userId,
organizationId,
collectionIds,
revisionDate: source.RevisionDate ?? source.revisionDate ?? revisionDate,
};
}
return {
userId: source.UserId ?? source.userId ?? userId,
date: source.Date ?? source.date ?? revisionDate,
};
}
export async function registerMobilePushDevice(
env: Env,
input: {
userId: string;
deviceIdentifier: string;
type: number;
pushUuid: string;
pushToken: string;
}
): Promise<boolean> {
const credentials = await ensurePushInstallationCredentials(env.DB);
if (!credentials) return false;
return postToPushRelay(env, '/push/register', {
deviceId: input.pushUuid,
pushToken: input.pushToken,
userId: input.userId,
type: input.type,
identifier: input.deviceIdentifier,
installationId: credentials.id,
});
}
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
const normalized = String(pushUuid || '').trim();
if (!normalized) return false;
return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`);
}
export async function notifyMobilePush(
env: Env,
input: {
userId: string;
updateType: number;
revisionDate: string;
contextId: string | null;
payload: Record<string, unknown> | null | undefined;
}
): Promise<void> {
const hasPushDevice = await env.DB
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
.bind(input.userId, '')
.first<{ '1': number }>();
if (!hasPushDevice) return;
let actingPushUuid: string | null = null;
if (input.contextId) {
const row = await env.DB
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
.bind(input.userId, input.contextId)
.first<{ push_uuid: string | null }>();
actingPushUuid = row?.push_uuid ?? null;
}
await postToPushRelay(env, '/push/send', {
userId: input.userId,
organizationId: null,
deviceId: actingPushUuid,
identifier: input.contextId,
type: input.updateType,
payload: mobilePayloadFromSignalR(input.updateType, input.userId, input.revisionDate, input.payload),
clientType: null,
installationId: null,
});
}
+8 -3
View File
@@ -87,7 +87,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null, archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
deletedAt: row.deleted_at ?? null, deletedAt: row.deleted_at ?? parsed.deletedAt ?? parsed.deletedDate ?? null,
}; };
} catch { } catch {
console.error('Corrupted cipher data, id:', row.id); console.error('Corrupted cipher data, id:', row.id);
@@ -244,7 +244,9 @@ export async function getCiphersPage(
limit: number, limit: number,
offset: number offset: number
): Promise<Cipher[]> { ): Promise<Cipher[]> {
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL'; const whereDeleted = includeDeleted
? ''
: "AND deleted_at IS NULL AND json_extract(data, '$.deletedAt') IS NULL AND json_extract(data, '$.deletedDate') IS NULL";
const res = await db const res = await db
.prepare( .prepare(
`SELECT ${selectCipherColumns()} FROM ciphers `SELECT ${selectCipherColumns()} FROM ciphers
@@ -341,7 +343,10 @@ export async function bulkArchiveCiphers(
`UPDATE ciphers `UPDATE ciphers
SET archived_at = ?, updated_at = ?, SET archived_at = ?, updated_at = ?,
data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL` WHERE user_id = ? AND id IN (${placeholders})
AND deleted_at IS NULL
AND json_extract(data, '$.deletedAt') IS NULL
AND json_extract(data, '$.deletedDate') IS NULL`
) )
.bind(now, now, userId, ...chunk) .bind(now, now, userId, ...chunk)
.run(); .run();
+66 -3
View File
@@ -1,4 +1,5 @@
import type { Device, TrustedDeviceTokenSummary, User } from '../types'; import type { Device, TrustedDeviceTokenSummary, User } from '../types';
import { generateUUID } from '../utils/uuid';
type GetUserByEmail = (email: string) => Promise<User | null>; type GetUserByEmail = (email: string) => Promise<User | null>;
type TrustedTokenKeyFn = (token: string) => Promise<string>; type TrustedTokenKeyFn = (token: string) => Promise<string>;
@@ -14,6 +15,8 @@ function mapDeviceRow(row: any): Device {
encryptedUserKey: row.encrypted_user_key ?? null, encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null, encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null, encryptedPrivateKey: row.encrypted_private_key ?? null,
pushUuid: row.push_uuid ?? null,
pushToken: row.push_token ?? null,
lastSeenAt: row.last_seen_at ?? null, lastSeenAt: row.last_seen_at ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
@@ -38,13 +41,15 @@ export async function upsertDevice(
const existingDevice = await getDeviceById(userId, deviceIdentifier); const existingDevice = await getDeviceById(userId, deviceIdentifier);
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || ''; const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim(); const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
const effectivePushUuid = String(existingDevice?.pushUuid || '').trim() || generateUUID();
await db await db
.prepare( .prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
'push_uuid=COALESCE(push_uuid, excluded.push_uuid), ' +
'last_seen_at=excluded.last_seen_at, ' + 'last_seen_at=excluded.last_seen_at, ' +
'updated_at=excluded.updated_at' 'updated_at=excluded.updated_at'
) )
@@ -57,6 +62,7 @@ export async function upsertDevice(
keys?.encryptedUserKey ?? null, keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null, keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null, keys?.encryptedPrivateKey ?? null,
effectivePushUuid,
existingDevice?.deviceNote ?? null, existingDevice?.deviceNote ?? null,
now, now,
now, now,
@@ -166,7 +172,7 @@ export async function isKnownDeviceByEmail(
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> { export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db const res = await db
.prepare( .prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC' 'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
) )
.bind(userId) .bind(userId)
@@ -177,7 +183,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> { export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
const row = await db const row = await db
.prepare( .prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
) )
.bind(userId, deviceIdentifier) .bind(userId, deviceIdentifier)
@@ -185,6 +191,63 @@ export async function getDevice(db: D1Database, userId: string, deviceIdentifier
return row ? mapDeviceRow(row) : null; return row ? mapDeviceRow(row) : null;
} }
export async function updateDevicePushToken(
db: D1Database,
userId: string,
deviceIdentifier: string,
pushUuid: string,
pushToken: string
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
'UPDATE devices SET push_uuid = ?, push_token = ?, updated_at = ? ' +
'WHERE user_id = ? AND device_identifier = ?'
)
.bind(pushUuid, pushToken, now, userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function clearDevicePushToken(
db: D1Database,
userId: string,
deviceIdentifier: string
): Promise<{ pushUuid: string | null } | null> {
const existing = await db
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
.bind(userId, deviceIdentifier)
.first<{ push_uuid: string | null }>();
if (!existing) return null;
await db
.prepare('UPDATE devices SET push_token = NULL, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
.bind(new Date().toISOString(), userId, deviceIdentifier)
.run();
return { pushUuid: existing.push_uuid ?? null };
}
export async function getDevicePushUuid(
db: D1Database,
userId: string,
deviceIdentifier: string
): Promise<string | null> {
const row = await db
.prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
.bind(userId, deviceIdentifier)
.first<{ push_uuid: string | null }>();
return row?.push_uuid ?? null;
}
export async function userHasPushDevice(db: D1Database, userId: string): Promise<boolean> {
const row = await db
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1')
.bind(userId, '')
.first<{ '1': number }>();
return !!row;
}
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> { export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
const result = await db const result = await db
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?') .prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
+4 -1
View File
@@ -94,7 +94,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' + 'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, push_uuid TEXT, push_token TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' + 'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
@@ -103,11 +103,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
'ALTER TABLE devices ADD COLUMN push_uuid TEXT',
'ALTER TABLE devices ADD COLUMN push_token TEXT',
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT', 'ALTER TABLE devices ADD COLUMN banned_at TEXT',
'ALTER TABLE devices ADD COLUMN device_note TEXT', 'ALTER TABLE devices ADD COLUMN device_note TEXT',
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT', 'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)',
'CREATE TABLE IF NOT EXISTS auth_requests (' + 'CREATE TABLE IF NOT EXISTS auth_requests (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
+28 -1
View File
@@ -1,5 +1,6 @@
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types'; import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { ensurePushInstallationCredentials } from './push-relay';
import { ensureStorageSchema } from './storage-schema'; import { ensureStorageSchema } from './storage-schema';
import { import {
getConfigValue as getStoredConfigValue, getConfigValue as getStoredConfigValue,
@@ -87,10 +88,12 @@ import {
import { import {
deleteDevice as deleteStoredDevice, deleteDevice as deleteStoredDevice,
deleteDevicesByUserId as deleteStoredDevicesByUserId, deleteDevicesByUserId as deleteStoredDevicesByUserId,
clearDevicePushToken as clearStoredDevicePushToken,
clearDeviceKeys as clearStoredDeviceKeys, clearDeviceKeys as clearStoredDeviceKeys,
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice, deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId, deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
getDevice as findStoredDevice, getDevice as findStoredDevice,
getDevicePushUuid as findStoredDevicePushUuid,
getDevicesByUserId as listStoredDevicesByUserId, getDevicesByUserId as listStoredDevicesByUserId,
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries, getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId, getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
@@ -101,7 +104,9 @@ import {
upsertDevice as saveStoredDevice, upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName, updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys, updateDeviceKeys as updateStoredDeviceKeys,
updateDevicePushToken as updateStoredDevicePushToken,
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice, updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
userHasPushDevice as getUserHasPushDevice,
} from './storage-device-repo'; } from './storage-device-repo';
import { import {
createAuthRequest as createStoredAuthRequest, createAuthRequest as createStoredAuthRequest,
@@ -143,7 +148,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-06-12-auth-requests'; const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
// D1-backed storage. // D1-backed storage.
@@ -235,6 +240,7 @@ export class StorageService {
await ensureStorageSchema(this.db); await ensureStorageSchema(this.db);
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION); await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
} }
await ensurePushInstallationCredentials(this.db);
StorageService.schemaVerified = true; StorageService.schemaVerified = true;
} }
@@ -713,6 +719,27 @@ export class StorageService {
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier); return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
} }
async updateDevicePushToken(
userId: string,
deviceIdentifier: string,
pushUuid: string,
pushToken: string
): Promise<boolean> {
return updateStoredDevicePushToken(this.db, userId, deviceIdentifier, pushUuid, pushToken);
}
async clearDevicePushToken(userId: string, deviceIdentifier: string): Promise<{ pushUuid: string | null } | null> {
return clearStoredDevicePushToken(this.db, userId, deviceIdentifier);
}
async getDevicePushUuid(userId: string, deviceIdentifier: string): Promise<string | null> {
return findStoredDevicePushUuid(this.db, userId, deviceIdentifier);
}
async userHasPushDevice(userId: string): Promise<boolean> {
return getUserHasPushDevice(this.db, userId);
}
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> { async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers); return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
} }
+17 -1
View File
@@ -231,6 +231,8 @@ export interface Device {
encryptedUserKey: string | null; encryptedUserKey: string | null;
encryptedPublicKey: string | null; encryptedPublicKey: string | null;
encryptedPrivateKey: string | null; encryptedPrivateKey: string | null;
pushUuid: string | null;
pushToken: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null; devicePendingAuthRequest?: DevicePendingAuthRequest | null;
lastSeenAt: string | null; lastSeenAt: string | null;
createdAt: string; createdAt: string;
@@ -465,7 +467,15 @@ export interface TokenResponse {
scope: string; scope: string;
unofficialServer: boolean; unofficialServer: boolean;
MasterPasswordPolicy?: { MasterPasswordPolicy?: {
minComplexity: number;
minLength: number;
requireUpper: boolean;
requireLower: boolean;
requireNumbers: boolean;
requireSpecial: boolean;
enforceOnLogin: boolean;
Object: string; Object: string;
object?: string;
} | null; } | null;
ApiUseKeyConnector?: boolean; ApiUseKeyConnector?: boolean;
AccountKeys?: any | null; AccountKeys?: any | null;
@@ -494,12 +504,13 @@ export interface ProfileResponse {
accountKeys: any | null; accountKeys: any | null;
securityStamp: string; securityStamp: string;
organizations: any[]; organizations: any[];
organizationsNew?: any[];
providers: any[]; providers: any[];
providerOrganizations: any[]; providerOrganizations: any[];
forcePasswordReset: boolean; forcePasswordReset: boolean;
avatarColor: string | null; avatarColor: string | null;
creationDate: string; creationDate: string;
verifyDevices?: boolean; verifyDevices: boolean;
role?: UserRole; role?: UserRole;
status?: UserStatus; status?: UserStatus;
object: string; object: string;
@@ -558,6 +569,7 @@ export interface SyncResponse {
ciphers: CipherResponse[]; ciphers: CipherResponse[];
domains: any; domains: any;
policies: any[]; policies: any[];
policiesNew?: any[];
sends: SendResponse[]; sends: SendResponse[];
UserDecryption?: { UserDecryption?: {
MasterPasswordUnlock: MasterPasswordUnlock | null; MasterPasswordUnlock: MasterPasswordUnlock | null;
@@ -565,6 +577,10 @@ export interface SyncResponse {
KeyConnectorOption?: null; KeyConnectorOption?: null;
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null; WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[]; WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[];
V2UpgradeToken?: {
WrappedUserKey1: string;
WrappedUserKey2: string;
} | null;
Object?: string; Object?: string;
} | null; } | null;
// PascalCase for desktop/browser clients // PascalCase for desktop/browser clients
+36
View File
@@ -0,0 +1,36 @@
import type { Env, ProfileResponse, User } from '../types';
import { buildAccountKeys } from './user-decryption';
export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
void env;
const organizations: any[] = [];
const accountKeys = buildAccountKeys(user);
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys,
securityStamp: user.securityStamp || user.id,
organizations,
organizationsNew: organizations,
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices !== false,
role: user.role,
status: user.status,
object: 'profile',
};
}
+1
View File
@@ -16,6 +16,7 @@ export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>):
publicKeyEncryptionKeyPair: { publicKeyEncryptionKeyPair: {
wrappedPrivateKey: user.privateKey, wrappedPrivateKey: user.privateKey,
publicKey, publicKey,
signedPublicKey: null,
Object: 'publicKeyEncryptionKeyPair', Object: 'publicKeyEncryptionKeyPair',
}, },
Object: 'privateKeys', Object: 'privateKeys',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Full-bleed background for any/maskable -->
<rect width="512" height="512" fill="#116FF9"/>
<!-- Logo scaled to ~50% centered in safe zone (inner 66% = Android adaptive icon guideline) -->
<g transform="translate(256,256) scale(0.5) translate(-380,-380)">
<path d="M386.5 183C497.785 183 588 271.2 588 380C588 419.877 575.879 456.986 555.046 488H17.6816C16.5766 481.834 16 475.484 16 469C16 413.617 58.0774 368.061 112.008 362.558C108.771 353.989 107 344.701 107 335C107 291.922 141.922 257 185 257C198.365 257 210.945 260.362 221.94 266.286C258.437 215.895 318.539 183 386.5 183Z" fill="#F6821F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.6568 91.0069C88.7796 262.923 101.55 381.119 143.869 469.459C186.188 557.799 258.092 616.353 372.665 668.892C485.877 616.354 556.929 557.802 598.746 469.461C640.564 381.12 653.181 262.923 649.35 91.0069H92.6568ZM539.796 432.933C570.479 365.533 581.347 278.379 582.419 153.939L582.422 153.432H377.661V593.786L378.405 593.364C458.602 547.962 509.101 500.36 539.796 432.933Z" fill="white"/>
<path d="M604.465 305C680.976 305 743 367.233 743 444C743 459.378 740.509 474.172 735.913 488H379V423.553C391.721 397.751 418.287 380 449 380C459.483 380 469.482 382.068 478.613 385.818C500.559 338.11 548.658 305 604.465 305Z" fill="#FD9C33"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+294 -24
View File
@@ -20,6 +20,7 @@ import {
saveProfileSnapshot, saveProfileSnapshot,
revokeCurrentSession, revokeCurrentSession,
getTotpStatus, getTotpStatus,
getVaultRevisionDate,
saveSession, saveSession,
stripProfileSecrets, stripProfileSecrets,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
@@ -31,9 +32,9 @@ import {
} from '@/lib/api/auth-requests'; } from '@/lib/api/auth-requests';
import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin'; import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
import { getSends } from '@/lib/api/send'; import { getSendById, getSends } from '@/lib/api/send';
import { repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault'; import { getCipherById, getFolderById, repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault';
import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot, saveVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
import { import {
parseSignalRTextFrames, parseSignalRTextFrames,
@@ -134,10 +135,22 @@ function normalizeRoutePath(path: string): string {
} }
const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1'; const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE = 0;
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE = 1;
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE = 3;
const SIGNALR_UPDATE_TYPE_SYNC_CIPHERS = 4;
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE = 7;
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE = 8;
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE = 9;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13;
const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
type ThemePreference = 'system' | 'light' | 'dark'; type ThemePreference = 'system' | 'light' | 'dark';
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30; type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
@@ -249,6 +262,7 @@ export default function App() {
const sessionRef = useRef<SessionState | null>(initialBootstrap.session); const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {}); const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>(''); const repairAttemptRef = useRef<string>('');
const uriChecksumRepairAttemptRef = useRef<string>(''); const uriChecksumRepairAttemptRef = useRef<string>('');
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null); const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
@@ -491,7 +505,7 @@ export default function App() {
}; };
}, [phase, session?.email, location, navigate]); }, [phase, session?.email, location, navigate]);
async function finalizeLogin(login: CompletedLogin, successMessage = t('txt_login_success')) { async function finalizeLogin(login: CompletedLogin) {
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false); setUnlockPreparing(false);
@@ -505,7 +519,6 @@ export default function App() {
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') { if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
navigate('/vault'); navigate('/vault');
} }
pushToast('success', successMessage);
void (async () => { void (async () => {
try { try {
const hydratedProfile = await login.profilePromise; const hydratedProfile = await login.profilePromise;
@@ -522,7 +535,7 @@ export default function App() {
if (IS_DEMO_MODE) { if (IS_DEMO_MODE) {
setPendingAuthAction('login'); setPendingAuthAction('login');
try { try {
await finalizeLogin(createDemoCompletedLogin(loginValues.email), t('txt_login_success')); await finalizeLogin(createDemoCompletedLogin(loginValues.email));
} finally { } finally {
setPendingAuthAction(null); setPendingAuthAction(null);
} }
@@ -594,7 +607,7 @@ export default function App() {
try { try {
const result = await performPasskeyLogin(defaultKdfIterations, expectedEmail); const result = await performPasskeyLogin(defaultKdfIterations, expectedEmail);
if (result.kind === 'success') { if (result.kind === 'success') {
await finalizeLogin(result.login, t('txt_unlocked')); await finalizeLogin(result.login);
return; return;
} }
if (result.kind === 'password') { if (result.kind === 'password') {
@@ -636,7 +649,7 @@ export default function App() {
setTotpSubmitting(true); setTotpSubmitting(true);
try { try {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
await finalizeLogin(login, pendingTotpMode === 'unlock' ? t('txt_unlocked') : t('txt_login_success')); await finalizeLogin(login);
} catch (error) { } catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
} finally { } finally {
@@ -776,7 +789,7 @@ export default function App() {
if (IS_DEMO_MODE) { if (IS_DEMO_MODE) {
setPendingAuthAction('unlock'); setPendingAuthAction('unlock');
try { try {
await finalizeLogin(createDemoCompletedLogin(session.email), t('txt_unlocked')); await finalizeLogin(createDemoCompletedLogin(session.email));
} finally { } finally {
setPendingAuthAction(null); setPendingAuthAction(null);
} }
@@ -790,7 +803,7 @@ export default function App() {
try { try {
const result = await performUnlock(session, profile, unlockPassword, defaultKdfIterations); const result = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
if (result.kind === 'success') { if (result.kind === 'success') {
await finalizeLogin(result.login, t('txt_unlocked')); await finalizeLogin(result.login);
return; return;
} }
if (result.kind === 'totp') { if (result.kind === 'totp') {
@@ -1072,8 +1085,9 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
const pendingAuthRequestsQuery = useQuery({ const pendingAuthRequestsQuery = useQuery({
queryKey: ['auth-requests-pending', vaultCacheKey || session?.email], queryKey: pendingAuthRequestsQueryKey,
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''), queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email), enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
staleTime: 5_000, staleTime: 5_000,
@@ -1327,6 +1341,193 @@ export default function App() {
silentRefreshVaultRef.current = refreshVaultSilently; silentRefreshVaultRef.current = refreshVaultSilently;
function normalizeVaultCoreSnapshot(snapshot?: Partial<VaultCoreSnapshot> | null): VaultCoreSnapshot {
return {
ciphers: Array.isArray(snapshot?.ciphers) ? snapshot.ciphers : [],
folders: Array.isArray(snapshot?.folders) ? snapshot.folders : [],
sends: Array.isArray(snapshot?.sends) ? snapshot.sends : [],
};
}
function upsertById<T extends { id: string }>(items: T[], nextItem: T): T[] {
const nextId = String(nextItem.id || '').trim();
if (!nextId) return items;
const index = items.findIndex((item) => String(item.id || '').trim() === nextId);
if (index < 0) return [...items, nextItem];
const next = items.slice();
next[index] = nextItem;
return next;
}
function removeById<T extends { id: string }>(items: T[], id: string): T[] {
const normalizedId = String(id || '').trim();
if (!normalizedId) return items;
return items.filter((item) => String(item.id || '').trim() !== normalizedId);
}
function revisionStampFromIso(value: unknown): number | null {
const stamp = new Date(String(value || '').trim()).getTime();
return Number.isFinite(stamp) && stamp > 0 ? stamp : null;
}
function patchVaultCoreSnapshot(
updater: (snapshot: VaultCoreSnapshot) => VaultCoreSnapshot,
options?: { revisionStamp?: number | null }
): void {
if (!vaultCacheKey) return;
let nextSnapshot: VaultCoreSnapshot | null = null;
queryClient.setQueryData(['vault-core', vaultCacheKey], (previous?: VaultCoreSnapshot) => {
const base = normalizeVaultCoreSnapshot(previous || cachedVaultCore);
nextSnapshot = updater(base);
return nextSnapshot;
});
if (nextSnapshot) {
setCachedVaultCore(nextSnapshot);
void saveVaultCoreSyncSnapshot(vaultCacheKey, nextSnapshot, options?.revisionStamp ?? null);
}
}
async function refreshVaultCoreRevisionStamp(): Promise<void> {
if (!vaultCacheKey || !session?.accessToken) return;
try {
const revisionStamp = await getVaultRevisionDate(authedFetch);
const currentSnapshot = normalizeVaultCoreSnapshot(
queryClient.getQueryData<VaultCoreSnapshot>(['vault-core', vaultCacheKey]) || cachedVaultCore
);
await saveVaultCoreSyncSnapshot(vaultCacheKey, currentSnapshot, revisionStamp);
} catch {
// A stale revision stamp only affects the next cache validation; the local resource patch remains valid.
}
}
function upsertEncryptedCipher(cipher: Cipher, revisionStamp?: number | null): void {
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
ciphers: upsertById(snapshot.ciphers, cipher),
}), { revisionStamp: revisionStamp ?? revisionStampFromIso(cipher.revisionDate) });
}
function deleteCipherLocally(cipherId: string, revisionStamp?: number | null): void {
const id = String(cipherId || '').trim();
if (!id) return;
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
ciphers: removeById(snapshot.ciphers, id),
}), { revisionStamp });
setDecryptedCiphers((current) => removeById(current, id));
}
function upsertEncryptedFolder(folder: VaultFolder, revisionStamp?: number | null): void {
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
folders: upsertById(snapshot.folders, folder),
}), { revisionStamp: revisionStamp ?? revisionStampFromIso(folder.revisionDate) });
}
function deleteFolderLocally(folderId: string, revisionStamp?: number | null): void {
const id = String(folderId || '').trim();
if (!id) return;
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
folders: removeById(snapshot.folders, id),
ciphers: snapshot.ciphers.map((cipher) => (
String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher
)),
}), { revisionStamp });
setDecryptedFolders((current) => removeById(current, id));
setDecryptedCiphers((current) => current.map((cipher) => (
String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher
)));
}
function upsertEncryptedSend(send: Send, revisionStamp?: number | null): void {
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
sends: upsertById(snapshot.sends, send),
}), { revisionStamp: revisionStamp ?? revisionStampFromIso(send.revisionDate) });
queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => upsertById(Array.isArray(previous) ? previous : [], send));
}
function deleteSendLocally(sendId: string, revisionStamp?: number | null): void {
const id = String(sendId || '').trim();
if (!id) return;
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
sends: removeById(snapshot.sends, id),
}), { revisionStamp });
queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => removeById(Array.isArray(previous) ? previous : [], id));
setDecryptedSends((current) => removeById(current, id));
}
async function upsertCipherFromNotification(cipherId: string, revisionStamp?: number | null): Promise<void> {
const id = String(cipherId || '').trim();
if (!id || !session?.symEncKey || !session?.symMacKey) return;
try {
const encrypted = await getCipherById(authedFetch, id);
upsertEncryptedCipher(encrypted, revisionStamp);
const result = await decryptVaultCore({
folders: [],
ciphers: [encrypted],
symEncKeyB64: session.symEncKey,
symMacKeyB64: session.symMacKey,
});
const decrypted = result.ciphers[0];
if (decrypted) setDecryptedCiphers((current) => upsertById(current, decrypted));
} catch (error) {
if ((error as { status?: number }).status === 404) {
deleteCipherLocally(id);
return;
}
console.warn('Failed to upsert cipher from notification:', error);
}
}
async function upsertFolderFromNotification(folderId: string, revisionStamp?: number | null): Promise<void> {
const id = String(folderId || '').trim();
if (!id || !session?.symEncKey || !session?.symMacKey) return;
try {
const encrypted = await getFolderById(authedFetch, id);
upsertEncryptedFolder(encrypted, revisionStamp);
const result = await decryptVaultCore({
folders: [encrypted],
ciphers: [],
symEncKeyB64: session.symEncKey,
symMacKeyB64: session.symMacKey,
});
const decrypted = result.folders[0];
if (decrypted) setDecryptedFolders((current) => upsertById(current, decrypted));
} catch (error) {
if ((error as { status?: number }).status === 404) {
deleteFolderLocally(id);
return;
}
console.warn('Failed to upsert folder from notification:', error);
}
}
async function upsertSendFromNotification(sendId: string, revisionStamp?: number | null): Promise<void> {
const id = String(sendId || '').trim();
if (!id || !session?.symEncKey || !session?.symMacKey) return;
try {
const encrypted = await getSendById(authedFetch, id);
upsertEncryptedSend(encrypted, revisionStamp);
const sends = await decryptSends({
sends: [encrypted],
symEncKeyB64: session.symEncKey,
symMacKeyB64: session.symMacKey,
origin: window.location.origin,
});
const decrypted = sends[0];
if (decrypted) setDecryptedSends((current) => upsertById(current, decrypted));
} catch (error) {
if ((error as { status?: number }).status === 404) {
deleteSendLocally(id);
return;
}
console.warn('Failed to upsert send from notification:', error);
}
}
useEffect(() => { useEffect(() => {
if (IS_DEMO_MODE) return; if (IS_DEMO_MODE) return;
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return; if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return;
@@ -1403,7 +1604,18 @@ export default function App() {
const frames = parseSignalRTextFrames(event.data); const frames = parseSignalRTextFrames(event.data);
for (const frame of frames) { for (const frame of frames) {
if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue; if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue;
const updateType = Number(frame.arguments?.[0]?.Type || 0); const message = frame.arguments?.[0] as Record<string, unknown> | undefined;
const updateType = Number(message?.Type || 0);
const contextId = String(message?.ContextId || '').trim();
const payload = message?.Payload;
const payloadRecord = payload && typeof payload === 'object' ? payload as Record<string, unknown> : null;
const resourceId = String(payloadRecord?.Id || payloadRecord?.id || '').trim();
const revisionStamp = revisionStampFromIso(
payloadRecord?.RevisionDate
|| payloadRecord?.revisionDate
|| message?.Date
|| message?.date
);
if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) { if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) {
logoutNow(); logoutNow();
return; return;
@@ -1412,21 +1624,49 @@ export default function App() {
void refreshAuthorizedDevicesRef.current(); void refreshAuthorizedDevicesRef.current();
continue; continue;
} }
if (updateType === SIGNALR_UPDATE_TYPE_AUTH_REQUEST || updateType === SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE) {
void refreshPendingAuthRequestsRef.current();
continue;
}
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) { if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
const payload = frame.arguments?.[0]?.Payload;
if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload); if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
continue; continue;
} }
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
if (contextId && contextId === getCurrentDeviceIdentifier()) continue; if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
if (notificationRefreshTimerRef.current !== null) { if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHERS || updateType === SIGNALR_UPDATE_TYPE_SYNC_VAULT) {
window.clearTimeout(notificationRefreshTimerRef.current); if (notificationRefreshTimerRef.current !== null) {
window.clearTimeout(notificationRefreshTimerRef.current);
}
notificationRefreshTimerRef.current = window.setTimeout(() => {
notificationRefreshTimerRef.current = null;
void silentRefreshVaultRef.current();
}, 250);
continue;
}
if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE) && resourceId) {
void upsertCipherFromNotification(resourceId, revisionStamp);
continue;
}
if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE && resourceId) {
deleteCipherLocally(resourceId, revisionStamp);
continue;
}
if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE) && resourceId) {
void upsertFolderFromNotification(resourceId, revisionStamp);
continue;
}
if (updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE && resourceId) {
deleteFolderLocally(resourceId, revisionStamp);
continue;
}
if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE) && resourceId) {
void upsertSendFromNotification(resourceId, revisionStamp);
continue;
}
if (updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE && resourceId) {
deleteSendLocally(resourceId, revisionStamp);
continue;
} }
notificationRefreshTimerRef.current = window.setTimeout(() => {
notificationRefreshTimerRef.current = null;
void silentRefreshVaultRef.current();
}, 250);
} }
}); });
@@ -1485,8 +1725,33 @@ export default function App() {
}, },
refetchSends: refetchSendsFromVaultCore, refetchSends: refetchSendsFromVaultCore,
onNotify: pushToast, onNotify: pushToast,
patchEncryptedCiphers: (updater) => {
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
ciphers: updater(snapshot.ciphers),
}));
},
patchEncryptedFolders: (updater) => {
patchVaultCoreSnapshot((snapshot) => ({
...snapshot,
folders: updater(snapshot.folders),
}));
},
patchEncryptedSends: (updater) => {
let nextSends: Send[] = [];
patchVaultCoreSnapshot((snapshot) => {
nextSends = updater(snapshot.sends);
return {
...snapshot,
sends: nextSends,
};
});
queryClient.setQueryData(sendsQueryKey, nextSends);
},
patchDecryptedCiphers: setDecryptedCiphers, patchDecryptedCiphers: setDecryptedCiphers,
patchDecryptedFolders: setDecryptedFolders, patchDecryptedFolders: setDecryptedFolders,
patchDecryptedSends: setDecryptedSends,
refreshVaultRevisionStamp: refreshVaultCoreRevisionStamp,
}); });
const accountSecurityActions = useAccountSecurityActions({ const accountSecurityActions = useAccountSecurityActions({
authedFetch, authedFetch,
@@ -1517,6 +1782,11 @@ export default function App() {
if (!vaultInitialDecryptDone) return; if (!vaultInitialDecryptDone) return;
await authorizedDevicesQuery.refetch(); await authorizedDevicesQuery.refetch();
}; };
refreshPendingAuthRequestsRef.current = async () => {
if (!vaultInitialDecryptDone || !(profile?.email || session?.email)) return;
setAuthRequestDialogDismissedId(null);
await pendingAuthRequestsQuery.refetch();
};
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : ''; const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw; const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
@@ -1535,7 +1805,7 @@ export default function App() {
const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute; const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute;
const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation)); const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation));
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation); const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends'); const showSidebarToggle = mobileLayout && location === '/sends';
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type'); const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
const demoDomainRules = useMemo<DomainRules>(() => ({ const demoDomainRules = useMemo<DomainRules>(() => ({
equivalentDomains: [ equivalentDomains: [
+5 -1
View File
@@ -83,6 +83,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
const [present, setPresent] = useState(props.open); const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const cardRef = useRef<HTMLFormElement | null>(null); const cardRef = useRef<HTMLFormElement | null>(null);
const maskPointerStartedRef = useRef(false);
const restoreFocusRef = useRef<HTMLElement | null>(null); const restoreFocusRef = useRef<HTMLElement | null>(null);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []); const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`; const titleId = `${dialogId}-title`;
@@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
return createPortal(( return createPortal((
<div <div
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`} className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
onPointerDown={(event) => {
maskPointerStartedRef.current = event.target === event.currentTarget;
}}
onClick={(event) => { onClick={(event) => {
if (event.target !== event.currentTarget || !canDismiss) return; if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
props.onCancel(); props.onCancel();
}} }}
> >
+1 -6
View File
@@ -23,7 +23,6 @@ export default function NetworkStatusBadge() {
const Icon = status === 'online' ? Wifi : WifiOff; const Icon = status === 'online' ? Wifi : WifiOff;
useEffect(() => { useEffect(() => {
let cancelled = false;
let timer = 0; let timer = 0;
const checkService = async () => { const checkService = async () => {
@@ -31,10 +30,7 @@ export default function NetworkStatusBadge() {
setCurrentNetworkStatus('offline'); setCurrentNetworkStatus('offline');
return; return;
} }
const reachable = await probeNodeWardenService(); await probeNodeWardenService();
if (!cancelled) {
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
}
}; };
const scheduleNextCheck = () => { const scheduleNextCheck = () => {
@@ -62,7 +58,6 @@ export default function NetworkStatusBadge() {
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
return () => { return () => {
cancelled = true;
unsubscribe(); unsubscribe();
window.clearTimeout(timer); window.clearTimeout(timer);
window.removeEventListener('online', handleOnline); window.removeEventListener('online', handleOnline);
+1 -1
View File
@@ -7,7 +7,7 @@ interface ThemeSwitchProps {
export default function ThemeSwitch(props: ThemeSwitchProps) { export default function ThemeSwitch(props: ThemeSwitchProps) {
return ( return (
<div className="theme-switch-wrap" title={props.title}> <div className="theme-switch-wrap" title={props.title}>
<label className="theme-switch" aria-label={props.title}> <label className={`theme-switch ${props.checked ? 'checked' : 'unchecked'}`} aria-label={props.title}>
<span className="sun" aria-hidden="true"> <span className="sun" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g fill="#ffd43b"> <g fill="#ffd43b">
+4 -2
View File
@@ -12,8 +12,10 @@ export default function ToastHost({ toasts, onClose }: ToastHostProps) {
{toasts.map((toast) => ( {toasts.map((toast) => (
<li key={toast.id} className={`toast-item ${toast.type}`}> <li key={toast.id} className={`toast-item ${toast.type}`}>
<div className="toast-text">{toast.text}</div> <div className="toast-text">{toast.text}</div>
<button type="button" className="toast-close" onClick={() => onClose(toast.id)}> <button type="button" className="toast-close" onClick={() => onClose(toast.id)} aria-label="关闭通知">
x <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M3 3l8 8M11 3l-8 8" />
</svg>
</button> </button>
<div className="toast-progress" /> <div className="toast-progress" />
</li> </li>
+1 -8
View File
@@ -6,7 +6,7 @@ import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import WebsiteIcon from '@/components/vault/WebsiteIcon'; import WebsiteIcon from '@/components/vault/WebsiteIcon';
import { isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers'; import { formatTotp, isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
interface TotpCodesPageProps { interface TotpCodesPageProps {
ciphers: Cipher[]; ciphers: Cipher[];
@@ -26,13 +26,6 @@ function getTotpTimeState(): { windowId: number; remain: number } {
}; };
} }
function formatTotp(code: string): string {
if (!code) return code;
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
if (code.length < 6) return code;
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
}
function TotpListIcon({ cipher }: { cipher: Cipher }) { function TotpListIcon({ cipher }: { cipher: Cipher }) {
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />; return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
} }
+52 -11
View File
@@ -17,13 +17,14 @@ import {
createEmptyDraft, createEmptyDraft,
creationTimeValue, creationTimeValue,
draftFromCipher, draftFromCipher,
buildCipherDuplicateSignature, buildCipherDuplicateSignatures,
firstCipherUri, firstCipherUri,
firstPasskeyCreationTime, firstPasskeyCreationTime,
isCipherVisibleInArchive, isCipherVisibleInArchive,
isCipherVisibleInNormalVault, isCipherVisibleInNormalVault,
isCipherVisibleInTrash, isCipherVisibleInTrash,
sortTimeValue, sortTimeValue,
type DuplicateDetectionMode,
type SidebarFilter, type SidebarFilter,
type VaultSortMode, type VaultSortMode,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
@@ -79,6 +80,7 @@ export default function VaultPage(props: VaultPageProps) {
const [sortMenuOpen, setSortMenuOpen] = useState(false); const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name'); const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false); const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
const [duplicateMode, setDuplicateMode] = useState<DuplicateDetectionMode>('exact');
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>>({});
@@ -338,16 +340,41 @@ export default function VaultPage(props: VaultPageProps) {
const duplicateSignatureInfo = useMemo(() => { const duplicateSignatureInfo = useMemo(() => {
if (sidebarFilter.kind !== 'duplicates') return null; if (sidebarFilter.kind !== 'duplicates') return null;
const byId = new Map<string, string>(); const byId = new Map<string, string[]>();
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const cipher of props.ciphers) { for (const cipher of props.ciphers) {
if (!isCipherVisibleInNormalVault(cipher)) continue; if (!isCipherVisibleInNormalVault(cipher)) continue;
const signature = buildCipherDuplicateSignature(cipher); const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode)));
byId.set(cipher.id, signature); byId.set(cipher.id, signatures);
counts.set(signature, (counts.get(signature) || 0) + 1); for (const signature of signatures) {
counts.set(signature, (counts.get(signature) || 0) + 1);
}
} }
return { byId, counts }; return { byId, counts };
}, [props.ciphers, sidebarFilter.kind]); }, [props.ciphers, sidebarFilter.kind, duplicateMode]);
const duplicateGroupIndexById = useMemo(() => {
if (!duplicateSignatureInfo) return new Map<string, number>();
const groupKeyById = new Map<string, string>();
const groupKeys = new Set<string>();
for (const cipher of props.ciphers) {
const groupKey = (duplicateSignatureInfo.byId.get(cipher.id) || [])
.filter((signature) => (duplicateSignatureInfo.counts.get(signature) || 0) >= 2)
.sort()[0];
if (!groupKey) continue;
groupKeyById.set(cipher.id, groupKey);
groupKeys.add(groupKey);
}
const groupIndexByKey = new Map<string, number>();
Array.from(groupKeys).sort().forEach((groupKey, index) => {
groupIndexByKey.set(groupKey, index % 64);
});
const byId = new Map<string, number>();
for (const [cipherId, groupKey] of groupKeyById.entries()) {
byId.set(cipherId, groupIndexByKey.get(groupKey) || 0);
}
return byId;
}, [props.ciphers, duplicateSignatureInfo]);
const filteredCiphers = useMemo(() => { const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => { const next = props.ciphers.filter((cipher) => {
@@ -358,8 +385,11 @@ export default function VaultPage(props: VaultPageProps) {
if (!isCipherVisibleInArchive(cipher)) return false; if (!isCipherVisibleInArchive(cipher)) return false;
} else { } else {
if (!isCipherVisibleInNormalVault(cipher)) return false; if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) { if (sidebarFilter.kind === 'duplicates') {
return false; const signatures = duplicateSignatureInfo?.byId.get(cipher.id) || [];
if (!signatures.some((signature) => (duplicateSignatureInfo?.counts.get(signature) || 0) >= 2)) {
return false;
}
} }
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false; if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false; if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
@@ -404,8 +434,9 @@ export default function VaultPage(props: VaultPageProps) {
const sidebarFilterKey = useMemo(() => { const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`; if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`; if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`;
return sidebarFilter.kind; return sidebarFilter.kind;
}, [sidebarFilter]); }, [sidebarFilter, duplicateMode]);
useEffect(() => { useEffect(() => {
setListScrollTop(0); setListScrollTop(0);
@@ -419,6 +450,10 @@ export default function VaultPage(props: VaultPageProps) {
} }
}, [sidebarFilter.kind, sortMode]); }, [sidebarFilter.kind, sortMode]);
useEffect(() => {
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
}, [sidebarFilter.kind, duplicateMode]);
useEffect(() => { useEffect(() => {
if (isCreating) return; if (isCreating) return;
if (!filteredCiphers.length) { if (!filteredCiphers.length) {
@@ -984,10 +1019,11 @@ const folderName = useCallback((id: string | null | undefined): string => {
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]); const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []); const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
const handleSelectDuplicates = useCallback(() => { const handleSelectDuplicates = useCallback(() => {
if (duplicateMode !== 'exact') return;
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
const seen = new Set<string>(); const seen = new Set<string>();
for (const cipher of filteredCiphers) { for (const cipher of filteredCiphers) {
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher); const signature = duplicateSignatureInfo?.byId.get(cipher.id)?.[0] || buildCipherDuplicateSignatures(cipher, 'exact')[0];
if (seen.has(signature)) { if (seen.has(signature)) {
map[cipher.id] = true; map[cipher.id] = true;
continue; continue;
@@ -995,7 +1031,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
seen.add(signature); seen.add(signature);
} }
setSelectedMap(map); setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo]); }, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
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;
@@ -1079,13 +1115,16 @@ const folderName = useCallback((id: string | null | undefined): string => {
busy={busy} busy={busy}
loading={props.loading} loading={props.loading}
error={props.error} error={props.error}
folders={props.folders}
searchInput={searchInput} searchInput={searchInput}
sortMode={sortMode} sortMode={sortMode}
sortMenuOpen={sortMenuOpen} sortMenuOpen={sortMenuOpen}
duplicateMode={duplicateMode}
selectedCount={selectedCount} selectedCount={selectedCount}
totalCipherCount={totalCipherCount} totalCipherCount={totalCipherCount}
filteredCiphers={filteredCiphers} filteredCiphers={filteredCiphers}
visibleCiphers={visibleCiphers} visibleCiphers={visibleCiphers}
duplicateGroupIndexById={duplicateGroupIndexById}
virtualRange={virtualRange} virtualRange={virtualRange}
selectedCipherId={selectedCipherId} selectedCipherId={selectedCipherId}
selectedMap={selectedMap} selectedMap={selectedMap}
@@ -1102,6 +1141,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
onSearchCompositionEnd={handleSearchCompositionEnd} onSearchCompositionEnd={handleSearchCompositionEnd}
onToggleSortMenu={handleToggleSortMenu} onToggleSortMenu={handleToggleSortMenu}
onSelectSortMode={handleSelectSortMode} onSelectSortMode={handleSelectSortMode}
onDuplicateModeChange={setDuplicateMode}
onChangeFilter={setSidebarFilter}
onSyncVault={handleSyncVault} onSyncVault={handleSyncVault}
onOpenBulkDelete={handleOpenBulkDelete} onOpenBulkDelete={handleOpenBulkDelete}
onSelectDuplicates={handleSelectDuplicates} onSelectDuplicates={handleSelectDuplicates}
@@ -2,6 +2,7 @@ import { CloudUpload, Save, Trash2 } from 'lucide-preact';
import type { import type {
BackupDestinationRecord, BackupDestinationRecord,
RemoteBackupBrowserResponse, RemoteBackupBrowserResponse,
S3BackupAddressingStyle,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '@/lib/api/backup'; } from '@/lib/api/backup';
@@ -401,7 +402,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
{props.selectedDestination.type === 's3' ? ( {props.selectedDestination.type === 's3' ? (
<div className="field-grid"> <div className="field-grid">
<label className="field field-span-2"> <label className="field">
<span>{t('txt_backup_s3_endpoint')}</span> <span>{t('txt_backup_s3_endpoint')}</span>
<input <input
className="input" className="input"
@@ -417,6 +418,24 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
}))} }))}
/> />
</label> </label>
<label className="field">
<span>{t('txt_backup_s3_addressing_style')}</span>
<select
className="input"
value={(props.selectedDestination.destination as S3BackupDestination).addressingStyle || 'path-style'}
disabled={props.loadingSettings || props.disableWhileBusy}
onChange={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as S3BackupDestination),
addressingStyle: (event.currentTarget as HTMLSelectElement).value as S3BackupAddressingStyle,
},
}))}
>
<option value="path-style">{t('txt_backup_s3_addressing_path_style')}</option>
<option value="virtual-hosted-style">{t('txt_backup_s3_addressing_virtual_hosted_style')}</option>
</select>
</label>
<label className="field"> <label className="field">
<span>{t('txt_backup_s3_bucket')}</span> <span>{t('txt_backup_s3_bucket')}</span>
<input <input
+271 -105
View File
@@ -1,15 +1,40 @@
import type { RefObject } from 'preact'; import type { ComponentChildren, RefObject } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact'; import {
Archive,
ArrowUpDown,
Check,
CheckCheck,
ChevronDown,
Copy,
CreditCard,
Folder as FolderIcon,
FolderInput,
FolderX,
Globe,
KeyRound,
LayoutGrid,
Plus,
RefreshCw,
RotateCcw,
ShieldUser,
Star,
StickyNote,
Trash2,
X,
} from 'lucide-preact';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import type { Cipher } from '@/lib/types'; import type { Cipher, Folder } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
CreateTypeIcon, CreateTypeIcon,
getCreateTypeOptions, getCreateTypeOptions,
getDuplicateDetectionOptions,
getVaultSortOptions, getVaultSortOptions,
VaultListIcon, VaultListIcon,
type DuplicateDetectionMode,
type SidebarFilter, type SidebarFilter,
type VaultSortMode, type VaultSortMode,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
@@ -25,13 +50,16 @@ interface VaultListPanelProps {
busy: boolean; busy: boolean;
loading: boolean; loading: boolean;
error: string; error: string;
folders: Folder[];
searchInput: string; searchInput: string;
sortMode: VaultSortMode; sortMode: VaultSortMode;
sortMenuOpen: boolean; sortMenuOpen: boolean;
duplicateMode: DuplicateDetectionMode;
selectedCount: number; selectedCount: number;
totalCipherCount: number; totalCipherCount: number;
filteredCiphers: Cipher[]; filteredCiphers: Cipher[];
visibleCiphers: Cipher[]; visibleCiphers: Cipher[];
duplicateGroupIndexById: Map<string, number>;
virtualRange: VirtualRange; virtualRange: VirtualRange;
selectedCipherId: string; selectedCipherId: string;
selectedMap: Record<string, boolean>; selectedMap: Record<string, boolean>;
@@ -48,6 +76,8 @@ interface VaultListPanelProps {
onSearchCompositionEnd: (value: string) => void; onSearchCompositionEnd: (value: string) => void;
onToggleSortMenu: () => void; onToggleSortMenu: () => void;
onSelectSortMode: (value: VaultSortMode) => void; onSelectSortMode: (value: VaultSortMode) => void;
onDuplicateModeChange: (value: DuplicateDetectionMode) => void;
onChangeFilter: (filter: SidebarFilter) => void;
onSyncVault: () => void; onSyncVault: () => void;
onOpenBulkDelete: () => void; onOpenBulkDelete: () => void;
onSelectDuplicates: () => void; onSelectDuplicates: () => void;
@@ -69,15 +99,28 @@ interface CipherListItemProps {
cipher: Cipher; cipher: Cipher;
selected: boolean; selected: boolean;
checked: boolean; checked: boolean;
duplicateGroupIndex: number | null;
subtitle: string; subtitle: string;
onToggleSelected: (cipherId: string, checked: boolean) => void; onToggleSelected: (cipherId: string, checked: boolean) => void;
onSelectCipher: (cipherId: string) => void; onSelectCipher: (cipherId: string) => void;
} }
type MobileFilterMenuKey = 'duplicate' | 'menu' | 'type' | 'folder';
interface MobileFilterOption {
value: string;
label: string;
icon: ComponentChildren;
active: boolean;
onSelect: () => void;
}
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) { const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
const duplicateGroupHue = props.duplicateGroupIndex === null ? null : (props.duplicateGroupIndex * 137.508) % 360;
return ( return (
<div <div
className={`list-item ${props.selected ? 'active' : ''}`} className={`list-item ${props.selected ? 'active' : ''} ${duplicateGroupHue === null ? '' : 'duplicate-group-item'}`}
style={duplicateGroupHue === null ? undefined : { '--duplicate-group-hue': `${duplicateGroupHue}deg` }}
onClick={(event) => { onClick={(event) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.closest('.row-check')) return; if (target.closest('.row-check')) return;
@@ -107,13 +150,116 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
}); });
export default function VaultListPanel(props: VaultListPanelProps) { export default function VaultListPanel(props: VaultListPanelProps) {
const [mobileFilterOpen, setMobileFilterOpen] = useState<MobileFilterMenuKey | null>(null);
const mobileFilterRef = useRef<HTMLDivElement | null>(null);
const createTypeOptions = getCreateTypeOptions(); const createTypeOptions = getCreateTypeOptions();
const duplicateDetectionOptions = getDuplicateDetectionOptions();
const vaultSortOptions = getVaultSortOptions(); const vaultSortOptions = getVaultSortOptions();
const createMenu = ( const duplicateModeOptions: MobileFilterOption[] = duplicateDetectionOptions.map((option) => ({
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}> value: option.value,
label: option.label,
icon: option.value === 'login-site' ? <Globe size={14} /> : option.value === 'exact' ? <Copy size={14} /> : <KeyRound size={14} />,
active: props.duplicateMode === option.value,
onSelect: () => props.onDuplicateModeChange(option.value),
}));
const menuFilterOptions: MobileFilterOption[] = [
{ value: 'all', label: t('txt_all_items'), icon: <LayoutGrid size={14} />, active: props.sidebarFilter.kind === 'all', onSelect: () => props.onChangeFilter({ kind: 'all' }) },
{ value: 'favorite', label: t('txt_favorites'), icon: <Star size={14} />, active: props.sidebarFilter.kind === 'favorite', onSelect: () => props.onChangeFilter({ kind: 'favorite' }) },
{ value: 'archive', label: t('txt_archive'), icon: <Archive size={14} />, active: props.sidebarFilter.kind === 'archive', onSelect: () => props.onChangeFilter({ kind: 'archive' }) },
{ value: 'trash', label: t('txt_trash'), icon: <Trash2 size={14} />, active: props.sidebarFilter.kind === 'trash', onSelect: () => props.onChangeFilter({ kind: 'trash' }) },
{ value: 'duplicates', label: t('txt_duplicates'), icon: <Copy size={14} />, active: props.sidebarFilter.kind === 'duplicates', onSelect: () => props.onChangeFilter({ kind: 'duplicates' }) },
];
const typeMobileFilterOptions: MobileFilterOption[] = [
{ value: 'login', label: t('txt_login'), icon: <Globe size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'login', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'login' }) },
{ value: 'card', label: t('txt_card'), icon: <CreditCard size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'card' }) },
{ value: 'identity', label: t('txt_identity'), icon: <ShieldUser size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'identity' }) },
{ value: 'note', label: t('txt_note'), icon: <StickyNote size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'note' }) },
{ value: 'ssh', label: t('txt_ssh_key'), icon: <KeyRound size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'ssh', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'ssh' }) },
];
const folderMobileFilterOptions: MobileFilterOption[] = [
{ value: '__none__', label: t('txt_no_folder'), icon: <FolderX size={14} />, active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null, onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: null }) },
...props.folders.map((folder) => ({
value: folder.id,
label: folder.decName || folder.name || folder.id,
icon: <FolderIcon size={14} />,
active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === folder.id,
onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: folder.id }),
})),
];
const menuFilterSelected = menuFilterOptions.find((option) => option.active);
const typeFilterSelected = typeMobileFilterOptions.find((option) => option.active);
const folderFilterSelected = folderMobileFilterOptions.find((option) => option.active);
const duplicateModeSelected = duplicateModeOptions.find((option) => option.active);
useEffect(() => {
const onPointerDown = (event: Event) => {
if (!mobileFilterOpen) return;
const target = event.target as Node | null;
if (mobileFilterRef.current && target && !mobileFilterRef.current.contains(target)) {
setMobileFilterOpen(null);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMobileFilterOpen(null);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [mobileFilterOpen]);
const renderMobileFilterMenu = (
key: MobileFilterMenuKey,
label: string,
selected: MobileFilterOption | undefined,
fallbackIcon: ComponentChildren,
options: MobileFilterOption[]
) => (
<div className="mobile-vault-filter-control">
<button <button
type="button" type="button"
className="btn btn-primary small mobile-fab-trigger" className={`mobile-vault-filter-trigger ${mobileFilterOpen === key ? 'active' : ''}`}
aria-haspopup="menu"
aria-expanded={mobileFilterOpen === key}
onClick={() => setMobileFilterOpen((open) => open === key ? null : key)}
>
<span className="mobile-vault-filter-trigger-icon">{selected?.icon || fallbackIcon}</span>
<span className="mobile-vault-filter-trigger-label">{selected?.label || label}</span>
<ChevronDown size={13} className="mobile-vault-filter-chevron" />
</button>
{mobileFilterOpen === key && (
<div className="sort-menu mobile-vault-filter-menu" role="menu">
{options.map((option) => (
<button
key={option.value}
type="button"
className={`sort-menu-item mobile-vault-filter-item ${option.active ? 'active' : ''}`}
onClick={() => {
option.onSelect();
setMobileFilterOpen(null);
}}
role="menuitemradio"
aria-checked={option.active}
>
<span className="mobile-vault-filter-item-main">
<span className="mobile-vault-filter-item-icon">{option.icon}</span>
<span>{option.label}</span>
</span>
{option.active ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
</button>
))}
</div>
)}
</div>
);
const createMenu = (
<div className={`create-menu-wrap ${props.isMobileLayout ? 'mobile-fab-wrap' : 'desktop-create-menu-wrap'}`} ref={props.createMenuRef}>
<button
type="button"
className={`btn btn-primary small ${props.isMobileLayout ? 'mobile-fab-trigger' : 'desktop-create-trigger'}`}
aria-label={t('txt_add')} aria-label={t('txt_add')}
title={t('txt_add')} title={t('txt_add')}
onClick={props.onToggleCreateMenu} onClick={props.onToggleCreateMenu}
@@ -135,108 +281,127 @@ export default function VaultListPanel(props: VaultListPanelProps) {
return ( return (
<section className="list-col"> <section className="list-col">
<div className="list-head"> <div className="list-toolbar-stack" ref={mobileFilterRef}>
<div className="search-input-wrap"> <div className={`list-head ${props.selectedCount > 0 ? 'selection-mode' : ''}`}>
<input {props.selectedCount > 0 ? (
className="search-input" <>
placeholder={t('txt_search_your_secure_vault')} {props.sidebarFilter.kind !== 'duplicates' && (
value={props.searchInput} <button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)} <CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !props.searchInput) return;
e.preventDefault();
props.onClearSearch();
}}
/>
{!!props.searchInput && (
<button
type="button"
className="search-clear-btn"
aria-label={t('txt_clear_search')}
title={t('txt_clear_search_esc')}
onClick={props.onClearSearch}
>
<X size={14} />
</button>
)}
</div>
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
<button
type="button"
className={`btn btn-secondary small sort-trigger ${props.sortMenuOpen ? 'active' : ''}`}
aria-label={t('txt_sort')}
title={t('txt_sort')}
onClick={props.onToggleSortMenu}
>
<ArrowUpDown size={14} className="btn-icon" />
</button>
{props.sortMenuOpen && (
<div className="sort-menu">
{vaultSortOptions.map((option) => (
<button
key={option.value}
type="button"
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
onClick={() => props.onSelectSortMode(option.value)}
>
<span>{option.label}</span>
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
</button> </button>
))} )}
</div> <button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
{props.sidebarFilter.kind === 'trash' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
</button>
)}
{props.sidebarFilter.kind === 'archive' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
)}
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
</button>
)}
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
</button>
)}
<button type="button" className="btn btn-danger small" disabled={props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button>
</>
) : (
<>
<div className="search-input-wrap">
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
<div className="duplicate-mode-head-menu">
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
</div>
) : (
<>
<input
className="search-input"
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !props.searchInput) return;
e.preventDefault();
props.onClearSearch();
}}
/>
{!!props.searchInput && (
<button
type="button"
className="search-clear-btn"
aria-label={t('txt_clear_search')}
title={t('txt_clear_search_esc')}
onClick={props.onClearSearch}
>
<X size={14} />
</button>
)}
</>
)}
</div>
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
<div className="duplicate-mode-head-menu">
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
</div>
)}
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
<button
type="button"
className={`btn btn-secondary small sort-trigger sort-trigger-labeled ${props.sortMenuOpen ? 'active' : ''}`}
aria-label={t('txt_sort')}
title={t('txt_sort')}
onClick={props.onToggleSortMenu}
>
<ArrowUpDown size={14} className="btn-icon" /> <span>{t('txt_sort')}</span>
</button>
{props.sortMenuOpen && (
<div className="sort-menu">
{vaultSortOptions.map((option) => (
<button
key={option.value}
type="button"
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
onClick={() => props.onSelectSortMode(option.value)}
>
<span>{option.label}</span>
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
</button>
))}
</div>
)}
</div>
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
</button>
{!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu}
</>
)} )}
</div> </div>
<div className="list-count" title={t('txt_total_items_count', { count: props.totalCipherCount })}> {props.isMobileLayout && (
{t('txt_total_items_count', { count: props.totalCipherCount })} <div className="mobile-vault-filter-row" aria-label={t('txt_filter')}>
</div> {renderMobileFilterMenu('menu', t('txt_menu'), menuFilterSelected, <LayoutGrid size={14} />, menuFilterOptions)}
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}> {renderMobileFilterMenu('type', t('txt_type'), typeFilterSelected, <Globe size={14} />, typeMobileFilterOptions)}
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')} {renderMobileFilterMenu('folder', t('txt_folder'), folderFilterSelected, <FolderIcon size={14} />, folderMobileFilterOptions)}
</button> </div>
)}
</div> </div>
<div className="toolbar actions"> {!props.selectedCount && props.isMobileLayout && props.sidebarFilter.kind !== 'duplicates' && typeof document !== 'undefined' && props.mobileFabVisible
{props.sidebarFilter.kind === 'duplicates' && ( ? createPortal(createMenu, document.body)
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}> : null}
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
</button>
)}
{props.selectedCount > 0 && (
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
)}
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button>
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
</button>
{props.isMobileLayout && typeof document !== 'undefined'
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
: createMenu}
</div>
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> <div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />} {props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
{!props.loading && !!props.error && !props.filteredCiphers.length && ( {!props.loading && !!props.error && !props.filteredCiphers.length && (
@@ -255,6 +420,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
cipher={cipher} cipher={cipher}
selected={props.selectedCipherId === cipher.id} selected={props.selectedCipherId === cipher.id}
checked={!!props.selectedMap[cipher.id]} checked={!!props.selectedMap[cipher.id]}
duplicateGroupIndex={props.sidebarFilter.kind === 'duplicates' ? props.duplicateGroupIndexById.get(cipher.id) ?? null : null}
subtitle={props.listSubtitle(cipher)} subtitle={props.listSubtitle(cipher)}
onToggleSelected={props.onToggleSelected} onToggleSelected={props.onToggleSelected}
onSelectCipher={props.onSelectCipher} onSelectCipher={props.onSelectCipher}
@@ -10,10 +10,13 @@ import {
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types'; import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
import WebsiteIcon from './WebsiteIcon'; import WebsiteIcon from './WebsiteIcon';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
export type VaultSortMode = 'edited' | 'created' | 'name'; export type VaultSortMode = 'edited' | 'created' | 'name';
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
export type SidebarFilter = export type SidebarFilter =
| { kind: 'all' } | { kind: 'all' }
| { kind: 'favorite' } | { kind: 'favorite' }
@@ -126,6 +129,16 @@ export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1';
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)'; export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
export const VAULT_LIST_ROW_HEIGHT = 74; export const VAULT_LIST_ROW_HEIGHT = 74;
export const VAULT_LIST_OVERSCAN = 10; export const VAULT_LIST_OVERSCAN = 10;
export function getDuplicateDetectionOptions(): Array<{ value: DuplicateDetectionMode; label: string }> {
return [
{ value: 'exact', label: t('txt_duplicate_mode_exact') },
{ value: 'login-site', label: t('txt_duplicate_mode_login_site') },
{ value: 'login-credentials', label: t('txt_duplicate_mode_login_credentials') },
{ value: 'password', label: t('txt_duplicate_mode_password') },
];
}
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> { export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
return [ return [
{ value: 'edited', label: t('txt_sort_last_edited') }, { value: 'edited', label: t('txt_sort_last_edited') },
@@ -242,7 +255,7 @@ export function toBooleanFieldValue(raw: string): boolean {
return v === '1' || v === 'true' || v === 'yes' || v === 'on'; return v === '1' || v === 'true' || v === 'yes' || v === 'on';
} }
export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils'; export { firstCipherUri, hostFromUri, websiteIconUrl };
export function createEmptyLoginUri(): VaultDraftLoginUri { export function createEmptyLoginUri(): VaultDraftLoginUri {
return { uri: '', match: null, originalUri: '', extra: {} }; return { uri: '', match: null, originalUri: '', extra: {} };
@@ -257,6 +270,30 @@ function valueOrFallback(value: string | null | undefined): string {
return String(value || ''); return String(value || '');
} }
function duplicateLoginUsername(cipher: Cipher): string {
return valueOrFallback(cipher.login?.decUsername ?? cipher.login?.username).trim().toLowerCase();
}
function duplicateLoginPassword(cipher: Cipher): string {
return valueOrFallback(cipher.login?.decPassword ?? cipher.login?.password);
}
function duplicateLoginSites(cipher: Cipher): string[] {
const sites = new Set<string>();
for (const uri of cipher.login?.uris || []) {
const raw = valueOrFallback(uri.decUri ?? uri.uri).trim();
if (!raw) continue;
const host = hostFromUri(raw).trim().toLowerCase().replace(/^www\./, '');
const site = normalizeEquivalentDomain(raw) || host;
if (site) sites.add(site);
}
return Array.from(sites).sort();
}
function duplicateSignature(parts: string[]): string {
return JSON.stringify(parts);
}
export function buildCipherDuplicateSignature(cipher: Cipher): string { export function buildCipherDuplicateSignature(cipher: Cipher): string {
const normalized = { const normalized = {
type: Number(cipher.type || 1), type: Number(cipher.type || 1),
@@ -333,6 +370,23 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
return JSON.stringify(normalized); return JSON.stringify(normalized);
} }
export function buildCipherDuplicateSignatures(cipher: Cipher, mode: DuplicateDetectionMode): string[] {
if (mode === 'exact') return [buildCipherDuplicateSignature(cipher)];
if (Number(cipher.type || 1) !== 1 || !cipher.login) return [];
const username = duplicateLoginUsername(cipher);
const password = duplicateLoginPassword(cipher);
if (mode === 'password') {
return password ? [duplicateSignature(['password', password])] : [];
}
if (!username || !password) return [];
if (mode === 'login-credentials') {
return [duplicateSignature(['login-credentials', username, password])];
}
return duplicateLoginSites(cipher).map((site) => duplicateSignature(['login-site', site, username, password]));
}
export function createEmptyDraft(type: number): VaultDraft { export function createEmptyDraft(type: number): VaultDraft {
return { return {
type, type,
@@ -453,8 +507,9 @@ export function maskSecret(value: string): string {
export function formatTotp(code: string): string { export function formatTotp(code: string): string {
if (!code) return code; if (!code) return code;
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`; if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
if (code.length < 6) return code; if (code.length <= 4) return code;
return `${code.slice(0, 3)} ${code.slice(3, 6)}`; if (code.length === 8) return `${code.slice(0, 4)} ${code.slice(4)}`;
return code.replace(/(.{3})(?=.)/g, '$1 ');
} }
export function formatHistoryTime(value: string | null | undefined): string { export function formatHistoryTime(value: string | null | undefined): string {
+163 -58
View File
@@ -4,6 +4,7 @@ import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
import { import {
attachNodeWardenEncryptedAttachmentPayload, attachNodeWardenEncryptedAttachmentPayload,
buildAccountEncryptedBitwardenJsonString, buildAccountEncryptedBitwardenJsonString,
buildBitwardenCsvString,
buildBitwardenZipBytes, buildBitwardenZipBytes,
buildExportFileName, buildExportFileName,
buildNodeWardenAttachmentRecords, buildNodeWardenAttachmentRecords,
@@ -40,6 +41,7 @@ import {
downloadCipherAttachmentDecrypted, downloadCipherAttachmentDecrypted,
encryptFolderImportName, encryptFolderImportName,
getAttachmentDownloadInfo, getAttachmentDownloadInfo,
getCipherById,
importCiphers, importCiphers,
permanentDeleteCipher, permanentDeleteCipher,
type CiphersImportPayload, type CiphersImportPayload,
@@ -68,8 +70,13 @@ interface UseVaultSendActionsOptions {
refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>; refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>;
refetchSends: () => Promise<unknown>; refetchSends: () => Promise<unknown>;
onNotify: Notify; onNotify: Notify;
patchEncryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
patchEncryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void;
patchEncryptedSends: (updater: (prev: Send[]) => Send[]) => void;
patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void; patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void;
patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void; patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void;
patchDecryptedSends: (updater: (prev: Send[]) => Send[]) => void;
refreshVaultRevisionStamp: () => Promise<void>;
} }
function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) { function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) {
@@ -287,8 +294,13 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
refetchFolders, refetchFolders,
refetchSends, refetchSends,
onNotify, onNotify,
patchEncryptedCiphers,
patchEncryptedFolders,
patchEncryptedSends,
patchDecryptedCiphers, patchDecryptedCiphers,
patchDecryptedFolders, patchDecryptedFolders,
patchDecryptedSends,
refreshVaultRevisionStamp,
} = options; } = options;
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState(''); const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null); const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
@@ -307,21 +319,20 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
throw new Error(t('txt_offline_vault_readonly')); throw new Error(t('txt_offline_vault_readonly'));
}; };
const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => {
const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())];
if (options?.includeFolders) {
tasks.push(Promise.resolve(refetchFolders()));
}
void Promise.all(tasks).catch((err) => {
console.warn('Background vault sync failed:', err);
});
};
async function decryptAndPatch(encrypted: Cipher) { async function decryptAndPatch(encrypted: Cipher) {
if (!session?.symEncKey || !session?.symMacKey) { if (!session?.symEncKey || !session?.symMacKey) {
await refetchCiphers(); await refetchCiphers();
return; return;
} }
patchEncryptedCiphers((prev) => {
const idx = prev.findIndex((c) => c.id === encrypted.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = encrypted;
return next;
}
return [encrypted, ...prev];
});
const encKey = base64ToBytes(session.symEncKey); const encKey = base64ToBytes(session.symEncKey);
const macKey = base64ToBytes(session.symMacKey); const macKey = base64ToBytes(session.symMacKey);
const decrypted = await decryptSingleCipher(encrypted, encKey, macKey); const decrypted = await decryptSingleCipher(encrypted, encKey, macKey);
@@ -341,6 +352,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
await refetchCiphers(); await refetchCiphers();
return; return;
} }
patchEncryptedCiphers((prev) => [encrypted, ...prev.filter((cipher) => cipher.id !== optimisticId && cipher.id !== encrypted.id)]);
const encKey = base64ToBytes(session.symEncKey); const encKey = base64ToBytes(session.symEncKey);
const macKey = base64ToBytes(session.symMacKey); const macKey = base64ToBytes(session.symMacKey);
const decrypted = await decryptSingleCipher(encrypted, encKey, macKey); const decrypted = await decryptSingleCipher(encrypted, encKey, macKey);
@@ -351,31 +363,70 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
} }
function removeCipherFromState(id: string) { function removeCipherFromState(id: string) {
patchEncryptedCiphers((prev) => prev.filter((c) => c.id !== id));
patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id)); patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id));
} }
function patchCipherBatch(ids: string[], updater: (cipher: Cipher) => Cipher | null) { function patchCipherBatch(
ids: string[],
updater: (cipher: Cipher) => Cipher | null,
options?: { patchEncrypted?: boolean; patchDecrypted?: boolean }
) {
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)); const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
if (!idSet.size) return; if (!idSet.size) return;
patchDecryptedCiphers((prev) => { const shouldPatchEncrypted = options?.patchEncrypted !== false;
let changed = false; const shouldPatchDecrypted = options?.patchDecrypted !== false;
const next: Cipher[] = []; if (shouldPatchEncrypted) {
for (const cipher of prev) { patchEncryptedCiphers((prev) => {
if (!idSet.has(cipher.id)) { let changed = false;
next.push(cipher); const next: Cipher[] = [];
continue; for (const cipher of prev) {
if (!idSet.has(cipher.id)) {
next.push(cipher);
continue;
}
const updated = updater(cipher);
changed = true;
if (updated) next.push(updated);
} }
const updated = updater(cipher); return changed ? next : prev;
changed = true; });
if (updated) next.push(updated); }
} if (shouldPatchDecrypted) {
return changed ? next : prev; patchDecryptedCiphers((prev) => {
}); let changed = false;
const next: Cipher[] = [];
for (const cipher of prev) {
if (!idSet.has(cipher.id)) {
next.push(cipher);
continue;
}
const updated = updater(cipher);
changed = true;
if (updated) next.push(updated);
}
return changed ? next : prev;
});
}
} }
function patchFolderBatch(ids: string[], updater: (folder: VaultFolder) => VaultFolder | null) { function patchFolderBatch(ids: string[], updater: (folder: VaultFolder) => VaultFolder | null) {
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)); const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
if (!idSet.size) return; if (!idSet.size) return;
patchEncryptedFolders((prev) => {
let changed = false;
const next: VaultFolder[] = [];
for (const folder of prev) {
if (!idSet.has(folder.id)) {
next.push(folder);
continue;
}
const updated = updater(folder);
changed = true;
if (updated) next.push(updated);
}
return changed ? next : prev;
});
patchDecryptedFolders((prev) => { patchDecryptedFolders((prev) => {
let changed = false; let changed = false;
const next: VaultFolder[] = []; const next: VaultFolder[] = [];
@@ -392,6 +443,31 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
}); });
} }
function upsertEncryptedFolder(folder: VaultFolder) {
patchEncryptedFolders((prev) => {
const index = prev.findIndex((item) => item.id === folder.id);
if (index < 0) return [folder, ...prev];
const next = [...prev];
next[index] = folder;
return next;
});
}
function upsertSend(send: Send) {
patchEncryptedSends((prev) => {
const index = prev.findIndex((item) => item.id === send.id);
if (index < 0) return [send, ...prev];
const next = [...prev];
next[index] = send;
return next;
});
}
function removeSend(id: string) {
patchEncryptedSends((prev) => prev.filter((send) => send.id !== id));
patchDecryptedSends((prev) => prev.filter((send) => send.id !== id));
}
const uploadImportedAttachments = async ( const uploadImportedAttachments = async (
attachments: ImportAttachmentFile[], attachments: ImportAttachmentFile[],
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> } idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
@@ -467,8 +543,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
setAttachmentUploadPercent(0); setAttachmentUploadPercent(0);
await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent); await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent);
} }
await decryptAndReplaceOptimistic(optimistic.id, created); const finalCipher = attachments.length ? await getCipherById(authedFetch, created.id) : created;
syncVaultCoreInBackground({ includeFolders: !!draft.folderId || attachments.length > 0 }); await decryptAndReplaceOptimistic(optimistic.id, finalCipher);
void refreshVaultRevisionStamp();
onNotify('success', t('txt_item_created')); onNotify('success', t('txt_item_created'));
} catch (error) { } catch (error) {
patchDecryptedCiphers((prev) => prev.filter((cipher) => cipher.id !== optimistic.id)); patchDecryptedCiphers((prev) => prev.filter((cipher) => cipher.id !== optimistic.id));
@@ -510,7 +587,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
.filter((attachment) => !removedSet.has(String(attachment?.id || '').trim())) .filter((attachment) => !removedSet.has(String(attachment?.id || '').trim()))
.map((attachment) => ({ ...attachment })); .map((attachment) => ({ ...attachment }));
} }
patchCipherBatch([cipher.id], () => optimistic); patchCipherBatch([cipher.id], () => optimistic, { patchEncrypted: false });
try { try {
const updated = await updateCipher(authedFetch, session, cipher, draft); const updated = await updateCipher(authedFetch, session, cipher, draft);
for (const attachmentId of removeAttachmentIds) { for (const attachmentId of removeAttachmentIds) {
@@ -523,16 +600,14 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
setAttachmentUploadPercent(0); setAttachmentUploadPercent(0);
await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent); await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent);
} }
await decryptAndPatch(updated); const finalCipher = addFiles.length || removeAttachmentIds.length
syncVaultCoreInBackground({ ? await getCipherById(authedFetch, cipher.id)
includeFolders: : updated;
draft.folderId !== (cipher.folderId || '') await decryptAndPatch(finalCipher);
|| addFiles.length > 0 void refreshVaultRevisionStamp();
|| removeAttachmentIds.length > 0,
});
onNotify('success', t('txt_item_updated')); onNotify('success', t('txt_item_updated'));
} catch (error) { } catch (error) {
patchCipherBatch([cipher.id], () => previousCipher); patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false });
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
throw error; throw error;
} finally { } finally {
@@ -571,7 +646,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
await permanentDeleteCipher(authedFetch, cipher.id); await permanentDeleteCipher(authedFetch, cipher.id);
patchCipherBatch([cipher.id], () => null); patchCipherBatch([cipher.id], () => null);
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_item_deleted_permanently')); onNotify('success', t('txt_item_deleted_permanently'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_permanent_delete_item_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_permanent_delete_item_failed'));
@@ -584,10 +659,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
const deleted = await deleteCipher(authedFetch, cipher.id); const deleted = await deleteCipher(authedFetch, cipher.id);
await decryptAndPatch(deleted); await decryptAndPatch(deleted);
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_item_deleted')); onNotify('success', t('txt_item_deleted'));
} catch (error) { } catch (error) {
patchCipherBatch([cipher.id], () => previousCipher); patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false });
onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
throw error; throw error;
} }
@@ -606,10 +681,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
const archived = await archiveCipher(authedFetch, cipher.id); const archived = await archiveCipher(authedFetch, cipher.id);
await decryptAndPatch(archived); await decryptAndPatch(archived);
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_item_archived')); onNotify('success', t('txt_item_archived'));
} catch (error) { } catch (error) {
patchCipherBatch([cipher.id], () => previousCipher); patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false });
onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed'));
throw error; throw error;
} }
@@ -628,10 +703,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
const unarchived = await unarchiveCipher(authedFetch, cipher.id); const unarchived = await unarchiveCipher(authedFetch, cipher.id);
await decryptAndPatch(unarchived); await decryptAndPatch(unarchived);
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_item_unarchived')); onNotify('success', t('txt_item_unarchived'));
} catch (error) { } catch (error) {
patchCipherBatch([cipher.id], () => previousCipher); patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false });
onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed'));
throw error; throw error;
} }
@@ -648,7 +723,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
await bulkDeleteCiphers(authedFetch, ids); await bulkDeleteCiphers(authedFetch, ids);
const deletedDate = new Date().toISOString(); const deletedDate = new Date().toISOString();
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate, archivedDate: null })); patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate, archivedDate: null }));
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_deleted_selected_items')); onNotify('success', t('txt_deleted_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed'));
@@ -667,7 +742,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
await bulkArchiveCiphers(authedFetch, ids); await bulkArchiveCiphers(authedFetch, ids);
const archivedDate = new Date().toISOString(); const archivedDate = new Date().toISOString();
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate, deletedDate: null })); patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate, deletedDate: null }));
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_archived_selected_items')); onNotify('success', t('txt_archived_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed'));
@@ -685,7 +760,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
await bulkUnarchiveCiphers(authedFetch, ids); await bulkUnarchiveCiphers(authedFetch, ids);
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null })); patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_unarchived_selected_items')); onNotify('success', t('txt_unarchived_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed'));
@@ -703,7 +778,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
await bulkMoveCiphers(authedFetch, ids, folderId); await bulkMoveCiphers(authedFetch, ids, folderId);
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId })); patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_moved_selected_items')); onNotify('success', t('txt_moved_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed'));
@@ -726,15 +801,18 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
if (!session) throw new Error(t('txt_vault_key_unavailable')); if (!session) throw new Error(t('txt_vault_key_unavailable'));
const created = await createFolder(authedFetch, session, folderName); const created = await createFolder(authedFetch, session, folderName);
upsertEncryptedFolder(created);
patchDecryptedFolders((prev) => [ patchDecryptedFolders((prev) => [
{ {
id: created.id, id: created.id,
name: created.name || folderName, name: created.name || folderName,
decName: folderName, decName: folderName,
revisionDate: created.revisionDate,
creationDate: created.creationDate,
}, },
...prev, ...prev,
]); ]);
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_folder_created')); onNotify('success', t('txt_folder_created'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed'));
@@ -757,8 +835,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
await deleteFolder(authedFetch, id); await deleteFolder(authedFetch, id);
patchFolderBatch([id], () => null); patchFolderBatch([id], () => null);
patchEncryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher)));
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher))); patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher)));
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_folder_deleted')); onNotify('success', t('txt_folder_deleted'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
@@ -785,9 +864,14 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
} }
try { try {
if (!session) throw new Error(t('txt_vault_key_unavailable')); if (!session) throw new Error(t('txt_vault_key_unavailable'));
await updateFolder(authedFetch, session, id, nextName); const updated = await updateFolder(authedFetch, session, id, nextName);
patchFolderBatch([id], (folder) => ({ ...folder, decName: nextName })); upsertEncryptedFolder(updated);
syncVaultCoreInBackground({ includeFolders: true }); patchDecryptedFolders((prev) => prev.map((folder) => (
folder.id === id
? { ...folder, name: updated.name || folder.name, decName: nextName, revisionDate: updated.revisionDate }
: folder
)));
void refreshVaultRevisionStamp();
onNotify('success', t('txt_folder_updated')); onNotify('success', t('txt_folder_updated'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
@@ -805,7 +889,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
await bulkRestoreCiphers(authedFetch, ids); await bulkRestoreCiphers(authedFetch, ids);
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null })); patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_restored_selected_items')); onNotify('success', t('txt_restored_selected_items'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed'));
@@ -823,7 +907,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
await bulkPermanentDeleteCiphers(authedFetch, ids); await bulkPermanentDeleteCiphers(authedFetch, ids);
patchCipherBatch(ids, () => null); patchCipherBatch(ids, () => null);
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_deleted_selected_items_permanently')); onNotify('success', t('txt_deleted_selected_items_permanently'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed'));
@@ -843,9 +927,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
try { try {
await bulkDeleteFolders(authedFetch, ids); await bulkDeleteFolders(authedFetch, ids);
const removedIds = new Set(ids); const removedIds = new Set(ids);
patchEncryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id)));
patchEncryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher)));
patchDecryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id))); patchDecryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id)));
patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher))); patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher)));
syncVaultCoreInBackground({ includeFolders: true }); void refreshVaultRevisionStamp();
onNotify('success', t('txt_folders_deleted')); onNotify('success', t('txt_folders_deleted'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed'));
@@ -873,7 +959,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
setSendUploadPercent(0); setSendUploadPercent(0);
} }
const created = await createSend(authedFetch, session, draft, fileName ? setSendUploadPercent : undefined); const created = await createSend(authedFetch, session, draft, fileName ? setSendUploadPercent : undefined);
await refetchSends(); upsertSend(created);
void refreshVaultRevisionStamp();
if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) { if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) {
const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey); const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey);
const shareUrl = buildPublicSendUrl(window.location.origin, created.accessId, keyPart); const shareUrl = buildPublicSendUrl(window.location.origin, created.accessId, keyPart);
@@ -899,7 +986,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
} }
try { try {
const updated = await updateSend(authedFetch, session, send, draft); const updated = await updateSend(authedFetch, session, send, draft);
await refetchSends(); upsertSend(updated);
void refreshVaultRevisionStamp();
if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) { if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) {
const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey); const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey);
const shareUrl = buildPublicSendUrl(window.location.origin, updated.accessId, keyPart); const shareUrl = buildPublicSendUrl(window.location.origin, updated.accessId, keyPart);
@@ -921,7 +1009,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
} }
try { try {
await deleteSend(authedFetch, send.id); await deleteSend(authedFetch, send.id);
await refetchSends(); removeSend(send.id);
void refreshVaultRevisionStamp();
onNotify('success', t('txt_send_deleted')); onNotify('success', t('txt_send_deleted'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_send_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_delete_send_failed'));
@@ -938,7 +1027,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
} }
try { try {
await bulkDeleteSends(authedFetch, ids); await bulkDeleteSends(authedFetch, ids);
await refetchSends(); const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
patchEncryptedSends((prev) => prev.filter((send) => !idSet.has(send.id)));
patchDecryptedSends((prev) => prev.filter((send) => !idSet.has(send.id)));
void refreshVaultRevisionStamp();
onNotify('success', t('txt_deleted_selected_sends')); onNotify('success', t('txt_deleted_selected_sends'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_sends_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_sends_failed'));
@@ -1190,6 +1282,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
mimeType: 'application/json', mimeType: 'application/json',
bytes: new TextEncoder().encode(await getPlainJson()), bytes: new TextEncoder().encode(await getPlainJson()),
}; };
} else if (format === 'bitwarden_csv') {
result = {
fileName: buildExportFileName(format),
mimeType: 'text/csv;charset=utf-8',
bytes: new TextEncoder().encode(buildBitwardenCsvString(await getPlainJsonDoc())),
};
} else if (format === 'bitwarden_encrypted_json') { } else if (format === 'bitwarden_encrypted_json') {
if (request.encryptedJsonMode === 'password') { if (request.encryptedJsonMode === 'password') {
const plainJson = await getPlainJson(); const plainJson = await getPlainJson();
@@ -1292,10 +1390,17 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
encryptedFolders, encryptedFolders,
importAuthedFetch, importAuthedFetch,
onNotify, onNotify,
patchDecryptedCiphers,
patchDecryptedFolders,
patchDecryptedSends,
patchEncryptedCiphers,
patchEncryptedFolders,
patchEncryptedSends,
profile, profile,
refetchCiphers, refetchCiphers,
refetchFolders, refetchFolders,
refetchSends, refetchSends,
refreshVaultRevisionStamp,
session, session,
sendUploadPercent, sendUploadPercent,
uploadingAttachmentName, uploadingAttachmentName,
+27 -6
View File
@@ -9,6 +9,7 @@ import type {
TokenSuccess, TokenSuccess,
} from '../types'; } from '../types';
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys'; import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
import { parseJson, type AuthedFetch, type SessionSetter } from './shared'; import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
const SESSION_KEY = 'nodewarden.web.session.v4'; const SESSION_KEY = 'nodewarden.web.session.v4';
@@ -474,6 +475,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
for (let attempt = 0; attempt < maxAttempts; attempt += 1) { for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try { try {
const response = await fetch(input, { ...init, headers }); const response = await fetch(input, { ...init, headers });
recordNodeWardenReachable();
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) { if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
return response; return response;
} }
@@ -484,6 +486,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
} catch (error) { } catch (error) {
lastError = error; lastError = error;
if (attempt === maxAttempts - 1) { if (attempt === maxAttempts - 1) {
recordNodeWardenUnreachable();
throw error; throw error;
} }
} }
@@ -497,7 +500,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly')); if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly'));
const headers = new Headers(init.headers || {}); const headers = new Headers(init.headers || {});
headers.set('Authorization', `Bearer ${session.accessToken}`); headers.set('Authorization', `Bearer ${session.accessToken}`);
headers.set('X-NodeWarden-Web', '1');
let resp = await retryableRequest(headers); let resp = await retryableRequest(headers);
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp; if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
@@ -506,7 +508,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
if (latest?.accessToken && latest.accessToken !== session.accessToken) { if (latest?.accessToken && latest.accessToken !== session.accessToken) {
const latestHeaders = new Headers(init.headers || {}); const latestHeaders = new Headers(init.headers || {});
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`); latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
latestHeaders.set('X-NodeWarden-Web', '1');
resp = await retryableRequest(latestHeaders); resp = await retryableRequest(latestHeaders);
if (resp.status !== 401) return resp; if (resp.status !== 401) return resp;
} }
@@ -532,7 +533,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
const retryHeaders = new Headers(init.headers || {}); const retryHeaders = new Headers(init.headers || {});
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`); retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
retryHeaders.set('X-NodeWarden-Web', '1');
resp = await retryableRequest(retryHeaders); resp = await retryableRequest(retryHeaders);
return resp; return resp;
}; };
@@ -596,14 +596,35 @@ export async function changeMasterPassword(
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32); const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32); const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac); const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac);
const newMasterPasswordHash = bytesToBase64(nextHash);
const resp = await authedFetch('/api/accounts/password', { const resp = await authedFetch('/api/accounts/password', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
currentPasswordHash: current.hash, masterPasswordHash: current.hash,
newMasterPasswordHash: bytesToBase64(nextHash), newMasterPasswordHash,
newKey, key: newKey,
authenticationData: {
kdf: {
kdfType: 0,
iterations: current.kdfIterations,
memory: null,
parallelism: null,
},
masterPasswordAuthenticationHash: newMasterPasswordHash,
salt: args.email.trim().toLowerCase(),
},
unlockData: {
kdf: {
kdfType: 0,
iterations: current.kdfIterations,
memory: null,
parallelism: null,
},
masterKeyWrappedUserKey: newKey,
salt: args.email.trim().toLowerCase(),
},
kdf: 0, kdf: 0,
kdfIterations: current.kdfIterations, kdfIterations: current.kdfIterations,
}), }),
+3
View File
@@ -6,6 +6,7 @@ import type {
BackupRuntimeState, BackupRuntimeState,
BackupScheduleConfig, BackupScheduleConfig,
BackupSettings as AdminBackupSettings, BackupSettings as AdminBackupSettings,
S3BackupAddressingStyle,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '@shared/backup-schema'; } from '@shared/backup-schema';
@@ -26,6 +27,7 @@ export type {
BackupRuntimeState, BackupRuntimeState,
BackupScheduleConfig, BackupScheduleConfig,
AdminBackupSettings, AdminBackupSettings,
S3BackupAddressingStyle,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
}; };
@@ -96,6 +98,7 @@ export interface AdminBackupImportCounts {
users: number; users: number;
domainSettings?: number; domainSettings?: number;
userRevisions: number; userRevisions: number;
trustedTwoFactorDeviceTokens?: number;
webauthnCredentials?: number; webauthnCredentials?: number;
folders: number; folders: number;
ciphers: number; ciphers: number;
+11
View File
@@ -67,6 +67,17 @@ export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
return body?.data || []; return body?.data || [];
} }
export async function getSendById(authedFetch: AuthedFetch, sendId: string): Promise<Send> {
const id = String(sendId || '').trim();
if (!id) throw new Error('Send id is required');
const resp = await authedFetch(`/api/sends/${encodeURIComponent(id)}`);
if (resp.status === 404) throw createApiError('Send not found', 404);
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Load send failed'));
const body = await parseJson<Send>(resp);
if (!body?.id) throw new Error('Load send failed');
return body;
}
export async function createSend( export async function createSend(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
session: SessionState, session: SessionState,
+23
View File
@@ -51,6 +51,29 @@ export async function invalidateVaultCoreSyncSnapshot(cacheKey: string): Promise
await clearCachedVaultCoreSnapshot(normalizedKey); await clearCachedVaultCoreSnapshot(normalizedKey);
} }
export async function saveVaultCoreSyncSnapshot(
cacheKey: string,
snapshot: VaultCoreSnapshot,
revisionStamp?: number | null
): Promise<void> {
const normalizedKey = String(cacheKey || '').trim();
if (!normalizedKey) return;
const normalizedSnapshot = normalizeCachedSnapshot(snapshot);
const currentMemory = memoryVaultCoreCache.get(normalizedKey);
let nextRevisionStamp = Number(revisionStamp);
if (!Number.isFinite(nextRevisionStamp) || nextRevisionStamp <= 0) {
const cached = await loadCachedVaultCoreSnapshot(normalizedKey);
nextRevisionStamp = currentMemory?.revisionStamp || cached?.revisionStamp || Date.now();
}
memoryVaultCoreCache.set(normalizedKey, {
revisionStamp: nextRevisionStamp,
snapshot: normalizedSnapshot,
});
await saveCachedVaultCoreSnapshot(normalizedKey, nextRevisionStamp, normalizedSnapshot);
}
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> { export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
const normalizedKey = String(cacheKey || '').trim(); const normalizedKey = String(cacheKey || '').trim();
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] }; if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };
+46 -10
View File
@@ -10,6 +10,7 @@ import type {
import { import {
BULK_API_CHUNK_SIZE, BULK_API_CHUNK_SIZE,
chunkArray, chunkArray,
createApiError,
parseErrorMessage, parseErrorMessage,
parseJson, parseJson,
uploadDirectEncryptedPayload, uploadDirectEncryptedPayload,
@@ -20,17 +21,29 @@ import { readResponseBytesWithProgress } from '../download';
import { loadVaultCoreSyncSnapshot } from './vault-sync'; import { loadVaultCoreSyncSnapshot } from './vault-sync';
type CipherLoginData = NonNullable<Cipher['login']>; type CipherLoginData = NonNullable<Cipher['login']>;
const NODEWARDEN_WEB_REPAIR_HEADER = 'X-NodeWarden-Web';
export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise<Folder[]> { export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise<Folder[]> {
const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey); const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
return body.folders || []; return body.folders || [];
} }
export async function getFolderById(authedFetch: AuthedFetch, folderId: string): Promise<Folder> {
const id = String(folderId || '').trim();
if (!id) throw new Error('Folder id is required');
const resp = await authedFetch(`/api/folders/${encodeURIComponent(id)}`);
if (resp.status === 404) throw createApiError('Folder not found', 404);
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Load folder failed'));
const body = await parseJson<Folder>(resp);
if (!body?.id) throw new Error('Load folder failed');
return body;
}
export async function createFolder( export async function createFolder(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
session: SessionState, session: SessionState,
name: string name: string
): Promise<{ id: string; name?: string | null }> { ): Promise<Folder> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const enc = base64ToBytes(session.symEncKey); const enc = base64ToBytes(session.symEncKey);
const mac = base64ToBytes(session.symMacKey); const mac = base64ToBytes(session.symMacKey);
@@ -41,9 +54,9 @@ export async function createFolder(
body: JSON.stringify({ name: encryptedName }), body: JSON.stringify({ name: encryptedName }),
}); });
if (!resp.ok) throw new Error('Create folder failed'); if (!resp.ok) throw new Error('Create folder failed');
const body = await parseJson<{ id?: string; name?: string | null }>(resp); const body = await parseJson<Folder>(resp);
if (!body?.id) throw new Error('Create folder failed'); if (!body?.id) throw new Error('Create folder failed');
return { id: body.id, name: body.name ?? null }; return body;
} }
export async function encryptFolderImportName(session: SessionState, name: string): Promise<string> { export async function encryptFolderImportName(session: SessionState, name: string): Promise<string> {
@@ -79,7 +92,7 @@ export async function updateFolder(
session: SessionState, session: SessionState,
folderId: string, folderId: string,
name: string name: string
): Promise<void> { ): Promise<Folder> {
const id = String(folderId || '').trim(); const id = String(folderId || '').trim();
if (!id) throw new Error('Folder id is required'); if (!id) throw new Error('Folder id is required');
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
@@ -92,6 +105,9 @@ export async function updateFolder(
body: JSON.stringify({ name: encryptedName }), body: JSON.stringify({ name: encryptedName }),
}); });
if (!resp.ok) throw new Error('Update folder failed'); if (!resp.ok) throw new Error('Update folder failed');
const body = await parseJson<Folder>(resp);
if (!body?.id) throw new Error('Update folder failed');
return body;
} }
export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Promise<Cipher[]> { export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Promise<Cipher[]> {
@@ -99,6 +115,17 @@ export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Pr
return body.ciphers || []; return body.ciphers || [];
} }
export async function getCipherById(authedFetch: AuthedFetch, cipherId: string): Promise<Cipher> {
const id = String(cipherId || '').trim();
if (!id) throw new Error('Cipher id is required');
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}`);
if (resp.status === 404) throw createApiError('Cipher not found', 404);
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Load cipher failed'));
const body = await parseJson<Cipher>(resp);
if (!body?.id) throw new Error('Load cipher failed');
return body;
}
export interface CiphersImportPayload { export interface CiphersImportPayload {
ciphers: Array<Record<string, unknown>>; ciphers: Array<Record<string, unknown>>;
folders: Array<{ name: string }>; folders: Array<{ name: string }>;
@@ -933,7 +960,7 @@ export async function repairCipherUriChecksums(
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', [NODEWARDEN_WEB_REPAIR_HEADER]: '1' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair URI checksum failed')); if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair URI checksum failed'));
@@ -1092,9 +1119,14 @@ export async function repairCipherKeyMismatches(
if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue; if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue;
if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue; if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue;
if (hasUnresolvedEncryptedFields(cipher)) continue; if (hasUnresolvedEncryptedFields(cipher)) continue;
await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher), { await updateCipher(
preserveRevisionDate: true, authedFetch,
}); session,
cipher,
draftFromDecryptedCipher(cipher),
{ preserveRevisionDate: true },
{ webRepair: true }
);
repaired += 1; repaired += 1;
} }
@@ -1229,7 +1261,8 @@ export async function updateCipher(
session: SessionState, session: SessionState,
cipher: Cipher, cipher: Cipher,
draft: VaultDraft, draft: VaultDraft,
extraPayload?: Record<string, unknown> extraPayload?: Record<string, unknown>,
options?: { webRepair?: boolean }
): Promise<Cipher> { ): Promise<Cipher> {
const payload = await buildCipherPayload(session, draft, cipher); const payload = await buildCipherPayload(session, draft, cipher);
if (extraPayload) { if (extraPayload) {
@@ -1238,7 +1271,10 @@ export async function updateCipher(
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...(options?.webRepair ? { [NODEWARDEN_WEB_REPAIR_HEADER]: '1' } : {}),
},
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!resp.ok) throw new Error('Update item failed'); if (!resp.ok) throw new Error('Update item failed');
+8 -18
View File
@@ -279,20 +279,16 @@ export async function hydrateLockedSession(
fallbackProfile: Profile | null = null fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> { ): Promise<{ session: SessionState | null; profile: Profile | null }> {
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email); const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
let serviceReachable = true; if (hasOfflineUnlock && browserReportsOffline()) {
if (hasOfflineUnlock) { return {
serviceReachable = await probeNodeWardenService(); session,
if (!serviceReachable) { profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
return { };
session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
};
}
} }
const refreshedSession = await maybeRefreshSession(session); const refreshedSession = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) { if (!refreshedSession?.accessToken) {
if (hasOfflineUnlock && !serviceReachable) { if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
return { return {
session, session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email), profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
@@ -571,14 +567,8 @@ export async function performUnlock(
} }
}; };
if (hasOfflineUnlock) { if (hasOfflineUnlock && browserReportsOffline()) {
if (browserReportsOffline()) { return unlockOffline();
return unlockOffline();
}
const serviceReachable = await probeNodeWardenService();
if (!serviceReachable) {
return unlockOffline();
}
} }
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string }; let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
+31 -2
View File
@@ -220,6 +220,25 @@ function normalizeTotpSecret(secret: string): string {
return secret.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, ''); return secret.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
} }
function readOtpAuthParam(raw: string, name: string): string {
const queryStart = raw.indexOf('?');
if (queryStart < 0) return '';
const fragmentStart = raw.indexOf('#', queryStart + 1);
const query = raw.slice(queryStart + 1, fragmentStart > queryStart ? fragmentStart : undefined);
for (const part of query.split('&')) {
const eq = part.indexOf('=');
const key = eq >= 0 ? part.slice(0, eq) : part;
if (key.trim().toLowerCase() !== name.toLowerCase()) continue;
const value = eq >= 0 ? part.slice(eq + 1) : '';
try {
return decodeURIComponent(value.replace(/\+/g, ' '));
} catch {
return value;
}
}
return '';
}
function parseSteamSecret(raw: string): string { function parseSteamSecret(raw: string): string {
const match = raw.trim().match(/^steam:\/\/([^/?#]+)(?:[/?#].*)?$/i); const match = raw.trim().match(/^steam:\/\/([^/?#]+)(?:[/?#].*)?$/i);
if (!match?.[1]) return ''; if (!match?.[1]) return '';
@@ -276,7 +295,8 @@ function parseTotpConfig(raw: string): TotpConfig {
if (/^otpauth:\/\//i.test(s)) { if (/^otpauth:\/\//i.test(s)) {
try { try {
const u = new URL(s); const u = new URL(s);
if (u.hostname.toLowerCase() !== 'totp') { const otpType = u.hostname.toLowerCase();
if (otpType !== 'totp') {
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG }; return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
} }
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase(); const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
@@ -291,7 +311,16 @@ function parseTotpConfig(raw: string): TotpConfig {
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600), period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
}; };
} catch { } catch {
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG }; const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
const steam = issuer === 'steam' || algorithm === 'steam';
return {
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
steam,
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm),
digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
};
} }
} }
return { secret: normalizeTotpSecret(s), steam: false, ...DEFAULT_TOTP_CONFIG }; return { secret: normalizeTotpSecret(s), steam: false, ...DEFAULT_TOTP_CONFIG };
+128
View File
@@ -9,6 +9,7 @@ configureZipJs({ useWebWorkers: false });
export const EXPORT_FORMATS = [ export const EXPORT_FORMATS = [
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' }, { id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
{ id: 'bitwarden_csv', label: 'Bitwarden (vault as csv)' },
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' }, { id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' }, { id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' }, { id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
@@ -70,6 +71,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'; return !!value && typeof value === 'object';
} }
function csvText(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function escapeCsvCell(value: unknown): string {
const text = csvText(value);
if (!/[",\r\n]/.test(text)) return text;
return `"${text.replace(/"/g, '""')}"`;
}
function buildCsvString(rows: string[][]): string {
return `${rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n')}\r\n`;
}
function buildSingleRowCsvString(values: string[]): string {
return values.map(escapeCsvCell).join(',');
}
function isCipherString(value: string): boolean { function isCipherString(value: string): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
} }
@@ -383,6 +409,106 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
return JSON.stringify(doc, null, 2); return JSON.stringify(doc, null, 2);
} }
const BITWARDEN_CSV_HEADERS = [
'folder',
'favorite',
'type',
'name',
'notes',
'fields',
'reprompt',
'login_uri',
'login_username',
'login_password',
'login_totp',
] as const;
function bitwardenCsvType(type: number): 'login' | 'note' {
return type === 1 ? 'login' : 'note';
}
function sourceTypeLabel(type: number): string {
if (type === 3) return 'card';
if (type === 4) return 'identity';
if (type === 5) return 'sshKey';
if (type === 2) return 'note';
return `type ${type}`;
}
function appendFieldLine(lines: string[], name: unknown, value: unknown): void {
const key = csvText(name).trim();
const text = csvText(value);
if (!key || !text) return;
lines.push(`${key}: ${text}`);
}
function appendRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
if (!isRecord(value)) return;
for (const [key, fieldValue] of Object.entries(value)) {
appendFieldLine(lines, `${prefix}.${key}`, fieldValue);
}
}
function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): string {
const lines: string[] = [];
const fields = Array.isArray(item.fields) ? item.fields : [];
for (const field of fields) {
if (!isRecord(field)) continue;
appendFieldLine(lines, field.name, field.value);
}
if (type !== 1 && type !== 2) {
appendFieldLine(lines, 'nodewardenType', sourceTypeLabel(type));
appendRecordFieldLines(lines, sourceTypeLabel(type), item[sourceTypeLabel(type)]);
}
return lines.join('\n');
}
function buildFolderNameById(foldersRaw: unknown): Map<string, string> {
const out = new Map<string, string>();
const folders = Array.isArray(foldersRaw) ? foldersRaw : [];
for (const folder of folders) {
if (!isRecord(folder)) continue;
const id = csvText(folder.id).trim();
if (!id) continue;
out.set(id, csvText(folder.name));
}
return out;
}
function buildBitwardenCsvLoginUri(login: Record<string, unknown> | null): string {
const uris = Array.isArray(login?.uris) ? login.uris : [];
return buildSingleRowCsvString(uris
.map((uri) => (isRecord(uri) ? csvText(uri.uri).trim() : ''))
.filter(Boolean));
}
export function buildBitwardenCsvString(bitwardenJsonDoc: Record<string, unknown>): string {
const folderNameById = buildFolderNameById(bitwardenJsonDoc.folders);
const rows: string[][] = [[...BITWARDEN_CSV_HEADERS]];
const items = Array.isArray(bitwardenJsonDoc.items) ? bitwardenJsonDoc.items : [];
for (const itemRaw of items) {
if (!isRecord(itemRaw)) continue;
const type = normalizeNumber(itemRaw.type, 1);
const isLogin = type === 1;
const login = isRecord(itemRaw.login) ? itemRaw.login : null;
const folderId = csvText(itemRaw.folderId).trim();
rows.push([
folderNameById.get(folderId) || '',
itemRaw.favorite ? '1' : '0',
bitwardenCsvType(type),
csvText(itemRaw.name) || '--',
csvText(itemRaw.notes),
buildBitwardenCsvFields(itemRaw, type),
String(normalizeNumber(itemRaw.reprompt, 0)),
isLogin ? buildBitwardenCsvLoginUri(login) : '',
isLogin ? csvText(login?.username) : '',
isLogin ? csvText(login?.password) : '',
isLogin ? csvText(login?.totp) : '',
]);
}
return `\uFEFF${buildCsvString(rows)}`;
}
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> { export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
const userEnc = base64ToBytes(args.userEncB64); const userEnc = base64ToBytes(args.userEncB64);
const userMac = base64ToBytes(args.userMacB64); const userMac = base64ToBytes(args.userMacB64);
@@ -566,11 +692,13 @@ function nowStamp(now = new Date()): string {
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string { export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
const stamp = nowStamp(); const stamp = nowStamp();
if ( if (
format === 'bitwarden_csv' ||
format === 'bitwarden_json' || format === 'bitwarden_json' ||
format === 'bitwarden_encrypted_json' || format === 'bitwarden_encrypted_json' ||
format === 'nodewarden_json' || format === 'nodewarden_json' ||
format === 'nodewarden_encrypted_json' format === 'nodewarden_encrypted_json'
) { ) {
if (format === 'bitwarden_csv') return `bitwarden_export_${stamp}.csv`;
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`; if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
return `bitwarden_export_${stamp}.json`; return `bitwarden_export_${stamp}.json`;
} }
+9
View File
@@ -267,6 +267,9 @@ const en: Record<string, string> = {
"txt_backup_webdav_password": "WebDAV Password", "txt_backup_webdav_password": "WebDAV Password",
"txt_backup_webdav_path": "Remote Folder", "txt_backup_webdav_path": "Remote Folder",
"txt_backup_s3_endpoint": "S3 Endpoint", "txt_backup_s3_endpoint": "S3 Endpoint",
"txt_backup_s3_addressing_style": "S3 Addressing Style",
"txt_backup_s3_addressing_path_style": "path-style (default)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "Bucket", "txt_backup_s3_bucket": "Bucket",
"txt_backup_s3_region": "Region", "txt_backup_s3_region": "Region",
"txt_backup_s3_access_key": "Access Key", "txt_backup_s3_access_key": "Access Key",
@@ -447,6 +450,11 @@ const en: Record<string, string> = {
"txt_favorite": "Favorite", "txt_favorite": "Favorite",
"txt_favorites": "Favorites", "txt_favorites": "Favorites",
"txt_duplicates": "Duplicates", "txt_duplicates": "Duplicates",
"txt_duplicate_detection_mode": "Match by",
"txt_duplicate_mode_exact": "Exact item",
"txt_duplicate_mode_login_site": "Site + username + password",
"txt_duplicate_mode_login_credentials": "Username + password",
"txt_duplicate_mode_password": "Reused password",
"txt_field": "Field", "txt_field": "Field",
"txt_field_label": "Field Label", "txt_field_label": "Field Label",
"txt_field_label_is_required": "Field label is required.", "txt_field_label_is_required": "Field label is required.",
@@ -750,6 +758,7 @@ const en: Record<string, string> = {
"txt_search_sends": "Search sends...", "txt_search_sends": "Search sends...",
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.", "txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
"txt_search_your_secure_vault": "Search your secure vault...", "txt_search_your_secure_vault": "Search your secure vault...",
"txt_search_items_count": "Search within {count} items...",
"txt_clear_search": "Clear search", "txt_clear_search": "Clear search",
"txt_clear_search_esc": "Clear search (Esc)", "txt_clear_search_esc": "Clear search (Esc)",
"txt_sort": "Sort", "txt_sort": "Sort",
+9
View File
@@ -267,6 +267,9 @@ const es: Record<string, string> = {
"txt_backup_webdav_password": "Contraseña WebDAV", "txt_backup_webdav_password": "Contraseña WebDAV",
"txt_backup_webdav_path": "Carpeta remota", "txt_backup_webdav_path": "Carpeta remota",
"txt_backup_s3_endpoint": "Endpoint S3", "txt_backup_s3_endpoint": "Endpoint S3",
"txt_backup_s3_addressing_style": "Estilo de direccionamiento S3",
"txt_backup_s3_addressing_path_style": "path-style (predeterminado)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "Bucket S3", "txt_backup_s3_bucket": "Bucket S3",
"txt_backup_s3_region": "Región", "txt_backup_s3_region": "Región",
"txt_backup_s3_access_key": "Clave de acceso", "txt_backup_s3_access_key": "Clave de acceso",
@@ -447,6 +450,11 @@ const es: Record<string, string> = {
"txt_favorite": "Favorito", "txt_favorite": "Favorito",
"txt_favorites": "Favoritos", "txt_favorites": "Favoritos",
"txt_duplicates": "Duplicados", "txt_duplicates": "Duplicados",
"txt_duplicate_detection_mode": "Coincidir por",
"txt_duplicate_mode_exact": "Elemento exacto",
"txt_duplicate_mode_login_site": "Sitio + usuario + contraseña",
"txt_duplicate_mode_login_credentials": "Usuario + contraseña",
"txt_duplicate_mode_password": "Contraseña reutilizada",
"txt_field": "Campo", "txt_field": "Campo",
"txt_field_label": "Etiqueta del campo", "txt_field_label": "Etiqueta del campo",
"txt_field_label_is_required": "La etiqueta del campo es obligatoria.", "txt_field_label_is_required": "La etiqueta del campo es obligatoria.",
@@ -750,6 +758,7 @@ const es: Record<string, string> = {
"txt_search_sends": "Buscar envíos...", "txt_search_sends": "Buscar envíos...",
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.", "txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
"txt_search_your_secure_vault": "Buscar en su bóveda segura...", "txt_search_your_secure_vault": "Buscar en su bóveda segura...",
"txt_search_items_count": "Buscar entre {count} elementos...",
"txt_clear_search": "Limpiar búsqueda", "txt_clear_search": "Limpiar búsqueda",
"txt_clear_search_esc": "Limpiar búsqueda (Esc)", "txt_clear_search_esc": "Limpiar búsqueda (Esc)",
"txt_sort": "Ordenar", "txt_sort": "Ordenar",
+9
View File
@@ -267,6 +267,9 @@ const ru: Record<string, string> = {
"txt_backup_webdav_password": "Пароль WebDAV", "txt_backup_webdav_password": "Пароль WebDAV",
"txt_backup_webdav_path": "Удаленная папка", "txt_backup_webdav_path": "Удаленная папка",
"txt_backup_s3_endpoint": "S3 endpoint", "txt_backup_s3_endpoint": "S3 endpoint",
"txt_backup_s3_addressing_style": "Стиль адресации S3",
"txt_backup_s3_addressing_path_style": "path-style (по умолчанию)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "Бакет", "txt_backup_s3_bucket": "Бакет",
"txt_backup_s3_region": "Регион", "txt_backup_s3_region": "Регион",
"txt_backup_s3_access_key": "Ключ доступа", "txt_backup_s3_access_key": "Ключ доступа",
@@ -447,6 +450,11 @@ const ru: Record<string, string> = {
"txt_favorite": "Любимый", "txt_favorite": "Любимый",
"txt_favorites": "Избранное", "txt_favorites": "Избранное",
"txt_duplicates": "Дубликаты", "txt_duplicates": "Дубликаты",
"txt_duplicate_detection_mode": "Сравнивать по",
"txt_duplicate_mode_exact": "Полное совпадение",
"txt_duplicate_mode_login_site": "Сайт + логин + пароль",
"txt_duplicate_mode_login_credentials": "Логин + пароль",
"txt_duplicate_mode_password": "Повтор пароля",
"txt_field": "Поле", "txt_field": "Поле",
"txt_field_label": "Метка поля", "txt_field_label": "Метка поля",
"txt_field_label_is_required": "Метка поля обязательна.", "txt_field_label_is_required": "Метка поля обязательна.",
@@ -750,6 +758,7 @@ const ru: Record<string, string> = {
"txt_search_sends": "Поиск отправляет...", "txt_search_sends": "Поиск отправляет...",
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.", "txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...", "txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
"txt_search_items_count": "Поиск по {count} элементам...",
"txt_clear_search": "Очистить поиск", "txt_clear_search": "Очистить поиск",
"txt_clear_search_esc": "Очистить поиск (Esc)", "txt_clear_search_esc": "Очистить поиск (Esc)",
"txt_sort": "Сортировать", "txt_sort": "Сортировать",
+9
View File
@@ -267,6 +267,9 @@ const zhCN: Record<string, string> = {
"txt_backup_webdav_password": "WebDAV 密码", "txt_backup_webdav_password": "WebDAV 密码",
"txt_backup_webdav_path": "远程目录", "txt_backup_webdav_path": "远程目录",
"txt_backup_s3_endpoint": "S3 端点", "txt_backup_s3_endpoint": "S3 端点",
"txt_backup_s3_addressing_style": "S3 寻址方式",
"txt_backup_s3_addressing_path_style": "path-style(默认)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "存储桶", "txt_backup_s3_bucket": "存储桶",
"txt_backup_s3_region": "区域", "txt_backup_s3_region": "区域",
"txt_backup_s3_access_key": "访问密钥", "txt_backup_s3_access_key": "访问密钥",
@@ -447,6 +450,11 @@ const zhCN: Record<string, string> = {
"txt_favorite": "收藏", "txt_favorite": "收藏",
"txt_favorites": "收藏", "txt_favorites": "收藏",
"txt_duplicates": "重复项", "txt_duplicates": "重复项",
"txt_duplicate_detection_mode": "匹配方式",
"txt_duplicate_mode_exact": "完全相同",
"txt_duplicate_mode_login_site": "网站+账号+密码",
"txt_duplicate_mode_login_credentials": "账号+密码",
"txt_duplicate_mode_password": "密码复用",
"txt_field": "字段", "txt_field": "字段",
"txt_field_label": "字段标签", "txt_field_label": "字段标签",
"txt_field_label_is_required": "字段标签不能为空", "txt_field_label_is_required": "字段标签不能为空",
@@ -750,6 +758,7 @@ const zhCN: Record<string, string> = {
"txt_search_sends": "搜索 Send...", "txt_search_sends": "搜索 Send...",
"txt_session_refresh_failed": "会话刷新失败,请重新登录", "txt_session_refresh_failed": "会话刷新失败,请重新登录",
"txt_search_your_secure_vault": "搜索你的密码库...", "txt_search_your_secure_vault": "搜索你的密码库...",
"txt_search_items_count": "共 {count} 项中搜索...",
"txt_clear_search": "清空搜索", "txt_clear_search": "清空搜索",
"txt_clear_search_esc": "清空搜索(Esc", "txt_clear_search_esc": "清空搜索(Esc",
"txt_sort": "排序", "txt_sort": "排序",
+9
View File
@@ -267,6 +267,9 @@ const zhTW: Record<string, string> = {
"txt_backup_webdav_password": "WebDAV 密碼", "txt_backup_webdav_password": "WebDAV 密碼",
"txt_backup_webdav_path": "遠程目錄", "txt_backup_webdav_path": "遠程目錄",
"txt_backup_s3_endpoint": "S3 端點", "txt_backup_s3_endpoint": "S3 端點",
"txt_backup_s3_addressing_style": "S3 定址方式",
"txt_backup_s3_addressing_path_style": "path-style(預設)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "儲存桶", "txt_backup_s3_bucket": "儲存桶",
"txt_backup_s3_region": "區域", "txt_backup_s3_region": "區域",
"txt_backup_s3_access_key": "存取金鑰", "txt_backup_s3_access_key": "存取金鑰",
@@ -447,6 +450,11 @@ const zhTW: Record<string, string> = {
"txt_favorite": "收藏", "txt_favorite": "收藏",
"txt_favorites": "收藏", "txt_favorites": "收藏",
"txt_duplicates": "重複項", "txt_duplicates": "重複項",
"txt_duplicate_detection_mode": "匹配方式",
"txt_duplicate_mode_exact": "完全相同",
"txt_duplicate_mode_login_site": "網站+帳號+密碼",
"txt_duplicate_mode_login_credentials": "帳號+密碼",
"txt_duplicate_mode_password": "密碼重複使用",
"txt_field": "字段", "txt_field": "字段",
"txt_field_label": "字段標籤", "txt_field_label": "字段標籤",
"txt_field_label_is_required": "字段標籤不能為空", "txt_field_label_is_required": "字段標籤不能為空",
@@ -750,6 +758,7 @@ const zhTW: Record<string, string> = {
"txt_search_sends": "搜索 Send...", "txt_search_sends": "搜索 Send...",
"txt_session_refresh_failed": "會話刷新失敗,請重新登入", "txt_session_refresh_failed": "會話刷新失敗,請重新登入",
"txt_search_your_secure_vault": "搜索你的密碼庫...", "txt_search_your_secure_vault": "搜索你的密碼庫...",
"txt_search_items_count": "在共 {count} 項中搜索...",
"txt_clear_search": "清空搜索", "txt_clear_search": "清空搜索",
"txt_clear_search_esc": "清空搜索(Esc", "txt_clear_search_esc": "清空搜索(Esc",
"txt_sort": "排序", "txt_sort": "排序",
+23 -3
View File
@@ -1,12 +1,14 @@
export type NetworkStatus = 'online' | 'offline'; export type NetworkStatus = 'online' | 'offline';
const STATUS_PROBE_TIMEOUT_MS = 3500; const STATUS_PROBE_TIMEOUT_MS = 8000;
const STATUS_PROBE_CACHE_MS = 5000; const STATUS_PROBE_CACHE_MS = 5000;
const PROBE_FAILURES_BEFORE_OFFLINE = 2;
const listeners = new Set<(status: NetworkStatus) => void>(); const listeners = new Set<(status: NetworkStatus) => void>();
let currentStatus: NetworkStatus = getInitialNetworkStatus(); let currentStatus: NetworkStatus = getInitialNetworkStatus();
let pendingProbe: Promise<boolean> | null = null; let pendingProbe: Promise<boolean> | null = null;
let lastProbeAt = 0; let lastProbeAt = 0;
let lastProbeResult = currentStatus === 'online'; let lastProbeResult = currentStatus === 'online';
let consecutiveProbeFailures = 0;
export function browserReportsOffline(): boolean { export function browserReportsOffline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine === false; return typeof navigator !== 'undefined' && navigator.onLine === false;
@@ -35,8 +37,23 @@ export function subscribeNetworkStatus(listener: (status: NetworkStatus) => void
}; };
} }
export function recordNodeWardenReachable(): void {
consecutiveProbeFailures = 0;
lastProbeResult = true;
setCurrentNetworkStatus('online');
}
export function recordNodeWardenUnreachable(): void {
lastProbeResult = false;
consecutiveProbeFailures += 1;
if (browserReportsOffline() || consecutiveProbeFailures >= PROBE_FAILURES_BEFORE_OFFLINE) {
setCurrentNetworkStatus('offline');
}
}
export async function probeNodeWardenService(): Promise<boolean> { export async function probeNodeWardenService(): Promise<boolean> {
if (browserReportsOffline()) { if (browserReportsOffline()) {
consecutiveProbeFailures = PROBE_FAILURES_BEFORE_OFFLINE;
setCurrentNetworkStatus('offline'); setCurrentNetworkStatus('offline');
return false; return false;
} }
@@ -68,8 +85,11 @@ export async function probeNodeWardenService(): Promise<boolean> {
.catch(() => false) .catch(() => false)
.then((result) => { .then((result) => {
lastProbeAt = Date.now(); lastProbeAt = Date.now();
lastProbeResult = result; if (result) {
setCurrentNetworkStatus(result ? 'online' : 'offline'); recordNodeWardenReachable();
} else {
recordNodeWardenUnreachable();
}
return result; return result;
}) })
.finally(() => { .finally(() => {
-2
View File
@@ -1,5 +1,4 @@
import type { Send } from './types'; import type { Send } from './types';
import { getCurrentNetworkStatus } from './network-status';
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt'; import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
type WorkerSuccess<T> = { id: number; ok: true; result: T }; type WorkerSuccess<T> = { id: number; ok: true; result: T };
@@ -13,7 +12,6 @@ const pending = new Map<number, { resolve: (value: any) => void; reject: (error:
function getWorker(): Worker | null { function getWorker(): Worker | null {
if (typeof Worker === 'undefined') return null; if (typeof Worker === 'undefined') return null;
if (worker) return worker; if (worker) return worker;
if (getCurrentNetworkStatus() === 'offline') return null;
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' }); worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => { worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
const message = event.data; const message = event.data;
+97 -1
View File
@@ -596,7 +596,7 @@ h4 {
} }
.list-head { .list-head {
margin-bottom: 8px; margin-bottom: 5px;
} }
.list-count { .list-count {
@@ -810,6 +810,101 @@ h4 {
background: transparent; background: transparent;
} }
/* Typography refinement: stronger scan targets for dense vault/admin surfaces. */
body {
font-family: var(--font-sans);
font-size: var(--font-base);
font-weight: 400;
}
button,
input,
select,
textarea {
font: inherit;
}
.side-link,
.side-group-trigger,
.side-sub-link,
.tree-btn,
.mobile-settings-link,
.backup-destination-item,
.backup-browser-entry,
.sort-menu-item,
.create-menu-item,
.nav-layout-option {
font-size: var(--font-sm);
font-weight: 600;
letter-spacing: 0;
}
.side-link.active,
.side-group-trigger.active,
.side-sub-link.active,
.tree-btn.active,
.mobile-tab.active,
.mobile-settings-link.active,
.nav-layout-option.active {
font-weight: 700;
}
.sidebar-title,
.list-count,
.field > span,
.table th,
.dialog-warning-kicker,
.backup-recommendation-group-title {
font-weight: 700;
letter-spacing: 0;
}
.list-title {
color: var(--text);
font-size: var(--font-base);
font-weight: 600;
letter-spacing: 0;
}
.list-sub,
.detail-sub,
.backup-destination-meta,
.totp-code-username,
.field-help,
.settings-field-note {
font-size: var(--font-sm);
line-height: var(--leading-snug);
}
.btn,
.input,
.search-input,
.user-chip,
.network-status-badge {
font-weight: 600;
letter-spacing: 0;
}
.btn-primary,
.btn-danger,
.btn.full,
.topbar-actions .btn,
.network-status-badge {
font-weight: 700;
}
.card h4,
.settings-module h3,
.section-head h3,
.section-head h4,
.detail-title,
.totp-code-name,
.backup-destination-name,
.mobile-sidebar-title {
font-weight: 700;
letter-spacing: 0;
}
.toast-item, .toast-item,
.dialog-card { .dialog-card {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -974,6 +1069,7 @@ h4 {
.list-panel { .list-panel {
padding: 6px; padding: 6px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin-top: 5px;
} }
.list-item { .list-item {
+5 -4
View File
@@ -8,11 +8,12 @@ body,
@apply m-0 h-full w-full p-0; @apply m-0 h-full w-full p-0;
color: var(--text); color: var(--text);
background: var(--bg-accent); background: var(--bg-accent);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif; font-family: var(--font-sans);
font-size: var(--font-base); font-size: var(--font-base);
line-height: var(--leading-normal); line-height: var(--leading-normal);
letter-spacing: var(--tracking-normal); letter-spacing: var(--tracking-normal);
font-feature-settings: 'liga' 1, 'kern' 1; font-feature-settings: 'liga' 1, 'kern' 1, 'calt' 1;
font-variant-numeric: tabular-nums;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@@ -33,8 +34,8 @@ body.dialog-open {
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
font-weight: 600; font-weight: 700;
letter-spacing: var(--tracking-tight); letter-spacing: 0;
line-height: var(--leading-tight); line-height: var(--leading-tight);
margin: 0; margin: 0;
} }
+23
View File
@@ -70,6 +70,7 @@
:root[data-theme='dark'] .textarea, :root[data-theme='dark'] .textarea,
:root[data-theme='dark'] select.input, :root[data-theme='dark'] select.input,
:root[data-theme='dark'] .search-input, :root[data-theme='dark'] .search-input,
:root[data-theme='dark'] .mobile-vault-filter-trigger,
:root[data-theme='dark'] .dialog input, :root[data-theme='dark'] .dialog input,
:root[data-theme='dark'] .dialog textarea, :root[data-theme='dark'] .dialog textarea,
:root[data-theme='dark'] .dialog select { :root[data-theme='dark'] .dialog select {
@@ -79,6 +80,13 @@
box-shadow: none; box-shadow: none;
} }
:root[data-theme='dark'] .mobile-vault-filter-trigger:hover,
:root[data-theme='dark'] .mobile-vault-filter-trigger.active {
background: var(--panel-muted);
border-color: color-mix(in srgb, var(--primary) 42%, var(--line));
color: var(--text);
}
:root[data-theme='dark'] .input::placeholder, :root[data-theme='dark'] .input::placeholder,
:root[data-theme='dark'] .textarea::placeholder, :root[data-theme='dark'] .textarea::placeholder,
:root[data-theme='dark'] input::placeholder, :root[data-theme='dark'] input::placeholder,
@@ -200,6 +208,21 @@
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
} }
:root[data-theme='dark'] .list-item.duplicate-group-item {
background: hsl(var(--duplicate-group-hue) 34% 18%);
border-color: hsl(var(--duplicate-group-hue) 28% 30%);
}
:root[data-theme='dark'] .list-item.duplicate-group-item:hover {
background: hsl(var(--duplicate-group-hue) 36% 21%);
border-color: hsl(var(--duplicate-group-hue) 34% 38%);
}
:root[data-theme='dark'] .list-item.duplicate-group-item.active {
background: hsl(var(--duplicate-group-hue) 38% 24%);
border-color: hsl(var(--duplicate-group-hue) 42% 48%);
}
:root[data-theme='dark'] .card-brand-icon { :root[data-theme='dark'] .card-brand-icon {
color: #bfdbfe; color: #bfdbfe;
background: linear-gradient(180deg, #1f2937 0%, #111827 100%); background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
+5 -12
View File
@@ -31,19 +31,12 @@
} }
select.input { select.input {
@apply py-0 pr-[42px]; @apply py-0 pr-3.5;
line-height: 1.5; line-height: 1.5;
appearance: none; appearance: auto;
-webkit-appearance: none; -webkit-appearance: auto;
-moz-appearance: none; -moz-appearance: auto;
background-image: background-image: none;
linear-gradient(45deg, transparent 50%, #365fa8 50%),
linear-gradient(135deg, #365fa8 50%, transparent 50%);
background-position:
calc(100% - 18px) calc(50% - 3px),
calc(100% - 12px) calc(50% - 3px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
} }
input[type='file'].input { input[type='file'].input {
+19 -4
View File
@@ -209,14 +209,29 @@
} }
.toast-close { .toast-close {
@apply cursor-pointer border-0 bg-transparent text-xl; @apply flex cursor-pointer items-center justify-center border-0;
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 8px;
background: transparent;
color: inherit; color: inherit;
transition: transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth); -webkit-tap-highlight-color: transparent;
transition: background 120ms ease, opacity 120ms ease;
} }
.toast-close:hover { .toast-close:hover {
transform: scale(1.08); background: rgba(0, 0, 0, 0.08);
opacity: 0.84; }
.toast-close:active {
background: rgba(0, 0, 0, 0.14);
}
.toast-close:focus-visible {
outline: 2px solid currentColor;
outline-offset: -2px;
border-radius: 8px;
} }
.toast-progress { .toast-progress {
+105 -6
View File
@@ -133,7 +133,10 @@
} }
.topbar-actions > .network-status-badge { .topbar-actions > .network-status-badge {
@apply h-8 px-2 text-[0]; @apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 rounded-xl p-0 text-[0];
background: color-mix(in srgb, var(--panel) 88%, transparent);
border-color: var(--line);
box-shadow: none;
} }
.topbar-actions > .network-status-badge svg { .topbar-actions > .network-status-badge svg {
@@ -142,7 +145,10 @@
.mobile-sidebar-toggle, .mobile-sidebar-toggle,
.mobile-lock-btn { .mobile-lock-btn {
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0]; @apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 rounded-xl p-0 text-[0];
background: color-mix(in srgb, var(--panel) 88%, transparent);
border-color: var(--line);
box-shadow: none;
} }
.mobile-sidebar-toggle .btn-icon, .mobile-sidebar-toggle .btn-icon,
@@ -155,10 +161,46 @@
} }
.mobile-theme-btn .theme-switch { .mobile-theme-btn .theme-switch {
transform: scale(0.8); @apply h-9 w-9 rounded-xl border;
background: color-mix(in srgb, var(--panel) 88%, transparent);
border-color: var(--line);
box-shadow: none;
transform: none;
transform-origin: center; transform-origin: center;
} }
.mobile-theme-btn .theme-switch-slider,
.mobile-theme-btn .theme-switch-slider::before {
@apply hidden;
}
.mobile-theme-btn .theme-switch .sun svg,
.mobile-theme-btn .theme-switch .moon svg {
@apply h-[18px] w-[18px];
top: 7px;
left: 8px;
z-index: 1;
opacity: 0;
transform: none;
}
.mobile-theme-btn .theme-switch.unchecked .sun svg,
.mobile-theme-btn .theme-switch.checked .moon svg {
opacity: 1;
}
.mobile-theme-btn .theme-switch.checked .moon svg {
fill: var(--primary);
}
.topbar-actions > .network-status-badge:hover,
.mobile-sidebar-toggle:hover,
.mobile-lock-btn:hover,
.mobile-theme-btn .theme-switch:hover {
background: var(--panel-subtle);
border-color: color-mix(in srgb, var(--primary) 28%, var(--line));
}
.app-main { .app-main {
@apply flex min-h-0 flex-col; @apply flex min-h-0 flex-col;
} }
@@ -302,10 +344,15 @@
} }
.list-head { .list-head {
@apply grid items-center gap-2; @apply grid items-center gap-1.5;
grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-columns: minmax(0, 1fr) auto auto auto;
} }
.list-head.selection-mode {
@apply justify-stretch;
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.list-count { .list-count {
grid-column: auto; grid-column: auto;
@apply w-auto whitespace-nowrap text-xs; @apply w-auto whitespace-nowrap text-xs;
@@ -316,11 +363,50 @@
} }
.list-head .search-input { .list-head .search-input {
@apply h-[42px] w-full min-w-0 rounded-[14px]; @apply h-[34px] w-full min-w-0 rounded-[10px] px-3 py-0 text-[13px] font-semibold;
line-height: 34px;
}
.mobile-vault-filter-row {
@apply grid min-w-0 gap-1.5;
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.list-head .duplicate-mode-head-select {
@apply h-[34px] min-w-0 w-auto max-w-full rounded-[10px];
} }
.list-icon-btn { .list-icon-btn {
@apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px]; @apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-[10px] px-3 py-0 text-[13px];
}
.list-head.selection-mode > .btn.small {
@apply h-[34px] min-w-0 w-full justify-center gap-1 px-2 text-[12px];
}
.list-head.selection-mode > .btn.small .btn-icon {
@apply m-0;
}
.sort-trigger {
@apply h-[34px] w-[34px] min-w-[34px] rounded-[10px];
}
.sort-trigger.sort-trigger-labeled {
@apply h-[34px] w-auto min-w-0 gap-1.5 px-3 text-[13px];
}
.sort-trigger.sort-trigger-labeled .btn-icon {
@apply mr-0;
}
.desktop-create-menu-wrap {
display: none;
}
.duplicate-mode-head-menu .mobile-vault-filter-menu {
min-width: max(100%, 190px);
} }
.toolbar.actions { .toolbar.actions {
@@ -329,6 +415,11 @@
gap: var(--actions-gap); gap: var(--actions-gap);
} }
.toolbar.actions.duplicates-toolbar {
@apply justify-start;
flex-wrap: wrap;
}
.actions { .actions {
gap: var(--actions-gap); gap: var(--actions-gap);
} }
@@ -337,6 +428,10 @@
@apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-full px-3 py-0 text-[13px]; @apply h-[34px] w-auto min-w-0 gap-1.5 whitespace-nowrap rounded-full px-3 py-0 text-[13px];
} }
.list-count-status {
@apply mb-1 px-1;
}
.mobile-fab-wrap { .mobile-fab-wrap {
@apply fixed right-3.5 z-[45]; @apply fixed right-3.5 z-[45];
bottom: calc(14px + var(--mobile-tabbar-height, 70px) + env(safe-area-inset-bottom)); bottom: calc(14px + var(--mobile-tabbar-height, 70px) + env(safe-area-inset-bottom));
@@ -883,6 +978,10 @@
font-size: 13px; font-size: 13px;
} }
.totp-code-main strong {
font-size: 20px;
}
.settings-module .field, .settings-module .field,
.auth-card .field { .auth-card .field {
margin-bottom: 8px; margin-bottom: 8px;
+18 -12
View File
@@ -46,16 +46,22 @@
--dur-slow: 350ms; --dur-slow: 350ms;
--actions-gap: clamp(5px, calc((100vw - 520px) * 1), 10px); --actions-gap: clamp(5px, calc((100vw - 520px) * 1), 10px);
/* Typography Families */
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei',
'Noto Sans CJK SC', 'Noto Sans SC', Arial, sans-serif;
--font-mono: 'SFMono-Regular', 'Cascadia Code', 'Cascadia Mono', Consolas, 'Liberation Mono', monospace;
/* Typography Scale */ /* Typography Scale */
--font-xs: 11px; --font-xs: 11px;
--font-sm: 13px; --font-sm: 14px;
--font-base: 14px; --font-base: 15px;
--font-md: 15px; --font-md: 16px;
--font-lg: 16px; --font-lg: 17px;
--font-xl: 18px; --font-xl: 19px;
--font-2xl: 20px; --font-2xl: 21px;
--font-3xl: 24px; --font-3xl: 25px;
--font-4xl: 28px; --font-4xl: 29px;
/* Line Heights */ /* Line Heights */
--leading-tight: 1.25; --leading-tight: 1.25;
@@ -65,11 +71,11 @@
--leading-loose: 1.75; --leading-loose: 1.75;
/* Letter Spacing */ /* Letter Spacing */
--tracking-tighter: -0.02em; --tracking-tighter: 0;
--tracking-tight: -0.01em; --tracking-tight: 0;
--tracking-normal: 0; --tracking-normal: 0;
--tracking-wide: 0.01em; --tracking-wide: 0;
--tracking-wider: 0.02em; --tracking-wider: 0;
/* Spacing Scale */ /* Spacing Scale */
--space-1: 4px; --space-1: 4px;
+187 -7
View File
@@ -184,24 +184,161 @@
gap: var(--actions-gap); gap: var(--actions-gap);
} }
.toolbar.actions.duplicates-toolbar {
@apply items-center;
}
.toolbar .btn.small { .toolbar .btn.small {
@apply h-[30px] rounded-full text-xs; @apply h-[32px] rounded-full text-xs;
}
.duplicate-mode-select {
@apply h-8 min-w-[150px] rounded-full py-0 pl-3 pr-6 text-xs;
border-color: rgba(74, 103, 150, 0.26);
box-shadow: none;
line-height: 32px;
background-position:
calc(100% - 10px) calc(50% - 2px),
calc(100% - 6px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
select.input.duplicate-mode-toolbar-select {
height: 32px;
padding-top: 0;
padding-right: 24px;
padding-bottom: 0;
line-height: 32px;
background-position:
calc(100% - 10px) calc(50% - 2px),
calc(100% - 6px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.duplicate-mode-head-select {
@apply h-[34px] w-auto min-w-[156px] max-w-full;
}
.duplicate-mode-head-menu {
@apply min-w-0;
}
.duplicate-mode-toolbar-select {
@apply w-auto max-w-[170px] shrink-0;
} }
.list-head { .list-head {
@apply mb-2 flex items-center gap-2.5; @apply mb-1.5 flex items-center gap-2;
min-height: 34px;
} }
.list-head .search-input-wrap { .list-head.selection-mode {
@apply gap-2;
}
.list-head.selection-mode > .btn.small {
@apply min-w-0 flex-1 justify-center;
}
.list-head .search-input-wrap,
.duplicate-mode-head-menu {
@apply min-w-0 flex-auto; @apply min-w-0 flex-auto;
} }
.list-head .search-input { .list-head .search-input {
@apply h-[42px]; @apply h-[34px] rounded-[10px] px-3 py-0 text-[13px] font-semibold;
line-height: 34px;
} }
.list-head .btn { .list-head .btn {
@apply whitespace-nowrap; @apply h-[34px] whitespace-nowrap rounded-[10px] px-3 py-0 text-[13px];
}
.list-head .btn,
.list-head .search-input,
.list-head .sort-trigger,
.list-head .list-icon-btn {
animation: none !important;
transition: none !important;
transform: none !important;
}
.list-head .btn:hover:not(:disabled),
.list-head .btn:active:not(:disabled) {
transform: none !important;
}
.mobile-vault-filter-row {
@apply hidden;
}
.mobile-vault-filter-control {
@apply relative min-w-0;
}
.mobile-vault-filter-trigger {
@apply flex h-[34px] w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-[10px] border px-2.5 py-0 text-left text-[13px] font-semibold;
background: var(--panel);
border-color: rgba(74, 103, 150, 0.28);
color: var(--muted-strong);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.74);
transition:
border-color var(--dur-fast) var(--ease-smooth),
background-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth),
box-shadow var(--dur-fast) var(--ease-smooth);
}
.mobile-vault-filter-trigger:hover,
.mobile-vault-filter-trigger.active {
border-color: rgba(43, 102, 217, 0.46);
background: #f8fbff;
color: var(--primary-strong);
}
.mobile-vault-filter-trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.86);
}
.mobile-vault-filter-trigger-icon,
.mobile-vault-filter-item-icon {
@apply inline-flex shrink-0 items-center justify-center;
}
.mobile-vault-filter-trigger-label {
@apply min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap;
}
.mobile-vault-filter-chevron {
@apply shrink-0;
}
.mobile-vault-filter-trigger.active .mobile-vault-filter-chevron {
transform: rotate(180deg);
}
.mobile-vault-filter-menu {
@apply left-0 right-auto max-h-[280px] min-w-full overflow-auto;
min-width: max(100%, 168px);
}
.mobile-vault-filter-control:last-child .mobile-vault-filter-menu {
@apply left-auto right-0;
}
.mobile-vault-filter-item {
@apply gap-2;
}
.mobile-vault-filter-item-main {
@apply flex min-w-0 items-center gap-2;
}
.mobile-vault-filter-item-main span:last-child {
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
} }
.list-count { .list-count {
@@ -225,6 +362,10 @@
@apply w-9 min-w-9 justify-center gap-0 p-0; @apply w-9 min-w-9 justify-center gap-0 p-0;
} }
.sort-trigger.sort-trigger-labeled {
@apply h-[34px] w-auto min-w-0 gap-1.5 rounded-[10px] px-3;
}
.sort-trigger.active { .sort-trigger.active {
background: #e9f1ff; background: #e9f1ff;
border-color: #a9c2ee; border-color: #a9c2ee;
@@ -266,6 +407,30 @@
@apply h-3.5 w-3.5 shrink-0; @apply h-3.5 w-3.5 shrink-0;
} }
.desktop-create-menu-wrap {
@apply shrink-0;
}
.desktop-create-trigger {
@apply h-[34px] w-[34px] min-w-[34px] gap-0 rounded-[10px] p-0 text-[0];
}
.desktop-create-trigger .btn-icon {
@apply m-0;
}
.duplicates-helper-toolbar {
@apply justify-start pb-0.5;
}
.duplicates-helper-toolbar .btn.small {
@apply h-[34px] rounded-[10px] px-3 py-0 text-[13px];
}
.list-count-status {
@apply mb-1 pl-1;
}
.list-panel { .list-panel {
@apply min-h-0 overflow-auto p-2; @apply min-h-0 overflow-auto p-2;
/* Virtualized rows update top/bottom spacers while scrolling. Chrome's scroll anchoring /* Virtualized rows update top/bottom spacers while scrolling. Chrome's scroll anchoring
@@ -281,6 +446,11 @@
overflow-anchor: none; overflow-anchor: none;
} }
.list-item.duplicate-group-item {
background: hsl(var(--duplicate-group-hue) 84% 94%);
border-color: hsl(var(--duplicate-group-hue) 42% 78%);
}
.list-item::before { .list-item::before {
content: ''; content: '';
@apply absolute inset-0 opacity-0; @apply absolute inset-0 opacity-0;
@@ -361,6 +531,11 @@
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(37, 99, 235, 0.04); box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(37, 99, 235, 0.04);
} }
.list-item.duplicate-group-item:hover {
background: hsl(var(--duplicate-group-hue) 88% 92%);
border-color: hsl(var(--duplicate-group-hue) 52% 68%);
}
.list-item:hover::before { .list-item:hover::before {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
@@ -372,6 +547,11 @@
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42);
} }
.list-item.duplicate-group-item.active {
background: hsl(var(--duplicate-group-hue) 88% 89%);
border-color: hsl(var(--duplicate-group-hue) 58% 58%);
}
.list-item.active::before { .list-item.active::before {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
@@ -900,7 +1080,7 @@
.totp-codes-list { .totp-codes-list {
@apply grid w-full items-start gap-2.5; @apply grid w-full items-start gap-2.5;
grid-template-columns: repeat(var(--totp-columns, 1), minmax(300px, 1fr)); grid-template-columns: repeat(var(--totp-columns, 1), minmax(0, 1fr));
} }
.totp-code-row { .totp-code-row {
@@ -925,7 +1105,7 @@
} }
.totp-code-main strong { .totp-code-main strong {
@apply whitespace-nowrap text-[22px] leading-none; @apply min-w-0 whitespace-nowrap text-[22px] leading-none;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }