mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-11-04 18:40:20 +00:00
Compare commits
25
Commits
9c1eafc2b5
..
v1.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e92c91e771 | ||
|
|
02081ea07f | ||
|
|
4c241aa64b | ||
|
|
293f5ce196 | ||
|
|
5f71cb388c | ||
|
|
588dce6a0a | ||
|
|
589557a757 | ||
|
|
99d404f7cb | ||
|
|
a72592e76e | ||
|
|
e63f9663e8 | ||
|
|
df6b0b9767 | ||
|
|
64037695b8 | ||
|
|
d43f21e8e0 | ||
|
|
e4368e6129 | ||
|
|
00433a7cda | ||
|
|
13bb0a0308 | ||
|
|
b3630bdaf5 | ||
|
|
38b0ff6263 | ||
|
|
ecc0d134ac | ||
|
|
f644baaf8d | ||
|
|
fb627f59f0 | ||
|
|
652952379b | ||
|
|
34fd2f0259 | ||
|
|
df493b4697 | ||
|
|
f5cc02b93f |
No files matched your search
@@ -18,6 +18,9 @@ updates:
|
||||
- dependency-name: "tailwindcss"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
- dependency-name: "@tailwindcss/*"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
||||
@@ -44,6 +44,7 @@ tmp/
|
||||
.tmp-bitwarden-clients/
|
||||
|
||||
nodewarden-wiki/
|
||||
nodewarden.wiki/
|
||||
wiki/
|
||||
AGENTS.md
|
||||
settings.json
|
||||
@@ -57,6 +58,7 @@ NodeWarden-compat/
|
||||
|
||||
.reasonix/
|
||||
.upstream/
|
||||
bitwarden-upstream/
|
||||
|
||||
# Compatibility analysis documents
|
||||
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24.18.0
|
||||
@@ -98,6 +98,14 @@ Password hints are reminders, not recovery secrets. They must never contain the
|
||||
master password, recovery codes, API keys, or anything that directly unlocks the
|
||||
vault.
|
||||
|
||||
### Web Vault Styles
|
||||
|
||||
The web vault uses Tailwind CSS 4 through `@tailwindcss/vite`. Theme aliases,
|
||||
dark-mode variants, and source paths are defined in `webapp/src/tailwind.css`,
|
||||
which also imports `styles.css` so component `@apply` rules share the same theme.
|
||||
Keep runtime light/dark design tokens in `webapp/src/styles/tokens.css`.
|
||||
Tailwind 4's browser baseline is Safari 16.4+, Chrome 111+, and Firefox 128+.
|
||||
|
||||
### i18n
|
||||
|
||||
Locale files are complete standalone bundles. When adding or changing user-facing
|
||||
@@ -111,6 +119,15 @@ For new locales, update:
|
||||
|
||||
## Recommended Checks
|
||||
|
||||
Use the Node.js version in `.node-version` for local and Cloudflare builds.
|
||||
Cloudflare Pages and Workers Builds read this file automatically. If a project
|
||||
sets `NODE_VERSION` in its dashboard, keep it aligned with this file.
|
||||
|
||||
When regenerating the lockfile, use a directory without `node_modules` and
|
||||
verify `npm ci` with npm 10.9.2, the version used by Cloudflare's build image.
|
||||
Include optional dependencies so the lockfile also covers native and WASM
|
||||
dependencies on other platforms.
|
||||
|
||||
For most backend or shared changes:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -91,6 +91,25 @@
|
||||
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||
|
||||
|
||||
## FAQ
|
||||
|
||||
- **After forking the repository, why can't I see my repository when connecting GitHub to Cloudflare, or why do I get a 404 after selecting it?**
|
||||
This is usually related to how the GitHub fork is identified or how Cloudflare handles repository authorization and synchronization. If the fork keeps a repository name, description, or other information that is very similar to the upstream project, it may be more likely to trigger related restrictions or issues. It is recommended to rename the repository to something different from the upstream project when creating the fork, and change the repository description as well. For example, you can rename it to `2233warden`. If you have already created the fork, you can rename the repository and update its description in the GitHub repository settings, then try connecting it to Cloudflare again.
|
||||
|
||||
- **I deleted my deployment and redeployed it. Why does registration require an invite code again?**
|
||||
Deleting the Worker or redeploying it does not automatically delete the persistent data that was already created. The users, invite codes, and related configuration stored in the D1 database and KV namespace are still there, so the newly deployed Worker continues to read the existing data and enforce the invite-code requirement.
|
||||
If you want to start completely from scratch, you need to delete the corresponding **D1 database and KV namespace** as well.
|
||||
|
||||
- **I configured `JWT_SECRET`, but the page still says it is missing. Why?**
|
||||
Make sure `JWT_SECRET` is configured under **Workers → Settings → Variables and Secrets**, specifically as a **Runtime variable or Secret**, rather than under **Build variables**.
|
||||
Build-time variables are only available during the build process. They are not available to the Worker at runtime, so the build may succeed while the application still reports that `JWT_SECRET` is missing.
|
||||
|
||||
- **Why does `JWT_SECRET` seem to disappear after an upgrade or redeployment?**
|
||||
It is recommended to store `JWT_SECRET` as a **Secret** rather than as a plain-text variable. `JWT_SECRET` is a sensitive runtime credential and should not be committed to the repository.
|
||||
If your deployment process recreates or overwrites the Worker variable configuration, ordinary variables may be affected. Secrets are more appropriate for sensitive configuration that needs to remain available across multiple deployments. If the application still reports that `JWT_SECRET` is missing after a redeployment, check **Variables and Secrets** for the current Worker and make sure the Secret is still configured.
|
||||
|
||||
---
|
||||
|
||||
## How to update
|
||||
|
||||
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork** → **Update branch**
|
||||
|
||||
@@ -91,6 +91,24 @@
|
||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
|
||||
|
||||
## 常见问题:
|
||||
- **Fork 完仓库后,在 Cloudflare 连接 GitHub 账户时看不到自己的仓库,或者选择仓库后返回 404?**
|
||||
这通常与 GitHub Fork 仓库的识别或 Cloudflare 对仓库的授权/同步有关。如果 Fork 后仓库名称、描述等信息与上游项目高度一致,可能更容易触发相关限制或异常。建议在 Fork 时就将仓库名称修改为与上游不同的名称,并同时修改仓库描述,例如改为 `2233warden`。如果已经完成 Fork,也可以直接在 GitHub 仓库设置中修改名称和描述,然后重新尝试在 Cloudflare 中连接。
|
||||
|
||||
- **我删掉部署后重新部署,为什么注册又开始要求邀请码了?**
|
||||
因为删除 Worker 或重新部署并不会自动删除已经创建的持久化数据。D1 数据库和 KV 命名空间中的用户、邀请码及相关配置仍然存在,因此重新部署后仍会读取原来的数据,并继续要求邀请码。
|
||||
如果希望完全重新开始,需要同时删除对应的 **D1 数据库和 KV 命名空间**。
|
||||
|
||||
- **我配置了 `JWT_SECRET`,为什么页面仍然提示缺少?**
|
||||
请将 `JWT_SECRET` 配置在 Cloudflare Workers 的 **Settings → Variables and Secrets** 中,并确保它属于 **Runtime variables and secrets**,而不是 **Build variables**。
|
||||
Build 阶段的变量只在项目构建过程中可用,Worker 实际运行时无法通过运行时环境读取,因此即使构建能够正常完成,页面仍可能提示 `JWT_SECRET` 缺失。
|
||||
|
||||
- **为什么升级或重新部署后,`JWT_SECRET` 好像消失了?**
|
||||
建议将 `JWT_SECRET` 配置为 **Secret**,而不是普通的明文变量。`JWT_SECRET` 属于敏感的运行时凭据,也不应该出现在代码仓库中。
|
||||
如果部署流程会重新生成或覆盖 Worker 的变量配置,普通变量可能受到影响;使用 Secret 更适合保存这类需要在多次部署之间持续存在的敏感配置。重新部署后如果仍提示缺失,请检查当前 Worker 的 **Variables and Secrets** 中是否仍存在该 Secret。
|
||||
|
||||
---
|
||||
|
||||
## 更新方法:
|
||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
|
||||
|
||||
@@ -21,6 +21,70 @@ Release note writing rules:
|
||||
7. The English and Chinese versions should match in content and ordering, not be two different summaries.
|
||||
-->
|
||||
|
||||
# v1.8.1 - Expanded Generators, Safer Vault Editing, and Client Compatibility
|
||||
|
||||
### Added
|
||||
|
||||
1. **More local generation tools.** The generator now creates numeric PINs, memorable usernames, email aliases, and Ed25519 or RSA SSH key pairs alongside passwords and passphrases. Passwords support minimum counts for each character type and an option to avoid ambiguous characters, while passphrases and usernames can use custom word lists. SSH keys are generated on the device and are not retained by NodeWarden, so save the private key before leaving the page. Commit: [df493b4](https://github.com/shuaiplus/nodewarden/commit/df493b46972bf864e9dad11370ed5d48de06af2a).
|
||||
|
||||
### Improved
|
||||
|
||||
1. **Current Bitwarden client compatibility.** Configuration, prelogin, and account-key responses now include fields expected by Bitwarden 2026.7 clients while retaining older response formats. Supported desktop clients also show the official settings dialog with browser integration controls. Commits: [a012890](https://github.com/shuaiplus/nodewarden/commit/a0128908e18557511590bcb06e13c8799b427343), [6ffdf05](https://github.com/shuaiplus/nodewarden/commit/6ffdf05dc65a0fcd3f60cfe586a04a31e19d6de3).
|
||||
|
||||
1. **Stronger account authentication with automatic migration.** New registrations and password changes use randomly salted server-side password verifiers, and existing accounts upgrade after a successful password login and any required two-factor verification. Existing vault keys and sessions are preserved, and failed logins use more consistent responses and verification work to reduce account discovery through errors or timing. Commit: [5f71cb3](https://github.com/shuaiplus/nodewarden/commit/5f71cb388c061a640e5f2e372e79fa6059f0e26b).
|
||||
|
||||
1. **Clearer deployment and build guidance.** Builds now declare support for Node.js 22.19+ within the 22.x series or Node.js 24.11+, with `.node-version` selecting 24.18.0; align any Cloudflare dashboard `NODE_VERSION` override with that file when upgrading. The deployment FAQ also explains repository connection problems, persistent registration data, and where to configure runtime secrets. Commits: [02081ea](https://github.com/shuaiplus/nodewarden/commit/02081ea07f07501330ba9cfcf1ff4611466def2e), [99d404f](https://github.com/shuaiplus/nodewarden/commit/99d404f7cbf47fa094ea9ed153c16e8f231ded56).
|
||||
|
||||
### Fixed
|
||||
|
||||
1. **Vault edits preserve fields and detect conflicting saves.** Web edits now retain linked custom-field metadata and unrecognized encrypted properties in supported item types. Outdated and concurrent saves are rejected instead of overwriting newer changes, and the editor keeps your draft with instructions to reopen the item. Official bulk delete and restore methods are also supported alongside legacy routes, with restore returning the restored items. Commit: [293f5ce](https://github.com/shuaiplus/nodewarden/commit/293f5ce19685855f393226c8dceaadd9af8a8782).
|
||||
|
||||
1. **Cleared notes stay cleared.** Full item updates now treat omitted notes and custom fields as cleared values, matching clients that leave these properties out after deletion. Previously saved encrypted notes no longer reappear through fallback merging. Commit: [a72592e](https://github.com/shuaiplus/nodewarden/commit/a72592e76e80f1e31eebde0c0216d2a77004440f).
|
||||
|
||||
1. **Safe duplicate selection across large vaults.** Duplicate groups now keep distinct identities even when there are more than 64 groups. Cleanup selection no longer confuses later groups with earlier ones or selects every item in an affected group. Commit: [df6b0b9](https://github.com/shuaiplus/nodewarden/commit/df6b0b97675ce69efba68f7895291ba67436208e).
|
||||
|
||||
1. **WebAuthn verification in official clients.** Desktop, browser-extension, and mobile connectors now follow Bitwarden's request and response formats, including mobile callbacks and supported desktop origins. Exact connector URLs are preserved in Worker deployments, avoiding redirects and framing restrictions that could interrupt security-key verification. Commit: [f761fff](https://github.com/shuaiplus/nodewarden/commit/f761fffd58ff7b6fd510ae57a1d409d247728a1b).
|
||||
|
||||
1. **Helpful registration errors when browser cryptography is unavailable.** Registration now detects insecure or unsupported browser contexts before creating account keys and shows localized guidance to use HTTPS in a supported browser. This replaces an unreadable cryptography runtime error with an actionable message. Commit: [82d9f61](https://github.com/shuaiplus/nodewarden/commit/82d9f61163f6d29803e3a3264b8f986dfa992ebc).
|
||||
|
||||
1. **Visible controls and values in narrow panels.** Remote backup rows now reflow according to the panel's available width, vault toolbars and authorized-device actions wrap, and item-detail values retain usable space. These changes prevent buttons and values from being clipped, while links, backup controls, and TOTP indicators follow the selected theme color more consistently. Commits: [589557a](https://github.com/shuaiplus/nodewarden/commit/589557a75737134574861f568b44703c4881795d), [38b0ff6](https://github.com/shuaiplus/nodewarden/commit/38b0ff6263e54e54e2fcc7f6c7ea3e6f34bb8c08), [ecc0d13](https://github.com/shuaiplus/nodewarden/commit/ecc0d134ac732d29240e6d5ba84e9798be222f17), [f644baa](https://github.com/shuaiplus/nodewarden/commit/f644baaf8d23df2b646d2c1c815bd6d7e1e60e26), [fb627f5](https://github.com/shuaiplus/nodewarden/commit/fb627f59f0ee6cfc396634b3f1674cc09a22e186).
|
||||
|
||||
1. **Credentials no longer accepted in sensitive request URLs.** Notification connections now use short-lived, single-use tickets obtained through authenticated negotiation instead of accepting access JWTs from URL query parameters. Admin backup attachment downloads require POST with a JSON body; integrations using the old GET endpoint must switch to POST so master-password verification credentials do not enter URL logs or browser history. Commits: [e63f966](https://github.com/shuaiplus/nodewarden/commit/e63f9663e845817268f842fff6c628ef921652db), [6529523](https://github.com/shuaiplus/nodewarden/commit/652952379b5d1d8350dc17096b3125919add4884).
|
||||
|
||||
1. **Updated dependencies and removal of vulnerable build tooling.** Dependency updates include Nano ID 3.3.18, Undici 8.10.2, Sharp 0.35.4, and a PostCSS path-traversal fix. The Web Vault also moves to Tailwind CSS 4, removing the vulnerable `braces` dependency from its previous build chain. Commits: [d43f21e](https://github.com/shuaiplus/nodewarden/commit/d43f21e8e03847df1ce81f2dc9534c23f82f54cb), [293f5ce](https://github.com/shuaiplus/nodewarden/commit/293f5ce19685855f393226c8dceaadd9af8a8782), [588dce6](https://github.com/shuaiplus/nodewarden/commit/588dce6a0a3ec67bd48fbbca7f250e181f4bc45f), [f6403b8](https://github.com/shuaiplus/nodewarden/commit/f6403b8cab98abfb456db66e343039c5a803c01b), [4c241aa](https://github.com/shuaiplus/nodewarden/commit/4c241aa64bff3c2833468f8dd5974cc856089aed).
|
||||
|
||||
---
|
||||
|
||||
### 新增
|
||||
|
||||
1. **更多本地生成工具。** 生成器在密码和口令短语之外,新增数字 PIN、易记用户名、邮箱别名以及 Ed25519 或 RSA SSH 密钥对。密码可设置各类字符的最少数量并排除易混淆字符,口令短语和用户名可使用自定义词表。SSH 密钥在设备本地生成,NodeWarden 不会保留,请在离开页面前保存私钥。提交:[df493b4](https://github.com/shuaiplus/nodewarden/commit/df493b46972bf864e9dad11370ed5d48de06af2a)。
|
||||
|
||||
### 改进
|
||||
|
||||
1. **兼容新版 Bitwarden 客户端。** 配置、预登录和账户密钥响应补齐了 Bitwarden 2026.7 客户端所需字段,同时保留旧版响应格式。受支持的桌面客户端也会显示包含浏览器集成选项的官方设置对话框。提交:[a012890](https://github.com/shuaiplus/nodewarden/commit/a0128908e18557511590bcb06e13c8799b427343)、[6ffdf05](https://github.com/shuaiplus/nodewarden/commit/6ffdf05dc65a0fcd3f60cfe586a04a31e19d6de3)。
|
||||
|
||||
1. **更安全的账户认证与自动迁移。** 新注册和修改密码使用带随机盐的服务端密码校验数据,已有账户在密码登录及所需双因素验证成功后自动升级。升级保留原有保险库密钥和会话,失败登录也采用更一致的响应与验证计算,减少通过错误信息或响应耗时探测账户的机会。提交:[5f71cb3](https://github.com/shuaiplus/nodewarden/commit/5f71cb388c061a640e5f2e372e79fa6059f0e26b)。
|
||||
|
||||
1. **更清晰的部署与构建说明。** 构建现已声明支持 Node.js 22.x 系列中的 22.19 及以上版本,或 Node.js 24.11 及以上版本;`.node-version` 指定使用 24.18.0,升级时请同步调整 Cloudflare 控制台中覆盖该文件的 `NODE_VERSION` 设置。部署常见问题也补充了仓库连接异常、注册数据持久化和运行时 Secret 配置位置的说明。提交:[02081ea](https://github.com/shuaiplus/nodewarden/commit/02081ea07f07501330ba9cfcf1ff4611466def2e)、[99d404f](https://github.com/shuaiplus/nodewarden/commit/99d404f7cbf47fa094ea9ed153c16e8f231ded56)。
|
||||
|
||||
### 修复
|
||||
|
||||
1. **编辑条目保留字段并识别保存冲突。** Web 编辑现在会保留关联自定义字段的元数据,以及受支持条目类型中尚未识别的加密属性。过期或并发保存会被拒绝,避免覆盖其他客户端的新修改;编辑器保留草稿,并提示重新打开条目。同时兼容官方批量删除、恢复方法和旧版路由,恢复操作会返回已恢复的条目。提交:[293f5ce](https://github.com/shuaiplus/nodewarden/commit/293f5ce19685855f393226c8dceaadd9af8a8782)。
|
||||
|
||||
1. **已清空的备注不再重新出现。** 完整更新条目时,省略的备注和自定义字段现在会按已清空处理,兼容删除后不再发送这些属性的客户端。旧的加密备注不会再被回退合并逻辑恢复。提交:[a72592e](https://github.com/shuaiplus/nodewarden/commit/a72592e76e80f1e31eebde0c0216d2a77004440f)。
|
||||
|
||||
1. **大量重复分组下正确选择待清理条目。** 重复分组超过 64 组时,各组仍会保留独立标识。清理选择不再混淆前后的分组,也不会误选受影响分组中的全部条目。提交:[df6b0b9](https://github.com/shuaiplus/nodewarden/commit/df6b0b97675ce69efba68f7895291ba67436208e)。
|
||||
|
||||
1. **官方客户端的 WebAuthn 验证。** 桌面端、浏览器扩展和移动端连接页面现已遵循 Bitwarden 的请求与响应格式,包括移动端回调和受支持的桌面端来源。Worker 部署保留连接页面的精确 URL,避免重定向和嵌入限制中断安全密钥验证。提交:[f761fff](https://github.com/shuaiplus/nodewarden/commit/f761fffd58ff7b6fd510ae57a1d409d247728a1b)。
|
||||
|
||||
1. **浏览器加密能力不可用时提供明确注册提示。** 注册现在会在创建账户密钥前检查不安全或不受支持的浏览器环境,并通过本地化提示引导用户使用受支持的浏览器和 HTTPS。原先难以理解的加密运行时错误改为可直接处理的提示。提交:[82d9f61](https://github.com/shuaiplus/nodewarden/commit/82d9f61163f6d29803e3a3264b8f986dfa992ebc)。
|
||||
|
||||
1. **窄面板中的按钮和内容保持可见。** 远端备份列表现在按面板可用宽度重新排版,保险库工具栏和已授权设备操作按钮可自动换行,条目详情的值也保留可读空间,避免按钮或内容被裁切。链接、备份控件和 TOTP 指示器也会更一致地使用所选主题颜色。提交:[589557a](https://github.com/shuaiplus/nodewarden/commit/589557a75737134574861f568b44703c4881795d)、[38b0ff6](https://github.com/shuaiplus/nodewarden/commit/38b0ff6263e54e54e2fcc7f6c7ea3e6f34bb8c08)、[ecc0d13](https://github.com/shuaiplus/nodewarden/commit/ecc0d134ac732d29240e6d5ba84e9798be222f17)、[f644baa](https://github.com/shuaiplus/nodewarden/commit/f644baaf8d23df2b646d2c1c815bd6d7e1e60e26)、[fb627f5](https://github.com/shuaiplus/nodewarden/commit/fb627f59f0ee6cfc396634b3f1674cc09a22e186)。
|
||||
|
||||
1. **敏感请求不再接受 URL 中的认证凭据。** 通知连接现在使用认证协商后取得的短期一次性票据,不再接受 URL 查询参数中的访问 JWT。管理员备份附件下载仅接受带 JSON 请求体的 POST;使用旧 GET 接口的集成需要改为 POST,避免主密码验证凭据进入 URL 日志或浏览器历史。提交:[e63f966](https://github.com/shuaiplus/nodewarden/commit/e63f9663e845817268f842fff6c628ef921652db)、[6529523](https://github.com/shuaiplus/nodewarden/commit/652952379b5d1d8350dc17096b3125919add4884)。
|
||||
|
||||
1. **更新依赖并移除存在漏洞的构建工具依赖。** 依赖更新包括 Nano ID 3.3.18、Undici 8.10.2、Sharp 0.35.4,以及 PostCSS 路径穿越问题的修复。Web Vault 同时迁移至 Tailwind CSS 4,移除了旧构建链中存在漏洞的 `braces` 依赖。提交:[d43f21e](https://github.com/shuaiplus/nodewarden/commit/d43f21e8e03847df1ce81f2dc9534c23f82f54cb)、[293f5ce](https://github.com/shuaiplus/nodewarden/commit/293f5ce19685855f393226c8dceaadd9af8a8782)、[588dce6](https://github.com/shuaiplus/nodewarden/commit/588dce6a0a3ec67bd48fbbca7f250e181f4bc45f)、[f6403b8](https://github.com/shuaiplus/nodewarden/commit/f6403b8cab98abfb456db66e343039c5a803c01b)、[4c241aa](https://github.com/shuaiplus/nodewarden/commit/4c241aa64bff3c2833468f8dd5974cc856089aed)。
|
||||
|
||||
# v1.8.0 - Deployment Control, Session Reliability, and Compatibility Fixes
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+607
-1557
File diff suppressed because it is too large.
Load diff
+14
-7
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^22.19.0 || >=24.11.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "wrangler dev -c wrangler.toml",
|
||||
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
||||
@@ -16,7 +19,9 @@
|
||||
"i18n": "node scripts/i18n-validate.cjs",
|
||||
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||
"test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts",
|
||||
"test:vault-compatibility": "tsx --tsconfig webapp/tsconfig.json --import ./scripts/offline-workers-loader.mjs --test scripts/vault-compatibility.test.ts",
|
||||
"test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
|
||||
"test:notifications-security": "tsx --test scripts/notifications-security.test.ts",
|
||||
"test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs",
|
||||
"test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
|
||||
"test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
|
||||
@@ -48,22 +53,24 @@
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"undici": ">=7.28.0",
|
||||
"nanoid": "3.3.18",
|
||||
"undici": "8.10.2",
|
||||
"@babel/core": ">=7.29.6",
|
||||
"esbuild": ">=0.28.1",
|
||||
"ws": "8.21.0",
|
||||
"sharp": "0.35.0"
|
||||
"sharp": "0.35.4",
|
||||
"browserslist": "4.28.7",
|
||||
"baseline-browser-mapping": "2.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260630.1",
|
||||
"@preact/preset-vite": "^2.10.5",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.0.1",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"opencc-js": "^1.3.2",
|
||||
"postcss": "^8.5.23",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^7.0.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"wrangler": "^4.105.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { handleNotificationsHub, handleNotificationsNegotiate } from '../src/handlers/notifications';
|
||||
import type { Env } from '../src/types';
|
||||
import { createJWT } from '../src/utils/jwt';
|
||||
|
||||
const secret = 'notification-security-test-secret-32-bytes';
|
||||
const userId = 'd75020e1-2de4-46e8-b8f1-475d127b51f2';
|
||||
const securityStamp = 'security-stamp';
|
||||
|
||||
function createTestEnv() {
|
||||
const connectionTokens = new Map<string, { userId: string; deviceIdentifier: string | null; expiresAt: number }>();
|
||||
const forwardedHubUrls: string[] = [];
|
||||
const durableObjectNames: string[] = [];
|
||||
const userRow = {
|
||||
id: userId,
|
||||
email: 'user@example.test',
|
||||
name: 'Test User',
|
||||
master_password_hint: null,
|
||||
master_password_hash: 'hash',
|
||||
key: 'key',
|
||||
private_key: null,
|
||||
public_key: null,
|
||||
kdf_type: 0,
|
||||
kdf_iterations: 600000,
|
||||
kdf_memory: null,
|
||||
kdf_parallelism: null,
|
||||
security_stamp: securityStamp,
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
verify_devices: 0,
|
||||
totp_secret: null,
|
||||
totp_recovery_code: null,
|
||||
yubikey_key1: null,
|
||||
yubikey_key2: null,
|
||||
yubikey_key3: null,
|
||||
yubikey_key4: null,
|
||||
yubikey_key5: null,
|
||||
yubikey_nfc: 0,
|
||||
api_key: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const db = {
|
||||
prepare() {
|
||||
return {
|
||||
bind() {
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return userRow;
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as D1Database;
|
||||
|
||||
const stub = {
|
||||
async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const request = new Request(input, init);
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === '/internal/ws-token') {
|
||||
const body = await request.json() as {
|
||||
token: string;
|
||||
userId: string;
|
||||
deviceIdentifier: string | null;
|
||||
expiresAt: number;
|
||||
};
|
||||
connectionTokens.set(body.token, body);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url.pathname === '/internal/ws-token/consume') {
|
||||
const { token } = await request.json() as { token: string };
|
||||
const connection = connectionTokens.get(token);
|
||||
connectionTokens.delete(token);
|
||||
if (!connection || connection.expiresAt <= Date.now()) return new Response(null, { status: 401 });
|
||||
return Response.json(connection);
|
||||
}
|
||||
forwardedHubUrls.push(request.url);
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
};
|
||||
|
||||
const env = {
|
||||
DB: db,
|
||||
JWT_SECRET: secret,
|
||||
NOTIFICATIONS_HUB: {
|
||||
idFromName(name: string) {
|
||||
durableObjectNames.push(name);
|
||||
return name;
|
||||
},
|
||||
get() {
|
||||
return stub;
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
|
||||
return { env, connectionTokens, durableObjectNames, forwardedHubUrls };
|
||||
}
|
||||
|
||||
async function validAccessToken(): Promise<string> {
|
||||
return createJWT({
|
||||
sub: userId,
|
||||
email: 'user@example.test',
|
||||
name: 'Test User',
|
||||
sstamp: securityStamp,
|
||||
}, secret);
|
||||
}
|
||||
|
||||
test('query access_token cannot authenticate a websocket', async () => {
|
||||
const { env, forwardedHubUrls } = createTestEnv();
|
||||
const token = await validAccessToken();
|
||||
const response = await handleNotificationsHub(new Request(
|
||||
`https://vault.example.test/notifications/hub?access_token=${encodeURIComponent(token)}`,
|
||||
{ headers: { Upgrade: 'websocket' } }
|
||||
), env);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(forwardedHubUrls, []);
|
||||
});
|
||||
|
||||
test('Authorization bearer token still authenticates notifications', async () => {
|
||||
const { env, forwardedHubUrls } = createTestEnv();
|
||||
const token = await validAccessToken();
|
||||
const response = await handleNotificationsHub(new Request(
|
||||
'https://vault.example.test/notifications/hub',
|
||||
{ headers: { Authorization: `Bearer ${token}`, Upgrade: 'websocket' } }
|
||||
), env);
|
||||
|
||||
assert.equal(response.status, 204);
|
||||
assert.equal(new URL(forwardedHubUrls[0]).searchParams.get('nw_uid'), userId);
|
||||
});
|
||||
|
||||
test('negotiate issues a short-lived one-time websocket connection token', async () => {
|
||||
const { env, connectionTokens, forwardedHubUrls } = createTestEnv();
|
||||
const accessToken = await validAccessToken();
|
||||
const negotiate = await handleNotificationsNegotiate(new Request(
|
||||
'https://vault.example.test/notifications/hub/negotiate',
|
||||
{ method: 'POST', headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
), env);
|
||||
const body = await negotiate.json() as { connectionToken: string };
|
||||
const stored = connectionTokens.get(body.connectionToken);
|
||||
|
||||
assert.equal(negotiate.status, 200);
|
||||
assert.ok(stored);
|
||||
assert.ok(stored.expiresAt > Date.now());
|
||||
assert.ok(stored.expiresAt <= Date.now() + 60_000);
|
||||
|
||||
const request = () => new Request(
|
||||
`https://vault.example.test/notifications/hub?id=${encodeURIComponent(body.connectionToken)}`,
|
||||
{ headers: { Upgrade: 'websocket' } }
|
||||
);
|
||||
assert.equal((await handleNotificationsHub(request(), env)).status, 204);
|
||||
assert.equal((await handleNotificationsHub(request(), env)).status, 401);
|
||||
assert.equal(forwardedHubUrls.length, 1);
|
||||
});
|
||||
|
||||
test('a non-upgrade request does not consume a websocket connection token', async () => {
|
||||
const { env, connectionTokens } = createTestEnv();
|
||||
const accessToken = await validAccessToken();
|
||||
const negotiate = await handleNotificationsNegotiate(new Request(
|
||||
'https://vault.example.test/notifications/hub/negotiate',
|
||||
{ method: 'POST', headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
), env);
|
||||
const { connectionToken } = await negotiate.json() as { connectionToken: string };
|
||||
const url = `https://vault.example.test/notifications/hub?id=${encodeURIComponent(connectionToken)}`;
|
||||
|
||||
assert.equal((await handleNotificationsHub(new Request(url), env)).status, 426);
|
||||
assert.ok(connectionTokens.has(connectionToken));
|
||||
assert.equal((await handleNotificationsHub(new Request(url, { headers: { Upgrade: 'websocket' } }), env)).status, 204);
|
||||
});
|
||||
|
||||
test('a forged ticket cannot select or activate a Durable Object', async () => {
|
||||
const { env, durableObjectNames } = createTestEnv();
|
||||
const response = await handleNotificationsHub(new Request(
|
||||
'https://vault.example.test/notifications/hub?id=attacker-controlled.invalid-signature',
|
||||
{ headers: { Upgrade: 'websocket' } }
|
||||
), env);
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(durableObjectNames, []);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { registerHooks } from 'node:module';
|
||||
|
||||
// Vite injects this build constant into modules shared with Web draft helpers.
|
||||
globalThis.__NODEWARDEN_DEMO__ = false;
|
||||
|
||||
// Tests exercise routes and storage without running Cloudflare Durable Objects.
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === 'cloudflare:workers') {
|
||||
const source = 'export class DurableObject {} export function waitUntil(p) { p.catch(() => {}); }';
|
||||
return { url: 'data:text/javascript,' + encodeURIComponent(source), shortCircuit: true };
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { StorageService } from '../src/services/storage';
|
||||
import { handleAuthenticatedRoute } from '../src/router-authenticated';
|
||||
import { cipherToResponse } from '../src/handlers/ciphers';
|
||||
import { bytesToBase64, encryptBw, decryptStr } from '../webapp/src/lib/crypto';
|
||||
import { decryptSingleCipher } from '../webapp/src/lib/decrypt-cipher';
|
||||
import { updateCipher, buildCipherImportPayload, repairCipherKeyMismatches } from '../webapp/src/lib/api/vault';
|
||||
import { importCipherToDraft } from '../webapp/src/lib/app-support';
|
||||
import { draftFromCipher } from '../webapp/src/components/vault/vault-page-helpers';
|
||||
import { t as translate } from '../webapp/src/lib/i18n';
|
||||
|
||||
const owner = '00000000-0000-4000-8000-000000000001';
|
||||
const otherOwner = '00000000-0000-4000-8000-000000000002';
|
||||
const id = '00000000-0000-4000-8000-000000000003';
|
||||
const otherId = '00000000-0000-4000-8000-000000000004';
|
||||
const attachmentId = '00000000-0000-4000-8000-000000000005';
|
||||
const revision = '2026-10-04T00:00:00.000Z';
|
||||
const enc = new Uint8Array(32).fill(1);
|
||||
const mac = new Uint8Array(32).fill(2);
|
||||
const session: any = { symEncKey: bytesToBase64(enc), symMacKey: bytesToBase64(mac) };
|
||||
const encrypt = (value: string) => encryptBw(new TextEncoder().encode(value), enc, mac);
|
||||
|
||||
async function fixture(ctx: TestContext) {
|
||||
// Execute production SQL in a fresh in-memory SQLite database. Only the D1
|
||||
// result wrapper and unrelated audit/notification integrations are substituted.
|
||||
const sqlite = new DatabaseSync(':memory:');
|
||||
ctx.after(async () => { await new Promise((resolve) => setImmediate(resolve)); sqlite.close(); });
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ciphers(id TEXT PRIMARY KEY, user_id TEXT, type INTEGER,
|
||||
folder_id TEXT, name TEXT, notes TEXT, favorite INTEGER, data TEXT,
|
||||
reprompt INTEGER, key TEXT, created_at TEXT, updated_at TEXT,
|
||||
archived_at TEXT, deleted_at TEXT);
|
||||
CREATE TABLE attachments(id TEXT PRIMARY KEY, cipher_id TEXT,
|
||||
file_name TEXT, size INTEGER, size_name TEXT, key TEXT);
|
||||
CREATE TABLE devices(user_id TEXT, push_token TEXT);
|
||||
`);
|
||||
const db: any = {
|
||||
prepare(sql: string) {
|
||||
let values: any[] = [];
|
||||
const statement = {
|
||||
bind(...bound: any[]) { values = bound; return statement; },
|
||||
async run() { const result = sqlite.prepare(sql).run(...values); return { success: true, meta: { changes: Number(result.changes) } }; },
|
||||
async all() { return { success: true, results: sqlite.prepare(sql).all(...values) }; },
|
||||
async first() { return sqlite.prepare(sql).get(...values) ?? null; },
|
||||
};
|
||||
return statement;
|
||||
},
|
||||
};
|
||||
ctx.mock.method(StorageService.prototype, 'updateRevisionDate', async () => revision);
|
||||
ctx.mock.method(StorageService.prototype, 'createAuditLog', async () => {});
|
||||
ctx.mock.method(StorageService.prototype, 'getConfigValue', async () => null);
|
||||
ctx.mock.method(StorageService.prototype, 'pruneAuditLogs', async () => {});
|
||||
const deletedBlobs: string[] = [];
|
||||
const notifications: any[] = [];
|
||||
const env: any = {
|
||||
DB: db,
|
||||
ATTACHMENTS: { delete: async (key: string) => { deletedBlobs.push(key); } },
|
||||
NOTIFICATIONS_HUB: {
|
||||
idFromName: (name: string) => name,
|
||||
get: () => ({ fetch: async (_url: string, init: RequestInit) => { notifications.push(JSON.parse(init.body as string)); return new Response(null, { status: 204 }); } }),
|
||||
},
|
||||
};
|
||||
const storage = new StorageService(db);
|
||||
const base: any = {
|
||||
id, userId: owner, type: 1, name: await encrypt('Initial title'),
|
||||
notes: await encrypt('Notes'), favorite: false, folderId: null, key: null,
|
||||
login: { username: await encrypt('Username'), password: await encrypt('Password') },
|
||||
createdAt: revision, updatedAt: revision, deletedAt: null,
|
||||
};
|
||||
await storage.saveCipher(base);
|
||||
await storage.saveCipher({ ...base, id: otherId, userId: otherOwner });
|
||||
const route = async (path: string, method: string, body?: unknown, headers: Record<string, string> = {}) => {
|
||||
const request = new Request(`https://vault.example.test${path}`, {
|
||||
method, headers: { 'Content-Type': 'application/json', ...headers },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
const response = await handleAuthenticatedRoute(request, env, owner, { id: owner } as any, path, method);
|
||||
assert.ok(response, `${method} ${path} must be handled`);
|
||||
return response;
|
||||
};
|
||||
const attachment = { id: attachmentId, cipherId: id, fileName: await encrypt('file.txt'), key: null, size: 7, sizeName: '7 B' };
|
||||
return { storage, base, env, sqlite, route, deletedBlobs, notifications, attachment };
|
||||
}
|
||||
|
||||
for (const method of ['PUT', 'POST']) {
|
||||
test(`${method} bulk soft delete and restore retain ownership and return restored items`, async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
await f.storage.saveAttachment(f.attachment);
|
||||
const ids = [id, otherId, id, '00000000-0000-4000-8000-000000000099'];
|
||||
assert.equal((await f.route('/api/ciphers/delete', method, { ids })).status, 204);
|
||||
assert.ok((await f.storage.getCipherForUser(id, owner))?.deletedAt);
|
||||
assert.equal((await f.storage.getCipherForUser(otherId, otherOwner))?.deletedAt, null);
|
||||
const response = await f.route('/api/ciphers/restore', method, { ids });
|
||||
assert.equal(response.status, 200);
|
||||
const body: any = await response.json();
|
||||
assert.equal(body.object, 'list');
|
||||
assert.equal(body.continuationToken, null);
|
||||
assert.equal(body.data.length, 1);
|
||||
assert.equal(body.data[0].id, id);
|
||||
assert.equal(body.data[0].deletedDate, null);
|
||||
assert.equal(body.data[0].attachments[0].id, attachmentId);
|
||||
assert.deepEqual(f.deletedBlobs, []);
|
||||
const empty: any = await (await f.route('/api/ciphers/restore', method, { ids: [] })).json();
|
||||
assert.deepEqual(empty.data, []);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [path, method] of [['/api/ciphers', 'DELETE'], ['/api/ciphers/delete-permanent', 'POST']]) {
|
||||
test(`${method} ${path} permanently deletes only owned items and their attachments`, async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
await f.storage.saveAttachment(f.attachment);
|
||||
await f.storage.saveAttachment({ ...f.attachment, id: otherId, cipherId: otherId });
|
||||
assert.equal((await f.route(path, method, { ids: [id, otherId, id] })).status, 204);
|
||||
assert.equal(await f.storage.getCipherForUser(id, owner), null);
|
||||
assert.ok(await f.storage.getCipherForUser(otherId, otherOwner));
|
||||
assert.equal(await f.storage.getAttachment(attachmentId), null);
|
||||
assert.ok(await f.storage.getAttachment(otherId));
|
||||
assert.deepEqual(f.deletedBlobs, [`${id}/${attachmentId}`]);
|
||||
});
|
||||
}
|
||||
|
||||
test('single POST delete alias is permanent; existing root DELETE still moves an active item to trash', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
assert.equal((await f.route(`/api/ciphers/${id}`, 'DELETE')).status, 200);
|
||||
assert.ok((await f.storage.getCipherForUser(id, owner))?.deletedAt);
|
||||
assert.equal((await f.route(`/api/ciphers/${id}/delete`, 'POST')).status, 204);
|
||||
assert.equal(await f.storage.getCipherForUser(id, owner), null);
|
||||
assert.equal((await f.route(`/api/ciphers/${otherId}/delete`, 'POST')).status, 404);
|
||||
});
|
||||
|
||||
test('invalid bulk requests are rejected without changing vault items', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
for (const [path, method] of [['/api/ciphers', 'DELETE'], ['/api/ciphers/delete', 'PUT'], ['/api/ciphers/restore', 'PUT']]) {
|
||||
assert.equal((await f.route(path, method, {})).status, 400);
|
||||
assert.equal((await f.route(path, method, { ids: 'invalid' })).status, 400);
|
||||
}
|
||||
assert.deepEqual(await f.storage.getCipherForUser(id, owner), await f.storage.getCipher(id));
|
||||
assert.equal((await f.storage.getCipherForUser(id, owner))?.deletedAt, null);
|
||||
});
|
||||
|
||||
test('stale updates less than a second apart cannot bypass checks with attachment metadata', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
await f.storage.saveAttachment(f.attachment);
|
||||
await f.storage.saveCipher({ ...f.base, updatedAt: '2026-10-04T00:00:00.001Z' });
|
||||
const response = await f.route(`/api/ciphers/${id}`, 'PUT', {
|
||||
name: await encrypt('Stale title'), lastKnownRevisionDate: revision,
|
||||
attachments2: { [attachmentId]: { fileName: await encrypt('Stale file') } },
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal((await f.storage.getCipherForUser(id, owner))?.name, f.base.name);
|
||||
assert.equal((await f.storage.getAttachment(attachmentId))?.fileName, f.attachment.fileName);
|
||||
assert.equal(f.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('a write conflict detected after reading leaves attachment metadata untouched', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
await f.storage.saveAttachment(f.attachment);
|
||||
const update = StorageService.prototype.updateCipherIfUnchanged;
|
||||
ctx.mock.method(StorageService.prototype, 'updateCipherIfUnchanged', async function (this: StorageService, cipher: any, expected: string) {
|
||||
await f.storage.saveCipher({ ...f.base, updatedAt: '2026-10-04T00:00:01.000Z' });
|
||||
return update.call(this, cipher, expected);
|
||||
});
|
||||
assert.equal((await f.route(`/api/ciphers/${id}`, 'PUT', {
|
||||
name: await encrypt('Stale title'), lastKnownRevisionDate: revision,
|
||||
attachments2: { [attachmentId]: { fileName: await encrypt('Stale file') } },
|
||||
})).status, 400);
|
||||
assert.equal((await f.storage.getAttachment(attachmentId))?.fileName, f.attachment.fileName);
|
||||
assert.equal(f.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('concurrent saves from the same revision accept only one, even in the same millisecond', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
ctx.mock.method(Date, 'now', () => Date.parse(revision));
|
||||
const response = await Promise.all(['First title', 'Second title'].map(async (title) =>
|
||||
f.route(`/api/ciphers/${id}`, 'PUT', { name: await encrypt(title), lastKnownRevisionDate: revision })
|
||||
));
|
||||
assert.deepEqual(response.map((r) => r.status).sort(), [200, 400]);
|
||||
assert.equal((await f.storage.getCipherForUser(id, owner))?.updatedAt, '2026-10-04T00:00:00.001Z');
|
||||
});
|
||||
|
||||
test('a competing deletion cannot be resurrected by a delayed update', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
const get = StorageService.prototype.getCipherForUser;
|
||||
ctx.mock.method(StorageService.prototype, 'getCipherForUser', async function (this: StorageService, ...args: [string, string]) {
|
||||
const result = await get.apply(this, args);
|
||||
await f.storage.deleteCipher(id, owner);
|
||||
return result;
|
||||
});
|
||||
assert.equal((await f.route(`/api/ciphers/${id}`, 'PUT', { name: await encrypt('Delayed title') })).status, 400);
|
||||
assert.equal(await f.storage.getCipher(id), null);
|
||||
});
|
||||
|
||||
test('normal legacy saves without a revision still work and nullable fields can be cleared', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
assert.equal((await f.route(`/api/ciphers/${id}`, 'PUT', { name: await encrypt('Legacy title'), notes: null, fields: [] })).status, 200);
|
||||
const saved = await f.storage.getCipherForUser(id, owner);
|
||||
assert.equal(saved?.notes, null);
|
||||
assert.deepEqual(saved?.fields, []);
|
||||
});
|
||||
|
||||
test('storage conditional updates cannot change another owner or recreate missing items', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
assert.equal(await f.storage.updateCipherIfUnchanged({ ...f.base, userId: otherOwner }, revision), false);
|
||||
await f.storage.deleteCipher(id, owner);
|
||||
assert.equal(await f.storage.updateCipherIfUnchanged(f.base, revision), false);
|
||||
assert.equal(await f.storage.getCipher(id), null);
|
||||
});
|
||||
|
||||
test('Web editing preserves linked IDs and field metadata through reorder, removal, encryption, storage and response', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
const extra = await encrypt('Future encrypted field');
|
||||
const cipher = {
|
||||
...f.base,
|
||||
fields: [
|
||||
{ type: 3, name: await encrypt('Linked username'), value: null, linkedId: 100, futureValue: extra },
|
||||
{ type: 0, name: await encrypt('Remove me'), value: await encrypt('Remove value') },
|
||||
{ type: 1, name: await encrypt('Keep me'), value: await encrypt('Secret'), futureValue: extra },
|
||||
],
|
||||
};
|
||||
await f.storage.saveCipher(cipher);
|
||||
const decrypted = await decryptSingleCipher(cipherToResponse(cipher) as any, enc, mac);
|
||||
const draft = draftFromCipher(decrypted);
|
||||
draft.customFields = [draft.customFields[2], draft.customFields[0]];
|
||||
draft.name = 'Edited title';
|
||||
const updated = await updateCipher((path, init) => f.route(path, init!.method!, JSON.parse(init!.body as string)), session, decrypted, draft);
|
||||
assert.equal(updated.fields?.length, 2);
|
||||
assert.equal('lastKnownRevisionDate' in updated, false);
|
||||
assert.equal(updated.fields?.[1].linkedId, 100);
|
||||
for (const field of updated.fields || []) {
|
||||
assert.equal((field as any).futureValue, extra);
|
||||
assert.equal('decName' in field, false);
|
||||
assert.equal('decValue' in field, false);
|
||||
}
|
||||
const roundtrip = await decryptSingleCipher(updated, enc, mac);
|
||||
assert.equal(roundtrip.decName, 'Edited title');
|
||||
assert.equal(roundtrip.fields?.[0].decValue, 'Secret');
|
||||
assert.equal((await f.storage.getCipherForUser(id, owner))?.fields?.[1].linkedId, 100);
|
||||
});
|
||||
|
||||
for (const [type, property] of [[3, 'card'], [4, 'identity'], [2, 'secureNote']] as const) {
|
||||
test(`Web editing ${property} keeps unknown encrypted values without sending decoded properties`, async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
const extra = await encrypt('Future secret');
|
||||
const source = { ...f.base, type, login: null, [property]: { futureEncrypted: extra, decFutureEncrypted: 'Decoded secret', type: 0 } };
|
||||
await f.storage.saveCipher(source);
|
||||
const decrypted = await decryptSingleCipher(cipherToResponse(source) as any, enc, mac);
|
||||
const draft = draftFromCipher(decrypted);
|
||||
draft.name = 'Changed';
|
||||
let payload: any;
|
||||
const updated = await updateCipher((path, init) => { payload = JSON.parse(init!.body as string); return f.route(path, init!.method!, payload); }, session, decrypted, draft);
|
||||
assert.equal(payload[property].futureEncrypted, extra);
|
||||
assert.equal('decFutureEncrypted' in payload[property], false);
|
||||
assert.equal((updated as any)[property].futureEncrypted, extra);
|
||||
});
|
||||
}
|
||||
|
||||
test('Web sends the revision captured at edit start and exposes a localized conflict while preserving the draft', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
const initial = await decryptSingleCipher(cipherToResponse(f.base) as any, enc, mac);
|
||||
const draft = draftFromCipher(initial);
|
||||
draft.name = 'Unsaved edit';
|
||||
const newer = { ...f.base, name: await encrypt('New remote title'), updatedAt: '2026-10-04T00:00:00.500Z' };
|
||||
await f.storage.saveCipher(newer);
|
||||
const synced = await decryptSingleCipher(cipherToResponse(newer) as any, enc, mac);
|
||||
let submitted: any;
|
||||
await assert.rejects(updateCipher((path, init) => {
|
||||
submitted = JSON.parse(init!.body as string);
|
||||
return f.route(path, init!.method!, submitted);
|
||||
}, session, synced, draft), (error: any) => {
|
||||
assert.equal(error.status, 400);
|
||||
assert.equal(error.message, translate('txt_item_changed_elsewhere'));
|
||||
return true;
|
||||
});
|
||||
assert.equal(submitted.lastKnownRevisionDate, revision);
|
||||
assert.equal(draft.name, 'Unsaved edit');
|
||||
assert.equal((await f.storage.getCipherForUser(id, owner))?.name, newer.name);
|
||||
});
|
||||
|
||||
test('plaintext import keeps linked field IDs attached to the encrypted field', async () => {
|
||||
const draft = importCipherToDraft({ type: 1, name: 'Imported', fields: [{ type: 3, name: 'Linked', value: null, linkedId: 100 }] }, '');
|
||||
assert.equal(draft.customFields[0].linkedId, 100);
|
||||
const payload: any = await buildCipherImportPayload(session, draft);
|
||||
assert.equal(payload.fields[0].linkedId, 100);
|
||||
assert.equal(await decryptStr(payload.fields[0].name, enc, mac), 'Linked');
|
||||
assert.equal('lastKnownRevisionDate' in payload, false);
|
||||
});
|
||||
|
||||
test('a draft cannot be saved into a different item after the selection changes', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
const cipher = await decryptSingleCipher(cipherToResponse(f.base) as any, enc, mac);
|
||||
const draft = draftFromCipher(cipher);
|
||||
let requested = false;
|
||||
await assert.rejects(updateCipher(async () => { requested = true; return new Response(); }, session, { ...cipher, id: otherId }, draft));
|
||||
assert.equal(requested, false);
|
||||
});
|
||||
|
||||
test('automatic item-key repair preserves linked metadata and the intentional revision-date behavior', async (ctx) => {
|
||||
const f = await fixture(ctx);
|
||||
const itemEnc = new Uint8Array(32).fill(3);
|
||||
const itemMac = new Uint8Array(32).fill(4);
|
||||
const itemKey = new Uint8Array([...itemEnc, ...itemMac]);
|
||||
const extra = await encryptBw(new TextEncoder().encode('Future secret'), itemEnc, itemMac);
|
||||
const mixed = { ...f.base, key: await encryptBw(itemKey, enc, mac), fields: [
|
||||
{ type: 3, name: await encrypt('Linked username'), value: null, linkedId: 100, futureEncrypted: extra },
|
||||
] };
|
||||
await f.storage.saveCipher(mixed);
|
||||
const decrypted = await decryptSingleCipher(cipherToResponse(mixed) as any, enc, mac);
|
||||
const repaired = await repairCipherKeyMismatches((path, init) =>
|
||||
f.route(path, init!.method!, JSON.parse(init!.body as string), init!.headers as Record<string, string>), session, [decrypted]);
|
||||
assert.equal(repaired, 1);
|
||||
const saved = (await f.storage.getCipherForUser(id, owner))!;
|
||||
assert.equal(saved.updatedAt, revision);
|
||||
assert.equal(saved.fields?.[0].linkedId, 100);
|
||||
assert.equal((saved.fields?.[0] as any).futureEncrypted, extra);
|
||||
assert.equal(await decryptStr(saved.name, itemEnc, itemMac), 'Initial title');
|
||||
assert.equal(await decryptStr(saved.fields![0].name!, itemEnc, itemMac), 'Linked username');
|
||||
assert.equal('lastKnownRevisionDate' in saved, false);
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.8.0';
|
||||
export const APP_VERSION = '1.8.1';
|
||||
@@ -19,6 +19,8 @@ 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_BACKUP_RESTORE_PROGRESS = 102;
|
||||
const WEBSOCKET_CONNECTION_TOKEN_PREFIX = 'ws-token:';
|
||||
const WEBSOCKET_CONNECTION_TOKEN_TTL_MS = 60 * 1000;
|
||||
|
||||
type HubProtocol = 'json' | 'messagepack';
|
||||
type HubKind = 'user' | 'anonymous-auth-request';
|
||||
@@ -32,6 +34,12 @@ interface WsAttachment {
|
||||
deviceIdentifier: string | null;
|
||||
}
|
||||
|
||||
interface WebSocketConnectionToken {
|
||||
userId: string;
|
||||
deviceIdentifier: string | null;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
@@ -207,6 +215,52 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/internal/ws-token' && request.method === 'POST') {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
token?: string;
|
||||
userId?: string;
|
||||
deviceIdentifier?: string | null;
|
||||
expiresAt?: number;
|
||||
} | null;
|
||||
const token = String(body?.token || '').trim();
|
||||
const userId = String(body?.userId || '').trim();
|
||||
const expiresAt = Number(body?.expiresAt || 0);
|
||||
if (!token || !userId || expiresAt <= Date.now() || expiresAt > Date.now() + WEBSOCKET_CONNECTION_TOKEN_TTL_MS) {
|
||||
return new Response('Invalid websocket connection token', { status: 400 });
|
||||
}
|
||||
await this.ctx.storage.put(`${WEBSOCKET_CONNECTION_TOKEN_PREFIX}${token}`, {
|
||||
userId,
|
||||
deviceIdentifier: String(body?.deviceIdentifier || '').trim() || null,
|
||||
expiresAt,
|
||||
} satisfies WebSocketConnectionToken);
|
||||
const currentAlarm = await this.ctx.storage.getAlarm();
|
||||
if (currentAlarm === null || expiresAt < currentAlarm) {
|
||||
await this.ctx.storage.setAlarm(expiresAt);
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/ws-token/consume' && request.method === 'POST') {
|
||||
const body = (await request.json().catch(() => null)) as { token?: string } | null;
|
||||
const token = String(body?.token || '').trim();
|
||||
if (!token) return new Response('Invalid websocket connection token', { status: 400 });
|
||||
|
||||
// Delete inside a transaction so a connection ticket cannot win two concurrent upgrades.
|
||||
const connection = await this.ctx.storage.transaction(async (txn) => {
|
||||
const key = `${WEBSOCKET_CONNECTION_TOKEN_PREFIX}${token}`;
|
||||
const stored = await txn.get<WebSocketConnectionToken>(key);
|
||||
if (stored) await txn.delete(key);
|
||||
return stored || null;
|
||||
});
|
||||
if (!connection || connection.expiresAt <= Date.now()) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
return new Response(JSON.stringify(connection), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/notify' && request.method === 'POST') {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
revisionDate?: string;
|
||||
@@ -301,6 +355,27 @@ export class NotificationsHub extends DurableObject<Env> {
|
||||
});
|
||||
}
|
||||
|
||||
async alarm(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const tokens = await this.ctx.storage.list<WebSocketConnectionToken>({
|
||||
prefix: WEBSOCKET_CONNECTION_TOKEN_PREFIX,
|
||||
});
|
||||
const expiredKeys: string[] = [];
|
||||
let nextExpiration: number | null = null;
|
||||
|
||||
for (const [key, token] of tokens) {
|
||||
if (token.expiresAt <= now) {
|
||||
expiredKeys.push(key);
|
||||
} else if (nextExpiration === null || token.expiresAt < nextExpiration) {
|
||||
nextExpiration = token.expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Negotiated tickets that never reach an upgrade must not remain in DO storage indefinitely.
|
||||
if (expiredKeys.length > 0) await this.ctx.storage.delete(expiredKeys);
|
||||
if (nextExpiration !== null) await this.ctx.storage.setAlarm(nextExpiration);
|
||||
}
|
||||
|
||||
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
if (!attachment) return;
|
||||
|
||||
@@ -316,7 +316,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const auth = new AuthService(env);
|
||||
const serverHash = await auth.hashPasswordServer(masterPasswordHash, email);
|
||||
const serverHash = await auth.hashPasswordServer(masterPasswordHash);
|
||||
|
||||
const user: User = {
|
||||
id: generateUUID(),
|
||||
@@ -764,7 +764,7 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
|
||||
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
|
||||
}
|
||||
|
||||
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
|
||||
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash);
|
||||
user.key = nextKey;
|
||||
if (nextPrivateKey) user.privateKey = nextPrivateKey;
|
||||
if (nextPublicKey) user.publicKey = nextPublicKey;
|
||||
|
||||
+8
-13
@@ -1247,19 +1247,14 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
let input: { blobName?: unknown; masterPasswordHash?: unknown } = {};
|
||||
if (request.method === 'POST') {
|
||||
try {
|
||||
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
|
||||
} catch {
|
||||
return errorResponse('Backup attachment download payload is invalid', 400);
|
||||
}
|
||||
} else {
|
||||
input = {
|
||||
blobName: url.searchParams.get('blobName') || '',
|
||||
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
|
||||
};
|
||||
// Read the request body only. Accepting these fields from the query string
|
||||
// would put the master-password authentication hash in the URL, where it is
|
||||
// captured by request logs, browser history and Referer headers.
|
||||
let input: { blobName?: unknown; masterPasswordHash?: unknown };
|
||||
try {
|
||||
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
|
||||
} catch {
|
||||
return errorResponse('Backup attachment download payload is invalid', 400);
|
||||
}
|
||||
|
||||
const verificationError = await requireBackupUserVerification(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
interface AliasedValue<T> {
|
||||
present: boolean;
|
||||
value: T | null | undefined;
|
||||
}
|
||||
|
||||
function readOwnAliasedValue<T>(source: unknown, aliases: readonly string[]): AliasedValue<T> {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return { present: false, value: undefined };
|
||||
}
|
||||
|
||||
const record = source as Record<string, unknown>;
|
||||
for (const alias of aliases) {
|
||||
if (Object.prototype.hasOwnProperty.call(record, alias)) {
|
||||
return { present: true, value: record[alias] as T | null | undefined };
|
||||
}
|
||||
}
|
||||
|
||||
return { present: false, value: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Full cipher updates use replacement semantics for nullable fields.
|
||||
* Bitwarden clients may omit a property after its value is cleared, so an
|
||||
* absent property must become null instead of falling back to stored data.
|
||||
*/
|
||||
export function readNullableFullUpdateField<T>(
|
||||
source: unknown,
|
||||
aliases: readonly string[]
|
||||
): T | null {
|
||||
const incoming = readOwnAliasedValue<T>(source, aliases);
|
||||
return incoming.present ? incoming.value ?? null : null;
|
||||
}
|
||||
+22
-22
@@ -27,6 +27,7 @@ import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from '.
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
import { readNullableFullUpdateField } from './cipher-full-update';
|
||||
|
||||
// CONTRACT:
|
||||
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
|
||||
@@ -173,7 +174,7 @@ function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: stri
|
||||
const existingTs = Date.parse(existingUpdatedAt);
|
||||
const clientTs = Date.parse(clientRevisionDate);
|
||||
if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false;
|
||||
return existingTs - clientTs > 1000;
|
||||
return existingTs > clientTs;
|
||||
}
|
||||
|
||||
function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
||||
@@ -521,6 +522,7 @@ function normalizeCipherSecureNoteForCompatibility(secureNote: any): CipherSecur
|
||||
if (!secureNote || typeof secureNote !== 'object') return null;
|
||||
const type = Number(secureNote?.type ?? secureNote?.Type ?? 0);
|
||||
return {
|
||||
...secureNote,
|
||||
type: Number.isFinite(type) ? type : 0,
|
||||
};
|
||||
}
|
||||
@@ -654,10 +656,6 @@ function readIncomingAttachmentMetadata(source: any): IncomingAttachmentMetadata
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function hasIncomingAttachmentMetadata(source: any): boolean {
|
||||
return readIncomingAttachmentMetadata(source).length > 0;
|
||||
}
|
||||
|
||||
async function syncIncomingAttachmentMetadata(
|
||||
storage: StorageService,
|
||||
cipherId: string,
|
||||
@@ -1047,7 +1045,6 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
const incomingPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
|
||||
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
|
||||
const preserveRevisionDate =
|
||||
shouldPreserveRepairableCipherUris(request)
|
||||
&& (body.preserveRevisionDate === true || cipherData.preserveRevisionDate === true);
|
||||
@@ -1056,7 +1053,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
|
||||
}
|
||||
|
||||
if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
|
||||
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
|
||||
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
||||
}
|
||||
|
||||
@@ -1064,7 +1061,13 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
|
||||
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
||||
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
||||
const { preserveRevisionDate: _preserveRevisionDate, PreserveRevisionDate: _pascalPreserveRevisionDate, ...cipherDataWithoutFlags } = cipherData;
|
||||
const {
|
||||
preserveRevisionDate: _preserveRevisionDate,
|
||||
PreserveRevisionDate: _pascalPreserveRevisionDate,
|
||||
lastKnownRevisionDate: _lastKnownRevisionDate,
|
||||
LastKnownRevisionDate: _pascalLastKnownRevisionDate,
|
||||
...cipherDataWithoutFlags
|
||||
} = cipherData;
|
||||
const cipher: Cipher = {
|
||||
...existingCipher, // start with all existing stored data (including unknowns)
|
||||
...cipherDataWithoutFlags, // overlay all client data (including new/unknown fields)
|
||||
@@ -1075,7 +1078,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||
createdAt: existingCipher.createdAt,
|
||||
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date().toISOString(),
|
||||
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date(Math.max(Date.now(), Date.parse(existingCipher.updatedAt) + 1)).toISOString(),
|
||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||
deletedAt: existingCipher.deletedAt,
|
||||
};
|
||||
@@ -1100,16 +1103,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
||||
}
|
||||
|
||||
// Custom fields deletion compatibility:
|
||||
// - Accept both camelCase "fields" and PascalCase "Fields".
|
||||
// - For full update (PUT/POST on this endpoint), missing fields means cleared fields.
|
||||
// This prevents stale custom fields from being resurrected by merge fallback.
|
||||
const incomingFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||
if (incomingFields.present) {
|
||||
cipher.fields = incomingFields.value ?? null;
|
||||
} else if (request.method === 'PUT' || request.method === 'POST') {
|
||||
cipher.fields = null;
|
||||
}
|
||||
// Nullable fields use replacement semantics on this full-update endpoint.
|
||||
// Some clients omit cleared values, so merge fallback must not resurrect them.
|
||||
cipher.notes = readNullableFullUpdateField<string>(cipherData, ['notes', 'Notes']);
|
||||
cipher.fields = readNullableFullUpdateField<Cipher['fields']>(cipherData, ['fields', 'Fields']);
|
||||
normalizeCipherForStorage(cipher);
|
||||
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
|
||||
if (compatibilityError) return errorResponse(compatibilityError, 400);
|
||||
@@ -1120,8 +1117,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||
}
|
||||
|
||||
if (!(await storage.updateCipherIfUnchanged(cipher, existingCipher.updatedAt))) {
|
||||
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
||||
}
|
||||
// Rejected updates must not modify attachment metadata either.
|
||||
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
|
||||
@@ -1456,7 +1456,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
// POST /api/ciphers/restore - Bulk restore
|
||||
// PUT /api/ciphers/restore (POST retained for older NodeWarden clients)
|
||||
export async function handleBulkRestoreCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
@@ -1477,7 +1477,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI
|
||||
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
return buildCipherListResponse(request, storage, userId, body.ids);
|
||||
}
|
||||
|
||||
// POST /api/ciphers/delete-permanent - Bulk permanent delete
|
||||
|
||||
+18
-28
@@ -391,35 +391,18 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
|
||||
const user = await storage.getUser(email);
|
||||
if (!user) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.login.failed.user_inactive',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
let validatedAuthRequestId: string | null = null;
|
||||
let authRequestLoginKey: string | null = null;
|
||||
let valid = false;
|
||||
const normalizedAuthRequestId = String(authRequestId || '').trim();
|
||||
if (normalizedAuthRequestId) {
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user.id);
|
||||
// Auth-request grants carry an access code, not a password hash. Pay the
|
||||
// same KDF cost and perform the lookup even when the account is missing.
|
||||
await auth.performDummyPasswordWork(passwordHash);
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user?.id ?? '');
|
||||
valid = !!(
|
||||
user &&
|
||||
user.status === 'active' &&
|
||||
authRequest &&
|
||||
authRequest.userId === user.id &&
|
||||
authRequest.type === 0 &&
|
||||
@@ -435,16 +418,19 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
authRequestLoginKey = authRequest!.key;
|
||||
}
|
||||
} else {
|
||||
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
||||
const passwordValid = await auth.verifyPassword(passwordHash, user?.masterPasswordHash ?? null, user?.email ?? email);
|
||||
valid = !!user && user.status === 'active' && passwordValid;
|
||||
}
|
||||
if (!valid) {
|
||||
if (!valid || !user) {
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
|
||||
actorUserId: user?.id,
|
||||
action: user && user.status !== 'active'
|
||||
? 'auth.login.failed.user_inactive'
|
||||
: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
targetId: user?.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
@@ -577,6 +563,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
}
|
||||
|
||||
if (!normalizedAuthRequestId) {
|
||||
await auth.upgradePasswordVerifier(user, passwordHash);
|
||||
}
|
||||
|
||||
// Persist device only after successful password + (optional) 2FA verification.
|
||||
const deviceSession = await persistAndResolveDeviceSession(storage, user.id, deviceInfo);
|
||||
if (deviceSession) {
|
||||
|
||||
@@ -4,18 +4,20 @@ import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||
import type { Env, JWTPayload } from '../types';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import {
|
||||
createWebSocketConnectionToken,
|
||||
verifyWebSocketConnectionToken,
|
||||
} from '../utils/websocket-connection-token';
|
||||
|
||||
const WEBSOCKET_CONNECTION_TOKEN_TTL_MS = 60 * 1000;
|
||||
|
||||
function extractAccessToken(request: Request): string | null {
|
||||
const url = new URL(request.url);
|
||||
const queryToken = String(url.searchParams.get('access_token') || '').trim();
|
||||
if (queryToken) return queryToken;
|
||||
|
||||
const authHeader = String(request.headers.get('Authorization') || '').trim();
|
||||
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
async function authenticateNotificationsRequest(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||
async function authenticateAccessToken(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||
const accessToken = extractAccessToken(request);
|
||||
if (!accessToken) return null;
|
||||
|
||||
@@ -23,30 +25,90 @@ async function authenticateNotificationsRequest(request: Request, env: Env): Pro
|
||||
return auth.verifyAccessToken(`Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
async function issueWebSocketConnectionToken(payload: JWTPayload, env: Env): Promise<string> {
|
||||
const expiresAt = Date.now() + WEBSOCKET_CONNECTION_TOKEN_TTL_MS;
|
||||
const token = await createWebSocketConnectionToken(payload.sub, expiresAt, env.JWT_SECRET);
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(payload.sub);
|
||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||
const response = await stub.fetch('https://notifications/internal/ws-token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
userId: payload.sub,
|
||||
deviceIdentifier: payload.did || null,
|
||||
expiresAt,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to issue websocket connection token');
|
||||
return token;
|
||||
}
|
||||
|
||||
async function consumeWebSocketConnectionToken(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||
const token = String(new URL(request.url).searchParams.get('id') || '').trim();
|
||||
const claims = await verifyWebSocketConnectionToken(token, env.JWT_SECRET);
|
||||
if (!claims) return null;
|
||||
|
||||
// Verify the signed routing claim before selecting a Durable Object. Otherwise an
|
||||
// attacker could activate arbitrary object names with forged token prefixes.
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(claims.userId);
|
||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||
const response = await stub.fetch('https://notifications/internal/ws-token/consume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const connection = (await response.json().catch(() => null)) as {
|
||||
userId?: string;
|
||||
deviceIdentifier?: string | null;
|
||||
} | null;
|
||||
if (connection?.userId !== claims.userId) return null;
|
||||
|
||||
return {
|
||||
sub: claims.userId,
|
||||
did: String(connection.deviceIdentifier || '').trim() || undefined,
|
||||
} as JWTPayload;
|
||||
}
|
||||
|
||||
async function authenticateNotificationsHub(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||
// Never accept an access JWT from the URL: URLs are routinely retained by logs,
|
||||
// browser history, proxies, monitoring, and error tracking systems.
|
||||
if (request.headers.has('Authorization')) {
|
||||
return authenticateAccessToken(request, env);
|
||||
}
|
||||
return consumeWebSocketConnectionToken(request, env);
|
||||
}
|
||||
|
||||
export async function handleNotificationsNegotiate(request: Request, env: Env): Promise<Response> {
|
||||
const payload = await authenticateNotificationsRequest(request, env);
|
||||
const payload = await authenticateAccessToken(request, env);
|
||||
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||
|
||||
const connectionId = generateUUID();
|
||||
return jsonResponse({
|
||||
connectionId,
|
||||
connectionToken: connectionId,
|
||||
negotiateVersion: 1,
|
||||
availableTransports: [
|
||||
{
|
||||
transport: 'WebSockets',
|
||||
transferFormats: ['Text', 'Binary'],
|
||||
},
|
||||
],
|
||||
});
|
||||
const connectionToken = await issueWebSocketConnectionToken(payload, env);
|
||||
return jsonResponse(
|
||||
{
|
||||
connectionId: generateUUID(),
|
||||
connectionToken,
|
||||
negotiateVersion: 1,
|
||||
availableTransports: [
|
||||
{
|
||||
transport: 'WebSockets',
|
||||
transferFormats: ['Text', 'Binary'],
|
||||
},
|
||||
],
|
||||
},
|
||||
200,
|
||||
{ 'Cache-Control': 'no-store' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleNotificationsHub(request: Request, env: Env): Promise<Response> {
|
||||
const payload = await authenticateNotificationsRequest(request, env);
|
||||
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
|
||||
return errorResponse('Expected websocket', 426);
|
||||
}
|
||||
const payload = await authenticateNotificationsHub(request, env);
|
||||
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||
|
||||
const userId = payload.sub;
|
||||
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
handleRunAdminConfiguredBackup,
|
||||
handleUpdateAdminBackupSettings,
|
||||
} from './handlers/backup';
|
||||
import { errorResponse } from './utils/response';
|
||||
|
||||
export async function handleAdminBackupRoute(
|
||||
request: Request,
|
||||
@@ -26,8 +27,22 @@ export async function handleAdminBackupRoute(
|
||||
return handleAdminExportBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) {
|
||||
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||
if (path === '/api/admin/backup/blob') {
|
||||
// POST only: this endpoint requires master-password verification, and a GET
|
||||
// could only carry that credential in the query string, where it would leak
|
||||
// into request logs, proxy logs, browser history and Referer headers.
|
||||
// The credential is the same value clients send to /identity/connect/token,
|
||||
// so a leaked copy is enough to sign in as this admin.
|
||||
if (method === 'POST') {
|
||||
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||
}
|
||||
if (method === 'GET') {
|
||||
return errorResponse(
|
||||
'Use POST with a JSON body for this endpoint. Credentials must not be sent in the URL.',
|
||||
405
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/settings') {
|
||||
|
||||
@@ -290,6 +290,7 @@ export async function handleAuthenticatedRoute(
|
||||
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
|
||||
if (method === 'GET') return handleGetCiphers(request, env, userId);
|
||||
if (method === 'POST') return handleCreateCipher(request, env, userId);
|
||||
if (path === '/api/ciphers' && method === 'DELETE') return handleBulkPermanentDeleteCiphers(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -297,7 +298,7 @@ export async function handleAuthenticatedRoute(
|
||||
return handleCiphersImport(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/delete' && method === 'POST') {
|
||||
if (path === '/api/ciphers/delete' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleBulkDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
@@ -305,7 +306,7 @@ export async function handleAuthenticatedRoute(
|
||||
return handleBulkPermanentDeleteCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/ciphers/restore' && method === 'POST') {
|
||||
if (path === '/api/ciphers/restore' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleBulkRestoreCiphers(request, env, userId);
|
||||
}
|
||||
|
||||
@@ -333,7 +334,7 @@ export async function handleAuthenticatedRoute(
|
||||
}
|
||||
|
||||
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/delete' && (method === 'DELETE' || method === 'POST')) return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/archive' && (method === 'PUT' || method === 'POST')) return handleArchiveCipher(request, env, userId, cipherId);
|
||||
if (subPath === '/unarchive' && (method === 'PUT' || method === 'POST')) return handleUnarchiveCipher(request, env, userId, cipherId);
|
||||
|
||||
+28
-41
@@ -2,12 +2,13 @@ import { Env, JWTPayload, User } from '../types';
|
||||
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
|
||||
import { getRefreshTokenSlidingTtlMs, LIMITS } from '../config/limits';
|
||||
import { StorageService } from './storage';
|
||||
import {
|
||||
createPasswordVerifier,
|
||||
needsPasswordVerifierUpgrade,
|
||||
performDummyPasswordWork,
|
||||
verifyPasswordVerifier,
|
||||
} from './password-verifier';
|
||||
|
||||
// Server-side iterations for second-layer hashing.
|
||||
// The client already does heavy PBKDF2 (600k iterations).
|
||||
// This second layer only needs to be non-trivial, not expensive.
|
||||
const SERVER_HASH_ITERATIONS = 100_000;
|
||||
const SERVER_HASH_PREFIX = '$s$';
|
||||
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
|
||||
|
||||
interface CachedUserEntry {
|
||||
@@ -134,47 +135,33 @@ export class AuthService {
|
||||
return device;
|
||||
}
|
||||
|
||||
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||
// Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
|
||||
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(clientHash),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits']
|
||||
);
|
||||
const salt = new TextEncoder().encode(email.toLowerCase().trim());
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: SERVER_HASH_ITERATIONS },
|
||||
keyMaterial,
|
||||
256
|
||||
);
|
||||
const bytes = new Uint8Array(bits);
|
||||
let binary = '';
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return SERVER_HASH_PREFIX + btoa(binary);
|
||||
async hashPasswordServer(clientHash: string): Promise<string> {
|
||||
return createPasswordVerifier(clientHash);
|
||||
}
|
||||
|
||||
// Verify password: new rows use server-side hashing; legacy rows store the raw client hash.
|
||||
async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
|
||||
if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
|
||||
return this.constantTimeEquals(inputHash, storedHash);
|
||||
}
|
||||
const serverHash = await this.hashPasswordServer(inputHash, email);
|
||||
return this.constantTimeEquals(serverHash, storedHash);
|
||||
async verifyPassword(inputHash: string, storedHash: string | null, email: string): Promise<boolean> {
|
||||
return verifyPasswordVerifier(inputHash, storedHash, email);
|
||||
}
|
||||
|
||||
private constantTimeEquals(a: string, b: string): boolean {
|
||||
const encA = new TextEncoder().encode(a);
|
||||
const encB = new TextEncoder().encode(b);
|
||||
if (encA.length !== encB.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < encA.length; i++) {
|
||||
diff |= encA[i] ^ encB[i];
|
||||
async performDummyPasswordWork(inputHash: string): Promise<void> {
|
||||
await performDummyPasswordWork(inputHash);
|
||||
}
|
||||
|
||||
// Call only after a password login and all required second factors succeed.
|
||||
// Auth-request access codes are not password hashes and must never migrate rows.
|
||||
async upgradePasswordVerifier(user: User, inputHash: string): Promise<void> {
|
||||
if (!needsPasswordVerifierUpgrade(user.masterPasswordHash)) return;
|
||||
try {
|
||||
const upgraded = await this.hashPasswordServer(inputHash);
|
||||
if (await this.storage.upgradePasswordVerifier(user.id, user.masterPasswordHash, user.securityStamp, upgraded)) {
|
||||
user.masterPasswordHash = upgraded;
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
}
|
||||
} catch {
|
||||
// Opportunistic migration must not prevent an existing user from logging in.
|
||||
// Retry at the next fully authenticated password login; never log credentials.
|
||||
console.warn('Password verifier upgrade deferred');
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
// Generate access token
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { constantTimeEquals } from '../utils/api-key';
|
||||
|
||||
// This is the server's second layer; the client performs its own KDF first.
|
||||
const ITERATIONS = 100_000;
|
||||
const LEGACY_PREFIX = '$s$';
|
||||
const PREFIX = '$s2$';
|
||||
const SALT_BYTES = 32;
|
||||
const DUMMY_SALT = new Uint8Array(SALT_BYTES);
|
||||
const BASE64_32_BYTES = /^[A-Za-z0-9+/]{43}=$/;
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
function decode32Bytes(value: string): Uint8Array | null {
|
||||
if (!BASE64_32_BYTES.test(value)) return null;
|
||||
const bytes = Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
|
||||
return bytes.length === SALT_BYTES && toBase64(bytes) === value ? bytes : null;
|
||||
}
|
||||
|
||||
function parseVerifier(stored: string): { salt: Uint8Array; digest: string } | null {
|
||||
const parts = stored.split('$');
|
||||
// Accept only the work factor this version writes, keeping verification work
|
||||
// equivalent and preventing corrupt/imported rows from requesting arbitrary work.
|
||||
if (parts.length !== 5 || parts[0] !== '' || parts[1] !== 's2' || parts[2] !== String(ITERATIONS)) return null;
|
||||
const salt = decode32Bytes(parts[3]);
|
||||
if (!salt || !decode32Bytes(parts[4])) return null;
|
||||
return { salt, digest: parts[4] };
|
||||
}
|
||||
|
||||
async function deriveDigest(clientHash: string, salt: Uint8Array): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw', new TextEncoder().encode(clientHash), 'PBKDF2', false, ['deriveBits']
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ITERATIONS }, key, 256
|
||||
);
|
||||
return toBase64(new Uint8Array(bits));
|
||||
}
|
||||
|
||||
export async function createPasswordVerifier(clientHash: string): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
||||
const digest = await deriveDigest(clientHash, salt);
|
||||
// Self-contained version, work factor, salt and digest; no schema change.
|
||||
return `${PREFIX}${ITERATIONS}$${toBase64(salt)}$${digest}`;
|
||||
}
|
||||
|
||||
export async function performDummyPasswordWork(clientHash: string): Promise<void> {
|
||||
// The result is discarded and can never authenticate anyone. The salt need
|
||||
// not be random here; no password verifier is being stored.
|
||||
await deriveDigest(clientHash, DUMMY_SALT);
|
||||
}
|
||||
|
||||
export async function verifyPasswordVerifier(
|
||||
clientHash: string,
|
||||
stored: string | null,
|
||||
email: string
|
||||
): Promise<boolean> {
|
||||
const verifier = stored?.startsWith(PREFIX) ? parseVerifier(stored) : null;
|
||||
if (verifier) {
|
||||
return constantTimeEquals(await deriveDigest(clientHash, verifier.salt), verifier.digest);
|
||||
}
|
||||
if (stored?.startsWith(LEGACY_PREFIX) && decode32Bytes(stored.slice(LEGACY_PREFIX.length))) {
|
||||
const digest = await deriveDigest(clientHash, new TextEncoder().encode(email.toLowerCase().trim()));
|
||||
return constantTimeEquals(`${LEGACY_PREFIX}${digest}`, stored);
|
||||
}
|
||||
|
||||
// Missing users, raw legacy verifiers and malformed/unknown formats all pay
|
||||
// the same PBKDF2 cost. Reserved formats must never fall back to raw equality.
|
||||
await performDummyPasswordWork(clientHash);
|
||||
return !!stored && !stored.startsWith('$') && constantTimeEquals(clientHash, stored);
|
||||
}
|
||||
|
||||
export function needsPasswordVerifierUpgrade(stored: string): boolean {
|
||||
return stored.startsWith(LEGACY_PREFIX) || !stored.startsWith('$');
|
||||
}
|
||||
@@ -49,6 +49,8 @@ const CIPHER_SCALAR_DATA_KEYS = new Set([
|
||||
'updatedAt',
|
||||
'updated_at',
|
||||
'revisionDate',
|
||||
'lastKnownRevisionDate',
|
||||
'LastKnownRevisionDate',
|
||||
'archivedAt',
|
||||
'archived_at',
|
||||
'archivedDate',
|
||||
@@ -144,6 +146,27 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
|
||||
).run();
|
||||
}
|
||||
|
||||
// A single conditional UPDATE prevents a competing save or deletion between
|
||||
// the handler's read and write from being overwritten (or resurrected).
|
||||
export async function updateCipherIfUnchanged(
|
||||
db: D1Database,
|
||||
safeBind: SafeBind,
|
||||
cipher: Cipher,
|
||||
expectedUpdatedAt: string
|
||||
): Promise<boolean> {
|
||||
const folderId = normalizeOptionalId(cipher.folderId);
|
||||
const result = await safeBind(
|
||||
db.prepare(
|
||||
'UPDATE ciphers SET type=?, folder_id=?, name=?, notes=?, favorite=?, data=?, reprompt=?, key=?, updated_at=?, archived_at=?, deleted_at=? ' +
|
||||
'WHERE id=? AND user_id=? AND updated_at=?'
|
||||
),
|
||||
Number(cipher.type) || 1, folderId, cipher.name, cipher.notes, cipher.favorite ? 1 : 0,
|
||||
buildCipherData(cipher, folderId), cipher.reprompt ?? 0, cipher.key, cipher.updatedAt,
|
||||
cipher.archivedAt ?? null, cipher.deletedAt, cipher.id, cipher.userId, expectedUpdatedAt
|
||||
).run();
|
||||
return result.meta.changes === 1;
|
||||
}
|
||||
|
||||
function sanitizeIds(ids: string[]): string[] {
|
||||
return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
@@ -113,6 +113,21 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
||||
await saveUser(db, safeBind, user);
|
||||
}
|
||||
|
||||
export async function upgradePasswordVerifier(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
expectedVerifier: string,
|
||||
securityStamp: string,
|
||||
newVerifier: string
|
||||
): Promise<boolean> {
|
||||
// Update only the verifier, preserving vault keys, timestamps and sessions.
|
||||
// A concurrent password change, disable or delete must not be overwritten.
|
||||
const result = await db.prepare(
|
||||
"UPDATE users SET master_password_hash = ? WHERE id = ? AND master_password_hash = ? AND security_stamp = ? AND status = 'active'"
|
||||
).bind(newVerifier, userId, expectedVerifier, securityStamp).run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getUserById as findStoredUserById,
|
||||
getUserCount as countStoredUsers,
|
||||
saveUser as saveStoredUser,
|
||||
upgradePasswordVerifier as upgradeStoredPasswordVerifier,
|
||||
} from './storage-user-repo';
|
||||
import {
|
||||
type AuditLogListOptions,
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
getCiphersByIds as listStoredCiphersByIds,
|
||||
getCiphersPage as listStoredCiphersPage,
|
||||
saveCipher as saveStoredCipher,
|
||||
updateCipherIfUnchanged as updateStoredCipherIfUnchanged,
|
||||
deleteCipher as deleteStoredCipher,
|
||||
} from './storage-cipher-repo';
|
||||
import {
|
||||
@@ -313,6 +315,10 @@ export class StorageService {
|
||||
await createStoredUser(this.db, this.safeBind.bind(this), user);
|
||||
}
|
||||
|
||||
async upgradePasswordVerifier(userId: string, expectedVerifier: string, securityStamp: string, newVerifier: string): Promise<boolean> {
|
||||
return upgradeStoredPasswordVerifier(this.db, userId, expectedVerifier, securityStamp, newVerifier);
|
||||
}
|
||||
|
||||
async createFirstUser(user: User): Promise<boolean> {
|
||||
return createFirstStoredUser(this.db, this.safeBind.bind(this), user);
|
||||
}
|
||||
@@ -491,6 +497,10 @@ export class StorageService {
|
||||
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
|
||||
}
|
||||
|
||||
async updateCipherIfUnchanged(cipher: Cipher, expectedUpdatedAt: string): Promise<boolean> {
|
||||
return updateStoredCipherIfUnchanged(this.db, this.safeBind.bind(this), cipher, expectedUpdatedAt);
|
||||
}
|
||||
|
||||
async deleteCipher(id: string, userId: string): Promise<void> {
|
||||
await deleteStoredCipher(this.db, id, userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { generateUUID } from './uuid';
|
||||
|
||||
const WEBSOCKET_NOTIFICATION_SCOPE = 'notifications.websocket';
|
||||
|
||||
export interface WebSocketConnectionTokenClaims {
|
||||
userId: string;
|
||||
expiresAt: number;
|
||||
nonce: string;
|
||||
scope: typeof WEBSOCKET_NOTIFICATION_SCOPE;
|
||||
}
|
||||
|
||||
function base64UrlEncode(data: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...data))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): Uint8Array {
|
||||
let normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (normalized.length % 4) normalized += '=';
|
||||
const binary = atob(normalized);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function getSigningKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
}
|
||||
|
||||
export async function createWebSocketConnectionToken(
|
||||
userId: string,
|
||||
expiresAt: number,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
const claims: WebSocketConnectionTokenClaims = {
|
||||
userId,
|
||||
expiresAt,
|
||||
nonce: generateUUID(),
|
||||
scope: WEBSOCKET_NOTIFICATION_SCOPE,
|
||||
};
|
||||
const payload = base64UrlEncode(new TextEncoder().encode(JSON.stringify(claims)));
|
||||
const signature = await crypto.subtle.sign(
|
||||
'HMAC',
|
||||
await getSigningKey(secret),
|
||||
new TextEncoder().encode(payload)
|
||||
);
|
||||
return `${payload}.${base64UrlEncode(new Uint8Array(signature))}`;
|
||||
}
|
||||
|
||||
export async function verifyWebSocketConnectionToken(
|
||||
token: string,
|
||||
secret: string
|
||||
): Promise<WebSocketConnectionTokenClaims | null> {
|
||||
try {
|
||||
if (!token || token.length > 1024) return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
const [payload, encodedSignature] = parts;
|
||||
const valid = await crypto.subtle.verify(
|
||||
'HMAC',
|
||||
await getSigningKey(secret),
|
||||
base64UrlDecode(encodedSignature),
|
||||
new TextEncoder().encode(payload)
|
||||
);
|
||||
if (!valid) return null;
|
||||
|
||||
const claims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))) as Partial<WebSocketConnectionTokenClaims>;
|
||||
if (
|
||||
claims.scope !== WEBSOCKET_NOTIFICATION_SCOPE
|
||||
|| !String(claims.userId || '').trim()
|
||||
|| !Number.isFinite(claims.expiresAt)
|
||||
|| Number(claims.expiresAt) <= Date.now()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return claims as WebSocketConnectionTokenClaims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./webapp/index.html', './webapp/src/**/*.{ts,tsx}'],
|
||||
darkMode: ['class', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
canvas: 'var(--bg-accent)',
|
||||
panel: 'var(--panel)',
|
||||
'panel-soft': 'var(--panel-soft)',
|
||||
'panel-muted': 'var(--panel-muted)',
|
||||
line: 'var(--line)',
|
||||
'line-soft': 'var(--line-soft)',
|
||||
ink: 'var(--text)',
|
||||
muted: 'var(--muted)',
|
||||
'muted-strong': 'var(--muted-strong)',
|
||||
brand: 'var(--primary)',
|
||||
'brand-hover': 'var(--primary-hover)',
|
||||
'brand-strong': 'var(--primary-strong)',
|
||||
danger: 'var(--danger)',
|
||||
},
|
||||
boxShadow: {
|
||||
soft: 'var(--shadow-sm)',
|
||||
panel: 'var(--shadow-md)',
|
||||
elevated: 'var(--shadow-lg)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
+12
-4
@@ -1674,17 +1674,25 @@ export default function App() {
|
||||
reconnectAttempts += 1;
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
void connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
const connect = async () => {
|
||||
if (disposed) return;
|
||||
const accessToken = session.accessToken;
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const negotiateResponse = await fetch('/notifications/hub/negotiate?negotiateVersion=1', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!negotiateResponse.ok) throw new Error('Notification negotiation failed');
|
||||
const negotiation = (await negotiateResponse.json()) as { connectionToken?: string };
|
||||
if (!negotiation.connectionToken || disposed) throw new Error('Notification connection token missing');
|
||||
|
||||
const hubUrl = new URL('/notifications/hub', window.location.origin);
|
||||
hubUrl.searchParams.set('access_token', accessToken);
|
||||
hubUrl.searchParams.set('id', negotiation.connectionToken);
|
||||
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(hubUrl.toString());
|
||||
} catch {
|
||||
@@ -1809,7 +1817,7 @@ export default function App() {
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
|
||||
@@ -1,158 +1,77 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
|
||||
import { Check, Copy, Download, LoaderCircle, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { EFFLongWordList } from '@/lib/eff-word-list';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
clampInteger,
|
||||
defaultGeneratorSettings,
|
||||
estimateStrength,
|
||||
generateValue,
|
||||
normalizeGeneratorSettings,
|
||||
type EmailMode,
|
||||
type EmailOptions,
|
||||
type GeneratorMode,
|
||||
type GeneratorSettings,
|
||||
type PassphraseOptions,
|
||||
type PasswordOptions,
|
||||
type PinOptions,
|
||||
type SshKeyOptions,
|
||||
type UsernameOptions,
|
||||
} from '@/lib/password-generator';
|
||||
import { generateSshKey, type GeneratedSshKey } from '@/lib/ssh-key-generator';
|
||||
|
||||
type GeneratorMode = 'password' | 'passphrase';
|
||||
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v2';
|
||||
|
||||
interface PasswordOptions {
|
||||
length: number;
|
||||
uppercase: boolean;
|
||||
lowercase: boolean;
|
||||
numbers: boolean;
|
||||
special: boolean;
|
||||
minNumbers: number;
|
||||
minSpecial: number;
|
||||
avoidAmbiguous: boolean;
|
||||
}
|
||||
|
||||
interface PassphraseOptions {
|
||||
words: number;
|
||||
separator: string;
|
||||
capitalize: boolean;
|
||||
includeNumber: boolean;
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v1';
|
||||
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const DIGITS = '0123456789';
|
||||
const SPECIAL = '!@#$%^&*';
|
||||
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1']);
|
||||
|
||||
const defaultPasswordOptions: PasswordOptions = {
|
||||
length: 14,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
numbers: true,
|
||||
special: false,
|
||||
minNumbers: 1,
|
||||
minSpecial: 1,
|
||||
avoidAmbiguous: false,
|
||||
};
|
||||
|
||||
const defaultPassphraseOptions: PassphraseOptions = {
|
||||
words: 6,
|
||||
separator: '-',
|
||||
capitalize: false,
|
||||
includeNumber: false,
|
||||
};
|
||||
|
||||
function clamp(value: unknown, minimum: number, maximum: number, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
|
||||
}
|
||||
|
||||
function readSettings(): { mode: GeneratorMode; password: PasswordOptions; passphrase: PassphraseOptions } {
|
||||
function readSettings(): GeneratorSettings {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') as Partial<{ mode: GeneratorMode; password: Partial<PasswordOptions>; passphrase: Partial<PassphraseOptions> }>;
|
||||
return {
|
||||
mode: stored.mode === 'passphrase' ? 'passphrase' : 'password',
|
||||
password: {
|
||||
...defaultPasswordOptions,
|
||||
...stored.password,
|
||||
length: clamp(stored.password?.length, 5, 128, defaultPasswordOptions.length),
|
||||
minNumbers: clamp(stored.password?.minNumbers, 0, 9, defaultPasswordOptions.minNumbers),
|
||||
minSpecial: clamp(stored.password?.minSpecial, 0, 9, defaultPasswordOptions.minSpecial),
|
||||
},
|
||||
passphrase: {
|
||||
...defaultPassphraseOptions,
|
||||
...stored.passphrase,
|
||||
words: clamp(stored.passphrase?.words, 3, 20, defaultPassphraseOptions.words),
|
||||
separator: String(stored.passphrase?.separator ?? defaultPassphraseOptions.separator).slice(0, 1),
|
||||
},
|
||||
};
|
||||
const current = localStorage.getItem(SETTINGS_KEY);
|
||||
if (current) return normalizeGeneratorSettings(JSON.parse(current));
|
||||
|
||||
// Preserve compatible options for users upgrading from the original generator.
|
||||
const legacy = JSON.parse(localStorage.getItem('nodewarden.passwordGenerator.settings.v1') || '{}');
|
||||
return normalizeGeneratorSettings(legacy);
|
||||
} catch {
|
||||
return { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions };
|
||||
return defaultGeneratorSettings;
|
||||
}
|
||||
}
|
||||
|
||||
function randomIndex(length: number): number {
|
||||
const range = 0x1_0000_0000;
|
||||
const upperBound = Math.floor(range / length) * length;
|
||||
const buffer = new Uint32Array(1);
|
||||
do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound);
|
||||
return buffer[0] % length;
|
||||
}
|
||||
|
||||
function pick(characters: string): string {
|
||||
return characters[randomIndex(characters.length)];
|
||||
}
|
||||
|
||||
function shuffle(value: string[]): string[] {
|
||||
for (let index = value.length - 1; index > 0; index -= 1) {
|
||||
const next = randomIndex(index + 1);
|
||||
[value[index], value[next]] = [value[next], value[index]];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function filtered(characters: string, avoidAmbiguous: boolean): string {
|
||||
return avoidAmbiguous ? characters.split('').filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
|
||||
}
|
||||
|
||||
function generatePassword(options: PasswordOptions): string {
|
||||
const sets: Array<{ chars: string; minimum: number }> = [];
|
||||
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||
if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers });
|
||||
if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial });
|
||||
if (!sets.length) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||
|
||||
const minimumLength = sets.reduce((total, set) => total + set.minimum, 0);
|
||||
const length = Math.max(options.length, minimumLength, 5);
|
||||
const allCharacters = sets.map((set) => set.chars).join('');
|
||||
const characters = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
|
||||
while (characters.length < length) characters.push(pick(allCharacters));
|
||||
return shuffle(characters).join('');
|
||||
}
|
||||
|
||||
function generatePassphrase(options: PassphraseOptions): string {
|
||||
const words = Array.from({ length: options.words }, () => EFFLongWordList[randomIndex(EFFLongWordList.length)]);
|
||||
if (options.capitalize) {
|
||||
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
|
||||
}
|
||||
if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10));
|
||||
return words.join(options.separator);
|
||||
}
|
||||
|
||||
function strengthLabel(mode: GeneratorMode, value: string): { label: string; score: number } {
|
||||
const score = mode === 'password' ? Math.min(4, Math.max(1, Math.floor(value.length / 5))) : Math.min(4, Math.max(1, Math.floor(value.split(/[-_. ]/).filter(Boolean).length / 2)));
|
||||
return { score, label: t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][score - 1]) };
|
||||
}
|
||||
|
||||
export default function PasswordGeneratorPage() {
|
||||
const initial = useMemo(readSettings, []);
|
||||
const [mode, setMode] = useState<GeneratorMode>(initial.mode);
|
||||
const [passwordOptions, setPasswordOptions] = useState<PasswordOptions>(initial.password);
|
||||
const [passphraseOptions, setPassphraseOptions] = useState<PassphraseOptions>(initial.passphrase);
|
||||
const [settings, setSettings] = useState<GeneratorSettings>(initial);
|
||||
const [seed, setSeed] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [sshKey, setSshKey] = useState<GeneratedSshKey | null>(null);
|
||||
const [sshKeyError, setSshKeyError] = useState('');
|
||||
const [sshKeyLoading, setSshKeyLoading] = useState(false);
|
||||
|
||||
const generated = useMemo(
|
||||
() => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)),
|
||||
[mode, passwordOptions, passphraseOptions, seed]
|
||||
const generated = useMemo(() => settings.mode === 'sshKey' ? sshKey?.fingerprint || '' : generateValue(settings), [settings, seed, sshKey]);
|
||||
const strength = useMemo(
|
||||
() => estimateStrength(settings.mode, generated, settings.mode === 'passphrase' ? settings.passphrase.words : undefined),
|
||||
[generated, settings.mode, settings.passphrase.words],
|
||||
);
|
||||
const strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]);
|
||||
const strengthLabel = strength
|
||||
? t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][strength - 1])
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions }));
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
// The generator remains fully usable when browser storage is unavailable.
|
||||
}
|
||||
}, [mode, passwordOptions, passphraseOptions]);
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.mode !== 'sshKey') return;
|
||||
let cancelled = false;
|
||||
setSshKeyLoading(true);
|
||||
setSshKeyError('');
|
||||
void generateSshKey({ ...settings.sshKey, comment: '' })
|
||||
.then((value) => { if (!cancelled) setSshKey(value); })
|
||||
.catch(() => { if (!cancelled) { setSshKey(null); setSshKeyError(t('txt_generator_ssh_error')); } })
|
||||
.finally(() => { if (!cancelled) setSshKeyLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [settings.mode, settings.sshKey.type, settings.sshKey.rsaLength, seed]);
|
||||
|
||||
const regenerate = () => {
|
||||
setCopied(false);
|
||||
@@ -160,17 +79,49 @@ export default function PasswordGeneratorPage() {
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
|
||||
const value = settings.mode === 'sshKey' && sshKey ? publicKeyWithComment(sshKey.publicKey, settings.sshKey.comment) : generated;
|
||||
await copyTextToClipboard(value, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
|
||||
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
|
||||
setPasswordOptions((current) => ({ ...current, [key]: value }));
|
||||
const changeMode = (mode: GeneratorMode) => {
|
||||
setSettings((current) => ({ ...current, mode }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
|
||||
setSettings((current) => ({ ...current, password: { ...current.password, [key]: value } }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changeCharacterType = (key: 'uppercase' | 'lowercase' | 'numbers' | 'special', checked: boolean) => {
|
||||
const enabled = ['uppercase', 'lowercase', 'numbers', 'special'].filter((item) => settings.password[item as 'uppercase']);
|
||||
if (!checked && enabled.length === 1 && enabled[0] === key) return;
|
||||
changePasswordOption(key, checked);
|
||||
};
|
||||
|
||||
const changePassphraseOption = <K extends keyof PassphraseOptions>(key: K, value: PassphraseOptions[K]) => {
|
||||
setPassphraseOptions((current) => ({ ...current, [key]: value }));
|
||||
setSettings((current) => ({ ...current, passphrase: { ...current.passphrase, [key]: value } }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changePinOption = <K extends keyof PinOptions>(key: K, value: PinOptions[K]) => {
|
||||
setSettings((current) => ({ ...current, pin: { ...current.pin, [key]: value } }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changeUsernameOption = <K extends keyof UsernameOptions>(key: K, value: UsernameOptions[K]) => {
|
||||
setSettings((current) => ({ ...current, username: { ...current.username, [key]: value } }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changeEmailOption = <K extends keyof EmailOptions>(key: K, value: EmailOptions[K]) => {
|
||||
setSettings((current) => ({ ...current, email: { ...current.email, [key]: value } }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changeSshKeyOption = <K extends keyof SshKeyOptions>(key: K, value: SshKeyOptions[K]) => {
|
||||
setSettings((current) => ({ ...current, sshKey: { ...current.sshKey, [key]: value } }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
@@ -178,62 +129,182 @@ export default function PasswordGeneratorPage() {
|
||||
<section className="generator-page" aria-label={t('txt_password_generator')}>
|
||||
<div className="generator-layout">
|
||||
<section className="generator-output-card" aria-live="polite">
|
||||
<div className="settings-category-tabs" role="tablist" aria-label={t('txt_generator_type')}>
|
||||
<button type="button" role="tab" aria-selected={mode === 'password'} className={`settings-category-tab ${mode === 'password' ? 'active' : ''}`} onClick={() => setMode('password')}>{t('txt_password')}</button>
|
||||
<button type="button" role="tab" aria-selected={mode === 'passphrase'} className={`settings-category-tab ${mode === 'passphrase' ? 'active' : ''}`} onClick={() => setMode('passphrase')}>{t('txt_passphrase')}</button>
|
||||
</div>
|
||||
<output className="generator-value" aria-label={t('txt_generated_password')}>{generated}</output>
|
||||
<div className="generator-strength-row">
|
||||
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strength.label}`}>
|
||||
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength.score ? `active level-${strength.score}` : ''} />)}
|
||||
</div>
|
||||
<span><ShieldCheck size={15} /> {strength.label}</span>
|
||||
<div className="settings-category-tabs generator-mode-tabs" role="tablist" aria-label={t('txt_generator_type')}>
|
||||
{([
|
||||
['password', 'txt_password'],
|
||||
['passphrase', 'txt_passphrase'],
|
||||
['pin', 'txt_generator_pin'],
|
||||
['username', 'txt_generator_username'],
|
||||
['email', 'txt_generator_email_alias'],
|
||||
['sshKey', 'txt_generator_ssh_key'],
|
||||
] as const).map(([mode, label]) => (
|
||||
<button key={mode} type="button" role="tab" aria-selected={settings.mode === mode} className={`settings-category-tab ${settings.mode === mode ? 'active' : ''}`} onClick={() => changeMode(mode)}>{t(label)}</button>
|
||||
))}
|
||||
</div>
|
||||
{settings.mode === 'sshKey' ? (
|
||||
<SshKeyOutput value={sshKey} loading={sshKeyLoading} error={sshKeyError} comment={settings.sshKey.comment} />
|
||||
) : <output className={`generator-value ${generated ? '' : 'empty'}`} aria-label={t('txt_generated_value')}>{generated || t('txt_generator_email_required_hint')}</output>}
|
||||
{settings.mode !== 'sshKey' && <div className="generator-meta-row">
|
||||
{strength > 0 ? (
|
||||
<>
|
||||
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strengthLabel}`}>
|
||||
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength ? `active level-${strength}` : ''} />)}
|
||||
</div>
|
||||
<span><ShieldCheck size={15} /> {strengthLabel}</span>
|
||||
</>
|
||||
) : <span />}
|
||||
<span>{t('txt_generator_character_count', { count: generated.length })}</span>
|
||||
</div>}
|
||||
<div className="actions generator-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={regenerate}><RefreshCw size={16} className="btn-icon" />{t('txt_regenerate')}</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : t('txt_copy')}</button>
|
||||
<button type="button" className="btn btn-primary" disabled={sshKeyLoading} onClick={regenerate}>{sshKeyLoading ? <LoaderCircle size={16} className="btn-icon generator-spinner" /> : <RefreshCw size={16} className="btn-icon" />}{t('txt_regenerate')}</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={(settings.mode === 'sshKey' && !sshKey) || !generated} onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : settings.mode === 'sshKey' ? t('txt_generator_copy_public_key') : t('txt_copy')}</button>
|
||||
</div>
|
||||
<p className="generator-security-note"><Check size={15} />{t('txt_generator_security_note')}</p>
|
||||
<p className="generator-security-note"><Check size={15} />{t(settings.mode === 'sshKey' ? 'txt_generator_ssh_security_note' : 'txt_generator_security_note')}</p>
|
||||
</section>
|
||||
|
||||
<section className="generator-options-card" aria-labelledby="generator-options-title">
|
||||
<h2 id="generator-options-title">{t('txt_options')}</h2>
|
||||
{mode === 'password' ? (
|
||||
{settings.mode === 'password' && (
|
||||
<PasswordOptionFields options={settings.password} onChange={changePasswordOption} onCharacterTypeChange={changeCharacterType} />
|
||||
)}
|
||||
{settings.mode === 'passphrase' && (
|
||||
<>
|
||||
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={passwordOptions.length} minimum={5} maximum={128} fallback={14} onChange={(value) => changePasswordOption('length', value)} />
|
||||
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
|
||||
<GeneratorToggle checked={passwordOptions.uppercase} onChange={(checked) => changePasswordOption('uppercase', checked)} label={t('txt_generator_uppercase')} />
|
||||
<GeneratorToggle checked={passwordOptions.lowercase} onChange={(checked) => changePasswordOption('lowercase', checked)} label={t('txt_generator_lowercase')} />
|
||||
<GeneratorToggle checked={passwordOptions.numbers} onChange={(checked) => changePasswordOption('numbers', checked)} label={t('txt_generator_numbers')} />
|
||||
{passwordOptions.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={passwordOptions.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minNumbers', value)} />}
|
||||
<GeneratorToggle checked={passwordOptions.special} onChange={(checked) => changePasswordOption('special', checked)} label={t('txt_generator_special')} />
|
||||
{passwordOptions.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={passwordOptions.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minSpecial', value)} />}
|
||||
</fieldset>
|
||||
<GeneratorToggle checked={passwordOptions.avoidAmbiguous} onChange={(checked) => changePasswordOption('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={passphraseOptions.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
|
||||
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={passphraseOptions.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
|
||||
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={settings.passphrase.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
|
||||
<label className="generator-select-field" htmlFor="generator-word-list"><span>{t('txt_generator_word_list')}</span><select id="generator-word-list" className="input" value={settings.passphrase.wordList} onChange={(event) => changePassphraseOption('wordList', event.currentTarget.value as 'eff' | 'custom')}><option value="eff">{t('txt_generator_eff_word_list')}</option><option value="custom">{t('txt_generator_custom_word_list')}</option></select></label>
|
||||
{settings.passphrase.wordList === 'custom' && <label className="generator-text-field" htmlFor="generator-custom-words"><span>{t('txt_generator_custom_words')}</span><textarea id="generator-custom-words" className="input generator-word-list-input" rows={6} spellcheck={false} placeholder={t('txt_generator_custom_words_placeholder')} value={settings.passphrase.customWords} onInput={(event) => changePassphraseOption('customWords', event.currentTarget.value)} /></label>}
|
||||
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={settings.passphrase.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
|
||||
<div className="generator-option-group">
|
||||
<GeneratorToggle checked={passphraseOptions.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
||||
<GeneratorToggle checked={passphraseOptions.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
||||
<GeneratorToggle checked={settings.passphrase.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
||||
<GeneratorToggle checked={settings.passphrase.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{settings.mode === 'pin' && (
|
||||
<>
|
||||
<GeneratorNumberStepper id="pin-length" label={t('txt_generator_length')} value={settings.pin.length} minimum={3} maximum={64} fallback={6} onChange={(value) => changePinOption('length', value)} />
|
||||
<p className="generator-options-note">{t('txt_generator_pin_description')}</p>
|
||||
</>
|
||||
)}
|
||||
{settings.mode === 'username' && (
|
||||
<UsernameOptionFields options={settings.username} onChange={changeUsernameOption} />
|
||||
)}
|
||||
{settings.mode === 'email' && (
|
||||
<EmailOptionFields options={settings.email} onChange={changeEmailOption} />
|
||||
)}
|
||||
{settings.mode === 'sshKey' && (
|
||||
<SshKeyOptionFields options={settings.sshKey} onChange={changeSshKeyOption} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordOptionFields(props: { options: PasswordOptions; onChange: <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => void; onCharacterTypeChange: (key: 'uppercase' | 'lowercase' | 'numbers' | 'special', checked: boolean) => void }) {
|
||||
const { options } = props;
|
||||
return (
|
||||
<>
|
||||
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={options.length} minimum={5} maximum={128} fallback={16} onChange={(value) => props.onChange('length', value)} />
|
||||
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
|
||||
<GeneratorToggle checked={options.uppercase} onChange={(checked) => props.onCharacterTypeChange('uppercase', checked)} label={t('txt_generator_uppercase')} />
|
||||
{options.uppercase && <GeneratorNumberStepper id="min-uppercase" compact label={t('txt_generator_minimum')} value={options.minUppercase} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minUppercase', value)} />}
|
||||
<GeneratorToggle checked={options.lowercase} onChange={(checked) => props.onCharacterTypeChange('lowercase', checked)} label={t('txt_generator_lowercase')} />
|
||||
{options.lowercase && <GeneratorNumberStepper id="min-lowercase" compact label={t('txt_generator_minimum')} value={options.minLowercase} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minLowercase', value)} />}
|
||||
<GeneratorToggle checked={options.numbers} onChange={(checked) => props.onCharacterTypeChange('numbers', checked)} label={t('txt_generator_numbers')} />
|
||||
{options.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={options.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minNumbers', value)} />}
|
||||
<GeneratorToggle checked={options.special} onChange={(checked) => props.onCharacterTypeChange('special', checked)} label={t('txt_generator_special')} />
|
||||
{options.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={options.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => props.onChange('minSpecial', value)} />}
|
||||
</fieldset>
|
||||
<GeneratorToggle checked={options.avoidAmbiguous} onChange={(checked) => props.onChange('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UsernameOptionFields(props: { options: UsernameOptions; onChange: <K extends keyof UsernameOptions>(key: K, value: UsernameOptions[K]) => void }) {
|
||||
return (
|
||||
<>
|
||||
<GeneratorNumberStepper id="username-words" label={t('txt_generator_words')} value={props.options.words} minimum={1} maximum={10} fallback={2} onChange={(value) => props.onChange('words', value)} />
|
||||
<div className="generator-option-group">
|
||||
<GeneratorToggle checked={props.options.capitalize} onChange={(checked) => props.onChange('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
||||
<GeneratorToggle checked={props.options.includeNumber} onChange={(checked) => props.onChange('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
||||
</div>
|
||||
<label className="generator-select-field" htmlFor="generator-username-word-list"><span>{t('txt_generator_word_list')}</span><select id="generator-username-word-list" className="input" value={props.options.wordList} onChange={(event) => props.onChange('wordList', event.currentTarget.value as 'eff' | 'custom')}><option value="eff">{t('txt_generator_eff_word_list')}</option><option value="custom">{t('txt_generator_custom_word_list')}</option></select></label>
|
||||
{props.options.wordList === 'custom' && <label className="generator-text-field" htmlFor="generator-username-custom-words"><span>{t('txt_generator_custom_words')}</span><textarea id="generator-username-custom-words" className="input generator-word-list-input" rows={6} spellcheck={false} placeholder={t('txt_generator_custom_words_placeholder')} value={props.options.customWords} onInput={(event) => props.onChange('customWords', event.currentTarget.value)} /></label>}
|
||||
<label className="generator-text-field" htmlFor="generator-username-custom-word"><span>{t('txt_generator_custom_word')}</span><input id="generator-username-custom-word" className="input" type="text" autocomplete="off" maxLength={128} value={props.options.customWord} onInput={(event) => props.onChange('customWord', event.currentTarget.value)} /></label>
|
||||
<label className="generator-text-field" htmlFor="generator-username-delimiter"><span>{t('txt_generator_separator')}</span><input id="generator-username-delimiter" className="input" type="text" autocomplete="off" maxLength={8} value={props.options.delimiter} onInput={(event) => props.onChange('delimiter', event.currentTarget.value.slice(0, 8))} /></label>
|
||||
<p className="generator-options-note">{t('txt_generator_long_word_username_description')}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailOptionFields(props: { options: EmailOptions; onChange: <K extends keyof EmailOptions>(key: K, value: EmailOptions[K]) => void }) {
|
||||
const types: Array<[EmailMode, string]> = [
|
||||
['plusAddressed', 'txt_generator_plus_addressed_email'],
|
||||
['catchAll', 'txt_generator_catch_all_email'],
|
||||
['subdomain', 'txt_generator_subdomain_email'],
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<label className="generator-select-field" htmlFor="generator-email-type"><span>{t('txt_generator_email_type')}</span><select id="generator-email-type" className="input" value={props.options.type} onChange={(event) => props.onChange('type', event.currentTarget.value as EmailMode)}>{types.map(([value, label]) => <option key={value} value={value}>{t(label)}</option>)}</select></label>
|
||||
{props.options.type === 'catchAll'
|
||||
? <label className="generator-text-field" htmlFor="generator-domain"><span>{t('txt_generator_domain')}</span><input id="generator-domain" className="input" type="text" autocomplete="off" value={props.options.domain} onInput={(event) => props.onChange('domain', event.currentTarget.value)} /></label>
|
||||
: <label className="generator-text-field" htmlFor="generator-email"><span>{t('txt_generator_email')}</span><input id="generator-email" className="input" type="email" autocomplete="off" value={props.options.email} onInput={(event) => props.onChange('email', event.currentTarget.value)} /></label>}
|
||||
<p className="generator-options-note">{t('txt_generator_email_description')}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function publicKeyWithComment(publicKey: string, comment: string): string {
|
||||
const base = publicKey.trim().split(/\s+/).slice(0, 2).join(' ');
|
||||
const safeComment = comment.replace(/[\r\n]+/g, ' ').trim();
|
||||
return safeComment ? `${base} ${safeComment}` : base;
|
||||
}
|
||||
|
||||
function downloadText(filename: string, value: string): void {
|
||||
const url = URL.createObjectURL(new Blob([value], { type: 'text/plain;charset=utf-8' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.style.display = 'none';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function SshKeyOutput(props: { value: GeneratedSshKey | null; loading: boolean; error: string; comment: string }) {
|
||||
if (props.loading) return <div className="generator-key-status"><LoaderCircle size={24} className="generator-spinner" /><span>{t('txt_generator_ssh_generating')}</span></div>;
|
||||
if (props.error) return <div className="generator-key-status error">{props.error}</div>;
|
||||
if (!props.value) return null;
|
||||
const publicKey = publicKeyWithComment(props.value.publicKey, props.comment);
|
||||
const copyField = (value: string) => void copyTextToClipboard(value);
|
||||
return (
|
||||
<div className="generator-key-output">
|
||||
<div className="generator-key-summary"><strong>{props.value.type}{props.value.type === 'RSA' ? ` ${props.value.bits}` : ''}</strong><code>{props.value.fingerprint}</code></div>
|
||||
<div className="generator-key-field"><span>{t('txt_generator_public_key')}</span><code>{publicKey}</code><div className="generator-key-field-actions"><button type="button" className="btn btn-secondary small" onClick={() => copyField(publicKey)}><Copy size={14} />{t('txt_copy')}</button><button type="button" className="btn btn-secondary small" onClick={() => downloadText('id_nodewarden.pub', `${publicKey}\n`)}><Download size={14} />{t('txt_download')}</button></div></div>
|
||||
<details className="generator-private-key"><summary>{t('txt_generator_private_key')}</summary><code>{props.value.privateKey}</code><div className="generator-key-field-actions"><button type="button" className="btn btn-secondary small" onClick={() => copyField(props.value!.privateKey)}><Copy size={14} />{t('txt_copy')}</button><button type="button" className="btn btn-secondary small" onClick={() => downloadText('id_nodewarden', props.value!.privateKey)}><Download size={14} />{t('txt_download')}</button></div></details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SshKeyOptionFields(props: { options: SshKeyOptions; onChange: <K extends keyof SshKeyOptions>(key: K, value: SshKeyOptions[K]) => void }) {
|
||||
return (
|
||||
<>
|
||||
<label className="generator-select-field" htmlFor="generator-ssh-type"><span>{t('txt_generator_ssh_algorithm')}</span><select id="generator-ssh-type" className="input" value={props.options.type} onChange={(event) => props.onChange('type', event.currentTarget.value as SshKeyOptions['type'])}><option value="ed25519">Ed25519</option><option value="rsa">RSA</option></select></label>
|
||||
{props.options.type === 'rsa' && <label className="generator-select-field" htmlFor="generator-rsa-length"><span>{t('txt_generator_key_length')}</span><select id="generator-rsa-length" className="input" value={props.options.rsaLength} onChange={(event) => props.onChange('rsaLength', Number(event.currentTarget.value) as SshKeyOptions['rsaLength'])}><option value={2048}>2048</option><option value={3072}>3072</option><option value={4096}>4096</option></select></label>}
|
||||
<label className="generator-text-field" htmlFor="generator-ssh-comment"><span>{t('txt_generator_ssh_comment')}</span><input id="generator-ssh-comment" className="input" type="text" autocomplete="off" maxLength={256} placeholder="user@example.com" value={props.options.comment} onInput={(event) => props.onChange('comment', event.currentTarget.value)} /></label>
|
||||
<p className="generator-options-note">{t(props.options.type === 'rsa' ? 'txt_generator_ssh_rsa_description' : 'txt_generator_ssh_ed25519_description')}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratorToggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||
return <label className="generator-toggle"><input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.currentTarget.checked)} /><span aria-hidden="true" /><strong>{props.label}</strong></label>;
|
||||
}
|
||||
|
||||
function GeneratorNumberStepper(props: { id: string; label: string; value: number; minimum: number; maximum: number; fallback: number; compact?: boolean; onChange: (value: number) => void }) {
|
||||
const id = `generator-stepper-${props.id}`;
|
||||
const setValue = (value: number) => props.onChange(clamp(value, props.minimum, props.maximum, props.fallback));
|
||||
const setValue = (value: number) => props.onChange(clampInteger(value, props.minimum, props.maximum, props.fallback));
|
||||
return (
|
||||
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
|
||||
<label htmlFor={id}>{props.label}</label>
|
||||
|
||||
@@ -382,7 +382,9 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
}
|
||||
const groupIndexByKey = new Map<string, number>();
|
||||
Array.from(groupKeys).sort().forEach((groupKey, index) => {
|
||||
groupIndexByKey.set(groupKey, index % 64);
|
||||
// Keep indices unique (no modulo): they are used both for group colors and
|
||||
// as the group identity when selecting duplicate items to delete.
|
||||
groupIndexByKey.set(groupKey, index);
|
||||
});
|
||||
const byId = new Map<string, number>();
|
||||
for (const [cipherId, groupKey] of groupKeyById.entries()) {
|
||||
@@ -831,13 +833,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setLocalError(t('txt_item_name_is_required'));
|
||||
return;
|
||||
}
|
||||
// Sync may change the current selection while a draft is open. Save only
|
||||
// the item that this draft belongs to, and retain it if the item disappeared.
|
||||
const editedCipher = isCreating ? null : cipherById.get(nextDraft.id || selectedCipherId);
|
||||
if (!isCreating && !editedCipher) {
|
||||
setLocalError(t('txt_item_changed_elsewhere'));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
if (isCreating) {
|
||||
await props.onCreate(nextDraft, attachmentQueue);
|
||||
} else if (selectedCipher) {
|
||||
} else if (editedCipher) {
|
||||
const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]);
|
||||
await props.onUpdate(selectedCipher, nextDraft, {
|
||||
await props.onUpdate(editedCipher, nextDraft, {
|
||||
addFiles: attachmentQueue,
|
||||
removeAttachmentIds,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { cipherFieldMetadata } from '@/lib/cipher-fields';
|
||||
import {
|
||||
BookUser,
|
||||
CreditCard,
|
||||
@@ -558,6 +559,7 @@ export function createEmptyDraft(type: number): VaultDraft {
|
||||
export function draftFromCipher(cipher: Cipher): VaultDraft {
|
||||
const draft = createEmptyDraft(Number(cipher.type || 1));
|
||||
draft.id = cipher.id;
|
||||
draft.revisionDate = cipher.revisionDate;
|
||||
draft.favorite = !!cipher.favorite;
|
||||
draft.name = cipher.decName || '';
|
||||
draft.folderId = cipher.folderId || '';
|
||||
@@ -655,6 +657,7 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
||||
draft.passportExpirationDate = cipher.passport.decExpirationDate || '';
|
||||
}
|
||||
draft.customFields = (cipher.fields || []).map((field) => ({
|
||||
...cipherFieldMetadata(field),
|
||||
type: parseFieldType(field.type),
|
||||
label: field.decName || '',
|
||||
value: field.decValue || '',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'preact/hooks';
|
||||
import { cipherFieldMetadata } from '@/lib/cipher-fields';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
|
||||
import {
|
||||
@@ -221,7 +222,9 @@ function optimisticCipherFromDraft(draft: VaultDraft, current?: Cipher | null):
|
||||
}
|
||||
|
||||
next.fields = draft.customFields.map((field) => ({
|
||||
...cipherFieldMetadata(field.extra || {}).extra,
|
||||
type: field.type,
|
||||
...(field.type === 3 ? { linkedId: field.linkedId ?? null } : {}),
|
||||
name: field.label,
|
||||
value: field.value,
|
||||
decName: field.label,
|
||||
@@ -608,6 +611,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
onNotify('success', t('txt_item_updated'));
|
||||
} catch (error) {
|
||||
patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false });
|
||||
if (error instanceof Error && error.message === t('txt_item_changed_elsewhere')) {
|
||||
// Fetch the current item for reopening; the editor keeps the draft.
|
||||
await refetchCiphers().catch(() => {});
|
||||
}
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -197,7 +197,7 @@ export async function downloadAdminBackupAttachmentBlob(
|
||||
authedFetch: AuthedFetch,
|
||||
blobName: string,
|
||||
masterPasswordHash: string
|
||||
): Promise<Uint8Array> {
|
||||
): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const resp = await authedFetch('/api/admin/backup/blob', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
} from './shared';
|
||||
import { readResponseBytesWithProgress } from '../download';
|
||||
import { loadVaultCoreSyncSnapshot } from './vault-sync';
|
||||
import { cipherFieldMetadata } from '../cipher-fields';
|
||||
import { t } from '../i18n';
|
||||
|
||||
type CipherLoginData = NonNullable<Cipher['login']>;
|
||||
const NODEWARDEN_WEB_REPAIR_HEADER = 'X-NodeWarden-Web';
|
||||
@@ -573,6 +575,8 @@ function plainCipherValue(decrypted: unknown, raw: unknown = ''): string {
|
||||
function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
|
||||
const type = Number(cipher.type || 1) || 1;
|
||||
const draft: VaultDraft = {
|
||||
id: cipher.id,
|
||||
revisionDate: cipher.revisionDate,
|
||||
type,
|
||||
name: plainCipherValue(cipher.decName, cipher.name).trim() || 'Untitled',
|
||||
notes: plainCipherValue(cipher.decNotes, cipher.notes),
|
||||
@@ -650,6 +654,7 @@ function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
|
||||
|
||||
draft.customFields = (cipher.fields || [])
|
||||
.map((field) => ({
|
||||
...cipherFieldMetadata(field),
|
||||
type: parseFieldType(field.type ?? 0),
|
||||
label: plainCipherValue(field.decName, field.name).trim(),
|
||||
value: plainCipherValue(field.decValue, field.value),
|
||||
@@ -797,15 +802,17 @@ async function encryptCustomFields(
|
||||
fields: VaultDraftField[],
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<Array<{ type: number; name: string | null; value: string | null }>> {
|
||||
const out: Array<{ type: number; name: string | null; value: string | null }> = [];
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
for (const field of fields || []) {
|
||||
const label = String(field.label || '').trim();
|
||||
if (!label) continue;
|
||||
out.push({
|
||||
...cipherFieldMetadata(field.extra || {}).extra,
|
||||
type: parseFieldType(field.type),
|
||||
name: await encryptTextValue(label, enc, mac),
|
||||
value: await encryptTextValue(String(field.value || ''), enc, mac),
|
||||
...(parseFieldType(field.type) === 3 ? { linkedId: field.linkedId ?? null } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -1300,6 +1307,8 @@ async function buildCipherPayload(
|
||||
if (cipher?.id) {
|
||||
payload.id = cipher.id;
|
||||
payload.key = keys.key;
|
||||
// Use the version captured when editing began, even if sync refreshed cipher.
|
||||
payload.lastKnownRevisionDate = draft.revisionDate ?? cipher.revisionDate;
|
||||
}
|
||||
|
||||
if (type === 1) {
|
||||
@@ -1327,6 +1336,7 @@ async function buildCipherPayload(
|
||||
payload.passwordHistory = await buildUpdatedPasswordHistory(cipher, draft, keys.enc, keys.mac);
|
||||
} else if (type === 3) {
|
||||
payload.card = {
|
||||
...stripDecodedObjectFields(cipher?.card),
|
||||
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
|
||||
number: await encryptTextValue(draft.cardNumber, keys.enc, keys.mac),
|
||||
brand: await encryptTextValue(draft.cardBrand, keys.enc, keys.mac),
|
||||
@@ -1336,6 +1346,7 @@ async function buildCipherPayload(
|
||||
};
|
||||
} else if (type === 4) {
|
||||
payload.identity = {
|
||||
...stripDecodedObjectFields(cipher?.identity),
|
||||
title: await encryptTextValue(draft.identTitle, keys.enc, keys.mac),
|
||||
firstName: await encryptTextValue(draft.identFirstName, keys.enc, keys.mac),
|
||||
middleName: await encryptTextValue(draft.identMiddleName, keys.enc, keys.mac),
|
||||
@@ -1426,7 +1437,7 @@ async function buildCipherPayload(
|
||||
keys.mac
|
||||
);
|
||||
} else if (type === 2) {
|
||||
payload.secureNote = { type: 0 };
|
||||
payload.secureNote = { ...stripDecodedObjectFields(cipher?.secureNote), type: 0 };
|
||||
}
|
||||
|
||||
return payload;
|
||||
@@ -1462,6 +1473,9 @@ export async function updateCipher(
|
||||
extraPayload?: Record<string, unknown>,
|
||||
options?: { webRepair?: boolean }
|
||||
): Promise<Cipher> {
|
||||
if (draft.id && draft.id !== cipher.id) {
|
||||
throw createApiError(t('txt_item_changed_elsewhere'), 400);
|
||||
}
|
||||
const payload = await buildCipherPayload(session, draft, cipher);
|
||||
if (extraPayload) {
|
||||
Object.assign(payload, extraPayload);
|
||||
@@ -1475,7 +1489,7 @@ export async function updateCipher(
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Update item failed');
|
||||
if (!resp.ok) throw createApiError(await parseErrorMessage(resp, t('txt_update_item_failed')), resp.status);
|
||||
return (await parseJson<Cipher>(resp))!;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { hkdf } from '@/lib/crypto';
|
||||
import { cipherFieldMetadata } from './cipher-fields';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { VaultDraft } from '@/lib/types';
|
||||
import type { ImportResultSummary } from '@/components/ImportPage';
|
||||
@@ -206,6 +207,8 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
||||
const fieldType = parsedType === 1 || parsedType === 2 || parsedType === 3 ? (parsedType as 1 | 2 | 3) : 0;
|
||||
return {
|
||||
type: fieldType,
|
||||
// Plaintext import cannot safely pass through unknown encrypted values.
|
||||
...cipherFieldMetadata({ linkedId: field.linkedId }),
|
||||
label,
|
||||
value: asText(field.value),
|
||||
};
|
||||
|
||||
@@ -186,7 +186,7 @@ export function invalidateRemoteBrowserCacheForDestination(
|
||||
cache: Record<string, RemoteBackupBrowserResponse>,
|
||||
pathByDestination: Record<string, string>,
|
||||
pageByKey: Record<string, number>
|
||||
): PersistedRemoteBrowserState {
|
||||
): Omit<PersistedRemoteBrowserState, 'refreshedAt'> {
|
||||
return {
|
||||
cache: Object.fromEntries(Object.entries(cache).filter(([key]) => !key.startsWith(`${destinationId}:`))),
|
||||
pathByDestination: Object.fromEntries(Object.entries(pathByDestination).filter(([key]) => key !== destinationId)),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { VaultDraftField } from './types';
|
||||
|
||||
// Keep metadata on each draft field so removing or reordering fields cannot
|
||||
// accidentally attach another field's link or unknown encrypted properties.
|
||||
export function cipherFieldMetadata(field: object): Pick<VaultDraftField, 'linkedId' | 'extra'> {
|
||||
const raw = field as Record<string, unknown>;
|
||||
return {
|
||||
...(typeof raw.linkedId === 'number' || raw.linkedId === null ? { linkedId: raw.linkedId } : {}),
|
||||
extra: Object.fromEntries(Object.entries(raw).filter(([key]) =>
|
||||
!['type', 'name', 'value', 'linkedId'].includes(key) && !/^dec[A-Z]/.test(key)
|
||||
)),
|
||||
};
|
||||
}
|
||||
@@ -163,6 +163,7 @@ export function translateServerError(message: string | null | undefined, fallbac
|
||||
|
||||
const key = {
|
||||
'Account is disabled': 'txt_server_error_account_disabled',
|
||||
'The client copy of this cipher is out of date. Resync the client and try again.': 'txt_item_changed_elsewhere',
|
||||
'Another backup or restore run is already in progress': 'txt_backup_error_another_backup_or_restore_running',
|
||||
'Another backup run is already in progress': 'txt_backup_error_another_backup_running',
|
||||
'Backup archive upload failed': 'txt_backup_error_archive_upload_failed',
|
||||
|
||||
@@ -8,6 +8,10 @@ const de: Record<string, string> = {
|
||||
"nav_vault_items": "Tresor",
|
||||
"nav_sends": "Sendungen",
|
||||
"nav_generator": "Passwortwerkzeug", "txt_password_generator": "Passwortgenerator", "txt_password_generator_description": "Erstellen Sie lokal auf diesem Gerät ein starkes, einzigartiges Passwort.", "txt_generator_type": "Generatortyp", "txt_passphrase": "Kennwortsatz", "txt_generated_password": "Generiertes Passwort", "txt_password_strength": "Stärke", "txt_password_strength_weak": "Schwach", "txt_password_strength_fair": "Mittel", "txt_password_strength_good": "Gut", "txt_password_strength_strong": "Stark", "txt_generator_security_note": "Die Erzeugung erfolgt lokal. Ihr Passwort wird nie an den Server gesendet.", "txt_generator_length": "Länge", "txt_generator_character_types": "Zeichentypen", "txt_generator_uppercase": "Großbuchstaben (A-Z)", "txt_generator_lowercase": "Kleinbuchstaben (a-z)", "txt_generator_numbers": "Zahlen (0-9)", "txt_generator_special": "Sonderzeichen (!@#$%^&*)", "txt_generator_minimum": "Mindestanzahl", "txt_generator_avoid_ambiguous": "Verwechselbare Zeichen vermeiden", "txt_generator_words": "Anzahl der Wörter", "txt_generator_separator": "Worttrenner", "txt_generator_capitalize": "Großschreibung", "txt_generator_include_number": "Eine Zahl einfügen",
|
||||
"txt_generated_value": "Erzeugter Wert", "txt_generator_character_count": "{count} Zeichen", "txt_generator_pin": "PIN-Code", "txt_generator_pin_description": "Erzeugt lokal eine numerische PIN mit kryptografisch sicheren Zufallswerten.", "txt_generator_username": "Benutzername", "txt_generator_plus_addressed_email": "E-Mail mit Plus-Adressierung", "txt_generator_catch_all_email": "Catch-all-E-Mail", "txt_generator_email": "E-Mail-Adresse", "txt_generator_domain": "Domäne",
|
||||
"txt_generator_word_list": "Wortliste", "txt_generator_eff_word_list": "Lange EFF-Wortliste", "txt_generator_custom_word_list": "Eigene Wortliste", "txt_generator_custom_words": "Eigene Wörter", "txt_generator_custom_words_placeholder": "Mindestens zwei Wörter durch Leerzeichen, Kommas oder Zeilen trennen",
|
||||
"txt_generator_long_word_username": "Langwort-Benutzername", "txt_generator_custom_word": "Eigenes Merkwort", "txt_generator_subdomain_email": "Subdomain-E-Mail", "txt_generator_ssh_key": "SSH-Schlüssel", "txt_generator_ssh_generating": "SSH-Schlüssel wird erzeugt…", "txt_generator_ssh_error": "Dieser Browser unterstützt keine SSH-Schlüsselerzeugung.", "txt_generator_copy_public_key": "Öffentlichen Schlüssel kopieren", "txt_generator_ssh_security_note": "Das Schlüsselpaar wird lokal erzeugt und nicht gesendet. Speichern Sie den privaten Schlüssel jetzt; NodeWarden behält ihn nicht.", "txt_generator_public_key": "Öffentlicher Schlüssel", "txt_generator_private_key": "Privater Schlüssel — geheim halten", "txt_generator_ssh_algorithm": "Algorithmus", "txt_generator_key_length": "Schlüssellänge", "txt_generator_ssh_comment": "Kommentar des öffentlichen Schlüssels", "txt_generator_ssh_rsa_description": "RSA ist breit kompatibel. Mindestens 2048 Bit verwenden; 4096 Bit werden empfohlen.", "txt_generator_ssh_ed25519_description": "Ed25519 ist schnell, kompakt und für moderne SSH-Clients empfohlen.",
|
||||
"txt_generator_email_alias": "E-Mail-Alias", "txt_generator_email_type": "Alias-Typ", "txt_generator_email_required_hint": "E-Mail-Adresse oder Domain eingeben, um einen Alias zu erzeugen.", "txt_generator_email_description": "Ihre Eingabe bleibt auf diesem Gerät und wird nur zum Zusammensetzen des Alias verwendet.", "txt_generator_long_word_username_description": "Ein oder mehrere lange Wörter zu einem einprägsamen Benutzernamen kombinieren.",
|
||||
"nav_backup_strategy": "Cloud-Backup",
|
||||
"nav_import_export": "Import und Export",
|
||||
"nav_group_data_backup": "Daten & Backup",
|
||||
@@ -1083,6 +1087,7 @@ const de: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Fehler beim Entfernen des Geräts",
|
||||
"txt_remove_all_devices_failed": "Fehler beim Entfernen aller Geräte",
|
||||
"txt_update_item_failed": "Fehler beim Aktualisieren des Eintrags",
|
||||
"txt_item_changed_elsewhere": "Dieser Eintrag wurde in einem anderen Client geändert. Deine Änderungen stehen noch im Formular. Kopiere sie, öffne den Eintrag erneut und speichere ihn.",
|
||||
"txt_update_send_failed": "Fehler beim Aktualisieren der Sendung",
|
||||
"txt_update_user_status_failed": "Fehler beim Aktualisieren des Benutzerstatus",
|
||||
"txt_use_recovery_code": "Wiederherstellungscode verwenden",
|
||||
|
||||
@@ -31,6 +31,40 @@ const en: Record<string, string> = {
|
||||
"txt_generator_separator": "Word separator",
|
||||
"txt_generator_capitalize": "Capitalize",
|
||||
"txt_generator_include_number": "Include a number",
|
||||
"txt_generated_value": "Generated value",
|
||||
"txt_generator_character_count": "{count} characters",
|
||||
"txt_generator_pin": "PIN",
|
||||
"txt_generator_pin_description": "Generate a numeric PIN locally using cryptographically secure randomness.",
|
||||
"txt_generator_username": "Username",
|
||||
"txt_generator_plus_addressed_email": "Plus-addressed email",
|
||||
"txt_generator_catch_all_email": "Catch-all email",
|
||||
"txt_generator_email": "Email address",
|
||||
"txt_generator_domain": "Domain",
|
||||
"txt_generator_word_list": "Word list",
|
||||
"txt_generator_eff_word_list": "EFF long word list",
|
||||
"txt_generator_custom_word_list": "Custom word list",
|
||||
"txt_generator_custom_words": "Custom words",
|
||||
"txt_generator_custom_words_placeholder": "Enter at least two words separated by spaces, commas, or new lines",
|
||||
"txt_generator_long_word_username": "Long-word username",
|
||||
"txt_generator_custom_word": "Custom helper word",
|
||||
"txt_generator_subdomain_email": "Subdomain email",
|
||||
"txt_generator_ssh_key": "SSH key",
|
||||
"txt_generator_ssh_generating": "Generating SSH key…",
|
||||
"txt_generator_ssh_error": "SSH key generation is not supported by this browser.",
|
||||
"txt_generator_copy_public_key": "Copy public key",
|
||||
"txt_generator_ssh_security_note": "The key pair is generated locally and is never sent to the server. Save the private key now; NodeWarden does not retain it.",
|
||||
"txt_generator_public_key": "Public key",
|
||||
"txt_generator_private_key": "Private key — keep secret",
|
||||
"txt_generator_ssh_algorithm": "Algorithm",
|
||||
"txt_generator_key_length": "Key length",
|
||||
"txt_generator_ssh_comment": "Public key comment",
|
||||
"txt_generator_ssh_rsa_description": "RSA is widely compatible. Use at least 2048 bits; 4096 bits is recommended.",
|
||||
"txt_generator_ssh_ed25519_description": "Ed25519 is fast, compact, and recommended for modern SSH clients.",
|
||||
"txt_generator_email_alias": "Email alias",
|
||||
"txt_generator_email_type": "Email alias type",
|
||||
"txt_generator_email_required_hint": "Enter your email address or domain to generate an alias.",
|
||||
"txt_generator_email_description": "Your address stays on this device and is only used to compose the alias.",
|
||||
"txt_generator_long_word_username_description": "Combine one or more long words into a memorable username.",
|
||||
"nav_backup_strategy": "Cloud Backup",
|
||||
"nav_import_export": "Import & Export",
|
||||
"nav_group_data_backup": "Data & Backup",
|
||||
@@ -1106,6 +1140,7 @@ const en: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Failed to remove device",
|
||||
"txt_remove_all_devices_failed": "Failed to remove all devices",
|
||||
"txt_update_item_failed": "Update item failed",
|
||||
"txt_item_changed_elsewhere": "This item was changed in another client. Your edits are still in the form. Copy the changes you need, reopen the item, and save again.",
|
||||
"txt_update_send_failed": "Update send failed",
|
||||
"txt_update_user_status_failed": "Failed to update user status",
|
||||
"txt_use_recovery_code": "Use Recovery Code",
|
||||
|
||||
@@ -8,6 +8,10 @@ const es: Record<string, string> = {
|
||||
"nav_vault_items": "Bóveda",
|
||||
"nav_sends": "Envíos",
|
||||
"nav_generator": "Generador", "txt_password_generator": "Generador de contraseñas", "txt_password_generator_description": "Crea una contraseña única y segura localmente en este dispositivo.", "txt_generator_type": "Tipo de generador", "txt_passphrase": "Frase de contraseña", "txt_generated_password": "Contraseña generada", "txt_password_strength": "Seguridad", "txt_password_strength_weak": "Débil", "txt_password_strength_fair": "Regular", "txt_password_strength_good": "Buena", "txt_password_strength_strong": "Fuerte", "txt_generator_security_note": "La generación se realiza localmente. Tu contraseña nunca se envía al servidor.", "txt_generator_length": "Longitud", "txt_generator_character_types": "Tipos de caracteres", "txt_generator_uppercase": "Mayúsculas (A-Z)", "txt_generator_lowercase": "Minúsculas (a-z)", "txt_generator_numbers": "Números (0-9)", "txt_generator_special": "Caracteres especiales (!@#$%^&*)", "txt_generator_minimum": "Mínimo", "txt_generator_avoid_ambiguous": "Evitar caracteres ambiguos", "txt_generator_words": "Número de palabras", "txt_generator_separator": "Separador de palabras", "txt_generator_capitalize": "Usar mayúsculas", "txt_generator_include_number": "Incluir un número",
|
||||
"txt_generated_value": "Valor generado", "txt_generator_character_count": "{count} caracteres", "txt_generator_pin": "PIN", "txt_generator_pin_description": "Genera localmente un PIN numérico con aleatoriedad criptográficamente segura.", "txt_generator_username": "Nombre de usuario", "txt_generator_plus_addressed_email": "Correo con direccionamiento plus", "txt_generator_catch_all_email": "Correo catch-all", "txt_generator_email": "Correo electrónico", "txt_generator_domain": "Dominio",
|
||||
"txt_generator_word_list": "Lista de palabras", "txt_generator_eff_word_list": "Lista larga de EFF", "txt_generator_custom_word_list": "Lista personalizada", "txt_generator_custom_words": "Palabras personalizadas", "txt_generator_custom_words_placeholder": "Introduce al menos dos palabras separadas por espacios, comas o líneas",
|
||||
"txt_generator_long_word_username": "Usuario de palabras largas", "txt_generator_custom_word": "Palabra auxiliar personalizada", "txt_generator_subdomain_email": "Correo de subdominio", "txt_generator_ssh_key": "Clave SSH", "txt_generator_ssh_generating": "Generando clave SSH…", "txt_generator_ssh_error": "Este navegador no admite la generación de claves SSH.", "txt_generator_copy_public_key": "Copiar clave pública", "txt_generator_ssh_security_note": "El par se genera localmente y no se envía. Guarda ahora la clave privada; NodeWarden no la conserva.", "txt_generator_public_key": "Clave pública", "txt_generator_private_key": "Clave privada — mantenla en secreto", "txt_generator_ssh_algorithm": "Algoritmo", "txt_generator_key_length": "Longitud de clave", "txt_generator_ssh_comment": "Comentario de clave pública", "txt_generator_ssh_rsa_description": "RSA es ampliamente compatible. Usa al menos 2048 bits; se recomiendan 4096.", "txt_generator_ssh_ed25519_description": "Ed25519 es rápido, compacto y recomendado para clientes SSH modernos.",
|
||||
"txt_generator_email_alias": "Alias de correo", "txt_generator_email_type": "Tipo de alias", "txt_generator_email_required_hint": "Introduce tu correo o dominio para generar un alias.", "txt_generator_email_description": "Los datos permanecen en este dispositivo y solo se usan para formar el alias.", "txt_generator_long_word_username_description": "Combina una o más palabras largas en un nombre de usuario fácil de recordar.",
|
||||
"nav_backup_strategy": "Copia de seguridad en la nube",
|
||||
"nav_import_export": "Importar y exportar",
|
||||
"nav_group_data_backup": "Datos y copias",
|
||||
@@ -1083,6 +1087,7 @@ const es: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Error al quitar dispositivo",
|
||||
"txt_remove_all_devices_failed": "Error al quitar todos los dispositivos",
|
||||
"txt_update_item_failed": "Error al actualizar elemento",
|
||||
"txt_item_changed_elsewhere": "Este elemento se modificó en otro cliente. Tus cambios siguen en el formulario. Copia los cambios necesarios, vuelve a abrir el elemento y guárdalo.",
|
||||
"txt_update_send_failed": "Error al actualizar envío",
|
||||
"txt_update_user_status_failed": "Error al actualizar estado de usuario",
|
||||
"txt_use_recovery_code": "Usar código de recuperación",
|
||||
|
||||
@@ -8,6 +8,10 @@ const fi: Record<string, string> = {
|
||||
"nav_vault_items": "Holvi",
|
||||
"nav_sends": "Lähetykset",
|
||||
"nav_generator": "Luoja", "txt_password_generator": "Salasanageneraattori", "txt_password_generator_description": "Luo vahva ja yksilöllinen salasana paikallisesti tällä laitteella.", "txt_generator_type": "Generaattorin tyyppi", "txt_passphrase": "Salalause", "txt_generated_password": "Luotu salasana", "txt_password_strength": "Vahvuus", "txt_password_strength_weak": "Heikko", "txt_password_strength_fair": "Kohtalainen", "txt_password_strength_good": "Hyvä", "txt_password_strength_strong": "Vahva", "txt_generator_security_note": "Generointi tapahtuu paikallisesti. Salasanaa ei koskaan lähetetä palvelimelle.", "txt_generator_length": "Pituus", "txt_generator_character_types": "Merkkityypit", "txt_generator_uppercase": "Isot kirjaimet (A-Z)", "txt_generator_lowercase": "Pienet kirjaimet (a-z)", "txt_generator_numbers": "Numerot (0-9)", "txt_generator_special": "Erikoismerkit (!@#$%^&*)", "txt_generator_minimum": "Vähintään", "txt_generator_avoid_ambiguous": "Vältä epäselviä merkkejä", "txt_generator_words": "Sanojen määrä", "txt_generator_separator": "Sanaerotin", "txt_generator_capitalize": "Iso alkukirjain", "txt_generator_include_number": "Sisällytä numero",
|
||||
"txt_generated_value": "Luotu arvo", "txt_generator_character_count": "{count} merkkiä", "txt_generator_pin": "PIN", "txt_generator_pin_description": "Luo numeerisen PIN-koodin paikallisesti kryptografisesti turvallisella satunnaisuudella.", "txt_generator_username": "Käyttäjänimi", "txt_generator_plus_addressed_email": "Plus-osoitteinen sähköposti", "txt_generator_catch_all_email": "Catch-all-sähköposti", "txt_generator_email": "Sähköpostiosoite", "txt_generator_domain": "Verkkotunnus",
|
||||
"txt_generator_word_list": "Sanalista", "txt_generator_eff_word_list": "Pitkä EFF-sanalista", "txt_generator_custom_word_list": "Oma sanalista", "txt_generator_custom_words": "Omat sanat", "txt_generator_custom_words_placeholder": "Anna vähintään kaksi sanaa välilyönnein, pilkuin tai riveittäin",
|
||||
"txt_generator_long_word_username": "Pitkäsanainen käyttäjänimi", "txt_generator_custom_word": "Oma muistisanaa", "txt_generator_subdomain_email": "Aliverkkotunnuksen sähköposti", "txt_generator_ssh_key": "SSH-avain", "txt_generator_ssh_generating": "Luodaan SSH-avainta…", "txt_generator_ssh_error": "Selain ei tue SSH-avaimen luontia.", "txt_generator_copy_public_key": "Kopioi julkinen avain", "txt_generator_ssh_security_note": "Avainpari luodaan paikallisesti eikä sitä lähetetä. Tallenna yksityinen avain nyt; NodeWarden ei säilytä sitä.", "txt_generator_public_key": "Julkinen avain", "txt_generator_private_key": "Yksityinen avain — pidä salassa", "txt_generator_ssh_algorithm": "Algoritmi", "txt_generator_key_length": "Avaimen pituus", "txt_generator_ssh_comment": "Julkisen avaimen kommentti", "txt_generator_ssh_rsa_description": "RSA on laajasti yhteensopiva. Käytä vähintään 2048 bittiä; 4096 on suositus.", "txt_generator_ssh_ed25519_description": "Ed25519 on nopea ja kompakti sekä suositeltu moderneille SSH-asiakkaille.",
|
||||
"txt_generator_email_alias": "Sähköpostialias", "txt_generator_email_type": "Aliaksen tyyppi", "txt_generator_email_required_hint": "Anna sähköpostiosoite tai verkkotunnus aliaksen luomiseksi.", "txt_generator_email_description": "Syöte pysyy tällä laitteella ja sitä käytetään vain aliaksen muodostamiseen.", "txt_generator_long_word_username_description": "Yhdistä yksi tai useampi pitkä sana helposti muistettavaksi käyttäjänimeksi.",
|
||||
"nav_backup_strategy": "Pilvivarmuuskopiointi",
|
||||
"nav_import_export": "Tuonti ja Vienti",
|
||||
"nav_group_data_backup": "Data & Varmuuskopiointi",
|
||||
@@ -1083,6 +1087,7 @@ const fi: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Laitteen poistaminen epäonnistui",
|
||||
"txt_remove_all_devices_failed": "Kaikkien laitteiden poistaminen epäonnistui",
|
||||
"txt_update_item_failed": "Nimikkeen päivitys epäonnistui",
|
||||
"txt_item_changed_elsewhere": "Kohdetta muutettiin toisessa sovelluksessa. Muokkauksesi ovat yhä lomakkeessa. Kopioi tarvittavat muutokset, avaa kohde uudelleen ja tallenna.",
|
||||
"txt_update_send_failed": "Lähetyksen päivitys epäonnistui",
|
||||
"txt_update_user_status_failed": "Käyttäjän tilan päivitys epäonnistui",
|
||||
"txt_use_recovery_code": "Käytä palautuskoodia",
|
||||
|
||||
@@ -8,6 +8,10 @@ const fr: Record<string, string> = {
|
||||
"nav_vault_items": "Coffre-fort",
|
||||
"nav_sends": "Envois",
|
||||
"nav_generator": "Générateur", "txt_password_generator": "Générateur de mots de passe", "txt_password_generator_description": "Créez un mot de passe fort et unique localement sur cet appareil.", "txt_generator_type": "Type de générateur", "txt_passphrase": "Phrase secrète", "txt_generated_password": "Mot de passe généré", "txt_password_strength": "Robustesse", "txt_password_strength_weak": "Faible", "txt_password_strength_fair": "Correcte", "txt_password_strength_good": "Bonne", "txt_password_strength_strong": "Forte", "txt_generator_security_note": "La génération est locale. Votre mot de passe n'est jamais envoyé au serveur.", "txt_generator_length": "Longueur", "txt_generator_character_types": "Types de caractères", "txt_generator_uppercase": "Majuscules (A-Z)", "txt_generator_lowercase": "Minuscules (a-z)", "txt_generator_numbers": "Chiffres (0-9)", "txt_generator_special": "Caractères spéciaux (!@#$%^&*)", "txt_generator_minimum": "Minimum", "txt_generator_avoid_ambiguous": "Éviter les caractères ambigus", "txt_generator_words": "Nombre de mots", "txt_generator_separator": "Séparateur de mots", "txt_generator_capitalize": "Mettre une majuscule", "txt_generator_include_number": "Inclure un chiffre",
|
||||
"txt_generated_value": "Valeur générée", "txt_generator_character_count": "{count} caractères", "txt_generator_pin": "PIN", "txt_generator_pin_description": "Génère localement un PIN numérique avec un aléa cryptographiquement sûr.", "txt_generator_username": "Nom d’utilisateur", "txt_generator_plus_addressed_email": "Adresse e-mail avec plus", "txt_generator_catch_all_email": "Adresse e-mail catch-all", "txt_generator_email": "Adresse e-mail", "txt_generator_domain": "Domaine",
|
||||
"txt_generator_word_list": "Liste de mots", "txt_generator_eff_word_list": "Liste longue EFF", "txt_generator_custom_word_list": "Liste personnalisée", "txt_generator_custom_words": "Mots personnalisés", "txt_generator_custom_words_placeholder": "Saisissez au moins deux mots séparés par des espaces, virgules ou lignes",
|
||||
"txt_generator_long_word_username": "Identifiant à mots longs", "txt_generator_custom_word": "Mot aide-mémoire personnalisé", "txt_generator_subdomain_email": "E-mail de sous-domaine", "txt_generator_ssh_key": "Clé SSH", "txt_generator_ssh_generating": "Génération de la clé SSH…", "txt_generator_ssh_error": "Ce navigateur ne prend pas en charge la génération de clés SSH.", "txt_generator_copy_public_key": "Copier la clé publique", "txt_generator_ssh_security_note": "La paire est générée localement et jamais envoyée. Enregistrez la clé privée maintenant ; NodeWarden ne la conserve pas.", "txt_generator_public_key": "Clé publique", "txt_generator_private_key": "Clé privée — à garder secrète", "txt_generator_ssh_algorithm": "Algorithme", "txt_generator_key_length": "Longueur de clé", "txt_generator_ssh_comment": "Commentaire de clé publique", "txt_generator_ssh_rsa_description": "RSA est largement compatible. Utilisez au moins 2048 bits ; 4096 sont recommandés.", "txt_generator_ssh_ed25519_description": "Ed25519 est rapide, compact et recommandé pour les clients SSH modernes.",
|
||||
"txt_generator_email_alias": "Alias e-mail", "txt_generator_email_type": "Type d’alias", "txt_generator_email_required_hint": "Saisissez votre adresse e-mail ou domaine pour générer un alias.", "txt_generator_email_description": "La saisie reste sur cet appareil et sert uniquement à composer l’alias.", "txt_generator_long_word_username_description": "Combinez un ou plusieurs mots longs en un identifiant mémorable.",
|
||||
"nav_backup_strategy": "Sauvegarde Cloud",
|
||||
"nav_import_export": "Importer & Exporter",
|
||||
"nav_group_data_backup": "Données & Sauvegarde",
|
||||
@@ -1083,6 +1087,7 @@ const fr: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Le retrait de l'appareil a échoué",
|
||||
"txt_remove_all_devices_failed": "Le retrait de tous les appareils a échoué",
|
||||
"txt_update_item_failed": "La mise à jour de l'élément a échoué",
|
||||
"txt_item_changed_elsewhere": "Cet élément a été modifié dans un autre client. Vos modifications restent dans le formulaire. Copiez les changements nécessaires, rouvrez cet élément et enregistrez-le.",
|
||||
"txt_update_send_failed": "La mise à jour de l'envoi a échoué",
|
||||
"txt_update_user_status_failed": "La mise à jour du statut de l'utilisateur a échoué",
|
||||
"txt_use_recovery_code": "Utiliser le code de récupération",
|
||||
|
||||
@@ -8,6 +8,10 @@ const it: Record<string, string> = {
|
||||
"nav_vault_items": "Cassaforte",
|
||||
"nav_sends": "Invii",
|
||||
"nav_generator": "Generatore", "txt_password_generator": "Generatore di password", "txt_password_generator_description": "Crea una password forte e univoca localmente su questo dispositivo.", "txt_generator_type": "Tipo di generatore", "txt_passphrase": "Frase segreta", "txt_generated_password": "Password generata", "txt_password_strength": "Robustezza", "txt_password_strength_weak": "Debole", "txt_password_strength_fair": "Discreta", "txt_password_strength_good": "Buona", "txt_password_strength_strong": "Forte", "txt_generator_security_note": "La generazione avviene localmente. La password non viene mai inviata al server.", "txt_generator_length": "Lunghezza", "txt_generator_character_types": "Tipi di caratteri", "txt_generator_uppercase": "Maiuscole (A-Z)", "txt_generator_lowercase": "Minuscole (a-z)", "txt_generator_numbers": "Numeri (0-9)", "txt_generator_special": "Caratteri speciali (!@#$%^&*)", "txt_generator_minimum": "Minimo", "txt_generator_avoid_ambiguous": "Evita caratteri ambigui", "txt_generator_words": "Numero di parole", "txt_generator_separator": "Separatore di parole", "txt_generator_capitalize": "Iniziale maiuscola", "txt_generator_include_number": "Includi un numero",
|
||||
"txt_generated_value": "Valore generato", "txt_generator_character_count": "{count} caratteri", "txt_generator_pin": "Codice PIN", "txt_generator_pin_description": "Genera localmente un PIN numerico con casualità crittograficamente sicura.", "txt_generator_username": "Nome utente", "txt_generator_plus_addressed_email": "Email con indirizzamento plus", "txt_generator_catch_all_email": "Email catch-all", "txt_generator_email": "Indirizzo email", "txt_generator_domain": "Dominio",
|
||||
"txt_generator_word_list": "Elenco parole", "txt_generator_eff_word_list": "Elenco lungo EFF", "txt_generator_custom_word_list": "Elenco personalizzato", "txt_generator_custom_words": "Parole personalizzate", "txt_generator_custom_words_placeholder": "Inserisci almeno due parole separate da spazi, virgole o righe",
|
||||
"txt_generator_long_word_username": "Nome utente a parole lunghe", "txt_generator_custom_word": "Parola mnemonica personalizzata", "txt_generator_subdomain_email": "Email con sottodominio", "txt_generator_ssh_key": "Chiave SSH", "txt_generator_ssh_generating": "Generazione chiave SSH…", "txt_generator_ssh_error": "Questo browser non supporta la generazione di chiavi SSH.", "txt_generator_copy_public_key": "Copia chiave pubblica", "txt_generator_ssh_security_note": "La coppia viene generata localmente e mai inviata. Salva ora la chiave privata; NodeWarden non la conserva.", "txt_generator_public_key": "Chiave pubblica", "txt_generator_private_key": "Chiave privata — mantienila segreta", "txt_generator_ssh_algorithm": "Algoritmo", "txt_generator_key_length": "Lunghezza chiave", "txt_generator_ssh_comment": "Commento chiave pubblica", "txt_generator_ssh_rsa_description": "RSA è ampiamente compatibile. Usa almeno 2048 bit; 4096 sono consigliati.", "txt_generator_ssh_ed25519_description": "Ed25519 è veloce, compatto e consigliato per client SSH moderni.",
|
||||
"txt_generator_email_alias": "Alias email", "txt_generator_email_type": "Tipo di alias", "txt_generator_email_required_hint": "Inserisci l’indirizzo email o il dominio per generare un alias.", "txt_generator_email_description": "I dati restano su questo dispositivo e servono solo a comporre l’alias.", "txt_generator_long_word_username_description": "Combina una o più parole lunghe in un nome utente facile da ricordare.",
|
||||
"nav_backup_strategy": "Backup su Cloud",
|
||||
"nav_import_export": "Importa ed Esporta",
|
||||
"nav_group_data_backup": "Dati e Backup",
|
||||
@@ -1083,6 +1087,7 @@ const it: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Impossibile rimuovere il dispositivo",
|
||||
"txt_remove_all_devices_failed": "Impossibile rimuovere tutti i dispositivi",
|
||||
"txt_update_item_failed": "Aggiornamento elemento fallito",
|
||||
"txt_item_changed_elsewhere": "Questo elemento è stato modificato in un altro client. Le tue modifiche sono ancora nel modulo. Copia quelle necessarie, riapri questo elemento e salva.",
|
||||
"txt_update_send_failed": "Aggiornamento invio fallito",
|
||||
"txt_update_user_status_failed": "Aggiornamento stato utente fallito",
|
||||
"txt_use_recovery_code": "Usa Codice di Recupero",
|
||||
|
||||
@@ -9,6 +9,10 @@ const ru: Record<string, string> = {
|
||||
"nav_vault_items": "Хранилище",
|
||||
"nav_sends": "Отправляет",
|
||||
"nav_generator": "Генератор", "txt_password_generator": "Генератор паролей", "txt_password_generator_description": "Создайте надежный уникальный пароль локально на этом устройстве.", "txt_generator_type": "Тип генератора", "txt_passphrase": "Парольная фраза", "txt_generated_password": "Созданный пароль", "txt_password_strength": "Надежность", "txt_password_strength_weak": "Слабый", "txt_password_strength_fair": "Средний", "txt_password_strength_good": "Хороший", "txt_password_strength_strong": "Надежный", "txt_generator_security_note": "Генерация выполняется локально. Пароль никогда не отправляется на сервер.", "txt_generator_length": "Длина", "txt_generator_character_types": "Типы символов", "txt_generator_uppercase": "Заглавные буквы (A-Z)", "txt_generator_lowercase": "Строчные буквы (a-z)", "txt_generator_numbers": "Цифры (0-9)", "txt_generator_special": "Специальные символы (!@#$%^&*)", "txt_generator_minimum": "Минимум", "txt_generator_avoid_ambiguous": "Исключить похожие символы", "txt_generator_words": "Количество слов", "txt_generator_separator": "Разделитель слов", "txt_generator_capitalize": "С заглавной буквы", "txt_generator_include_number": "Добавить число",
|
||||
"txt_generated_value": "Созданное значение", "txt_generator_character_count": "Символов: {count}", "txt_generator_pin": "PIN-код", "txt_generator_pin_description": "Создаёт числовой PIN локально с криптографически стойкой случайностью.", "txt_generator_username": "Имя пользователя", "txt_generator_plus_addressed_email": "Email с плюс-адресацией", "txt_generator_catch_all_email": "Catch-all email", "txt_generator_email": "Адрес электронной почты", "txt_generator_domain": "Домен",
|
||||
"txt_generator_word_list": "Список слов", "txt_generator_eff_word_list": "Длинный список EFF", "txt_generator_custom_word_list": "Свой список слов", "txt_generator_custom_words": "Свои слова", "txt_generator_custom_words_placeholder": "Введите не менее двух слов через пробел, запятую или с новой строки",
|
||||
"txt_generator_long_word_username": "Имя из длинных слов", "txt_generator_custom_word": "Своё мнемоническое слово", "txt_generator_subdomain_email": "Email с поддоменом", "txt_generator_ssh_key": "SSH-ключ", "txt_generator_ssh_generating": "Создание SSH-ключа…", "txt_generator_ssh_error": "Этот браузер не поддерживает создание SSH-ключей.", "txt_generator_copy_public_key": "Копировать открытый ключ", "txt_generator_ssh_security_note": "Пара создаётся локально и не отправляется. Сохраните закрытый ключ сейчас; NodeWarden его не хранит.", "txt_generator_public_key": "Открытый ключ", "txt_generator_private_key": "Закрытый ключ — храните в тайне", "txt_generator_ssh_algorithm": "Алгоритм", "txt_generator_key_length": "Длина ключа", "txt_generator_ssh_comment": "Комментарий открытого ключа", "txt_generator_ssh_rsa_description": "RSA широко совместим. Используйте минимум 2048 бит; рекомендуется 4096.", "txt_generator_ssh_ed25519_description": "Ed25519 быстр, компактен и рекомендуется для современных SSH-клиентов.",
|
||||
"txt_generator_email_alias": "Почтовый псевдоним", "txt_generator_email_type": "Тип псевдонима", "txt_generator_email_required_hint": "Введите адрес электронной почты или домен, чтобы создать псевдоним.", "txt_generator_email_description": "Введённые данные остаются на устройстве и используются только для создания псевдонима.", "txt_generator_long_word_username_description": "Объедините одно или несколько длинных слов в запоминающееся имя пользователя.",
|
||||
"nav_backup_strategy": "Облачное резервное копирование",
|
||||
"nav_import_export": "Импорт и экспорт",
|
||||
"nav_group_data_backup": "Данные и резервные копии",
|
||||
@@ -1083,6 +1087,7 @@ const ru: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Не удалось удалить устройство.",
|
||||
"txt_remove_all_devices_failed": "Не удалось удалить все устройства.",
|
||||
"txt_update_item_failed": "Обновить элемент не удалось",
|
||||
"txt_item_changed_elsewhere": "Элемент изменён в другом клиенте. Ваши правки остались в форме. Скопируйте нужные изменения, откройте элемент заново и сохраните его.",
|
||||
"txt_update_send_failed": "Send обновления не удалась",
|
||||
"txt_update_user_status_failed": "Не удалось обновить статус пользователя",
|
||||
"txt_use_recovery_code": "Использовать код восстановления",
|
||||
|
||||
@@ -8,6 +8,10 @@ const sv: Record<string, string> = {
|
||||
"nav_vault_items": "Valv",
|
||||
"nav_sends": "Skickat",
|
||||
"nav_generator": "Generator", "txt_password_generator": "Lösenordsgenerator", "txt_password_generator_description": "Skapa ett starkt och unikt lösenord lokalt på den här enheten.", "txt_generator_type": "Generatortyp", "txt_passphrase": "Lösenfras", "txt_generated_password": "Genererat lösenord", "txt_password_strength": "Styrka", "txt_password_strength_weak": "Svagt", "txt_password_strength_fair": "Medel", "txt_password_strength_good": "Bra", "txt_password_strength_strong": "Starkt", "txt_generator_security_note": "Generering sker lokalt. Ditt lösenord skickas aldrig till servern.", "txt_generator_length": "Längd", "txt_generator_character_types": "Teckentyper", "txt_generator_uppercase": "Versaler (A-Z)", "txt_generator_lowercase": "Gemener (a-z)", "txt_generator_numbers": "Siffror (0-9)", "txt_generator_special": "Specialtecken (!@#$%^&*)", "txt_generator_minimum": "Minst", "txt_generator_avoid_ambiguous": "Undvik tvetydiga tecken", "txt_generator_words": "Antal ord", "txt_generator_separator": "Ordavgränsare", "txt_generator_capitalize": "Stor begynnelsebokstav", "txt_generator_include_number": "Inkludera en siffra",
|
||||
"txt_generated_value": "Genererat värde", "txt_generator_character_count": "{count} tecken", "txt_generator_pin": "PIN-kod", "txt_generator_pin_description": "Skapar en numerisk PIN lokalt med kryptografiskt säker slumpning.", "txt_generator_username": "Användarnamn", "txt_generator_plus_addressed_email": "Plusadresserad e-post", "txt_generator_catch_all_email": "Catch-all-e-post", "txt_generator_email": "E-postadress", "txt_generator_domain": "Domän",
|
||||
"txt_generator_word_list": "Ordlista", "txt_generator_eff_word_list": "Lång EFF-ordlista", "txt_generator_custom_word_list": "Egen ordlista", "txt_generator_custom_words": "Egna ord", "txt_generator_custom_words_placeholder": "Ange minst två ord åtskilda med blanksteg, kommatecken eller rader",
|
||||
"txt_generator_long_word_username": "Användarnamn med långa ord", "txt_generator_custom_word": "Eget minnesord", "txt_generator_subdomain_email": "E-post med underdomän", "txt_generator_ssh_key": "SSH-nyckel", "txt_generator_ssh_generating": "Skapar SSH-nyckel…", "txt_generator_ssh_error": "Webbläsaren stöder inte skapande av SSH-nycklar.", "txt_generator_copy_public_key": "Kopiera offentlig nyckel", "txt_generator_ssh_security_note": "Nyckelparet skapas lokalt och skickas aldrig. Spara den privata nyckeln nu; NodeWarden behåller den inte.", "txt_generator_public_key": "Offentlig nyckel", "txt_generator_private_key": "Privat nyckel — håll den hemlig", "txt_generator_ssh_algorithm": "Algoritm", "txt_generator_key_length": "Nyckellängd", "txt_generator_ssh_comment": "Kommentar för offentlig nyckel", "txt_generator_ssh_rsa_description": "RSA har bred kompatibilitet. Använd minst 2048 bitar; 4096 rekommenderas.", "txt_generator_ssh_ed25519_description": "Ed25519 är snabb, kompakt och rekommenderas för moderna SSH-klienter.",
|
||||
"txt_generator_email_alias": "E-postalias", "txt_generator_email_type": "Aliastyp", "txt_generator_email_required_hint": "Ange din e-postadress eller domän för att skapa ett alias.", "txt_generator_email_description": "Inmatningen stannar på enheten och används endast för att skapa aliaset.", "txt_generator_long_word_username_description": "Kombinera ett eller flera långa ord till ett minnesvärt användarnamn.",
|
||||
"nav_backup_strategy": "Molnsäkerhetskopiering",
|
||||
"nav_import_export": "Importera och Exportera",
|
||||
"nav_group_data_backup": "Data och Säkerhetskopiering",
|
||||
@@ -1083,6 +1087,7 @@ const sv: Record<string, string> = {
|
||||
"txt_remove_device_failed": "Misslyckades med att ta bort enhet",
|
||||
"txt_remove_all_devices_failed": "Misslyckades med att ta bort alla enheter",
|
||||
"txt_update_item_failed": "Misslyckades med att uppdatera objekt",
|
||||
"txt_item_changed_elsewhere": "Objektet ändrades i en annan klient. Dina ändringar finns kvar i formuläret. Kopiera de ändringar du behöver, öppna objektet igen och spara.",
|
||||
"txt_update_send_failed": "Misslyckades med att uppdatera sändning",
|
||||
"txt_update_user_status_failed": "Misslyckades med att uppdatera användarstatus",
|
||||
"txt_use_recovery_code": "Använd återställningskod",
|
||||
|
||||
@@ -11,6 +11,10 @@ const zhCN: Record<string, string> = {
|
||||
"txt_password_generator": "密码生成器",
|
||||
"txt_password_generator_description": "在此设备本地生成强且唯一的密码。",
|
||||
"txt_generator_type": "生成类型", "txt_passphrase": "密码短语", "txt_generated_password": "已生成密码", "txt_password_strength": "强度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "一般", "txt_password_strength_good": "良好", "txt_password_strength_strong": "强", "txt_generator_security_note": "生成过程仅在本地进行,密码不会发送到服务器。", "txt_generator_length": "长度", "txt_generator_character_types": "字符类型", "txt_generator_uppercase": "大写字母 (A-Z)", "txt_generator_lowercase": "小写字母 (a-z)", "txt_generator_numbers": "数字 (0-9)", "txt_generator_special": "特殊字符 (!@#$%^&*)", "txt_generator_minimum": "最少数量", "txt_generator_avoid_ambiguous": "避免易混淆字符", "txt_generator_words": "单词数量", "txt_generator_separator": "单词分隔符", "txt_generator_capitalize": "首字母大写", "txt_generator_include_number": "包含数字",
|
||||
"txt_generated_value": "已生成内容", "txt_generator_character_count": "{count} 个字符", "txt_generator_pin": "PIN 码", "txt_generator_pin_description": "使用密码学安全随机数在本地生成纯数字 PIN 码。", "txt_generator_username": "用户名", "txt_generator_plus_addressed_email": "加号别名邮箱", "txt_generator_catch_all_email": "全域接收邮箱", "txt_generator_email": "邮箱地址", "txt_generator_domain": "域名",
|
||||
"txt_generator_word_list": "词表", "txt_generator_eff_word_list": "EFF 长词表", "txt_generator_custom_word_list": "自定义词表", "txt_generator_custom_words": "自定义单词", "txt_generator_custom_words_placeholder": "输入至少两个单词,用空格、逗号或换行分隔",
|
||||
"txt_generator_long_word_username": "长单词用户名", "txt_generator_custom_word": "自定义助记词", "txt_generator_subdomain_email": "子域电子邮件", "txt_generator_ssh_key": "SSH 密钥", "txt_generator_ssh_generating": "正在生成 SSH 密钥…", "txt_generator_ssh_error": "此浏览器不支持 SSH 密钥生成。", "txt_generator_copy_public_key": "复制公钥", "txt_generator_ssh_security_note": "密钥对仅在本地生成,不会发送到服务器。请立即保存私钥,NodeWarden 不会保留它。", "txt_generator_public_key": "公钥", "txt_generator_private_key": "私钥 — 请保密", "txt_generator_ssh_algorithm": "算法", "txt_generator_key_length": "密钥长度", "txt_generator_ssh_comment": "公钥注释", "txt_generator_ssh_rsa_description": "RSA 兼容性广泛。请至少使用 2048 位,推荐 4096 位。", "txt_generator_ssh_ed25519_description": "Ed25519 快速且紧凑,推荐用于现代 SSH 客户端。",
|
||||
"txt_generator_email_alias": "邮箱别名", "txt_generator_email_type": "邮箱别名类型", "txt_generator_email_required_hint": "输入邮箱地址或域名后,即可生成别名。", "txt_generator_email_description": "输入内容只保留在此设备上,仅用于组合邮箱别名。", "txt_generator_long_word_username_description": "将一个或多个长单词组合成易记的用户名。",
|
||||
"nav_backup_strategy": "云端备份",
|
||||
"nav_import_export": "导入导出",
|
||||
"nav_group_data_backup": "数据与备份",
|
||||
@@ -1086,6 +1090,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_remove_device_failed": "移除设备失败",
|
||||
"txt_remove_all_devices_failed": "移除所有设备失败",
|
||||
"txt_update_item_failed": "更新项目失败",
|
||||
"txt_item_changed_elsewhere": "条目已在其他客户端修改。你的编辑内容仍保留在表单中,请复制需要的修改,重新打开条目后再保存。",
|
||||
"txt_update_send_failed": "更新 Send 失败",
|
||||
"txt_update_user_status_failed": "更新用户状态失败",
|
||||
"txt_use_recovery_code": "使用恢复代码",
|
||||
|
||||
@@ -11,6 +11,10 @@ const zhTW: Record<string, string> = {
|
||||
"txt_password_generator": "密碼產生器",
|
||||
"txt_password_generator_description": "在此裝置本機建立強而唯一的密碼。",
|
||||
"txt_generator_type": "產生類型", "txt_passphrase": "密碼片語", "txt_generated_password": "已產生密碼", "txt_password_strength": "強度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "普通", "txt_password_strength_good": "良好", "txt_password_strength_strong": "強", "txt_generator_security_note": "產生程序僅在本機進行,密碼不會傳送到伺服器。", "txt_generator_length": "長度", "txt_generator_character_types": "字元類型", "txt_generator_uppercase": "大寫字母 (A-Z)", "txt_generator_lowercase": "小寫字母 (a-z)", "txt_generator_numbers": "數字 (0-9)", "txt_generator_special": "特殊字元 (!@#$%^&*)", "txt_generator_minimum": "最少數量", "txt_generator_avoid_ambiguous": "避免易混淆字元", "txt_generator_words": "單字數量", "txt_generator_separator": "單字分隔符號", "txt_generator_capitalize": "首字母大寫", "txt_generator_include_number": "包含數字",
|
||||
"txt_generated_value": "已產生內容", "txt_generator_character_count": "{count} 個字元", "txt_generator_pin": "PIN 碼", "txt_generator_pin_description": "使用密碼學安全亂數在本機產生純數字 PIN 碼。", "txt_generator_username": "使用者名稱", "txt_generator_plus_addressed_email": "加號別名信箱", "txt_generator_catch_all_email": "全域接收信箱", "txt_generator_email": "電子郵件地址", "txt_generator_domain": "網域",
|
||||
"txt_generator_word_list": "詞表", "txt_generator_eff_word_list": "EFF 長詞表", "txt_generator_custom_word_list": "自訂詞表", "txt_generator_custom_words": "自訂單字", "txt_generator_custom_words_placeholder": "輸入至少兩個單字,以空格、逗號或換行分隔",
|
||||
"txt_generator_long_word_username": "長單字使用者名稱", "txt_generator_custom_word": "自訂助記詞", "txt_generator_subdomain_email": "子網域電子郵件", "txt_generator_ssh_key": "SSH 金鑰", "txt_generator_ssh_generating": "正在產生 SSH 金鑰…", "txt_generator_ssh_error": "此瀏覽器不支援 SSH 金鑰產生。", "txt_generator_copy_public_key": "複製公鑰", "txt_generator_ssh_security_note": "金鑰組只在本機產生,不會傳送到伺服器。請立即儲存私鑰,NodeWarden 不會保留它。", "txt_generator_public_key": "公鑰", "txt_generator_private_key": "私鑰 — 請保密", "txt_generator_ssh_algorithm": "演算法", "txt_generator_key_length": "金鑰長度", "txt_generator_ssh_comment": "公鑰註解", "txt_generator_ssh_rsa_description": "RSA 相容性廣泛。請至少使用 2048 位元,建議 4096 位元。", "txt_generator_ssh_ed25519_description": "Ed25519 快速且精簡,建議用於現代 SSH 用戶端。",
|
||||
"txt_generator_email_alias": "信箱別名", "txt_generator_email_type": "信箱別名類型", "txt_generator_email_required_hint": "輸入電子郵件地址或網域後,即可產生別名。", "txt_generator_email_description": "輸入內容只保留在此裝置上,僅用於組合信箱別名。", "txt_generator_long_word_username_description": "將一個或多個長單字組合成容易記住的使用者名稱。",
|
||||
"nav_backup_strategy": "雲端備份",
|
||||
"nav_import_export": "導入導出",
|
||||
"nav_group_data_backup": "資料與備份",
|
||||
@@ -1086,6 +1090,7 @@ const zhTW: Record<string, string> = {
|
||||
"txt_remove_device_failed": "移除設備失敗",
|
||||
"txt_remove_all_devices_failed": "移除所有設備失敗",
|
||||
"txt_update_item_failed": "更新項目失敗",
|
||||
"txt_item_changed_elsewhere": "項目已在其他用戶端修改。你的編輯內容仍保留在表單中,請複製需要的修改,重新開啟項目後再儲存。",
|
||||
"txt_update_send_failed": "更新 Send 失敗",
|
||||
"txt_update_user_status_failed": "更新用戶狀態失敗",
|
||||
"txt_use_recovery_code": "使用恢復代碼",
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { EFFLongWordList } from '@/lib/eff-word-list';
|
||||
|
||||
export type GeneratorMode = 'password' | 'passphrase' | 'pin' | 'username' | 'email' | 'sshKey';
|
||||
export type EmailMode = 'plusAddressed' | 'catchAll' | 'subdomain';
|
||||
|
||||
export interface PasswordOptions {
|
||||
length: number;
|
||||
uppercase: boolean;
|
||||
lowercase: boolean;
|
||||
numbers: boolean;
|
||||
special: boolean;
|
||||
minUppercase: number;
|
||||
minLowercase: number;
|
||||
minNumbers: number;
|
||||
minSpecial: number;
|
||||
avoidAmbiguous: boolean;
|
||||
}
|
||||
|
||||
export interface PassphraseOptions {
|
||||
words: number;
|
||||
separator: string;
|
||||
capitalize: boolean;
|
||||
includeNumber: boolean;
|
||||
wordList: 'eff' | 'custom';
|
||||
customWords: string;
|
||||
}
|
||||
|
||||
export interface PinOptions { length: number }
|
||||
|
||||
export interface UsernameOptions {
|
||||
words: number;
|
||||
delimiter: string;
|
||||
capitalize: boolean;
|
||||
includeNumber: boolean;
|
||||
customWord: string;
|
||||
wordList: 'eff' | 'custom';
|
||||
customWords: string;
|
||||
}
|
||||
|
||||
export interface EmailOptions {
|
||||
type: EmailMode;
|
||||
email: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface SshKeyOptions {
|
||||
type: 'ed25519' | 'rsa';
|
||||
rsaLength: 2048 | 3072 | 4096;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface GeneratorSettings {
|
||||
mode: GeneratorMode;
|
||||
password: PasswordOptions;
|
||||
passphrase: PassphraseOptions;
|
||||
pin: PinOptions;
|
||||
username: UsernameOptions;
|
||||
email: EmailOptions;
|
||||
sshKey: SshKeyOptions;
|
||||
}
|
||||
|
||||
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const DIGITS = '0123456789';
|
||||
const SPECIAL = '!@#$%^&*_-+=:;,.?~';
|
||||
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1', '|']);
|
||||
|
||||
export const defaultGeneratorSettings: GeneratorSettings = {
|
||||
mode: 'password',
|
||||
password: {
|
||||
length: 16,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
numbers: true,
|
||||
special: false,
|
||||
minUppercase: 1,
|
||||
minLowercase: 1,
|
||||
minNumbers: 1,
|
||||
minSpecial: 1,
|
||||
avoidAmbiguous: false,
|
||||
},
|
||||
passphrase: { words: 6, separator: '-', capitalize: false, includeNumber: false, wordList: 'eff', customWords: '' },
|
||||
pin: { length: 6 },
|
||||
username: { words: 2, delimiter: '', capitalize: true, includeNumber: true, customWord: '', wordList: 'eff', customWords: '' },
|
||||
email: { type: 'plusAddressed', email: '', domain: '' },
|
||||
sshKey: { type: 'ed25519', rsaLength: 4096, comment: '' },
|
||||
};
|
||||
|
||||
export function clampInteger(value: unknown, minimum: number, maximum: number, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
|
||||
}
|
||||
|
||||
export function normalizeGeneratorSettings(value: unknown): GeneratorSettings {
|
||||
const stored = value && typeof value === 'object' ? value as Partial<GeneratorSettings> : {};
|
||||
const password: Partial<PasswordOptions> = stored.password && typeof stored.password === 'object' ? stored.password : {};
|
||||
const passphrase: Partial<PassphraseOptions> = stored.passphrase && typeof stored.passphrase === 'object' ? stored.passphrase : {};
|
||||
const pin: Partial<PinOptions> = stored.pin && typeof stored.pin === 'object' ? stored.pin : {};
|
||||
const username: Partial<UsernameOptions> = stored.username && typeof stored.username === 'object' ? stored.username : {};
|
||||
const legacyUsername = stored.username && typeof stored.username === 'object' ? stored.username as Partial<UsernameOptions> & Partial<EmailOptions> : {};
|
||||
const email: Partial<EmailOptions> = stored.email && typeof stored.email === 'object' ? stored.email : legacyUsername;
|
||||
const sshKey: Partial<SshKeyOptions> = stored.sshKey && typeof stored.sshKey === 'object' ? stored.sshKey : {};
|
||||
const modes: GeneratorMode[] = ['password', 'passphrase', 'pin', 'username', 'email', 'sshKey'];
|
||||
const emailModes: EmailMode[] = ['plusAddressed', 'catchAll', 'subdomain'];
|
||||
const rsaLengths: SshKeyOptions['rsaLength'][] = [2048, 3072, 4096];
|
||||
const mode = stored.mode === 'username' && emailModes.includes(legacyUsername.type as EmailMode)
|
||||
? 'email'
|
||||
: modes.includes(stored.mode as GeneratorMode) ? stored.mode as GeneratorMode : defaultGeneratorSettings.mode;
|
||||
|
||||
return {
|
||||
mode,
|
||||
password: {
|
||||
...defaultGeneratorSettings.password,
|
||||
...password,
|
||||
length: clampInteger(password.length, 5, 128, defaultGeneratorSettings.password.length),
|
||||
minUppercase: clampInteger(password.minUppercase, 0, 9, defaultGeneratorSettings.password.minUppercase),
|
||||
minLowercase: clampInteger(password.minLowercase, 0, 9, defaultGeneratorSettings.password.minLowercase),
|
||||
minNumbers: clampInteger(password.minNumbers, 0, 9, defaultGeneratorSettings.password.minNumbers),
|
||||
minSpecial: clampInteger(password.minSpecial, 0, 9, defaultGeneratorSettings.password.minSpecial),
|
||||
},
|
||||
passphrase: {
|
||||
...defaultGeneratorSettings.passphrase,
|
||||
...passphrase,
|
||||
words: clampInteger(passphrase.words, 3, 20, defaultGeneratorSettings.passphrase.words),
|
||||
separator: String(passphrase.separator ?? defaultGeneratorSettings.passphrase.separator).slice(0, 1),
|
||||
wordList: passphrase.wordList === 'custom' ? 'custom' : 'eff',
|
||||
customWords: String(passphrase.customWords ?? '').slice(0, 50_000),
|
||||
},
|
||||
pin: { length: clampInteger(pin.length, 3, 64, defaultGeneratorSettings.pin.length) },
|
||||
username: {
|
||||
...defaultGeneratorSettings.username,
|
||||
...username,
|
||||
words: clampInteger(username.words, 1, 10, defaultGeneratorSettings.username.words),
|
||||
delimiter: String(username.delimiter ?? defaultGeneratorSettings.username.delimiter).slice(0, 8),
|
||||
customWord: String(username.customWord ?? '').trim().slice(0, 128),
|
||||
wordList: username.wordList === 'custom' ? 'custom' : 'eff',
|
||||
customWords: String(username.customWords ?? '').slice(0, 50_000),
|
||||
},
|
||||
email: {
|
||||
type: emailModes.includes(email.type as EmailMode) ? email.type as EmailMode : defaultGeneratorSettings.email.type,
|
||||
email: String(email.email ?? '').trim().slice(0, 254),
|
||||
domain: String(email.domain ?? '').trim().slice(0, 253),
|
||||
},
|
||||
sshKey: {
|
||||
type: sshKey.type === 'rsa' ? 'rsa' : 'ed25519',
|
||||
rsaLength: rsaLengths.includes(sshKey.rsaLength as SshKeyOptions['rsaLength']) ? sshKey.rsaLength as SshKeyOptions['rsaLength'] : defaultGeneratorSettings.sshKey.rsaLength,
|
||||
comment: String(sshKey.comment ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, 256),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function randomIndex(length: number): number {
|
||||
if (!Number.isSafeInteger(length) || length < 1) throw new RangeError('Random source must not be empty');
|
||||
const range = 0x1_0000_0000;
|
||||
const upperBound = Math.floor(range / length) * length;
|
||||
const buffer = new Uint32Array(1);
|
||||
do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound);
|
||||
return buffer[0] % length;
|
||||
}
|
||||
|
||||
function pick(characters: string): string { return characters[randomIndex(characters.length)]; }
|
||||
|
||||
function shuffle(value: string[]): string[] {
|
||||
for (let index = value.length - 1; index > 0; index -= 1) {
|
||||
const next = randomIndex(index + 1);
|
||||
[value[index], value[next]] = [value[next], value[index]];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function filtered(characters: string, avoidAmbiguous: boolean): string {
|
||||
return avoidAmbiguous ? [...characters].filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
|
||||
}
|
||||
|
||||
export function generatePassword(options: PasswordOptions): string {
|
||||
const sets: Array<{ chars: string; minimum: number }> = [];
|
||||
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: options.minUppercase });
|
||||
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: options.minLowercase });
|
||||
if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers });
|
||||
if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial });
|
||||
if (!sets.length) sets.push({ chars: LOWERCASE, minimum: 1 });
|
||||
|
||||
const required = sets.reduce((total, set) => total + set.minimum, 0);
|
||||
const length = Math.max(options.length, required, 5);
|
||||
const allCharacters = sets.map((set) => set.chars).join('');
|
||||
const result = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
|
||||
while (result.length < length) result.push(pick(allCharacters));
|
||||
return shuffle(result).join('');
|
||||
}
|
||||
|
||||
export function generatePassphrase(options: PassphraseOptions): string {
|
||||
const customWords = [...new Set(options.customWords.split(/[\s,;]+/).map((word) => word.trim()).filter(Boolean))];
|
||||
const wordList = options.wordList === 'custom' && customWords.length >= 2 ? customWords : EFFLongWordList;
|
||||
const words = Array.from({ length: options.words }, () => wordList[randomIndex(wordList.length)]);
|
||||
if (options.capitalize) {
|
||||
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
|
||||
}
|
||||
if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10));
|
||||
return words.join(options.separator);
|
||||
}
|
||||
|
||||
export function generatePin(options: PinOptions): string {
|
||||
return Array.from({ length: options.length }, () => pick(DIGITS)).join('');
|
||||
}
|
||||
|
||||
function randomWord(options: Pick<UsernameOptions, 'words' | 'delimiter' | 'capitalize' | 'includeNumber' | 'customWord' | 'wordList' | 'customWords'>): string {
|
||||
const customWords = [...new Set(options.customWords.split(/[\s,;]+/).map((word) => word.trim()).filter(Boolean))];
|
||||
const wordList = options.wordList === 'custom' && customWords.length >= 2 ? customWords : EFFLongWordList;
|
||||
const words = Array.from({ length: options.words }, () => wordList[randomIndex(wordList.length)]);
|
||||
if (options.customWord) words[randomIndex(words.length)] = options.customWord;
|
||||
if (options.capitalize) {
|
||||
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
|
||||
}
|
||||
if (options.includeNumber) {
|
||||
const digits = options.words === 1 ? 4 : options.words === 2 ? 3 : 2;
|
||||
const minimum = 10 ** (digits - 1);
|
||||
words[randomIndex(words.length)] += String(minimum + randomIndex(9 * minimum));
|
||||
}
|
||||
return words.join(options.delimiter);
|
||||
}
|
||||
|
||||
function normalizeDomain(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0].replace(/^@/, '');
|
||||
}
|
||||
|
||||
export function generateUsername(options: UsernameOptions): string {
|
||||
return randomWord(options);
|
||||
}
|
||||
|
||||
function validDomain(value: string): string | null {
|
||||
const domain = normalizeDomain(value);
|
||||
return /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(domain) ? domain : null;
|
||||
}
|
||||
|
||||
function validEmail(value: string): { local: string; domain: string } | null {
|
||||
const match = value.trim().match(/^([^\s@+]+)(?:\+[^\s@]*)?@([^\s@]+)$/);
|
||||
const domain = match ? validDomain(match[2]) : null;
|
||||
return match && domain ? { local: match[1], domain } : null;
|
||||
}
|
||||
|
||||
export function generateEmail(options: EmailOptions): string {
|
||||
const suffix = randomWord({ ...defaultGeneratorSettings.username, words: 1, capitalize: false, includeNumber: true });
|
||||
if (options.type === 'catchAll') {
|
||||
const domain = validDomain(options.domain);
|
||||
return domain ? `${suffix}@${domain}` : '';
|
||||
}
|
||||
const email = validEmail(options.email);
|
||||
if (!email) return '';
|
||||
return options.type === 'subdomain'
|
||||
? `${email.local}@${suffix}.${email.domain}`
|
||||
: `${email.local}+${suffix}@${email.domain}`;
|
||||
}
|
||||
|
||||
export function generateValue(settings: GeneratorSettings): string {
|
||||
if (settings.mode === 'passphrase') return generatePassphrase(settings.passphrase);
|
||||
if (settings.mode === 'pin') return generatePin(settings.pin);
|
||||
if (settings.mode === 'username') return generateUsername(settings.username);
|
||||
if (settings.mode === 'email') return generateEmail(settings.email);
|
||||
if (settings.mode === 'sshKey') return '';
|
||||
return generatePassword(settings.password);
|
||||
}
|
||||
|
||||
export function estimateStrength(mode: GeneratorMode, value: string, passphraseWordCount?: number): number {
|
||||
if (mode === 'username' || mode === 'email' || mode === 'sshKey') return 0;
|
||||
if (mode === 'pin') return value.length >= 10 ? 4 : value.length >= 8 ? 3 : value.length >= 6 ? 2 : 1;
|
||||
if (mode === 'passphrase') {
|
||||
const words = passphraseWordCount ?? value.split(/[-_. ]/).filter(Boolean).length;
|
||||
return Math.min(4, Math.max(1, Math.floor(words / 2)));
|
||||
}
|
||||
return Math.min(4, Math.max(1, Math.floor(value.length / 5)));
|
||||
}
|
||||
@@ -23,7 +23,7 @@ function createState(fingerprint: string): InternalPasswordSecurityState {
|
||||
return { fingerprint, report: null, scannedAt: null, scanning: false, progress: { checked: 0, total: 0 }, scanError: false, controller: null };
|
||||
}
|
||||
|
||||
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
|
||||
function ensurePasswordSecurityState(fingerprint: string): InternalPasswordSecurityState {
|
||||
if (state?.fingerprint !== fingerprint) {
|
||||
state?.controller?.abort();
|
||||
state = createState(fingerprint);
|
||||
@@ -31,6 +31,10 @@ export function getPasswordSecurityState(fingerprint: string): PasswordSecurityS
|
||||
return state;
|
||||
}
|
||||
|
||||
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
|
||||
return ensurePasswordSecurityState(fingerprint);
|
||||
}
|
||||
|
||||
export function readPasswordSecurityState(fingerprint: string): PasswordSecurityState | null {
|
||||
return state?.fingerprint === fingerprint ? state : null;
|
||||
}
|
||||
@@ -41,7 +45,7 @@ export function subscribePasswordSecurityState(listener: () => void): () => void
|
||||
}
|
||||
|
||||
export function startPasswordSecurityScan(fingerprint: string, ciphers: Cipher[]): void {
|
||||
const current = getPasswordSecurityState(fingerprint);
|
||||
const current = ensurePasswordSecurityState(fingerprint);
|
||||
current.controller?.abort();
|
||||
const controller = new AbortController();
|
||||
const total = ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { SshKeyOptions } from '@/lib/password-generator';
|
||||
|
||||
export interface GeneratedSshKey {
|
||||
type: 'ED25519' | 'RSA';
|
||||
bits: number;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function concat(...chunks: Uint8Array[]): Uint8Array {
|
||||
const output = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function uint32(value: number): Uint8Array {
|
||||
const output = new Uint8Array(4);
|
||||
new DataView(output.buffer).setUint32(0, value, false);
|
||||
return output;
|
||||
}
|
||||
|
||||
function sshString(value: string | Uint8Array): Uint8Array {
|
||||
const bytes = typeof value === 'string' ? encoder.encode(value) : value;
|
||||
return concat(uint32(bytes.length), bytes);
|
||||
}
|
||||
|
||||
function base64UrlBytes(value: string): Uint8Array {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const decoded = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='));
|
||||
return Uint8Array.from(decoded, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function base64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function mpint(value: Uint8Array): Uint8Array {
|
||||
let offset = 0;
|
||||
while (offset < value.length - 1 && value[offset] === 0) offset += 1;
|
||||
let bytes = value.subarray(offset);
|
||||
if (bytes[0] & 0x80) bytes = concat(new Uint8Array([0]), bytes);
|
||||
return sshString(bytes);
|
||||
}
|
||||
|
||||
function openSshPrivateKey(publicBlob: Uint8Array, fields: Uint8Array[]): string {
|
||||
const check = new Uint32Array(1);
|
||||
crypto.getRandomValues(check);
|
||||
let privateBlock = concat(uint32(check[0]), uint32(check[0]), ...fields, sshString(''));
|
||||
const paddingLength = 8 - (privateBlock.length % 8);
|
||||
privateBlock = concat(privateBlock, Uint8Array.from({ length: paddingLength }, (_, index) => index + 1));
|
||||
const envelope = concat(
|
||||
encoder.encode('openssh-key-v1\0'),
|
||||
sshString('none'),
|
||||
sshString('none'),
|
||||
sshString(new Uint8Array()),
|
||||
uint32(1),
|
||||
sshString(publicBlob),
|
||||
sshString(privateBlock),
|
||||
);
|
||||
const body = base64(envelope).match(/.{1,70}/g)?.join('\n') || '';
|
||||
return `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
||||
}
|
||||
|
||||
async function fingerprint(publicBlob: Uint8Array): Promise<string> {
|
||||
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', Uint8Array.from(publicBlob).buffer));
|
||||
return `SHA256:${base64(digest).replace(/=+$/, '')}`;
|
||||
}
|
||||
|
||||
function required(jwk: JsonWebKey, property: keyof JsonWebKey): Uint8Array {
|
||||
const value = jwk[property];
|
||||
if (typeof value !== 'string' || !value) throw new Error(`The generated key is missing ${String(property)}`);
|
||||
return base64UrlBytes(value);
|
||||
}
|
||||
|
||||
async function generateEd25519(comment: string): Promise<GeneratedSshKey> {
|
||||
const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']) as CryptoKeyPair;
|
||||
const [privateJwk, publicJwk] = await Promise.all([
|
||||
crypto.subtle.exportKey('jwk', pair.privateKey),
|
||||
crypto.subtle.exportKey('jwk', pair.publicKey),
|
||||
]);
|
||||
const publicBytes = required(publicJwk, 'x');
|
||||
const seed = required(privateJwk, 'd');
|
||||
const publicBlob = concat(sshString('ssh-ed25519'), sshString(publicBytes));
|
||||
const privateKey = openSshPrivateKey(publicBlob, [
|
||||
sshString('ssh-ed25519'),
|
||||
sshString(publicBytes),
|
||||
sshString(concat(seed, publicBytes)),
|
||||
]);
|
||||
return {
|
||||
type: 'ED25519',
|
||||
bits: 256,
|
||||
publicKey: `ssh-ed25519 ${base64(publicBlob)}${comment ? ` ${comment}` : ''}`,
|
||||
privateKey,
|
||||
fingerprint: await fingerprint(publicBlob),
|
||||
};
|
||||
}
|
||||
|
||||
async function generateRsa(length: SshKeyOptions['rsaLength'], comment: string): Promise<GeneratedSshKey> {
|
||||
const pair = await crypto.subtle.generateKey({
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength: length,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
}, true, ['sign', 'verify']) as CryptoKeyPair;
|
||||
const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
||||
const n = required(privateJwk, 'n');
|
||||
const e = required(privateJwk, 'e');
|
||||
const d = required(privateJwk, 'd');
|
||||
const qi = required(privateJwk, 'qi');
|
||||
const p = required(privateJwk, 'p');
|
||||
const q = required(privateJwk, 'q');
|
||||
const publicBlob = concat(sshString('ssh-rsa'), mpint(e), mpint(n));
|
||||
const privateKey = openSshPrivateKey(publicBlob, [
|
||||
sshString('ssh-rsa'),
|
||||
mpint(n), mpint(e), mpint(d), mpint(qi), mpint(p), mpint(q),
|
||||
]);
|
||||
return {
|
||||
type: 'RSA',
|
||||
bits: length,
|
||||
publicKey: `ssh-rsa ${base64(publicBlob)}${comment ? ` ${comment}` : ''}`,
|
||||
privateKey,
|
||||
fingerprint: await fingerprint(publicBlob),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSshKey(options: SshKeyOptions): Promise<GeneratedSshKey> {
|
||||
if (!crypto?.subtle) throw new Error('Web Crypto is unavailable');
|
||||
const comment = options.comment.replace(/[\r\n]+/g, ' ').trim();
|
||||
return options.type === 'rsa' ? generateRsa(options.rsaLength, comment) : generateEd25519(comment);
|
||||
}
|
||||
@@ -320,10 +320,13 @@ export interface VaultDraftField {
|
||||
type: CustomFieldType;
|
||||
label: string;
|
||||
value: string;
|
||||
linkedId?: number | null;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface VaultDraft {
|
||||
id?: string;
|
||||
revisionDate?: string;
|
||||
type: number;
|
||||
favorite: boolean;
|
||||
name: string;
|
||||
|
||||
@@ -4,7 +4,6 @@ import App from './App';
|
||||
import { initI18n } from './lib/i18n';
|
||||
import { registerNodeWardenServiceWorker } from './lib/pwa';
|
||||
import './tailwind.css';
|
||||
import './styles.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
@@ -353,7 +353,7 @@
|
||||
}
|
||||
|
||||
.not-found-logo {
|
||||
@apply h-14 w-14 flex-shrink-0 object-contain;
|
||||
@apply h-14 w-14 shrink-0 object-contain;
|
||||
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@
|
||||
.not-found-code {
|
||||
@apply rounded-full px-3 py-1 text-sm font-extrabold;
|
||||
background: #eef4ff;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.not-found-copy {
|
||||
@@ -465,7 +465,7 @@
|
||||
}
|
||||
|
||||
.standalone-brand-logo {
|
||||
@apply h-14 w-[70px] flex-shrink-0 object-contain;
|
||||
@apply h-14 w-[70px] shrink-0 object-contain;
|
||||
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
|
||||
}
|
||||
|
||||
@@ -574,7 +574,7 @@
|
||||
|
||||
.jwt-inline-link {
|
||||
@apply font-bold no-underline;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.jwt-inline-link:hover {
|
||||
@@ -613,7 +613,7 @@
|
||||
|
||||
.standalone-footer a {
|
||||
@apply font-bold no-underline;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.standalone-footer a:hover {
|
||||
@@ -622,5 +622,5 @@
|
||||
|
||||
.standalone-version {
|
||||
@apply font-bold;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
@@ -220,7 +220,7 @@
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .card-brand-icon {
|
||||
color: #bfdbfe;
|
||||
color: var(--primary-strong);
|
||||
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--line));
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 outline-none;
|
||||
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 outline-hidden;
|
||||
background: var(--panel);
|
||||
border-color: rgba(74, 103, 150, 0.34);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
.generator-options-card { grid-area: options; padding: 19px; }
|
||||
.generator-options-card h2 { margin: 0 0 18px; font-size: 17px; }
|
||||
.generator-value { display: block; min-height: 110px; margin: 18px 0 10px; padding: 18px; border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--line)); border-radius: 16px; background: color-mix(in srgb, var(--primary) 5%, var(--panel)); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: clamp(19px, 2.2vw, 27px); font-weight: 700; line-height: 1.45; overflow-wrap: anywhere; user-select: all; }
|
||||
.generator-strength-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted-strong); font-size: 13px; font-weight: 700; }
|
||||
.generator-strength-row > span, .generator-security-note { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.generator-value.empty { display: grid; place-items: center; color: var(--muted); font-family: inherit; font-size: 14px; font-weight: 500; text-align: center; user-select: none; }
|
||||
.generator-mode-tabs { flex-wrap: wrap; }
|
||||
.generator-meta-row { display: grid; grid-template-columns: minmax(90px, 1fr) auto auto; align-items: center; gap: 12px; color: var(--muted-strong); font-size: 13px; font-weight: 700; }
|
||||
.generator-meta-row > span, .generator-security-note { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.generator-strength { display: flex; flex: 1; gap: 4px; }
|
||||
.generator-strength span { height: 5px; flex: 1; border-radius: 999px; background: var(--line); }
|
||||
.generator-strength span.active.level-1 { background: #e87171; }.generator-strength span.active.level-2 { background: #db9b38; }.generator-strength span.active.level-3 { background: #46936c; }.generator-strength span.active.level-4 { background: var(--primary); }
|
||||
@@ -20,6 +22,11 @@
|
||||
.generator-number-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 15px; color: var(--text); font-size: 14px; font-weight: 700; }.generator-number-field > label { min-width: 0; }.generator-stepper { display: grid; grid-template-columns: 38px 64px 38px; align-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }.generator-stepper button { display: grid; width: 38px; height: 40px; place-items: center; border: 0; background: transparent; color: var(--primary-strong); cursor: pointer; transition: background-color 160ms ease, color 160ms ease; }.generator-stepper button:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 10%, var(--panel)); }.generator-stepper button:active:not(:disabled) { background: color-mix(in srgb, var(--primary) 17%, var(--panel)); }.generator-stepper button:focus-visible { position: relative; z-index: 1; outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); outline-offset: -3px; }.generator-stepper button:disabled { color: var(--muted); cursor: not-allowed; }.generator-stepper .input { width: 64px; height: 40px; min-width: 0; border: 0; border-radius: 0; padding: 0; background: transparent; text-align: center; font-variant-numeric: tabular-nums; }.generator-stepper .input:focus { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary) 36%, transparent); }.generator-number-field.compact { grid-template-columns: minmax(0, 1fr) auto; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 32px 46px 32px; border-radius: 9px; }.generator-number-field.compact .generator-stepper button { width: 32px; height: 34px; }.generator-number-field.compact .generator-stepper .input { width: 46px; height: 34px; font-size: 13px; }
|
||||
.generator-option-group { display: grid; gap: 9px; margin: 18px 0; padding: 0; border: 0; }.generator-option-group legend { margin-bottom: 10px; padding: 0; font-size: 14px; font-weight: 700; }
|
||||
.generator-toggle { display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 32px; cursor: pointer; }.generator-toggle input { position: absolute; opacity: 0; }.generator-toggle > span { position: relative; width: 38px; height: 22px; border-radius: 999px; background: #cbd5e1; transition: background 180ms ease; }.generator-toggle > span::after { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.25); content: ''; transition: transform 180ms ease; }.generator-toggle input:checked + span { background: var(--primary); }.generator-toggle input:checked + span::after { transform: translateX(16px); }.generator-toggle input:focus-visible + span { outline: 3px solid color-mix(in srgb, var(--primary) 30%, transparent); outline-offset: 2px; }.generator-toggle strong { font-size: 14px; font-weight: 600; }
|
||||
.generator-select-field, .generator-text-field { display: grid; gap: 8px; margin-bottom: 16px; color: var(--text); font-size: 14px; font-weight: 700; }.generator-select-field .input, .generator-text-field .input { width: 100%; }.generator-options-note { margin: 14px 0 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
.generator-word-list-input { min-height: 118px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; line-height: 1.5; }
|
||||
.generator-key-status { display: flex; min-height: 180px; margin: 18px 0 10px; align-items: center; justify-content: center; gap: 10px; border: 1px solid var(--line); border-radius: 16px; color: var(--muted-strong); }.generator-key-status.error { padding: 18px; color: var(--danger); text-align: center; }
|
||||
.generator-spinner { animation: generator-spin 900ms linear infinite; }.generator-key-output { display: grid; gap: 12px; margin: 18px 0 10px; }.generator-key-summary, .generator-key-field, .generator-private-key { border: 1px solid var(--line); border-radius: 14px; background: color-mix(in srgb, var(--primary) 4%, var(--panel)); }.generator-key-summary { display: grid; gap: 6px; padding: 14px; }.generator-key-summary code { overflow-wrap: anywhere; color: var(--muted-strong); font-size: 12px; }.generator-key-field { display: grid; gap: 9px; padding: 14px; }.generator-key-field > span { font-size: 13px; font-weight: 700; }.generator-key-field > code, .generator-private-key > code { display: block; max-height: 150px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; line-height: 1.45; user-select: all; }.generator-key-field-actions { display: flex; flex-wrap: wrap; gap: 8px; }.generator-key-field-actions .btn { display: inline-flex; gap: 6px; }.generator-private-key { padding: 0 14px 14px; }.generator-private-key summary { padding: 14px 0; cursor: pointer; font-size: 13px; font-weight: 700; }.generator-private-key .generator-key-field-actions { margin-top: 10px; }
|
||||
@keyframes generator-spin { to { transform: rotate(360deg); } }
|
||||
.generator-inline-number { display: grid; grid-template-columns: minmax(0, 1fr) 72px; align-items: center; gap: 14px; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-inline-number .input { height: 34px; text-align: center; }
|
||||
@media (max-width: 760px) { .generator-page { width: 100%; padding: 0 0 18px; }.generator-layout { grid-template-columns: 1fr; grid-template-areas: 'output' 'options'; gap: 10px; }.generator-output-card, .generator-options-card { padding: 15px; border-radius: 16px; }.generator-value { min-height: 94px; margin: 14px 0 10px; padding: 14px; font-size: 19px; }.generator-actions .btn { justify-content: center; padding-inline: 10px; }.generator-option-group { margin: 15px 0; }.generator-toggle { min-height: 44px; }.generator-number-field.compact { margin-left: 50px; }.generator-stepper { grid-template-columns: 40px 64px 40px; }.generator-stepper button { width: 40px; min-height: 44px; }.generator-stepper .input { height: 44px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 36px 46px 36px; }.generator-number-field.compact .generator-stepper button { width: 36px; height: 40px; min-height: 40px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .generator-toggle > span, .generator-toggle > span::after { transition: none; } }
|
||||
@media (max-width: 760px) { .generator-page { width: 100%; padding: 0 0 18px; }.generator-layout { grid-template-columns: 1fr; grid-template-areas: 'output' 'options'; gap: 10px; }.generator-output-card, .generator-options-card { padding: 15px; border-radius: 16px; }.generator-value { min-height: 94px; margin: 14px 0 10px; padding: 14px; font-size: 19px; }.generator-meta-row { grid-template-columns: minmax(60px, 1fr) auto; }.generator-meta-row > span:last-child { grid-column: 1 / -1; }.generator-actions .btn { justify-content: center; padding-inline: 10px; }.generator-option-group { margin: 15px 0; }.generator-toggle { min-height: 44px; }.generator-number-field.compact { margin-left: 50px; }.generator-stepper { grid-template-columns: 40px 64px 40px; }.generator-stepper button { width: 40px; min-height: 44px; }.generator-stepper .input { height: 44px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 36px 46px 36px; }.generator-number-field.compact .generator-stepper button { width: 36px; height: 40px; min-height: 40px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .generator-toggle > span, .generator-toggle > span::after { transition: none; }.generator-spinner { animation: none; } }
|
||||
@@ -30,6 +30,14 @@
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
/* The detail panel is only as wide as the viewport minus the app sidebar and the
|
||||
two backup sidebars (~858px of chrome), so viewport breakpoints cannot tell when
|
||||
the remote backup list no longer fits. Query the panel's own width instead. */
|
||||
.backup-detail-panel {
|
||||
container-type: inline-size;
|
||||
container-name: backup-detail;
|
||||
}
|
||||
|
||||
.backup-actions-stack {
|
||||
@apply grid gap-2.5;
|
||||
}
|
||||
@@ -99,7 +107,7 @@
|
||||
@apply inline-flex h-[22px] w-[22px] shrink-0 cursor-pointer items-center justify-center rounded-full p-0 text-[13px] font-extrabold leading-none;
|
||||
border: 1px solid #bfd1f3;
|
||||
background: #eef4ff;
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.backup-help-trigger:hover,
|
||||
@@ -194,7 +202,7 @@
|
||||
}
|
||||
|
||||
.backup-recommendation-step a {
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
@@ -316,14 +324,14 @@
|
||||
}
|
||||
|
||||
.backup-interval-preset:hover:not(:disabled) {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.backup-interval-preset.active {
|
||||
border-color: #2563eb;
|
||||
background: #2563eb;
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -371,13 +379,21 @@
|
||||
}
|
||||
|
||||
.backup-browser-list {
|
||||
@apply overflow-hidden rounded-lg border bg-white;
|
||||
@apply rounded-lg border bg-white;
|
||||
border: 1px solid var(--line);
|
||||
/* Never clip silently: if a row still overflows (long locale labels, different
|
||||
font metrics), scroll instead of hiding the action buttons. */
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.backup-browser-head,
|
||||
.backup-browser-row {
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||
}
|
||||
|
||||
.backup-browser-head {
|
||||
@apply grid items-center gap-3 px-3 py-2 text-[11px] font-bold uppercase tracking-[0.08em];
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
@@ -404,7 +420,6 @@
|
||||
|
||||
.backup-browser-row {
|
||||
@apply grid items-center gap-3 px-3 py-2;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
@@ -434,7 +449,42 @@
|
||||
|
||||
.backup-browser-actions {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
/* Wrap instead of clipping: three non-shrinking buttons overflow the 220px
|
||||
actions track once the panel is narrow, hiding Delete entirely. */
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.backup-browser-actions .btn.small {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Must stay after every base .backup-browser-* rule above: container queries add
|
||||
no specificity, so source order decides which declaration wins.
|
||||
The four tracks need ~702px, plus headroom for the widest locale label
|
||||
(fr "Restauration en cours..." pushes a button past the 220px actions track).
|
||||
Below that, stack the row into a card and drop the column header. */
|
||||
@container backup-detail (max-width: 820px) {
|
||||
.backup-browser-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.backup-browser-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backup-browser-meta {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-browser-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.backup-browser-nav {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.backup-browser-empty {
|
||||
@@ -752,15 +802,25 @@
|
||||
}
|
||||
|
||||
.yubikey-manage-dialog-body {
|
||||
@apply mt-3 space-y-4;
|
||||
@apply mt-3;
|
||||
}
|
||||
|
||||
.yubikey-manage-dialog-body > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-plain-steps {
|
||||
@apply m-0 space-y-1 border-b pb-3 pl-5;
|
||||
@apply m-0 border-b pb-3 pl-5;
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-plain-steps > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.yubikey-input-row {
|
||||
@apply grid items-center gap-2;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -775,8 +835,9 @@
|
||||
@apply h-9 w-9 p-0;
|
||||
}
|
||||
|
||||
.settings-checkbox-block {
|
||||
@apply space-y-2;
|
||||
.settings-checkbox-block > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.checkbox-inline {
|
||||
@@ -1786,7 +1847,7 @@
|
||||
}
|
||||
|
||||
.restore-progress-item.active {
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.restore-progress-item.done {
|
||||
@@ -1799,7 +1860,7 @@
|
||||
}
|
||||
|
||||
.restore-progress-item.active .restore-progress-dot {
|
||||
background: #1d4ed8;
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.restore-progress-item.done .restore-progress-dot {
|
||||
@@ -1871,7 +1932,7 @@
|
||||
.authorized-device-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #2563eb;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.authorized-devices-table td:first-child {
|
||||
@@ -1879,7 +1940,9 @@
|
||||
}
|
||||
|
||||
.authorized-devices-actions {
|
||||
flex-wrap: nowrap;
|
||||
/* Wrap instead of clipping: four non-shrinking buttons overflow the
|
||||
fixed 26% actions column at common widths, hiding Delete entirely. */
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,29 +41,10 @@
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.import-export-panels,
|
||||
.backup-browser-row {
|
||||
.import-export-panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-browser-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backup-browser-meta {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-browser-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.backup-browser-nav {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-twofactor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -151,7 +132,7 @@
|
||||
}
|
||||
|
||||
.topbar-actions > .network-status-badge {
|
||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 rounded-xl p-0 text-[0];
|
||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 rounded-xl p-0 text-[0px];
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
border-color: var(--line);
|
||||
box-shadow: none;
|
||||
@@ -163,7 +144,7 @@
|
||||
|
||||
.mobile-sidebar-toggle,
|
||||
.mobile-lock-btn {
|
||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 rounded-xl p-0 text-[0];
|
||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 rounded-xl p-0 text-[0px];
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
border-color: var(--line);
|
||||
box-shadow: none;
|
||||
@@ -470,7 +451,7 @@
|
||||
}
|
||||
|
||||
.mobile-fab-trigger {
|
||||
@apply h-9 w-9 gap-0 rounded-full p-0 text-[0];
|
||||
@apply h-9 w-9 gap-0 rounded-full p-0 text-[0px];
|
||||
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28);
|
||||
transition: transform 180ms var(--ease-spring), box-shadow var(--dur-fast) var(--ease-out-soft);
|
||||
}
|
||||
@@ -1248,29 +1229,10 @@
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.backup-browser-row,
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-browser-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backup-browser-meta {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-browser-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.backup-browser-nav {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.backup-grid {
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
|
||||
Vendored
+15
-4
@@ -31,7 +31,12 @@
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
@apply min-w-0 flex-1 space-y-2;
|
||||
@apply min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.skeleton-content > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
@@ -63,13 +68,19 @@
|
||||
}
|
||||
|
||||
.skeleton-page {
|
||||
@apply h-full space-y-4 p-4;
|
||||
@apply h-full p-4;
|
||||
}
|
||||
|
||||
.skeleton-page > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
@apply mb-6;
|
||||
}
|
||||
|
||||
.skeleton-body {
|
||||
@apply space-y-3;
|
||||
.skeleton-body > :not([hidden]) ~ :not([hidden]) {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
.vault-grid {
|
||||
@apply grid h-full min-h-0 gap-3 p-0.5;
|
||||
grid-template-columns: 270px minmax(400px, 36%) minmax(400px, 1fr);
|
||||
/* The list and detail panes already scroll internally (overflow: auto), which
|
||||
zeroes their automatic minimum size. Keeping the track minimums at 0 lets the
|
||||
grid fit the content area at any width instead of overflowing the page; wide
|
||||
viewports are unaffected because 36%/1fr were never binding there. */
|
||||
grid-template-columns: 270px minmax(0, 36%) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
@@ -47,11 +51,11 @@
|
||||
}
|
||||
|
||||
.folder-add-btn:hover {
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base text-ink outline-none transition;
|
||||
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base text-ink outline-hidden transition;
|
||||
background: var(--panel);
|
||||
border-color: rgba(74, 103, 150, 0.34);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
||||
@@ -161,7 +165,7 @@
|
||||
}
|
||||
|
||||
.folder-sort-btn:hover {
|
||||
color: #1d4ed8;
|
||||
color: var(--primary-hover);
|
||||
background: #dbeafe;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
@@ -230,7 +234,7 @@ select.input.duplicate-mode-toolbar-select {
|
||||
}
|
||||
|
||||
.list-head {
|
||||
@apply mb-1.5 flex items-center gap-2;
|
||||
@apply mb-1.5 flex flex-wrap items-center gap-2;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
@@ -412,7 +416,7 @@ select.input.duplicate-mode-toolbar-select {
|
||||
}
|
||||
|
||||
.desktop-create-trigger {
|
||||
@apply h-[34px] w-[34px] min-w-[34px] gap-0 rounded-[10px] p-0 text-[0];
|
||||
@apply h-[34px] w-[34px] min-w-[34px] gap-0 rounded-[10px] p-0 text-[0px];
|
||||
}
|
||||
|
||||
.desktop-create-trigger .btn-icon {
|
||||
@@ -582,7 +586,7 @@ select.input.duplicate-mode-toolbar-select {
|
||||
}
|
||||
|
||||
.card-brand-icon {
|
||||
@apply inline-grid h-6 w-9 shrink-0 place-items-center rounded border text-[7px] font-black uppercase leading-none;
|
||||
@apply inline-grid h-6 w-9 shrink-0 place-items-center rounded-sm border text-[7px] font-black uppercase leading-none;
|
||||
letter-spacing: 0;
|
||||
color: #1e3a8a;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f4f7fb 100%);
|
||||
@@ -860,7 +864,9 @@ select.input.duplicate-mode-toolbar-select {
|
||||
|
||||
.kv-row {
|
||||
@apply grid items-center gap-2.5 py-2.5;
|
||||
grid-template-columns: minmax(0px, 80px) minmax(0, 1fr) auto;
|
||||
/* Floor the value column so the auto-sized actions column wraps its
|
||||
buttons instead of collapsing the value to 0 width. */
|
||||
grid-template-columns: minmax(0px, 80px) minmax(min(35%, 140px), 1fr) auto;
|
||||
border-bottom: 1px solid rgba(154, 172, 205, 0.22);
|
||||
}
|
||||
|
||||
@@ -962,7 +968,7 @@ select.input.duplicate-mode-toolbar-select {
|
||||
}
|
||||
|
||||
.totp-ring-progress {
|
||||
stroke: #2563eb;
|
||||
stroke: var(--primary);
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 260ms linear, stroke 200ms ease;
|
||||
}
|
||||
|
||||
+71
-3
@@ -1,3 +1,71 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss' source(none);
|
||||
@import './styles.css';
|
||||
|
||||
@source '../index.html';
|
||||
@source './**/*.{ts,tsx}';
|
||||
|
||||
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
|
||||
|
||||
/* Inline aliases keep utilities tied to the active light/dark design tokens. */
|
||||
@theme inline {
|
||||
--color-canvas: var(--bg-accent);
|
||||
--color-panel: var(--panel);
|
||||
--color-panel-soft: var(--panel-soft);
|
||||
--color-panel-muted: var(--panel-muted);
|
||||
--color-line: var(--line);
|
||||
--color-line-soft: var(--line-soft);
|
||||
--color-ink: var(--text);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-strong: var(--muted-strong);
|
||||
--color-brand: var(--primary);
|
||||
--color-brand-hover: var(--primary-hover);
|
||||
--color-brand-strong: var(--primary-strong);
|
||||
--color-danger: var(--danger);
|
||||
|
||||
--shadow-soft: var(--shadow-sm);
|
||||
--shadow-panel: var(--shadow-md);
|
||||
--shadow-elevated: var(--shadow-lg);
|
||||
|
||||
--font-sans: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
|
||||
/* Preserve utility radii independently of tokens.css's --radius-* names. */
|
||||
--radius-xs: 0.125rem;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
--radius-3xl: 1.5rem;
|
||||
|
||||
/* Keep line heights fixed when component styles override the font size. */
|
||||
--text-xs--line-height: 1rem;
|
||||
--text-sm--line-height: 1.25rem;
|
||||
--text-base--line-height: 1.5rem;
|
||||
--text-lg--line-height: 1.75rem;
|
||||
--text-xl--line-height: 1.75rem;
|
||||
--text-2xl--line-height: 2rem;
|
||||
--text-3xl--line-height: 2.25rem;
|
||||
--text-4xl--line-height: 2.5rem;
|
||||
}
|
||||
|
||||
/* Retain the v3 defaults where components do not set their own values. */
|
||||
@layer base {
|
||||
*,
|
||||
::before,
|
||||
::after,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role='button']:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
+23
-61
@@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import preact from '@preact/preset-vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig, type Plugin } from 'vite';
|
||||
|
||||
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
@@ -258,39 +259,12 @@ function searchIndexPolicyPlugin(isDemo: boolean): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
function resourcePriorityPlugin(isDemo: boolean): Plugin {
|
||||
return {
|
||||
name: 'nodewarden-resource-priority',
|
||||
enforce: 'post' as const,
|
||||
transformIndexHtml(html: string) {
|
||||
if (isDemo || !html.includes('/assets/app-suite-')) return html;
|
||||
|
||||
const scriptMatch = html.match(/^\s*<script type="module" crossorigin src="\/assets\/index-[^"]+\.js"><\/script>\s*$/m);
|
||||
const appSuiteMatch = html.match(/^\s*<link rel="modulepreload" crossorigin href="\/assets\/app-suite-[^"]+\.js">\s*$/m);
|
||||
const stylesheetMatch = html.match(/^\s*<link rel="stylesheet" crossorigin href="\/assets\/index-[^"]+\.css">\s*$/m);
|
||||
|
||||
if (!scriptMatch || !appSuiteMatch || !stylesheetMatch) return html;
|
||||
|
||||
const prioritizedTags = [
|
||||
stylesheetMatch[0].replace('rel="stylesheet"', 'rel="stylesheet" fetchpriority="high"'),
|
||||
appSuiteMatch[0].replace('rel="modulepreload"', 'rel="modulepreload" fetchpriority="high"'),
|
||||
scriptMatch[0].replace('type="module"', 'type="module" fetchpriority="high"'),
|
||||
].join('\n');
|
||||
|
||||
return html
|
||||
.replace(scriptMatch[0], '')
|
||||
.replace(appSuiteMatch[0], '')
|
||||
.replace(stylesheetMatch[0], prioritizedTags);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDemo = mode === 'demo';
|
||||
|
||||
return {
|
||||
root: rootDir,
|
||||
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
||||
plugins: [preact(), tailwindcss(), searchIndexPolicyPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
||||
define: {
|
||||
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
||||
},
|
||||
@@ -311,41 +285,29 @@ export default defineConfig(({ mode }) => {
|
||||
sourcemap: false,
|
||||
target: 'esnext',
|
||||
chunkSizeWarningLimit: 800,
|
||||
rollupOptions: {
|
||||
treeshake: {
|
||||
preset: 'smallest',
|
||||
rolldownOptions: {
|
||||
checks: {
|
||||
pluginTimings: false,
|
||||
},
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
const normalized = id.replace(/\\/g, '/');
|
||||
|
||||
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
|
||||
if (localeMatch) {
|
||||
if (localeMatch[1] === 'en') return undefined;
|
||||
return `i18n-${localeMatch[1]}`;
|
||||
}
|
||||
|
||||
if (
|
||||
!isDemo &&
|
||||
(
|
||||
normalized.includes('/src/components/VaultPage.tsx') ||
|
||||
normalized.includes('/src/components/ImportPage.tsx') ||
|
||||
normalized.includes('/src/lib/import-') ||
|
||||
normalized.includes('/src/lib/export-formats.ts') ||
|
||||
normalized.includes('/src/components/SendsPage.tsx') ||
|
||||
normalized.includes('/src/components/TotpCodesPage.tsx') ||
|
||||
normalized.includes('/src/components/DomainRulesPage.tsx') ||
|
||||
normalized.includes('/src/components/BackupCenterPage.tsx') ||
|
||||
normalized.includes('/src/components/backup-center/') ||
|
||||
normalized.includes('/src/components/SettingsPage.tsx') ||
|
||||
normalized.includes('/src/components/SecurityDevicesPage.tsx') ||
|
||||
normalized.includes('/src/components/AdminPage.tsx')
|
||||
)
|
||||
) {
|
||||
return 'app-suite';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
{
|
||||
name: 'shared',
|
||||
minShareCount: 2,
|
||||
minSize: 50 * 1024,
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
name(id) {
|
||||
const normalized = id.replace(/\\/g, '/');
|
||||
const localeMatch = normalized.match(/\/src\/lib\/i18n\/locales\/(.+)\.ts$/);
|
||||
return localeMatch && localeMatch[1] !== 'en' ? `i18n-${localeMatch[1]}` : null;
|
||||
},
|
||||
test: /[\\/]src[\\/]lib[\\/]i18n[\\/]locales[\\/]/,
|
||||
priority: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user