34 Commits

Author SHA1 Message Date
shuaiplus 408874ac05 feat: update version to 1.4.4 in package.json, package-lock.json, and app-version.ts 2026-04-18 04:02:49 +08:00
shuaiplus dabd2c923e feat: optimize attachment handling in backup process 2026-04-18 03:55:27 +08:00
shuaiplus 08414d7cf2 feat: add support for new cipher properties and enhance import functionality 2026-04-18 03:44:17 +08:00
shuaiplus 38b33df719 feat: add password history feature with dialog and encryption handling 2026-04-18 02:05:01 +08:00
shuaiplus 7ebd12fa07 feat: add device note and last seen tracking to devices, enhance device management features 2026-04-18 01:43:21 +08:00
entsalze f7cbdaf730 feat: update NodeWarden logo image 2026-04-17 15:19:54 +08:00
shuaiplus 6cae5cb218 feat: update version to 1.4.3 in package.json, package-lock.json, and app-version.ts 2026-04-16 23:01:20 +08:00
shuaiplus d96ad9bb1c Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-04-16 22:30:01 +08:00
shuaiplus 92d1f07998 feat: enhance cipher handling with nested object merging and additional fields 2026-04-16 22:29:55 +08:00
maooyer a8432ab94b feat: add issue templates 2026-04-13 00:31:21 +08:00
shuaiplus 2230f75d8a feat: add loading state management for TOTP and import/export operations 2026-04-09 23:27:40 +08:00
shuaiplus a982a5a57b feat: enhance database indexing and optimize sync response handling 2026-04-09 23:05:00 +08:00
github-actions[bot] 4d7ee2164a chore: restore sync-upstream workflow after sync 2026-04-09 17:23:42 +08:00
shuaiplus 34d4851981 feat: add links to documentation homepage and quick start in README 2026-04-09 17:05:52 +08:00
shuaiplus 4827a4958e Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-04-09 16:50:49 +08:00
shuaiplus 70463d3fc7 feat: add Telegram channel and group links to README files 2026-04-09 16:50:43 +08:00
shuaiplus 681705ee13 feat: add passkey deletion functionality and related UI components 2026-04-08 14:47:53 +08:00
shuaiplus 5bf7c79ada feat: add FIDO2 credentials support in cipher handling and UI components 2026-04-08 14:40:49 +08:00
shuaiplus c516194d54 feat: implement web session handling and enhance token management 2026-04-07 22:14:26 +08:00
shuaiplus 53231a4878 feat: enhance backup progress handling and improve user status toggling 2026-04-07 20:58:23 +08:00
shuaiplus c9e7417825 feat: add timezone support for backup file naming and extraction 2026-04-07 20:24:28 +08:00
shuaiplus 76623d7201 Refactor: Remove passkey-related functionality and types
- Deleted passkey-related interfaces and types from index.ts and types.ts.
- Removed passkey handling from App component, including related state and functions.
- Cleaned up API calls in auth.ts, removing passkey registration and login functions.
- Updated vault and import formats to eliminate passkey references.
- Removed passkey support checks and UI elements from AuthViews and SettingsPage.
- Cleaned up unused passkey helper functions and constants.
- Adjusted related components and hooks to ensure consistent functionality without passkey support.
2026-04-06 00:46:13 +08:00
shuaiplus 90a7731351 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-04-01 23:05:47 +08:00
shuaiplus f4adeb8ec9 fix: enhance QR code visibility with background and border adjustments 2026-04-01 23:05:44 +08:00
saleacy bb0b82f838 Update folderId assignment to include c.folderId
修复导入数据时选择指定文件夹未生效的BUG。
2026-04-01 22:54:56 +08:00
qaz741wsd856 be82c953d6 feat: add request URL normalization 2026-03-31 10:42:57 +08:00
Shuai edd2ba2e44 refine passkey settings list, rename and delete UX 2026-03-31 01:24:12 +08:00
Shuai 0f6da7d147 feat: add passkey-first login and management flow 2026-03-31 01:24:12 +08:00
Shuai 1184cb8d9a feat(vault): add folder rename action in sidebar 2026-03-31 00:29:34 +08:00
shuaiplus 882fa2e8c8 chore: ignore local wiki repo 2026-03-29 01:07:28 +08:00
shuaiplus b6b7e46f79 feat: Update README 2026-03-29 01:00:33 +08:00
shuaiplus 144d3d9406 feat: add decodeIncomingMessage function and improve webSocketMessage handling 2026-03-28 15:28:46 +08:00
qaz741wsd856 10707cf902 refactor: refactor NotificationsHub to use hibernation api
- Updated NotificationsHub class to extend DurableObject.
- Persisted connection state into attachment instead of memory.
- Removed unnecessary ping functions & server-side periodic ping logic and added auto response which integrated into the WebSocket lifecycle.
- Added echo for binary ws messages (for keeplive of MessagePack).
- Added ping timer functionality in the App component to manage WebSocket connections more effectively.
2026-03-28 14:56:40 +08:00
shuaiplus 3bd4f6a9fe feat: enhance error handling for remote attachment index loading 2026-03-28 14:51:58 +08:00
69 changed files with 2121 additions and 701 deletions
+70
View File
@@ -0,0 +1,70 @@
name: "Bug Report"
description: "Report a reproducible bug / 反馈可复现问题"
title: "[Bug] "
labels: ["bug", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting. Please provide enough detail so maintainers can reproduce quickly.
感谢反馈,请尽量提供可复现信息,方便快速定位。
- type: checkboxes
id: checklist
attributes:
label: Pre-check / 提交前确认
options:
- label: I have searched existing issues and did not find a duplicate. / 我已搜索现有 issue,确认不是重复问题。
required: true
- label: I have read README and Project Wiki / 我已阅读 README 与 项目 Wiki。
required: true
- type: input
id: version
attributes:
label: Version / 版本
description: "Which version of NodeWarden are you using? Please provide the exact version or commit hash."
placeholder: "1.0.0"
validations:
required: true
- type: textarea
id: reproduce_steps
attributes:
label: Steps to Reproduce / 复现步骤
placeholder: |
1. Start service with ...
2. Open ...
3. Click ...
4. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior / 预期行为
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior / 实际行为
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs and Screenshots / 日志与截图
description: "Please paste key logs (docker logs / browser console / network errors)."
render: shell
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional Context / 补充信息
description: "Any workaround, frequency, impact scope, etc."
+12
View File
@@ -0,0 +1,12 @@
blank_issues_enabled: false
contact_links:
- name: Project Wiki/ 项目文档
url: https://github.com/shuaiplus/nodewarden/wiki
about: |
Please check the documentation for common questions and troubleshooting steps.
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
- name: Project Discussions / 讨论区
url: https://github.com/shuaiplus/nodewarden/discussions
about: |
For general questions, feature discussions, or if you're not sure which template to use, please post in the Discussions section.
如果你有一般性问题、功能讨论,或者不确定使用哪个模板,请在讨论区发帖。
@@ -0,0 +1,62 @@
name: "Feature Request"
description: "Suggest an improvement / 功能建议"
title: "[Feature] "
labels: ["enhancement", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Proposals with clear use-case and expected value are easier to evaluate.
说明清晰的使用场景和价值,有助于快速评估。
- type: checkboxes
id: checklist
attributes:
label: Pre-check / 提交前确认
options:
- label: I have searched existing issues and this request is not duplicated. / 我已搜索现有 issue,确认不是重复建议。
required: true
- type: textarea
id: problem
attributes:
label: Problem Statement / 现存问题
description: "What is difficult or missing today?"
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed Solution / 建议方案
description: "Describe your expected behavior, UI flow, API changes, etc."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered / 备选方案
description: "Any alternatives or workarounds you've considered."
validations:
required: false
- type: textarea
id: impact
attributes:
label: Expected Impact / 预期价值
description: "Who benefits? Any performance/security/maintenance concerns?"
validations:
required: true
- type: input
id: scope
attributes:
label: Scope (Optional) / 影响范围(可选)
placeholder: "frontend / backend / docs / deployment"
- type: textarea
id: extra
attributes:
label: Additional Context / 补充信息
description: "Mockups, references, related links, etc."
+117 -8
View File
@@ -4,6 +4,11 @@ on:
schedule: schedule:
- cron: "0 3 * * *" - cron: "0 3 * * *"
workflow_dispatch: workflow_dispatch:
inputs:
target_commit:
description: 'Commit hash (leave blank to use latest commit)'
required: false
type: string
permissions: permissions:
contents: write contents: write
@@ -11,9 +16,8 @@ permissions:
jobs: jobs:
sync: sync:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -22,13 +26,118 @@ jobs:
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Sync main from upstream - name: Add upstream
run: | run: |
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
git fetch upstream git fetch upstream --tags
git checkout main
git merge upstream/main
- name: Push synced main - name: Resolve target commit
id: resolve
run: | run: |
git push origin main TRIGGER="${{ github.event_name }}"
MANUAL_INPUT="${{ github.event.inputs.target_commit }}"
if [ "$TRIGGER" = "schedule" ]; then
# Auto mode: resolve latest upstream release tag
LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name)
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
echo "No release found in upstream."
exit 1
fi
TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Tag '$LATEST_TAG' not found after fetch."
exit 1
fi
echo "mode=auto" >> $GITHUB_OUTPUT
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
elif [ -n "$MANUAL_INPUT" ]; then
# Manual mode: use provided commit hash or tag
TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
exit 1
fi
echo "mode=manual" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
else
# Manual mode, blank input: use latest commit on upstream/main
TARGET_SHA=$(git rev-parse upstream/main)
echo "mode=manual" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Manual mode — latest commit: $TARGET_SHA"
fi
- name: Check if update is needed
id: check
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
if [ "$MODE" = "manual" ]; then
# Manual: skip only if HEAD is exactly this commit
CURRENT_SHA=$(git rev-parse HEAD)
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
echo "Already at $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT
else
echo "Switching to $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT
fi
else
# Auto: skip if target is already in ancestry
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
echo "Already up to date with $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT
else
echo "Update needed — target: $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT
fi
fi
- name: Apply update
if: steps.check.outputs.needs_update == 'true'
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
git checkout main
if [ "$MODE" = "manual" ]; then
# Hard reset allows both upgrade and rollback
git reset --hard "$TARGET_SHA"
else
git merge "$TARGET_SHA" --no-edit
fi
- name: Restore workflow file
if: steps.check.outputs.needs_update == 'true'
run: |
# Always keep our own workflow file, never let upstream overwrite it
git checkout HEAD@{1} -- .github/workflows/sync-upstream.yml 2>/dev/null || true
if ! git diff --cached --quiet; then
git commit -m "chore: restore sync-upstream workflow after sync"
fi
- name: Push
if: steps.check.outputs.needs_update == 'true'
run: |
if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then
git push origin main --force
else
git push origin main
fi
- name: Summary
run: |
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
echo "### Synced successfully" >> $GITHUB_STEP_SUMMARY
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}" >> $GITHUB_STEP_SUMMARY
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`" >> $GITHUB_STEP_SUMMARY
else
echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY
fi
+2
View File
@@ -40,3 +40,5 @@ npm-debug.log*
tmp/ tmp/
.tmp/ .tmp/
nodewarden.wiki/
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 472 KiB

+22 -11
View File
@@ -13,6 +13,10 @@
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest) [更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
[文档首页](./nodewarden.wiki/Home.md) | [快速开始](./nodewarden.wiki/快速开始.md)
[Telegram 频道](https://t.me/NodeWarden_News) | [Telegram 群组](https://t.me/NodeWarden_Official)
English: [`README_EN.md`](./README_EN.md) English: [`README_EN.md`](./README_EN.md)
> **免责声明** > **免责声明**
@@ -52,19 +56,26 @@ English: [`README_EN.md`](./README_EN.md)
## 网页部署 ## 网页部署
1. Fork `NodeWarden` 仓库到自己的 GitHub 账号
1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。 2. 进入 [Cloudflare Workers 创建页面](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可用 KV 来代替,将**部署命令**改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接 3. 选择 `Continue with GitHub`
4. 选择你刚刚 Fork 的仓库
| 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 | 5. 保持默认配置继续部署
|---|---|---|---| 6. 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
| R2 | 需要 | 100 MB(软限制可更改) | 10 GB | 7. 等部署完成后,打开生成的 Workers 域名
| KV | 不需要 | 25 MiBCloudflare限制) | 1 GB | 8. 根据页面提示设置`JWT_SECRET` ,不建议临时乱填。这个值直接关系到令牌签发安全,正式环境至少使用 32 个字符以上的随机字符串。
> [!TIP] > [!TIP]
> 同步方法(更新仓库) > 默认R2与可选KV的区别
>- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch` > | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
>- 自动:进入你的 Fork 仓库 ➜ `Actions``Sync upstream``Enable workflow`,会在每天凌晨 3 点自动同步上游。 > |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
> | KV | 不需要 | 25 MiBCloudflare限制) | 1 GB |
## 更新方法:
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
- 自动:进入你的 Fork 仓库 ➜ `Actions``Sync upstream``Enable workflow`,会在每天凌晨 3 点自动同步上游。
+24 -24
View File
@@ -5,32 +5,32 @@
<p align="center"> <p align="center">
A third-party Bitwarden-compatible server running on Cloudflare Workers. A third-party Bitwarden-compatible server running on Cloudflare Workers.
</p> </p>
[![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/) [![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
[![License: LGPL-3.0](https://img.shields.io/badge/License-LGPL--3.0-2ea44f)](./LICENSE) [![License: LGPL-3.0](https://img.shields.io/badge/License-LGPL--3.0-2ea44f)](./LICENSE)
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest) [![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest)
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml) [![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest) [Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
[Telegram Channel](https://t.me/NodeWarden_News) | [Telegram Group](https://t.me/NodeWarden_Official)
English: [`README.md`](./README.md) 中文说明:[`README.md`](./README.md)
> **Disclaimer** > **Disclaimer**
> This project is for learning and communication purposes only. Please back up your vault regularly. >
> This project is for learning and discussion purposes only. Please back up your vault regularly.
>
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team. > This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
--- ---
## Feature Comparison with Official Bitwarden Server ## Feature Comparison with the Official Bitwarden Server
| Capability | Bitwarden | NodeWarden | Notes | | Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---| |---|---|---|---|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** | | Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| Full sync `/api/sync` | ✅ | ✅ | Optimized for official clients | | Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV | | Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends | | Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** | | Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | ❌ | ✅ | **Supports scheduled backups with WebDAV / E3** | | **Cloud Backup Center** | ❌ | ✅ | **Scheduled backup to WebDAV / E3** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** | | Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support | | TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration | | Multi-user | ✅ | ✅ | Invite-based registration |
@@ -46,19 +46,20 @@ English: [`README.md`](./README.md)
- ✅ Mobile app - ✅ Mobile app
- ✅ Browser extension - ✅ Browser extension
- ✅ Linux desktop client - ✅ Linux desktop client
- ⚠️ macOS desktop client not fully verified - ⚠️ macOS desktop client has not been fully verified yet
--- ---
## Web Deploy ## Web Deploy
1. Fork this repository. If this project helps you, please consider giving it a Star. 1. Fork this repository. If this project helps you, consider giving it a Star.
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> `Next` -> deploy. 2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
R2 is used by default. If R2 is unavailable for your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`. 3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
4. Deploy and open the generated URL.
| Storage | Card required | Single attachment / Send file limit | Free tier | | Storage | Card required | Single attachment / Send file limit | Free tier |
|---|---|---|---| |---|---|---|---|
| R2 | Yes | 100 MB (soft limit, can be adjusted) | 10 GB | | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
| KV | No | 25 MiB (Cloudflare limit) | 1 GB | | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
> [!TIP] > [!TIP]
@@ -71,7 +72,6 @@ English: [`README.md`](./README.md)
```powershell ```powershell
git clone https://github.com/shuaiplus/NodeWarden.git git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden cd NodeWarden
npm install npm install
npx wrangler login npx wrangler login
@@ -92,13 +92,13 @@ npm run dev:kv
- Remote backup supports **WebDAV** and **E3** - Remote backup supports **WebDAV** and **E3**
- When `Include attachments` is enabled: - When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json` - the ZIP still contains only `db.json` and `manifest.json`
- real attachment files are stored separately under `attachments/` - actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of uploading everything again - later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- During remote restore: - During remote restore:
- required attachment files are loaded from `attachments/` - required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely - missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database - skipped attachments do not leave broken rows in the restored database
--- ---
@@ -110,7 +110,7 @@ Current supported import sources include:
- Bitwarden CSV - Bitwarden CSV
- Bitwarden vault + attachments ZIP - Bitwarden vault + attachments ZIP
- NodeWarden JSON - NodeWarden JSON
- Multiple browser / password-manager formats visible in the web import selector - Multiple browser / password-manager formats available in the web import selector
Current supported export formats include: Current supported export formats include:
@@ -130,9 +130,9 @@ LGPL-3.0 License
## Credits ## Credits
- [Bitwarden](https://bitwarden.com/) - original design and clients - [Bitwarden](https://bitwarden.com/) - Original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference - [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform - [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
--- ---
+5
View File
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS ciphers (
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at); CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at); CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at);
CREATE TABLE IF NOT EXISTS folders ( CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -106,6 +107,7 @@ CREATE TABLE IF NOT EXISTS sends (
); );
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date); CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date);
CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id);
CREATE TABLE IF NOT EXISTS refresh_tokens ( CREATE TABLE IF NOT EXISTS refresh_tokens (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
@@ -151,12 +153,15 @@ CREATE TABLE IF NOT EXISTS devices (
encrypted_user_key TEXT, encrypted_user_key TEXT,
encrypted_public_key TEXT, encrypted_public_key TEXT,
encrypted_private_key TEXT, encrypted_private_key TEXT,
device_note TEXT,
last_seen_at TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_identifier), PRIMARY KEY (user_id, device_identifier),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens ( CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.2", "version": "1.4.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.2", "version": "1.4.4",
"license": "LGPL-3.0", "license": "LGPL-3.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.2", "version": "1.4.4",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.4.2'; export const APP_VERSION = '1.4.4';
+3
View File
@@ -130,6 +130,9 @@
// Max total items (folders + ciphers) allowed in a single import. // Max total items (folders + ciphers) allowed in a single import.
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。 // 单次导入允许的最大条目数(文件夹 + 密码项合计)。
importItemLimit: 5000, importItemLimit: 5000,
// Small fixed concurrency for blob/attachment batch cleanup work.
// 附件 / blob 批量清理时的保守并发数。
attachmentDeleteConcurrency: 4,
}, },
request: { request: {
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt. // Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
+76 -115
View File
@@ -1,3 +1,4 @@
import { DurableObject } from 'cloudflare:workers';
import type { Env } from '../types'; import type { Env } from '../types';
const SIGNALR_RECORD_SEPARATOR = 0x1e; const SIGNALR_RECORD_SEPARATOR = 0x1e;
@@ -6,11 +7,11 @@ const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
const SIGNALR_PING_INTERVAL_MS = 15_000;
type HubProtocol = 'json' | 'messagepack'; type HubProtocol = 'json' | 'messagepack';
interface ConnectionState { interface WsAttachment {
userId: string;
handshakeComplete: boolean; handshakeComplete: boolean;
protocol: HubProtocol; protocol: HubProtocol;
deviceIdentifier: string | null; deviceIdentifier: string | null;
@@ -31,6 +32,12 @@ function encodeUtf8(value: string): Uint8Array {
return new TextEncoder().encode(value); return new TextEncoder().encode(value);
} }
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
if (typeof data === 'string') return data;
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
}
function encodeMsgPackInteger(value: number): Uint8Array { function encodeMsgPackInteger(value: number): Uint8Array {
const normalized = Math.trunc(value); const normalized = Math.trunc(value);
if (normalized >= 0 && normalized <= 0x7f) { if (normalized >= 0 && normalized <= 0x7f) {
@@ -145,10 +152,6 @@ function buildSignalRJsonInvocation(
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR); }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
} }
function buildSignalRJsonPing(): string {
return JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
}
function buildSignalRMessagePackInvocation( function buildSignalRMessagePackInvocation(
updateType: number, updateType: number,
messagePayload: Record<string, unknown>, messagePayload: Record<string, unknown>,
@@ -172,24 +175,15 @@ function buildSignalRMessagePackInvocation(
return frameSignalRBinary(encodedPayload); return frameSignalRBinary(encodedPayload);
} }
function buildSignalRMessagePackPing(): Uint8Array { export class NotificationsHub extends DurableObject<Env> {
return frameSignalRBinary(encodeMsgPack([6])); constructor(ctx: DurableObjectState, env: Env) {
} super(ctx, env);
this.ctx.setWebSocketAutoResponse(
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string { new WebSocketRequestResponsePair(
if (typeof data === 'string') return data; JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR),
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data)); JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR)
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); )
} );
export class NotificationsHub {
private readonly connections = new Map<WebSocket, ConnectionState>();
private userId = '';
private pingTimer: ReturnType<typeof setInterval> | null = null;
constructor(private readonly state: DurableObjectState, private readonly env: Env) {
void this.state;
void this.env;
} }
async fetch(request: Request): Promise<Response> { async fetch(request: Request): Promise<Response> {
@@ -205,14 +199,14 @@ export class NotificationsHub {
payload?: Record<string, unknown> | null; payload?: Record<string, unknown> | null;
} | null; } | null;
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString(); const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim(); const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
const contextId = String(body?.contextId || '').trim() || null; const contextId = String(body?.contextId || '').trim() || null;
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT; const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null; const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
const payload = body?.payload && typeof body.payload === 'object' const payload = body?.payload && typeof body.payload === 'object'
? body.payload ? body.payload
: { : {
UserId: this.userId, UserId: userId,
Date: revisionDate, Date: revisionDate,
}; };
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier); this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
@@ -238,46 +232,27 @@ export class NotificationsHub {
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim(); const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null; const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
if (requestUserId) {
this.userId = requestUserId;
}
if (!this.userId) { if (!requestUserId) {
return new Response('Unauthorized', { status: 401 }); return new Response('Unauthorized', { status: 401 });
} }
const pair = new WebSocketPair(); const pair = new WebSocketPair();
const client = pair[0]; const client = pair[0];
const server = pair[1]; const server = pair[1];
server.accept();
this.connections.set(server, { const tags: string[] = [];
if (requestDeviceIdentifier) {
tags.push(`device:${requestDeviceIdentifier}`);
}
this.ctx.acceptWebSocket(server, tags);
server.serializeAttachment({
userId: requestUserId,
handshakeComplete: false, handshakeComplete: false,
protocol: 'messagepack', protocol: 'messagepack',
deviceIdentifier: requestDeviceIdentifier, deviceIdentifier: requestDeviceIdentifier,
}); } satisfies WsAttachment);
this.ensurePingLoop();
server.addEventListener('message', (event) => {
void this.handleSocketMessage(server, event.data);
});
server.addEventListener('close', () => {
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
this.connections.delete(server);
this.stopPingLoopIfIdle();
if (shouldBroadcast) this.broadcastDeviceStatus();
});
server.addEventListener('error', () => {
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
this.connections.delete(server);
this.stopPingLoopIfIdle();
if (shouldBroadcast) this.broadcastDeviceStatus();
try {
server.close(1011, 'Socket error');
} catch {
// ignore close races
}
});
return new Response(null, { return new Response(null, {
status: 101, status: 101,
@@ -285,21 +260,21 @@ export class NotificationsHub {
}); });
} }
private async handleSocketMessage(socket: WebSocket, rawData: string | ArrayBuffer | ArrayBufferView): Promise<void> { async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
const connection = this.connections.get(socket); const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!connection) return; if (!attachment) return;
if (!connection.handshakeComplete) { if (!attachment.handshakeComplete) {
const text = decodeIncomingMessage(rawData); const text = decodeIncomingMessage(message);
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean); const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
for (const frame of frames) { for (const frame of frames) {
try { try {
const handshake = JSON.parse(frame) as { protocol?: string }; const handshake = JSON.parse(frame) as { protocol?: string };
const protocol = handshake.protocol === 'json' ? 'json' : 'messagepack'; attachment.protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
connection.protocol = protocol; attachment.handshakeComplete = true;
connection.handshakeComplete = true; ws.serializeAttachment(attachment);
socket.send(SIGNALR_HANDSHAKE_ACK); ws.send(SIGNALR_HANDSHAKE_ACK);
this.broadcastDeviceStatus(); this.broadcastDeviceStatus(attachment.userId);
return; return;
} catch { } catch {
// Ignore malformed pre-handshake payloads. // Ignore malformed pre-handshake payloads.
@@ -307,53 +282,38 @@ export class NotificationsHub {
} }
return; return;
} }
}
private ensurePingLoop(): void { if (typeof message !== 'string') {
if (this.pingTimer !== null) return;
this.pingTimer = setInterval(() => {
this.broadcastPing();
}, SIGNALR_PING_INTERVAL_MS);
}
private stopPingLoopIfIdle(): void {
if (this.connections.size > 0 || this.pingTimer === null) return;
clearInterval(this.pingTimer);
this.pingTimer = null;
}
private broadcastPing(): void {
if (this.connections.size === 0) {
this.stopPingLoopIfIdle();
return;
}
for (const [socket, connection] of this.connections) {
if (!connection.handshakeComplete) continue;
try { try {
if (connection.protocol === 'json') { ws.send(message);
socket.send(buildSignalRJsonPing());
} else {
socket.send(buildSignalRMessagePackPing());
}
} catch { } catch {
this.connections.delete(socket); // ignore send errors on echo
try {
socket.close(1011, 'Ping send failed');
} catch {
// ignore close races
}
} }
} }
}
this.stopPingLoopIfIdle(); async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
const shouldBroadcast = !!attachment?.handshakeComplete;
if (shouldBroadcast && attachment?.userId) {
this.broadcastDeviceStatus(attachment.userId);
}
}
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
const shouldBroadcast = !!attachment?.handshakeComplete;
if (shouldBroadcast && attachment?.userId) {
this.broadcastDeviceStatus(attachment.userId);
}
} }
private getOnlineDeviceIdentifiers(): string[] { private getOnlineDeviceIdentifiers(): string[] {
const out = new Set<string>(); const out = new Set<string>();
for (const connection of this.connections.values()) { for (const ws of this.ctx.getWebSockets()) {
if (!connection.handshakeComplete || !connection.deviceIdentifier) continue; const attachment = ws.deserializeAttachment() as WsAttachment | null;
out.add(connection.deviceIdentifier); if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
out.add(attachment.deviceIdentifier);
} }
return Array.from(out); return Array.from(out);
} }
@@ -364,35 +324,36 @@ export class NotificationsHub {
contextId: string | null, contextId: string | null,
targetDeviceIdentifier: string | null targetDeviceIdentifier: string | null
): void { ): void {
if (!this.userId || this.connections.size === 0) return; const sockets = targetDeviceIdentifier
? this.ctx.getWebSockets(`device:${targetDeviceIdentifier}`)
: this.ctx.getWebSockets();
for (const [socket, connection] of this.connections) { if (sockets.length === 0) return;
if (!connection.handshakeComplete) continue;
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue; for (const ws of sockets) {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!attachment?.handshakeComplete) continue;
try { try {
if (connection.protocol === 'json') { if (attachment.protocol === 'json') {
socket.send(buildSignalRJsonInvocation(updateType, payload, contextId)); ws.send(buildSignalRJsonInvocation(updateType, payload, contextId));
} else { } else {
socket.send(buildSignalRMessagePackInvocation(updateType, payload, contextId)); ws.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
} }
} catch { } catch {
this.connections.delete(socket);
try { try {
socket.close(1011, 'Notification send failed'); ws.close(1011, 'Notification send failed');
} catch { } catch {
// ignore close races // ignore close races
} }
} }
} }
this.stopPingLoopIfIdle();
} }
private broadcastDeviceStatus(): void { private broadcastDeviceStatus(userId: string): void {
this.broadcastMessage( this.broadcastMessage(
SIGNALR_UPDATE_TYPE_DEVICE_STATUS, SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
{ {
UserId: this.userId, UserId: userId,
Date: new Date().toISOString(), Date: new Date().toISOString(),
}, },
null, null,
+2 -1
View File
@@ -87,6 +87,7 @@ async function verifyUserSecret(
function toProfile(user: User, env: Env): ProfileResponse { function toProfile(user: User, env: Env): ProfileResponse {
void env; void env;
const accountKeys = buildAccountKeys(user);
return { return {
id: user.id, id: user.id,
name: user.name, name: user.name,
@@ -100,7 +101,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
twoFactorEnabled: !!user.totpSecret, twoFactorEnabled: !!user.totpSecret,
key: user.key, key: user.key,
privateKey: user.privateKey, privateKey: user.privateKey,
accountKeys: buildAccountKeys(user), accountKeys,
securityStamp: user.securityStamp || user.id, securityStamp: user.securityStamp || user.id,
organizations: [], organizations: [],
providers: [], providers: [],
+17 -10
View File
@@ -10,7 +10,7 @@ import {
verifyAttachmentUploadToken, verifyAttachmentUploadToken,
verifyFileDownloadToken, verifyFileDownloadToken,
} from '../utils/jwt'; } from '../utils/jwt';
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers'; import { cipherToResponse } from './ciphers';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { readActingDeviceIdentifier } from '../utils/device'; import { readActingDeviceIdentifier } from '../utils/device';
import { import {
@@ -38,6 +38,18 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
} }
async function runWithConcurrency<T>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<void>
): Promise<void> {
if (items.length === 0) return;
const limit = Math.max(1, concurrency);
for (let index = 0; index < items.length; index += limit) {
await Promise.all(items.slice(index, index + limit).map(worker));
}
}
async function processAttachmentUpload( async function processAttachmentUpload(
request: Request, request: Request,
env: Env, env: Env,
@@ -158,9 +170,7 @@ export async function handleCreateAttachment(
attachmentId: attachmentId, attachmentId: attachmentId,
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken), url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
fileUploadType: 1, fileUploadType: 1,
cipherResponse: cipherToResponse(updatedCipher!, attachments, { cipherResponse: cipherToResponse(updatedCipher!, attachments),
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
}),
}); });
} }
@@ -372,9 +382,7 @@ export async function handleDeleteAttachment(
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
return jsonResponse({ return jsonResponse({
cipher: cipherToResponse(updatedCipher!, attachments, { cipher: cipherToResponse(updatedCipher!, attachments),
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
}),
}); });
} }
@@ -385,10 +393,9 @@ export async function deleteAllAttachmentsForCipher(
): Promise<void> { ): Promise<void> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => {
for (const attachment of attachments) {
const path = getAttachmentObjectKey(cipherId, attachment.id); const path = getAttachmentObjectKey(cipherId, attachment.id);
await deleteBlobObject(env, path); await deleteBlobObject(env, path);
await storage.deleteAttachment(attachment.id); await storage.deleteAttachment(attachment.id);
} });
} }
+34 -19
View File
@@ -113,7 +113,19 @@ async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession):
); );
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (message.includes('404') || message.includes('Please select a backup file')) { const normalized = message.toLowerCase();
// Some WebDAV providers return non-standard codes such as 530 when the
// attachment index does not exist yet. Treat these "missing file" style
// responses as an empty index so first-time incremental backups can proceed.
if (
normalized.includes('404')
|| normalized.includes('403')
|| normalized.includes('530')
|| normalized.includes('not found')
|| normalized.includes('file not found')
|| normalized.includes('does not exist')
|| normalized.includes('please select a backup file')
) {
return new Map<string, number>(); return new Map<string, number>();
} }
throw error; throw error;
@@ -180,6 +192,7 @@ async function executeConfiguredBackup(
}); });
const archive = await buildBackupArchive(env, now, { const archive = await buildBackupArchive(env, now, {
includeAttachments: destination.includeAttachments, includeAttachments: destination.includeAttachments,
timeZone: destination.schedule.timezone,
progress: progress progress: progress
? async (event) => { ? async (event) => {
if (event.step === 'archive_ready') { if (event.step === 'archive_ready') {
@@ -205,26 +218,28 @@ async function executeConfiguredBackup(
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail', : 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
}); });
const remoteSession = createRemoteBackupTransferSession(destination); const remoteSession = createRemoteBackupTransferSession(destination);
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession); if (destination.includeAttachments) {
let attachmentIndexChanged = false; const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
for (const attachment of archive.manifest.attachmentBlobs || []) { let attachmentIndexChanged = false;
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) { for (const attachment of archive.manifest.attachmentBlobs || []) {
continue; if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
continue;
}
const remotePath = `attachments/${attachment.blobName}`;
const object = await getBlobObject(env, attachment.blobName);
if (!object) {
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
}
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
await remoteSession.putFile(remotePath, bytes, {
contentType: object.contentType,
});
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
attachmentIndexChanged = true;
} }
const remotePath = `attachments/${attachment.blobName}`; if (attachmentIndexChanged) {
const object = await getBlobObject(env, attachment.blobName); await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
if (!object) {
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
} }
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
await remoteSession.putFile(remotePath, bytes, {
contentType: object.contentType,
});
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
attachmentIndexChanged = true;
}
if (attachmentIndexChanged) {
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
} }
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null; let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) { for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
+100 -107
View File
@@ -1,4 +1,15 @@
import { Env, Cipher, CipherResponse, Attachment } from '../types'; import {
Env,
Cipher,
CipherCard,
CipherIdentity,
CipherLogin,
CipherResponse,
CipherSecureNote,
CipherSshKey,
Attachment,
PasswordHistory,
} from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { notifyUserVaultSync } from '../durable/notifications-hub'; import { notifyUserVaultSync } from '../durable/notifications-hub';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
@@ -32,6 +43,10 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
return { present: false, value: undefined }; return { present: false, value: undefined };
} }
function readCipherProp<T = unknown>(source: any, aliases: string[]): { present: boolean; value: T | undefined } {
return getAliasedProp(source, aliases) as { present: boolean; value: T | undefined };
}
function normalizeCipherTimestamp(value: unknown): string | null { function normalizeCipherTimestamp(value: unknown): string | null {
if (value == null || value === '') return null; if (value == null || value === '') return null;
const parsed = new Date(String(value)); const parsed = new Date(String(value));
@@ -44,6 +59,19 @@ function readCipherArchivedAt(source: any, fallback: string | null = null): stri
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback; return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
} }
function readCipherRevisionDate(source: any): string | null {
const revision = getAliasedProp(source, ['lastKnownRevisionDate', 'LastKnownRevisionDate']);
return revision.present ? normalizeCipherTimestamp(revision.value) : null;
}
function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: string | null): boolean {
if (!clientRevisionDate) return false;
const existingTs = Date.parse(existingUpdatedAt);
const clientTs = Date.parse(clientRevisionDate);
if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false;
return existingTs - clientTs > 1000;
}
function syncCipherComputedAliases(cipher: Cipher): Cipher { function syncCipherComputedAliases(cipher: Cipher): Cipher {
cipher.archivedDate = cipher.archivedAt ?? null; cipher.archivedDate = cipher.archivedAt ?? null;
cipher.deletedDate = cipher.deletedAt ?? null; cipher.deletedDate = cipher.deletedAt ?? null;
@@ -61,80 +89,18 @@ function normalizeCipherForStorage(cipher: Cipher): Cipher {
return syncCipherComputedAliases(cipher); return syncCipherComputedAliases(cipher);
} }
function looksLikeCipherString(value: unknown): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
}
export function shouldOmitPasskeysForResponse(request: Request | null | undefined): boolean {
const userAgent = String(request?.headers.get('user-agent') || '').toLowerCase();
if (!userAgent) return false;
// Temporary compatibility fallback:
// mobile clients expect official EncString payloads for most FIDO2 fields.
// Keep passkeys available everywhere, but suppress only legacy malformed data
// for mobile clients so newly-saved credentials can flow through unchanged.
return (
userAgent.includes('android') ||
userAgent.includes('iphone') ||
userAgent.includes('ipad') ||
userAgent.includes('ios')
);
}
export function normalizeCipherLoginForStorage(login: any): any { export function normalizeCipherLoginForStorage(login: any): any {
if (!login || typeof login !== 'object') return login ?? null; if (!login || typeof login !== 'object') return login ?? null;
return { return {
...login, ...login,
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null, fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
}; };
} }
export function normalizeCipherLoginForCompatibility( export function normalizeCipherLoginForCompatibility(login: any): any {
login: any,
options?: { omitFido2Credentials?: boolean }
): any {
const normalized = normalizeCipherLoginForStorage(login); const normalized = normalizeCipherLoginForStorage(login);
if (!normalized || typeof normalized !== 'object') return normalized ?? null; if (!normalized || typeof normalized !== 'object') return normalized ?? null;
if (!options?.omitFido2Credentials) return normalized; return normalized;
const credentials = Array.isArray(normalized.fido2Credentials) ? normalized.fido2Credentials : null;
if (!credentials?.length) return normalized;
const hasMalformedCredential = credentials.some((credential: any) => {
if (!credential || typeof credential !== 'object') return true;
const requiredEncryptedFields = [
credential.credentialId,
credential.keyType,
credential.keyAlgorithm,
credential.keyCurve,
credential.keyValue,
credential.rpId,
credential.counter,
credential.discoverable,
];
const optionalEncryptedFields = [
credential.userHandle,
credential.userName,
credential.rpName,
credential.userDisplayName,
];
if (requiredEncryptedFields.some((value) => !looksLikeCipherString(value))) {
return true;
}
if (optionalEncryptedFields.some((value) => value != null && !looksLikeCipherString(value))) {
return true;
}
return false;
});
return hasMalformedCredential
? {
...normalized,
fido2Credentials: null,
}
: normalized;
} }
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads. // Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
@@ -180,12 +146,11 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
// survive a round-trip without code changes. // survive a round-trip without code changes.
export function cipherToResponse( export function cipherToResponse(
cipher: Cipher, cipher: Cipher,
attachments: Attachment[] = [], attachments: Attachment[] = []
options?: { omitFido2Credentials?: boolean }
): CipherResponse { ): CipherResponse {
// Strip internal-only fields that must not appear in the API response // Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher; const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options); const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null); const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
return { return {
@@ -194,8 +159,8 @@ export function cipherToResponse(
// Server-computed / enforced fields (always override) // Server-computed / enforced fields (always override)
folderId: normalizeOptionalId(cipher.folderId), folderId: normalizeOptionalId(cipher.folderId),
type: Number(cipher.type) || 1, type: Number(cipher.type) || 1,
organizationId: null, organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: false, organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
creationDate: createdAt, creationDate: createdAt,
revisionDate: updatedAt, revisionDate: updatedAt,
deletedDate: deletedAt, deletedDate: deletedAt,
@@ -206,12 +171,12 @@ export function cipherToResponse(
delete: true, delete: true,
restore: true, restore: true,
}, },
object: 'cipher', object: 'cipherDetails',
collectionIds: [], collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
attachments: formatAttachments(attachments), attachments: formatAttachments(attachments),
login: normalizedLogin, login: normalizedLogin,
sshKey: normalizedSshKey, sshKey: normalizedSshKey,
encryptedFor: null, encryptedFor: (passthrough as any).encryptedFor ?? null,
}; };
} }
@@ -221,7 +186,6 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const url = new URL(request.url); const url = new URL(request.url);
const includeDeleted = url.searchParams.get('deleted') === 'true'; const includeDeleted = url.searchParams.get('deleted') === 'true';
const pagination = parsePagination(url); const pagination = parsePagination(url);
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
let filteredCiphers: Cipher[]; let filteredCiphers: Cipher[];
let continuationToken: string | null = null; let continuationToken: string | null = null;
@@ -242,13 +206,15 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
: ciphers.filter(c => !c.deletedAt); : ciphers.filter(c => !c.deletedAt);
} }
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId); const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
filteredCiphers.map((cipher) => cipher.id)
);
// Get attachments for all ciphers // Build responses only for the current page to keep pagination cheap.
const cipherResponses = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of filteredCiphers) { for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || []; const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials })); cipherResponses.push(cipherToResponse(cipher, attachments));
} }
return jsonResponse({ return jsonResponse({
@@ -269,9 +235,7 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, attachments, { cipherToResponse(cipher, attachments)
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
); );
} }
@@ -295,6 +259,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
// Handle nested cipher object (from some clients) // Handle nested cipher object (from some clients)
// Android client sends PascalCase "Cipher" for organization ciphers // Android client sends PascalCase "Cipher" for organization ciphers
const cipherData = body.Cipher || body.cipher || body; const cipherData = body.Cipher || body.cipher || body;
const createFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
const createKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
const createLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
const createCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const now = new Date().toISOString(); const now = new Date().toISOString();
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones, // Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
@@ -312,6 +284,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
archivedAt: readCipherArchivedAt(cipherData, null), archivedAt: readCipherArchivedAt(cipherData, null),
deletedAt: null, deletedAt: null,
}; };
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null);
cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
normalizeCipherForStorage(cipher); normalizeCipherForStorage(cipher);
@@ -327,9 +307,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
await notifyVaultSyncForRequest(request, env, userId, revisionDate); await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, [], { cipherToResponse(cipher, []),
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
}),
200 200
); );
} }
@@ -353,6 +331,21 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Handle nested cipher object // Handle nested cipher object
// Android client sends PascalCase "Cipher" for organization ciphers // Android client sends PascalCase "Cipher" for organization ciphers
const cipherData = body.Cipher || body.cipher || body; const cipherData = body.Cipher || body.cipher || body;
const incomingFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
const incomingKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
const incomingLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
const incomingCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData);
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
}
const nextType = Number(cipherData.type) || existingCipher.type;
// Opaque passthrough: merge existing stored data with ALL incoming client fields. // Opaque passthrough: merge existing stored data with ALL incoming client fields.
// Unknown/future fields from the client are preserved; server-controlled fields are protected. // Unknown/future fields from the client are preserved; server-controlled fields are protected.
@@ -362,7 +355,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Server-controlled fields (never from client) // Server-controlled fields (never from client)
id: existingCipher.id, id: existingCipher.id,
userId: existingCipher.userId, userId: existingCipher.userId,
type: Number(cipherData.type) || existingCipher.type, type: nextType,
favorite: cipherData.favorite ?? existingCipher.favorite, favorite: cipherData.favorite ?? existingCipher.favorite,
reprompt: cipherData.reprompt ?? existingCipher.reprompt, reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt, createdAt: existingCipher.createdAt,
@@ -370,6 +363,20 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null), archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt, deletedAt: existingCipher.deletedAt,
}; };
if (incomingFolderId.present) {
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
}
if (incomingKey.present) {
cipher.key = incomingKey.value ?? null;
}
cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null;
cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
if (incomingPasswordHistory.present) {
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
}
// Custom fields deletion compatibility: // Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields". // - Accept both camelCase "fields" and PascalCase "Fields".
@@ -392,11 +399,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate); await notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, [], { cipherToResponse(cipher, attachments)
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
); );
} }
@@ -418,9 +424,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
await notifyVaultSyncForRequest(request, env, userId, revisionDate); await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, [], { cipherToResponse(cipher, [])
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
); );
} }
@@ -484,9 +488,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
await notifyVaultSyncForRequest(request, env, userId, revisionDate); await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, [], { cipherToResponse(cipher, [])
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
); );
} }
@@ -525,9 +527,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
await notifyVaultSyncForRequest(request, env, userId, revisionDate); await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, [], { cipherToResponse(cipher, [])
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
); );
} }
@@ -568,13 +568,10 @@ async function buildCipherListResponse(
): Promise<Response> { ): Promise<Response> {
const ciphers = await storage.getCiphersByIds(ids, userId); const ciphers = await storage.getCiphersByIds(ids, userId);
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id)); const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
return jsonResponse({ return jsonResponse({
data: ciphers.map((cipher) => data: ciphers.map((cipher) =>
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])
omitFido2Credentials,
})
), ),
object: 'list', object: 'list',
continuationToken: null, continuationToken: null,
@@ -607,9 +604,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, attachments, { cipherToResponse(cipher, attachments)
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
); );
} }
@@ -631,9 +626,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, attachments, { cipherToResponse(cipher, attachments)
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
); );
} }
+47 -4
View File
@@ -23,13 +23,18 @@ function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPub
} }
function buildDeviceResponse(device: Device): DeviceResponse { function buildDeviceResponse(device: Device): DeviceResponse {
const displayName = String(device.deviceNote || '').trim() || device.name;
const response = { const response = {
Id: device.deviceIdentifier, Id: device.deviceIdentifier,
id: device.deviceIdentifier, id: device.deviceIdentifier,
UserId: device.userId, UserId: device.userId,
userId: device.userId, userId: device.userId,
Name: device.name, Name: displayName,
name: device.name, name: displayName,
SystemName: device.name,
systemName: device.name,
DeviceNote: device.deviceNote,
deviceNote: device.deviceNote,
Identifier: device.deviceIdentifier, Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier, identifier: device.deviceIdentifier,
Type: device.type, Type: device.type,
@@ -38,6 +43,10 @@ function buildDeviceResponse(device: Device): DeviceResponse {
creationDate: device.createdAt, creationDate: device.createdAt,
RevisionDate: device.updatedAt, RevisionDate: device.updatedAt,
revisionDate: device.updatedAt, revisionDate: device.updatedAt,
LastSeenAt: device.lastSeenAt,
lastSeenAt: device.lastSeenAt,
HasStoredDevice: true,
hasStoredDevice: true,
IsTrusted: isTrustedDevice(device), IsTrusted: isTrustedDevice(device),
isTrusted: isTrustedDevice(device), isTrusted: isTrustedDevice(device),
EncryptedUserKey: device.encryptedUserKey, EncryptedUserKey: device.encryptedUserKey,
@@ -55,8 +64,12 @@ function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireRespon
const response = { const response = {
Id: device.deviceIdentifier, Id: device.deviceIdentifier,
id: device.deviceIdentifier, id: device.deviceIdentifier,
Name: device.name, Name: String(device.deviceNote || '').trim() || device.name,
name: device.name, name: String(device.deviceNote || '').trim() || device.name,
SystemName: device.name,
systemName: device.name,
DeviceNote: device.deviceNote,
deviceNote: device.deviceNote,
Identifier: device.deviceIdentifier, Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier, identifier: device.deviceIdentifier,
Type: device.type, Type: device.type,
@@ -101,6 +114,10 @@ async function readJsonBody(request: Request): Promise<any> {
} }
} }
function parseDeviceName(value: unknown): string {
return String(value || '').trim().slice(0, 128);
}
// GET /api/devices/knowndevice // GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior: // Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding // - X-Request-Email: base64url(email) without padding
@@ -203,12 +220,15 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
encryptedPublicKey: null, encryptedPublicKey: null,
encryptedPrivateKey: null, encryptedPrivateKey: null,
devicePendingAuthRequest: null, devicePendingAuthRequest: null,
deviceNote: null,
lastSeenAt: null,
createdAt: '', createdAt: '',
updatedAt: '', updatedAt: '',
}; };
data.push({ data.push({
...buildDeviceResponse(placeholderDevice), ...buildDeviceResponse(placeholderDevice),
isTrusted: true, isTrusted: true,
hasStoredDevice: false,
online: onlineSet.has(row.deviceIdentifier), online: onlineSet.has(row.deviceIdentifier),
trusted: true, trusted: true,
trustedTokenCount: row.tokenCount, trustedTokenCount: row.tokenCount,
@@ -269,6 +289,29 @@ export async function handleDeleteDevice(
return jsonResponse({ success: deleted }); return jsonResponse({ success: deleted });
} }
// PUT /api/devices/:deviceIdentifier/name
export async function handleUpdateDeviceName(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const body = await readJsonBody(request);
const name = parseDeviceName(body?.name);
if (!name) return errorResponse('Device name is required', 400);
const storage = new StorageService(env.DB);
const updated = await storage.updateDeviceName(userId, normalized, name);
if (!updated) return errorResponse('Device not found', 404);
const device = await storage.getDevice(userId, normalized);
if (!device) return errorResponse('Device not found', 404);
return jsonResponse(buildDeviceResponse(device));
}
// DELETE /api/devices // DELETE /api/devices
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request; void request;
+92 -16
View File
@@ -18,6 +18,7 @@ import {
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_REMEMBER = 5; const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code. // Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
// Keep request parsing backward-compatible with historical provider values (8 / 100). // Keep request parsing backward-compatible with historical provider values (8 / 100).
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1'; const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
@@ -31,6 +32,54 @@ function resolveTotpSecret(userSecret: string | null): string | null {
return null; return null;
} }
function shouldUseWebSession(request: Request): boolean {
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
}
function parseCookieValue(request: Request, name: string): string | null {
const rawCookie = String(request.headers.get('Cookie') || '').trim();
if (!rawCookie) return null;
for (const part of rawCookie.split(';')) {
const [key, ...rest] = part.trim().split('=');
if (key !== name) continue;
const value = rest.join('=').trim();
return value ? decodeURIComponent(value) : null;
}
return null;
}
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
const isHttps = new URL(request.url).protocol === 'https:';
const parts = [
`${WEB_REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}`,
'Path=/identity/connect',
'HttpOnly',
'SameSite=Strict',
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
];
if (isHttps) parts.push('Secure');
return parts.join('; ');
}
function buildClearedRefreshCookie(request: Request): string {
return buildRefreshCookie(request, '', 0);
}
function withWebRefreshCookie(request: Request, response: Response, refreshToken: string | null): Response {
const headers = new Headers(response.headers);
headers.append(
'Set-Cookie',
refreshToken
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000))
: buildClearedRefreshCookie(request)
);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
function buildPreloginResponse( function buildPreloginResponse(
email: string, email: string,
kdfType: number, kdfType: number,
@@ -278,17 +327,19 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
const response: TokenResponse = { const response: TokenResponse = {
access_token: accessToken, access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds, expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer', token_type: 'Bearer',
refresh_token: refreshToken, ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: user.key, Key: user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: buildAccountKeys(user), AccountKeys: accountKeys,
accountKeys: buildAccountKeys(user), accountKeys: accountKeys,
Kdf: user.kdfType, Kdf: user.kdfType,
KdfIterations: user.kdfIterations, KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory, KdfMemory: user.kdfMemory,
@@ -301,11 +352,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
UserDecryptionOptions: buildUserDecryptionOptions(user), UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: buildUserDecryptionOptions(user), userDecryptionOptions: userDecryptionOptions,
}; };
return jsonResponse(response); const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'send_access') { } else if (grantType === 'send_access') {
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute); const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
@@ -371,14 +425,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// Refresh token // Refresh token
const refreshToken = body.refresh_token; const refreshToken = String(body.refresh_token || '').trim() || (
shouldUseWebSession(request)
? parseCookieValue(request, WEB_REFRESH_COOKIE)
: null
);
if (!refreshToken) { if (!refreshToken) {
return identityErrorResponse('Refresh token is required', 'invalid_request', 400); return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
} }
const result = await auth.refreshAccessToken(refreshToken); const result = await auth.refreshAccessToken(refreshToken);
if (!result) { if (!result) {
return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400); const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, invalidResponse, null)
: invalidResponse;
} }
// Keep a short overlap window for old refresh token to absorb // Keep a short overlap window for old refresh token to absorb
@@ -389,17 +450,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
const { accessToken, user, device } = result; const { accessToken, user, device } = result;
if (device?.identifier) {
await storage.touchDeviceLastSeen(user.id, device.identifier);
}
const newRefreshToken = await auth.generateRefreshToken(user.id, device); const newRefreshToken = await auth.generateRefreshToken(user.id, device);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
const response: TokenResponse = { const response: TokenResponse = {
access_token: accessToken, access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds, expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer', token_type: 'Bearer',
refresh_token: newRefreshToken, ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
Key: user.key, Key: user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: buildAccountKeys(user), AccountKeys: accountKeys,
accountKeys: buildAccountKeys(user), accountKeys: accountKeys,
Kdf: user.kdfType, Kdf: user.kdfType,
KdfIterations: user.kdfIterations, KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory, KdfMemory: user.kdfMemory,
@@ -412,11 +478,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
UserDecryptionOptions: buildUserDecryptionOptions(user), UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: buildUserDecryptionOptions(user), userDecryptionOptions: userDecryptionOptions,
}; };
return jsonResponse(response); const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, newRefreshToken)
: baseResponse;
} }
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400); return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
@@ -470,10 +539,17 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
const token = String(body.token || '').trim(); const token = String(body.token || '').trim() || (
shouldUseWebSession(request)
? (parseCookieValue(request, WEB_REFRESH_COOKIE) || '')
: ''
);
if (token) { if (token) {
await storage.deleteRefreshToken(token); await storage.deleteRefreshToken(token);
} }
return new Response(null, { status: 200 }); const baseResponse = new Response(null, { status: 200 });
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, null)
: baseResponse;
} }
+60 -44
View File
@@ -24,7 +24,6 @@ interface CiphersImportRequest {
password?: string | null; password?: string | null;
totp?: string | null; totp?: string | null;
autofillOnPageLoad?: boolean | null; autofillOnPageLoad?: boolean | null;
fido2Credentials?: any[] | null;
uri?: string | null; uri?: string | null;
passwordRevisionDate?: string | null; passwordRevisionDate?: string | null;
[key: string]: any; [key: string]: any;
@@ -83,6 +82,16 @@ function bindNull(v: any): any {
return v === undefined ? null : v; return v === undefined ? null : v;
} }
function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T | undefined {
if (!source || typeof source !== 'object') return undefined;
for (const key of aliases) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
return source[key] as T;
}
}
return undefined;
}
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> { async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
for (let i = 0; i < statements.length; i += chunkSize) { for (let i = 0; i < statements.length; i += chunkSize) {
const chunk = statements.slice(i, i + chunkSize); const chunk = statements.slice(i, i + chunkSize);
@@ -159,9 +168,16 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = []; const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
for (let i = 0; i < ciphers.length; i++) { for (let i = 0; i < ciphers.length; i++) {
const c = ciphers[i]; const c = ciphers[i];
const folderId = cipherFolderMap.get(i) || null; const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
const sourceIdRaw = String(c?.id ?? '').trim(); const sourceIdRaw = String(c?.id ?? '').trim();
const sourceId = sourceIdRaw || null; const sourceId = sourceIdRaw || null;
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
const cipher: Cipher = { const cipher: Cipher = {
...c, ...c,
@@ -172,64 +188,64 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
name: c.name ?? 'Untitled', name: c.name ?? 'Untitled',
notes: c.notes ?? null, notes: c.notes ?? null,
favorite: c.favorite ?? false, favorite: c.favorite ?? false,
login: c.login ? { login: login ? {
...c.login, ...login,
username: c.login.username ?? null, username: login.username ?? null,
password: c.login.password ?? null, password: login.password ?? null,
uris: c.login.uris?.map(u => ({ uris: login.uris?.map((u: any) => ({
...u, ...u,
uri: u.uri ?? null, uri: u.uri ?? null,
uriChecksum: null, uriChecksum: null,
match: u.match ?? null, match: u.match ?? null,
})) || null, })) || null,
totp: c.login.totp ?? null, totp: login.totp ?? null,
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null, autofillOnPageLoad: login.autofillOnPageLoad ?? null,
fido2Credentials: c.login.fido2Credentials ?? null, fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
uri: c.login.uri ?? null, uri: login.uri ?? null,
passwordRevisionDate: c.login.passwordRevisionDate ?? null, passwordRevisionDate: login.passwordRevisionDate ?? null,
} : null, } : null,
card: c.card ? { card: card ? {
...c.card, ...card,
cardholderName: c.card.cardholderName ?? null, cardholderName: card.cardholderName ?? null,
brand: c.card.brand ?? null, brand: card.brand ?? null,
number: c.card.number ?? null, number: card.number ?? null,
expMonth: c.card.expMonth ?? null, expMonth: card.expMonth ?? null,
expYear: c.card.expYear ?? null, expYear: card.expYear ?? null,
code: c.card.code ?? null, code: card.code ?? null,
} : null, } : null,
identity: c.identity ? { identity: identity ? {
...c.identity, ...identity,
title: c.identity.title ?? null, title: identity.title ?? null,
firstName: c.identity.firstName ?? null, firstName: identity.firstName ?? null,
middleName: c.identity.middleName ?? null, middleName: identity.middleName ?? null,
lastName: c.identity.lastName ?? null, lastName: identity.lastName ?? null,
address1: c.identity.address1 ?? null, address1: identity.address1 ?? null,
address2: c.identity.address2 ?? null, address2: identity.address2 ?? null,
address3: c.identity.address3 ?? null, address3: identity.address3 ?? null,
city: c.identity.city ?? null, city: identity.city ?? null,
state: c.identity.state ?? null, state: identity.state ?? null,
postalCode: c.identity.postalCode ?? null, postalCode: identity.postalCode ?? null,
country: c.identity.country ?? null, country: identity.country ?? null,
company: c.identity.company ?? null, company: identity.company ?? null,
email: c.identity.email ?? null, email: identity.email ?? null,
phone: c.identity.phone ?? null, phone: identity.phone ?? null,
ssn: c.identity.ssn ?? null, ssn: identity.ssn ?? null,
username: c.identity.username ?? null, username: identity.username ?? null,
passportNumber: c.identity.passportNumber ?? null, passportNumber: identity.passportNumber ?? null,
licenseNumber: c.identity.licenseNumber ?? null, licenseNumber: identity.licenseNumber ?? null,
} : null, } : null,
secureNote: c.secureNote ?? null, secureNote: secureNote ?? null,
fields: c.fields?.map(f => ({ fields: fields?.map((f: any) => ({
...f, ...f,
name: f.name ?? null, name: f.name ?? null,
value: f.value ?? null, value: f.value ?? null,
type: f.type, type: f.type,
linkedId: f.linkedId ?? null, linkedId: f.linkedId ?? null,
})) || null, })) || null,
passwordHistory: c.passwordHistory ?? null, passwordHistory: passwordHistory ?? null,
reprompt: c.reprompt ?? 0, reprompt: c.reprompt ?? 0,
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null), sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
key: (c as any).key ?? null, key: key ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
archivedAt: null, archivedAt: null,
+2 -1
View File
@@ -97,8 +97,9 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
sends = await storage.getAllSends(userId); sends = await storage.getAllSends(userId);
} }
const sendResponses = sends.map(sendToResponse);
return jsonResponse({ return jsonResponse({
data: sends.map(sendToResponse), data: sendResponses,
object: 'list', object: 'list',
continuationToken, continuationToken,
}); });
+47 -116
View File
@@ -10,87 +10,23 @@ import {
buildUserDecryptionOptions, buildUserDecryptionOptions,
} from '../utils/user-decryption'; } from '../utils/user-decryption';
interface SyncCacheEntry { function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request {
userId: string; const url = new URL(request.url);
revisionDate: string; const cacheUrl = new URL(
body: string; `/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`,
expiresAt: number; url.origin
bytes: number; );
return new Request(cacheUrl.toString(), { method: 'GET' });
} }
const syncResponseCache = new Map<string, SyncCacheEntry>(); async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
let syncResponseCacheTotalBytes = 0; const hit = await caches.default.match(cacheRequest);
const textEncoder = new TextEncoder();
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
}
function readSyncCache(key: string): string | null {
const hit = syncResponseCache.get(key);
if (!hit) return null; if (!hit) return null;
if (hit.expiresAt <= Date.now()) { return new Response(hit.body, hit);
deleteSyncCacheEntry(key, hit);
return null;
}
return hit.body;
} }
function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void { async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
const existing = entry ?? syncResponseCache.get(key); await caches.default.put(cacheRequest, response.clone());
if (!existing) return;
syncResponseCache.delete(key);
syncResponseCacheTotalBytes = Math.max(0, syncResponseCacheTotalBytes - existing.bytes);
}
function pruneExpiredSyncCache(nowMs: number = Date.now()): void {
for (const [key, entry] of syncResponseCache.entries()) {
if (entry.expiresAt <= nowMs) {
deleteSyncCacheEntry(key, entry);
}
}
}
function pruneStaleUserSyncCache(userId: string, revisionDate: string): void {
for (const [key, entry] of syncResponseCache.entries()) {
if (entry.userId === userId && entry.revisionDate !== revisionDate) {
deleteSyncCacheEntry(key, entry);
}
}
}
function writeSyncCache(userId: string, revisionDate: string, key: string, body: string): void {
const nowMs = Date.now();
pruneExpiredSyncCache(nowMs);
pruneStaleUserSyncCache(userId, revisionDate);
const bodyBytes = textEncoder.encode(body).byteLength;
if (bodyBytes > LIMITS.cache.syncResponseMaxBodyBytes) {
return;
}
const existing = syncResponseCache.get(key);
if (existing) {
deleteSyncCacheEntry(key, existing);
}
while (
syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries ||
syncResponseCacheTotalBytes + bodyBytes > LIMITS.cache.syncResponseMaxTotalBytes
) {
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
if (!oldestKey) break;
deleteSyncCacheEntry(oldestKey);
}
syncResponseCache.set(key, {
userId,
revisionDate,
body,
expiresAt: nowMs + LIMITS.cache.syncResponseTtlMs,
bytes: bodyBytes,
});
syncResponseCacheTotalBytes += bodyBytes;
} }
// GET /api/sync // GET /api/sync
@@ -99,12 +35,6 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const url = new URL(request.url); const url = new URL(request.url);
const excludeDomainsParam = url.searchParams.get('excludeDomains'); const excludeDomainsParam = url.searchParams.get('excludeDomains');
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam); const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
const userAgent = String(request.headers.get('user-agent') || '').toLowerCase();
const omitFido2Credentials =
userAgent.includes('android') ||
userAgent.includes('iphone') ||
userAgent.includes('ipad') ||
userAgent.includes('ios');
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
@@ -112,21 +42,21 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
} }
const revisionDate = await storage.getRevisionDate(userId); const revisionDate = await storage.getRevisionDate(userId);
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains); const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains);
const cachedBody = readSyncCache(cacheKey); const cachedResponse = await readSyncCache(cacheRequest);
if (cachedBody) { if (cachedResponse) {
return new Response(cachedBody, { return cachedResponse;
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} }
const ciphers = await storage.getAllCiphers(userId); const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
const folders = await storage.getAllFolders(userId); storage.getAllCiphers(userId),
const sends = await storage.getAllSends(userId); storage.getAllFolders(userId),
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId); storage.getAllSends(userId),
storage.getAttachmentsByUserId(userId),
]);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
// Build profile response
const profile: ProfileResponse = { const profile: ProfileResponse = {
id: user.id, id: user.id,
name: user.name, name: user.name,
@@ -140,7 +70,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
twoFactorEnabled: !!user.totpSecret, twoFactorEnabled: !!user.totpSecret,
key: user.key, key: user.key,
privateKey: user.privateKey, privateKey: user.privateKey,
accountKeys: buildAccountKeys(user), accountKeys,
securityStamp: user.securityStamp || user.id, securityStamp: user.securityStamp || user.id,
organizations: [], organizations: [],
providers: [], providers: [],
@@ -152,23 +82,24 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
object: 'profile', object: 'profile',
}; };
// Build cipher responses with attachments
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) { for (const cipher of ciphers) {
const attachments = attachmentsByCipher.get(cipher.id) || []; cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []));
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
} }
// Build folder responses const folderResponses: FolderResponse[] = [];
const folderResponses: FolderResponse[] = folders.map(folder => ({ for (const folder of folders) {
id: folder.id, folderResponses.push({
name: folder.name, id: folder.id,
revisionDate: folder.updatedAt, name: folder.name,
object: 'folder', revisionDate: folder.updatedAt,
})); object: 'folder',
});
}
const sendResponses = sends.map(sendToResponse);
const syncResponse: SyncResponse = { const syncResponse: SyncResponse = {
profile: profile, profile,
folders: folderResponses, folders: folderResponses,
collections: [], collections: [],
ciphers: cipherResponses, ciphers: cipherResponses,
@@ -180,25 +111,25 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
object: 'domains', object: 'domains',
}, },
policies: [], policies: [],
sends: sends.map(sendToResponse), sends: sendResponses,
UserDecryption: { UserDecryption: {
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock, MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
TrustedDeviceOption: null, TrustedDeviceOption: null,
KeyConnectorOption: null, KeyConnectorOption: null,
Object: 'userDecryption', Object: 'userDecryption',
}, },
// PascalCase for desktop/browser clients UserDecryptionOptions: userDecryptionOptions,
UserDecryptionOptions: buildUserDecryptionOptions(user),
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'], userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
object: 'sync', object: 'sync',
}; };
const body = JSON.stringify(syncResponse); const response = new Response(JSON.stringify(syncResponse), {
writeSyncCache(userId, revisionDate, cacheKey, body);
return new Response(body, {
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
'Cache-Control': `private, max-age=${Math.max(1, Math.floor(LIMITS.cache.syncResponseTtlMs / 1000))}`,
},
}); });
await writeSyncCache(cacheRequest, response);
return response;
} }
+15 -5
View File
@@ -9,6 +9,15 @@ let dbInitialized = false;
let dbInitError: string | null = null; let dbInitError: string | null = null;
let dbInitPromise: Promise<void> | null = null; let dbInitPromise: Promise<void> | null = null;
function normalizeRequestUrl(request: Request): Request {
const url = new URL(request.url);
const normalizedPathname = url.pathname.length <= 1 ? url.pathname : url.pathname.replace(/\/+$/, '');
if (normalizedPathname === url.pathname) return request;
url.pathname = normalizedPathname;
return new Request(url.toString(), request);
}
function isWorkerHandledPath(path: string): boolean { function isWorkerHandledPath(path: string): boolean {
return ( return (
path.startsWith('/api/') || path.startsWith('/api/') ||
@@ -56,9 +65,10 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
export default { export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
void ctx; void ctx;
const assetResponse = await maybeServeAsset(request, env); const normalizedRequest = normalizeRequestUrl(request);
const assetResponse = await maybeServeAsset(normalizedRequest, env);
if (assetResponse) { if (assetResponse) {
return applyCors(request, assetResponse); return applyCors(normalizedRequest, assetResponse);
} }
await ensureDatabaseInitialized(env); await ensureDatabaseInitialized(env);
@@ -76,11 +86,11 @@ export default {
}, },
500 500
); );
return applyCors(request, resp); return applyCors(normalizedRequest, resp);
} }
const resp = await handleRequest(request, env); const resp = await handleRequest(normalizedRequest, env);
return applyCors(request, resp); return applyCors(normalizedRequest, resp);
}, },
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
+7
View File
@@ -13,6 +13,7 @@ import {
handleRevokeTrustedDevice, handleRevokeTrustedDevice,
handleDeleteAllDevices, handleDeleteAllDevices,
handleDeleteDevice, handleDeleteDevice,
handleUpdateDeviceName,
handleUpdateDeviceToken, handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth, handleUpdateDeviceWebPushAuth,
handleClearDeviceToken, handleClearDeviceToken,
@@ -53,6 +54,12 @@ export async function handleAuthenticatedDeviceRoute(
return handleDeleteDevice(request, env, userId, deviceIdentifier); return handleDeleteDevice(request, env, userId, deviceIdentifier);
} }
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
if (updateDeviceNameMatch && method === 'PUT') {
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
}
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i); const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
if (identifierMatch && method === 'GET') { if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]); const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
+1
View File
@@ -104,6 +104,7 @@ function buildConfigResponse(origin: string) {
_icon_service_url: buildIconServiceTemplate(origin), _icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin), _icon_service_csp: buildIconServiceCsp(origin),
featureStates: { featureStates: {
'cipher-key-encryption': true,
'duo-redirect': true, 'duo-redirect': true,
'email-verification': true, 'email-verification': true,
'pm-19051-send-email-verification': false, 'pm-19051-send-email-verification': false,
+26 -11
View File
@@ -71,6 +71,7 @@ export interface BackupFileIntegrityCheckResult {
export interface BuildBackupArchiveOptions { export interface BuildBackupArchiveOptions {
includeAttachments?: boolean; includeAttachments?: boolean;
progress?: BackupArchiveBuildProgressReporter; progress?: BackupArchiveBuildProgressReporter;
timeZone?: string;
} }
export interface BackupArchiveBuildProgressEvent { export interface BackupArchiveBuildProgressEvent {
@@ -93,17 +94,30 @@ async function sha256Hex(bytes: Uint8Array): Promise<string> {
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join(''); return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
} }
function buildBackupFileName(date: Date = new Date(), checksumPrefix: string | null = null): string { function getDateParts(date: Date, timeZone: string): string {
const parts = [ const formatter = new Intl.DateTimeFormat('en-CA', {
date.getUTCFullYear().toString().padStart(4, '0'), timeZone,
(date.getUTCMonth() + 1).toString().padStart(2, '0'), year: 'numeric',
date.getUTCDate().toString().padStart(2, '0'), month: '2-digit',
date.getUTCHours().toString().padStart(2, '0'), day: '2-digit',
date.getUTCMinutes().toString().padStart(2, '0'), hour: '2-digit',
date.getUTCSeconds().toString().padStart(2, '0'), minute: '2-digit',
]; second: '2-digit',
hourCycle: 'h23',
});
const parts = formatter.formatToParts(date);
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
return `${pick('year')}${pick('month')}${pick('day')}_${pick('hour')}${pick('minute')}${pick('second')}`;
}
function buildBackupFileNameInTimeZone(
date: Date = new Date(),
checksumPrefix: string | null = null,
timeZone: string = 'UTC'
): string {
const parts = getDateParts(date, timeZone);
const suffix = checksumPrefix ? `_${checksumPrefix}` : ''; const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}${suffix}.zip`; return `nodewarden_backup_${parts}${suffix}.zip`;
} }
export function extractBackupFileChecksumPrefix(fileName: string): string | null { export function extractBackupFileChecksumPrefix(fileName: string): string | null {
@@ -398,7 +412,8 @@ export async function buildBackupArchive(
}); });
const bytes = zipSync(createZipEntries(files)); const bytes = zipSync(createZipEntries(files));
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH); const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
const fileName = buildBackupFileName(date, fileHashPrefix); const backupTimeZone = options.timeZone || 'UTC';
const fileName = buildBackupFileNameInTimeZone(date, fileHashPrefix, backupTimeZone);
await options.progress?.({ await options.progress?.({
step: 'archive_ready', step: 'archive_ready',
fileName, fileName,
+39 -6
View File
@@ -8,11 +8,13 @@ function mapDeviceRow(row: any): Device {
userId: row.user_id, userId: row.user_id,
deviceIdentifier: row.device_identifier, deviceIdentifier: row.device_identifier,
name: row.name, name: row.name,
deviceNote: row.device_note ?? null,
type: row.type, type: row.type,
sessionStamp: row.session_stamp || '', sessionStamp: row.session_stamp || '',
encryptedUserKey: row.encrypted_user_key ?? null, encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null, encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null, encryptedPrivateKey: row.encrypted_private_key ?? null,
lastSeenAt: row.last_seen_at ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@@ -33,31 +35,62 @@ export async function upsertDevice(
} }
): Promise<void> { ): Promise<void> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || ''; const existingDevice = await getDeviceById(userId, deviceIdentifier);
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
await db await db
.prepare( .prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
'last_seen_at=excluded.last_seen_at, ' +
'updated_at=excluded.updated_at' 'updated_at=excluded.updated_at'
) )
.bind( .bind(
userId, userId,
deviceIdentifier, deviceIdentifier,
name, effectiveName,
type, type,
effectiveSessionStamp, effectiveSessionStamp,
keys?.encryptedUserKey ?? null, keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null, keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null, keys?.encryptedPrivateKey ?? null,
existingDevice?.deviceNote ?? null,
now,
now, now,
now now
) )
.run(); .run();
} }
export async function updateDeviceName(
db: D1Database,
userId: string,
deviceIdentifier: string,
name: string
): Promise<boolean> {
const result = await db
.prepare('UPDATE devices SET device_note = ? WHERE user_id = ? AND device_identifier = ?')
.bind(String(name || '').trim(), userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function touchDeviceLastSeen(
db: D1Database,
userId: string,
deviceIdentifier: string
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare('UPDATE devices SET last_seen_at = ? WHERE user_id = ? AND device_identifier = ?')
.bind(now, userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function updateDeviceKeys( export async function updateDeviceKeys(
db: D1Database, db: D1Database,
userId: string, userId: string,
@@ -133,8 +166,8 @@ export async function isKnownDeviceByEmail(
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> { export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db const res = await db
.prepare( .prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC' 'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
) )
.bind(userId) .bind(userId)
.all<any>(); .all<any>();
@@ -144,7 +177,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> { export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
const row = await db const row = await db
.prepare( .prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
) )
.bind(userId, deviceIdentifier) .bind(userId, deviceIdentifier)
+6 -1
View File
@@ -27,6 +27,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)', 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)', 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)', 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at)',
'CREATE TABLE IF NOT EXISTS folders (' + 'CREATE TABLE IF NOT EXISTS folders (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
@@ -47,6 +48,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)', 'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)', 'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id)',
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2', 'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
'ALTER TABLE sends ADD COLUMN emails TEXT', 'ALTER TABLE sends ADD COLUMN emails TEXT',
@@ -71,7 +73,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)', 'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' + 'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' + 'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
@@ -82,6 +84,9 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT', 'ALTER TABLE devices ADD COLUMN banned_at TEXT',
'ALTER TABLE devices ADD COLUMN device_note TEXT',
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' + 'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
+11 -1
View File
@@ -92,7 +92,9 @@ import {
isKnownDevice as getKnownStoredDevice, isKnownDevice as getKnownStoredDevice,
isKnownDeviceByEmail as getKnownStoredDeviceByEmail, isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken, saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
touchDeviceLastSeen as touchStoredDeviceLastSeen,
upsertDevice as saveStoredDevice, upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys, updateDeviceKeys as updateStoredDeviceKeys,
} from './storage-device-repo'; } from './storage-device-repo';
import { import {
@@ -106,7 +108,7 @@ import {
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
const STORAGE_SCHEMA_VERSION = '2026-03-23.1'; const STORAGE_SCHEMA_VERSION = '2026-04-18.1';
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -550,6 +552,14 @@ export class StorageService {
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys); return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
} }
async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise<boolean> {
return updateStoredDeviceName(this.db, userId, deviceIdentifier, name);
}
async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise<boolean> {
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
}
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> { async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers); return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
} }
+12 -1
View File
@@ -189,12 +189,14 @@ export interface Device {
userId: string; userId: string;
deviceIdentifier: string; deviceIdentifier: string;
name: string; name: string;
deviceNote: string | null;
type: number; type: number;
sessionStamp: string; sessionStamp: string;
encryptedUserKey: string | null; encryptedUserKey: string | null;
encryptedPublicKey: string | null; encryptedPublicKey: string | null;
encryptedPrivateKey: string | null; encryptedPrivateKey: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null; devicePendingAuthRequest?: DevicePendingAuthRequest | null;
lastSeenAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -208,10 +210,14 @@ export interface DeviceResponse {
id: string; id: string;
userId?: string | null; userId?: string | null;
name: string; name: string;
systemName?: string | null;
deviceNote?: string | null;
identifier: string; identifier: string;
type: number; type: number;
creationDate: string; creationDate: string;
revisionDate: string; revisionDate: string;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
isTrusted: boolean; isTrusted: boolean;
encryptedUserKey: string | null; encryptedUserKey: string | null;
encryptedPublicKey: string | null; encryptedPublicKey: string | null;
@@ -347,7 +353,8 @@ export interface TokenResponse {
access_token: string; access_token: string;
expires_in: number; expires_in: number;
token_type: string; token_type: string;
refresh_token: string; refresh_token?: string;
web_session?: boolean;
TwoFactorToken?: string; TwoFactorToken?: string;
Key: string; Key: string;
PrivateKey: string | null; PrivateKey: string | null;
@@ -367,6 +374,10 @@ export interface TokenResponse {
accountKeys?: any | null; accountKeys?: any | null;
UserDecryptionOptions: UserDecryptionOptions; UserDecryptionOptions: UserDecryptionOptions;
userDecryptionOptions?: UserDecryptionOptions; userDecryptionOptions?: UserDecryptionOptions;
VaultKeys?: {
symEncKey: string;
symMacKey: string;
};
} }
export interface ProfileResponse { export interface ProfileResponse {
+30
View File
@@ -0,0 +1,30 @@
export function bytesToBase64Url(bytes: Uint8Array): string {
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function base64UrlToBytes(input: string): Uint8Array {
const normalized = String(input || '').replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
const binary = atob(padded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
export function randomChallenge(size: number = 32): string {
return bytesToBase64Url(crypto.getRandomValues(new Uint8Array(size)));
}
export function parseClientDataJSON(base64Url: string): { type?: string; challenge?: string; origin?: string } | null {
try {
const raw = base64UrlToBytes(base64Url);
const text = new TextDecoder().decode(raw);
const parsed = JSON.parse(text) as { type?: string; challenge?: string; origin?: string };
if (!parsed || typeof parsed !== 'object') return null;
return parsed;
} catch {
return null;
}
}
+39 -8
View File
@@ -15,12 +15,42 @@ const DEFAULT_CORS_HEADERS = [
'X-Request-Email', 'X-Request-Email',
'X-Device-Identifier', 'X-Device-Identifier',
'X-Device-Name', 'X-Device-Name',
'X-NodeWarden-Web-Session',
]; ];
function getAllowedOrigin(request: Request): string | null { function isExtensionOrigin(origin: string): boolean {
return (
origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://')
|| origin.startsWith('safari-web-extension://')
);
}
function isWildcardCorsPath(path: string): boolean {
return (
path.startsWith('/icons/')
|| path === '/config'
|| path === '/api/config'
|| path === '/api/version'
);
}
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
const url = new URL(request.url);
const origin = request.headers.get('Origin'); const origin = request.headers.get('Origin');
if (!origin) return '*'; if (isWildcardCorsPath(url.pathname)) {
return origin; return { allowOrigin: '*', allowCredentials: false };
}
if (!origin) {
return { allowOrigin: null, allowCredentials: false };
}
if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isExtensionOrigin(origin)) {
return { allowOrigin: origin, allowCredentials: false };
}
return { allowOrigin: null, allowCredentials: false };
} }
function buildCorsHeaders(request: Request): Record<string, string> { function buildCorsHeaders(request: Request): Record<string, string> {
@@ -35,13 +65,14 @@ function buildCorsHeaders(request: Request): Record<string, string> {
'Access-Control-Allow-Headers': allowHeaders.join(', '), 'Access-Control-Allow-Headers': allowHeaders.join(', '),
'Access-Control-Expose-Headers': '*', 'Access-Control-Expose-Headers': '*',
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds), 'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
'Access-Control-Allow-Private-Network': 'true',
}; };
const allowedOrigin = getAllowedOrigin(request); const corsPolicy = getCorsPolicy(request);
if (allowedOrigin) { if (corsPolicy.allowOrigin) {
headers['Access-Control-Allow-Origin'] = allowedOrigin; headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
headers['Access-Control-Allow-Credentials'] = 'true'; if (corsPolicy.allowCredentials) {
headers['Access-Control-Allow-Credentials'] = 'true';
}
headers['Vary'] = 'Origin, Access-Control-Request-Headers'; headers['Vary'] = 'Origin, Access-Control-Request-Headers';
} }
+125 -20
View File
@@ -10,8 +10,12 @@ import JwtWarningPage from '@/components/JwtWarningPage';
import { import {
createAuthedFetch, createAuthedFetch,
getAuthorizedDevices, getAuthorizedDevices,
clearProfileSnapshot,
getCurrentDeviceIdentifier, getCurrentDeviceIdentifier,
getPasswordHint, getPasswordHint,
loadProfileSnapshot,
saveProfileSnapshot,
revokeCurrentSession,
getTotpStatus, getTotpStatus,
saveSession, saveSession,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
@@ -39,6 +43,7 @@ import {
performRecoverTwoFactorLogin, performRecoverTwoFactorLogin,
performRegistration, performRegistration,
performTotpLogin, performTotpLogin,
hydrateLockedSession,
performUnlock, performUnlock,
type JwtUnsafeReason, type JwtUnsafeReason,
type PendingTotp, type PendingTotp,
@@ -53,6 +58,17 @@ import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress'; import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
if (!value || typeof value !== 'object') return false;
const detail = value as Record<string, unknown>;
const operation = detail.operation;
return (
(operation === 'backup-restore' || operation === 'backup-export' || operation === 'backup-remote-run')
&& typeof detail.step === 'string'
&& typeof detail.fileName === 'string'
);
}
const IMPORT_ROUTE = '/backup/import-export'; const IMPORT_ROUTE = '/backup/import-export';
const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const; const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE)); const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
@@ -124,11 +140,12 @@ function resolveSystemTheme(): 'light' | 'dark' {
export default function App() { export default function App() {
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []); const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []); const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]);
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null); const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase); const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session); const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
const [profile, setProfile] = useState<Profile | null>(null); const [profile, setProfile] = useState<Profile | null>(initialProfileSnapshot);
const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations); const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning); const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
@@ -155,12 +172,15 @@ export default function App() {
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null); const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [totpCode, setTotpCode] = useState(''); const [totpCode, setTotpCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(true); const [rememberDevice, setRememberDevice] = useState(true);
const [totpSubmitting, setTotpSubmitting] = useState(false);
const [disableTotpOpen, setDisableTotpOpen] = useState(false); const [disableTotpOpen, setDisableTotpOpen] = useState(false);
const [disableTotpPassword, setDisableTotpPassword] = useState(''); const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference()); const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme()); const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key);
const [confirm, setConfirm] = useState<AppConfirmState | null>(null); const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false); const [mobileLayout, setMobileLayout] = useState(false);
@@ -262,6 +282,16 @@ export default function App() {
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference); window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
}, [themePreference]); }, [themePreference]);
useEffect(() => {
saveProfileSnapshot(profile);
}, [profile]);
useEffect(() => {
if (phase === 'locked' && profile?.key && session) {
setUnlockPreparing(false);
}
}, [phase, profile, session]);
useEffect(() => installMagneticUiFeedback(), []); useEffect(() => installMagneticUiFeedback(), []);
function handleToggleTheme() { function handleToggleTheme() {
@@ -323,6 +353,7 @@ export default function App() {
setSession(boot.session); setSession(boot.session);
setProfile(boot.profile); setProfile(boot.profile);
setPhase(boot.phase); setPhase(boot.phase);
setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key);
})(); })();
return () => { return () => {
@@ -330,9 +361,34 @@ export default function App() {
}; };
}, [initialBootstrap]); }, [initialBootstrap]);
useEffect(() => {
if (phase !== 'locked' || !session) return;
let cancelled = false;
void (async () => {
const result = await hydrateLockedSession(session, profile);
if (cancelled) return;
if (!result.session) {
setSession(null);
setProfile(null);
setUnlockPreparing(false);
setPhase('login');
if (location !== '/login') navigate('/login');
return;
}
setSession(result.session);
if (result.profile) {
setProfile(result.profile);
}
})();
return () => {
cancelled = true;
};
}, [phase, session?.email, location, navigate]);
async function finalizeLogin(login: CompletedLogin) { async function finalizeLogin(login: CompletedLogin) {
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false);
setPendingTotp(null); setPendingTotp(null);
setTotpCode(''); setTotpCode('');
setPhase('app'); setPhase('app');
@@ -379,16 +435,20 @@ export default function App() {
} }
async function handleTotpVerify() { async function handleTotpVerify() {
if (totpSubmitting) return;
if (!pendingTotp) return; if (!pendingTotp) return;
if (!totpCode.trim()) { if (!totpCode.trim()) {
pushToast('error', t('txt_please_input_totp_code')); pushToast('error', t('txt_please_input_totp_code'));
return; return;
} }
setTotpSubmitting(true);
try { try {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
await finalizeLogin(login); await finalizeLogin(login);
} catch (error) { } catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
} finally {
setTotpSubmitting(false);
} }
} }
@@ -517,6 +577,7 @@ export default function App() {
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations); const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
setSession(nextSession); setSession(nextSession);
setUnlockPassword(''); setUnlockPassword('');
setUnlockPreparing(false);
setPhase('app'); setPhase('app');
if (location === '/' || location === '/lock') navigate('/vault'); if (location === '/' || location === '/lock') navigate('/vault');
pushToast('success', t('txt_unlocked')); pushToast('success', t('txt_unlocked'));
@@ -533,14 +594,18 @@ export default function App() {
delete nextSession.symEncKey; delete nextSession.symEncKey;
delete nextSession.symMacKey; delete nextSession.symMacKey;
setSession(nextSession); setSession(nextSession);
setUnlockPreparing(false);
setPhase('locked'); setPhase('locked');
navigate('/lock'); navigate('/lock');
} }
function logoutNow() { function logoutNow() {
void revokeCurrentSession(sessionRef.current);
setConfirm(null); setConfirm(null);
setSession(null); setSession(null);
clearProfileSnapshot();
setProfile(null); setProfile(null);
setUnlockPreparing(false);
setPendingTotp(null); setPendingTotp(null);
setPhase('login'); setPhase('login');
navigate('/login'); navigate('/login');
@@ -572,11 +637,13 @@ export default function App() {
onConfirmTotp={() => {}} onConfirmTotp={() => {}}
onCancelTotp={() => {}} onCancelTotp={() => {}}
onUseRecoveryCode={() => {}} onUseRecoveryCode={() => {}}
totpSubmitting={false}
disableTotpOpen={false} disableTotpOpen={false}
disableTotpPassword="" disableTotpPassword=""
onDisableTotpPasswordChange={() => {}} onDisableTotpPasswordChange={() => {}}
onConfirmDisableTotp={() => {}} onConfirmDisableTotp={() => {}}
onCancelDisableTotp={() => {}} onCancelDisableTotp={() => {}}
disableTotpSubmitting={false}
/> />
); );
} }
@@ -691,9 +758,6 @@ export default function App() {
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac), decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac), decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac), decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
: null,
uris: await Promise.all( uris: await Promise.all(
(cipher.login.uris || []).map(async (u) => ({ (cipher.login.uris || []).map(async (u) => ({
...u, ...u,
@@ -702,6 +766,14 @@ export default function App() {
), ),
}; };
} }
if (Array.isArray(cipher.passwordHistory)) {
nextCipher.passwordHistory = await Promise.all(
cipher.passwordHistory.map(async (entry) => ({
...entry,
decPassword: await decryptField(entry?.password || '', itemEnc, itemMac),
}))
);
}
if (cipher.card) { if (cipher.card) {
nextCipher.card = { nextCipher.card = {
...cipher.card, ...cipher.card,
@@ -874,9 +946,11 @@ export default function App() {
const connect = () => { const connect = () => {
if (disposed) return; if (disposed) return;
const accessToken = session.accessToken;
if (!accessToken) return;
try { try {
const hubUrl = new URL('/notifications/hub', window.location.origin); const hubUrl = new URL('/notifications/hub', window.location.origin);
hubUrl.searchParams.set('access_token', session.accessToken); hubUrl.searchParams.set('access_token', accessToken);
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:'; hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(hubUrl.toString()); socket = new WebSocket(hubUrl.toString());
} catch { } catch {
@@ -884,6 +958,15 @@ export default function App() {
return; return;
} }
let pingTimer: number | null = null;
const clearPingTimer = () => {
if (pingTimer !== null) {
window.clearInterval(pingTimer);
pingTimer = null;
}
};
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
reconnectAttempts = 0; reconnectAttempts = 0;
void refreshAuthorizedDevicesRef.current(); void refreshAuthorizedDevicesRef.current();
@@ -891,7 +974,16 @@ export default function App() {
socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`); socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`);
} catch { } catch {
socket?.close(); socket?.close();
return;
} }
clearPingTimer();
pingTimer = window.setInterval(() => {
try {
socket?.send(`{"type":6}${SIGNALR_RECORD_SEPARATOR}`);
} catch {
// send failure will trigger close event
}
}, 15_000);
}); });
socket.addEventListener('message', (event) => { socket.addEventListener('message', (event) => {
@@ -912,17 +1004,7 @@ export default function App() {
} }
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) { if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
const payload = frame.arguments?.[0]?.Payload; const payload = frame.arguments?.[0]?.Payload;
if ( if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
payload
&& typeof payload === 'object'
&& (
payload.operation === 'backup-restore'
|| payload.operation === 'backup-export'
|| payload.operation === 'backup-remote-run'
)
) {
dispatchBackupProgress(payload as BackupProgressDetail);
}
continue; continue;
} }
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
@@ -934,6 +1016,7 @@ export default function App() {
socket.addEventListener('close', () => { socket.addEventListener('close', () => {
socket = null; socket = null;
clearPingTimer();
void refreshAuthorizedDevicesRef.current(); void refreshAuthorizedDevicesRef.current();
scheduleReconnect(); scheduleReconnect();
}); });
@@ -952,9 +1035,11 @@ export default function App() {
return () => { return () => {
disposed = true; disposed = true;
clearReconnectTimer(); clearReconnectTimer();
if (socket && socket.readyState === WebSocket.OPEN) { if (socket) {
const s = socket;
socket = null;
try { try {
socket.close(); s.close();
} catch { } catch {
// ignore close races // ignore close races
} }
@@ -1095,6 +1180,7 @@ export default function App() {
onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems, onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems,
onVerifyMasterPassword: vaultSendActions.verifyMasterPassword, onVerifyMasterPassword: vaultSendActions.verifyMasterPassword,
onCreateFolder: vaultSendActions.createFolder, onCreateFolder: vaultSendActions.createFolder,
onRenameFolder: vaultSendActions.renameFolder,
onDeleteFolder: vaultSendActions.deleteFolder, onDeleteFolder: vaultSendActions.deleteFolder,
onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders, onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders,
onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment, onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment,
@@ -1118,6 +1204,7 @@ export default function App() {
onOpenDisableTotp: () => setDisableTotpOpen(true), onOpenDisableTotp: () => setDisableTotpOpen(true),
onGetRecoveryCode: accountSecurityActions.getRecoveryCode, onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onRemoveDevice: accountSecurityActions.openRemoveDevice, onRemoveDevice: accountSecurityActions.openRemoveDevice,
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust, onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
@@ -1178,7 +1265,8 @@ export default function App() {
<AuthViews <AuthViews
mode={phase} mode={phase}
pendingAction={pendingAuthAction} pendingAction={pendingAuthAction}
unlockReady={!!profile} unlockReady={!!profile?.key && !!session}
unlockPreparing={unlockPreparing}
loginValues={loginValues} loginValues={loginValues}
registerValues={registerValues} registerValues={registerValues}
unlockPassword={unlockPassword} unlockPassword={unlockPassword}
@@ -1217,21 +1305,25 @@ export default function App() {
onRememberDeviceChange={setRememberDevice} onRememberDeviceChange={setRememberDevice}
onConfirmTotp={() => void handleTotpVerify()} onConfirmTotp={() => void handleTotpVerify()}
onCancelTotp={() => { onCancelTotp={() => {
if (totpSubmitting) return;
setPendingTotp(null); setPendingTotp(null);
setTotpCode(''); setTotpCode('');
setRememberDevice(true); setRememberDevice(true);
}} }}
onUseRecoveryCode={() => { onUseRecoveryCode={() => {
if (totpSubmitting) return;
setPendingTotp(null); setPendingTotp(null);
setTotpCode(''); setTotpCode('');
setRememberDevice(true); setRememberDevice(true);
navigate('/recover-2fa'); navigate('/recover-2fa');
}} }}
totpSubmitting={totpSubmitting}
disableTotpOpen={false} disableTotpOpen={false}
disableTotpPassword="" disableTotpPassword=""
onDisableTotpPasswordChange={() => {}} onDisableTotpPasswordChange={() => {}}
onConfirmDisableTotp={() => {}} onConfirmDisableTotp={() => {}}
onCancelDisableTotp={() => {}} onCancelDisableTotp={() => {}}
disableTotpSubmitting={false}
/> />
</> </>
); );
@@ -1270,14 +1362,27 @@ export default function App() {
onConfirmTotp={() => {}} onConfirmTotp={() => {}}
onCancelTotp={() => {}} onCancelTotp={() => {}}
onUseRecoveryCode={() => {}} onUseRecoveryCode={() => {}}
totpSubmitting={false}
disableTotpOpen={disableTotpOpen} disableTotpOpen={disableTotpOpen}
disableTotpPassword={disableTotpPassword} disableTotpPassword={disableTotpPassword}
onDisableTotpPasswordChange={setDisableTotpPassword} onDisableTotpPasswordChange={setDisableTotpPassword}
onConfirmDisableTotp={() => void accountSecurityActions.disableTotp()} onConfirmDisableTotp={() => {
if (disableTotpSubmitting) return;
void (async () => {
setDisableTotpSubmitting(true);
try {
await accountSecurityActions.disableTotp();
} finally {
setDisableTotpSubmitting(false);
}
})();
}}
onCancelDisableTotp={() => { onCancelDisableTotp={() => {
if (disableTotpSubmitting) return;
setDisableTotpOpen(false); setDisableTotpOpen(false);
setDisableTotpPassword(''); setDisableTotpPassword('');
}} }}
disableTotpSubmitting={disableTotpSubmitting}
/> />
</> </>
); );
+18 -6
View File
@@ -40,6 +40,12 @@ export default function AdminPage(props: AdminPageProps) {
return status || '-'; return status || '-';
}; };
const normalizeToggleableStatus = (status: string): 'active' | 'banned' | null => {
const normalized = String(status || '').toLowerCase();
if (normalized === 'active' || normalized === 'banned') return normalized;
return null;
};
return ( return (
<div className="stack"> <div className="stack">
<section className="card"> <section className="card">
@@ -55,8 +61,10 @@ export default function AdminPage(props: AdminPageProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{props.users.map((user) => ( {props.users.map((user) => {
<tr key={user.id}> const toggleableStatus = normalizeToggleableStatus(user.status);
return (
<tr key={user.id}>
<td data-label={t('txt_email')}>{user.email}</td> <td data-label={t('txt_email')}>{user.email}</td>
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td> <td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
<td data-label={t('txt_role')}>{roleText(user.role)}</td> <td data-label={t('txt_role')}>{roleText(user.role)}</td>
@@ -66,8 +74,11 @@ export default function AdminPage(props: AdminPageProps) {
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
disabled={user.id === props.currentUserId} disabled={user.id === props.currentUserId || !toggleableStatus}
onClick={() => void props.onToggleUserStatus(user.id, user.status)} onClick={() => {
if (!toggleableStatus) return;
void props.onToggleUserStatus(user.id, toggleableStatus);
}}
> >
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />} {user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
{user.status === 'active' ? t('txt_ban') : t('txt_unban')} {user.status === 'active' ? t('txt_ban') : t('txt_unban')}
@@ -80,8 +91,9 @@ export default function AdminPage(props: AdminPageProps) {
)} )}
</div> </div>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</section> </section>
+7 -1
View File
@@ -27,11 +27,13 @@ interface AppGlobalOverlaysProps {
onConfirmTotp: () => void; onConfirmTotp: () => void;
onCancelTotp: () => void; onCancelTotp: () => void;
onUseRecoveryCode: () => void; onUseRecoveryCode: () => void;
totpSubmitting: boolean;
disableTotpOpen: boolean; disableTotpOpen: boolean;
disableTotpPassword: string; disableTotpPassword: string;
onDisableTotpPasswordChange: (value: string) => void; onDisableTotpPasswordChange: (value: string) => void;
onConfirmDisableTotp: () => void; onConfirmDisableTotp: () => void;
onCancelDisableTotp: () => void; onCancelDisableTotp: () => void;
disableTotpSubmitting: boolean;
} }
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
@@ -57,12 +59,14 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
confirmText={t('txt_verify')} confirmText={t('txt_verify')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
showIcon={false} showIcon={false}
confirmDisabled={props.totpSubmitting}
cancelDisabled={props.totpSubmitting}
onConfirm={props.onConfirmTotp} onConfirm={props.onConfirmTotp}
onCancel={props.onCancelTotp} onCancel={props.onCancelTotp}
afterActions={( afterActions={(
<div className="dialog-extra"> <div className="dialog-extra">
<div className="dialog-divider" /> <div className="dialog-divider" />
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onUseRecoveryCode}> <button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
{t('txt_use_recovery_code')} {t('txt_use_recovery_code')}
</button> </button>
</div> </div>
@@ -86,6 +90,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
danger danger
showIcon={false} showIcon={false}
confirmDisabled={props.disableTotpSubmitting}
cancelDisabled={props.disableTotpSubmitting}
onConfirm={props.onConfirmDisableTotp} onConfirm={props.onConfirmDisableTotp}
onCancel={props.onCancelDisableTotp} onCancel={props.onCancelDisableTotp}
> >
+4
View File
@@ -74,6 +74,7 @@ export interface AppMainRoutesProps {
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>; onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
onVerifyMasterPassword: (email: string, password: string) => Promise<void>; onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onCreateFolder: (name: string) => Promise<void>; onCreateFolder: (name: string) => Promise<void>;
onRenameFolder: (folderId: string, name: string) => Promise<void>;
onDeleteFolder: (folderId: string) => Promise<void>; onDeleteFolder: (folderId: string) => Promise<void>;
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>; onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>; onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
@@ -94,6 +95,7 @@ export interface AppMainRoutesProps {
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onRefreshAuthorizedDevices: () => Promise<void>; onRefreshAuthorizedDevices: () => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAllDeviceTrust: () => void; onRevokeAllDeviceTrust: () => void;
@@ -192,6 +194,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onVerifyMasterPassword={props.onVerifyMasterPassword} onVerifyMasterPassword={props.onVerifyMasterPassword}
onNotify={props.onNotify} onNotify={props.onNotify}
onCreateFolder={props.onCreateFolder} onCreateFolder={props.onCreateFolder}
onRenameFolder={props.onRenameFolder}
onDeleteFolder={props.onDeleteFolder} onDeleteFolder={props.onDeleteFolder}
onBulkDeleteFolders={props.onBulkDeleteFolders} onBulkDeleteFolders={props.onBulkDeleteFolders}
onDownloadAttachment={props.onDownloadVaultAttachment} onDownloadAttachment={props.onDownloadVaultAttachment}
@@ -279,6 +282,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
devices={props.authorizedDevices} devices={props.authorizedDevices}
loading={props.authorizedDevicesLoading} loading={props.authorizedDevicesLoading}
onRefresh={() => void props.onRefreshAuthorizedDevices()} onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRenameDevice={props.onRenameAuthorizedDevice}
onRevokeTrust={props.onRevokeDeviceTrust} onRevokeTrust={props.onRevokeDeviceTrust}
onRemoveDevice={props.onRemoveDevice} onRemoveDevice={props.onRemoveDevice}
onRevokeAll={props.onRevokeAllDeviceTrust} onRevokeAll={props.onRevokeAllDeviceTrust}
+7 -3
View File
@@ -21,6 +21,7 @@ interface AuthViewsProps {
mode: 'login' | 'register' | 'locked'; mode: 'login' | 'register' | 'locked';
pendingAction: 'login' | 'register' | 'unlock' | null; pendingAction: 'login' | 'register' | 'unlock' | null;
unlockReady: boolean; unlockReady: boolean;
unlockPreparing: boolean;
loginValues: LoginValues; loginValues: LoginValues;
registerValues: RegisterValues; registerValues: RegisterValues;
unlockPassword: string; unlockPassword: string;
@@ -97,14 +98,17 @@ export default function AuthViews(props: AuthViewsProps) {
type="button" type="button"
className="auth-link-btn" className="auth-link-btn"
onClick={props.onShowLockedPasswordHint} onClick={props.onShowLockedPasswordHint}
disabled={unlockBusy} disabled={unlockBusy || props.unlockPreparing}
> >
{t('txt_show_password_hint')} {t('txt_show_password_hint')}
</button> </button>
</div> </div>
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}> {props.unlockPreparing ? (
<p className="muted standalone-muted">{t('txt_loading')}</p>
) : null}
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || props.unlockPreparing || !props.unlockReady}>
<Unlock size={16} className="btn-icon" /> <Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')} {unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
</button> </button>
<div className="or">{t('txt_or')}</div> <div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}> <button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
+13 -1
View File
@@ -528,6 +528,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) { ) {
if (importing) return;
if (!selectedFile) { if (!selectedFile) {
const message = t('txt_backup_file_required'); const message = t('txt_backup_file_required');
setLocalError(message); setLocalError(message);
@@ -625,7 +626,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setSettings(result.settings); setSettings(result.settings);
setSelectedDestinationId(selectedDestination.id); setSelectedDestinationId(selectedDestination.id);
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true }); await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.fileName })); props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed'); const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
setLocalError(message); setLocalError(message);
@@ -654,6 +655,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleDeleteRemote(path: string) { async function handleDeleteRemote(path: string) {
if (deletingRemotePath) return;
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
setDeletingRemotePath(path); setDeletingRemotePath(path);
setLocalError(''); setLocalError('');
@@ -723,6 +725,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) { ) {
if (restoringRemotePath) return;
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
setConfirmRemoteReplaceOpen(false); setConfirmRemoteReplaceOpen(false);
setConfirmIntegrityWarningOpen(false); setConfirmIntegrityWarningOpen(false);
@@ -896,9 +899,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')} message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')}
confirmText={t('txt_backup_import')} confirmText={t('txt_backup_import')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
confirmDisabled={importing}
cancelDisabled={importing}
danger danger
onConfirm={() => void runLocalRestore(false)} onConfirm={() => void runLocalRestore(false)}
onCancel={() => { onCancel={() => {
if (importing) return;
setConfirmLocalRestoreOpen(false); setConfirmLocalRestoreOpen(false);
resetSelectedFile(); resetSelectedFile();
resetPendingIntegrityWarning(); resetPendingIntegrityWarning();
@@ -959,6 +965,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
variant="warning" variant="warning"
confirmText={t('txt_backup_restore_checksum_warning_confirm')} confirmText={t('txt_backup_restore_checksum_warning_confirm')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
confirmDisabled={importing || !!restoringRemotePath}
cancelDisabled={importing || !!restoringRemotePath}
danger danger
onConfirm={() => { onConfirm={() => {
if (!pendingRestoreIntegrity) return; if (!pendingRestoreIntegrity) return;
@@ -984,6 +992,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })} message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
confirmText={t('txt_delete')} confirmText={t('txt_delete')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
confirmDisabled={!!deletingRemotePath}
cancelDisabled={!!deletingRemotePath}
danger danger
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)} onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
onCancel={() => { onCancel={() => {
@@ -1001,6 +1011,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
})} })}
confirmText={t('txt_delete')} confirmText={t('txt_delete')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
confirmDisabled={savingSettings}
cancelDisabled={savingSettings}
danger danger
onConfirm={() => void handleDeleteDestination()} onConfirm={() => void handleDeleteDestination()}
onCancel={() => { onCancel={() => {
+9
View File
@@ -468,6 +468,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
} }
async function handlePasswordImportConfirm() { async function handlePasswordImportConfirm() {
if (isPasswordSubmitting) return;
if (!pendingPasswordImport) return; if (!pendingPasswordImport) return;
setIsPasswordSubmitting(true); setIsPasswordSubmitting(true);
try { try {
@@ -486,6 +487,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
} }
async function handleZipPasswordImportConfirm() { async function handleZipPasswordImportConfirm() {
if (isZipPasswordSubmitting) return;
if (!pendingZipFile) return; if (!pendingZipFile) return;
setIsZipPasswordSubmitting(true); setIsZipPasswordSubmitting(true);
try { try {
@@ -558,6 +560,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
} }
async function handleExportConfirmPassword() { async function handleExportConfirmPassword() {
if (isExporting) return;
const masterPassword = String(exportAuthPassword || '').trim(); const masterPassword = String(exportAuthPassword || '').trim();
if (!masterPassword) { if (!masterPassword) {
onNotify('error', t('txt_master_password_is_required')); onNotify('error', t('txt_master_password_is_required'));
@@ -736,6 +739,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
confirmText={isExporting ? t('txt_loading') : t('txt_verify')} confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
showIcon={false} showIcon={false}
confirmDisabled={isExporting}
cancelDisabled={isExporting}
onConfirm={() => void handleExportConfirmPassword()} onConfirm={() => void handleExportConfirmPassword()}
onCancel={() => { onCancel={() => {
if (isExporting) return; if (isExporting) return;
@@ -761,6 +766,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')} confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
showIcon={false} showIcon={false}
confirmDisabled={isPasswordSubmitting}
cancelDisabled={isPasswordSubmitting}
onConfirm={() => void handlePasswordImportConfirm()} onConfirm={() => void handlePasswordImportConfirm()}
onCancel={() => { onCancel={() => {
if (isPasswordSubmitting) return; if (isPasswordSubmitting) return;
@@ -787,6 +794,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')} confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
showIcon={false} showIcon={false}
confirmDisabled={isZipPasswordSubmitting}
cancelDisabled={isZipPasswordSubmitting}
onConfirm={() => void handleZipPasswordImportConfirm()} onConfirm={() => void handleZipPasswordImportConfirm()}
onCancel={() => { onCancel={() => {
if (isZipPasswordSubmitting) return; if (isZipPasswordSubmitting) return;
+4 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact'; import { Download, Eye, Lock } from 'lucide-preact';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send'; import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
import { toBufferSource } from '@/lib/crypto';
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download'; import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
import StandalonePageFrame from '@/components/StandalonePageFrame'; import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -61,13 +62,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
if (props.keyPart) { if (props.keyPart) {
try { try {
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart); const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
blob = new Blob([decryptedBytes as unknown as BlobPart], { type: 'application/octet-stream' }); blob = new Blob([toBufferSource(decryptedBytes)], { type: 'application/octet-stream' });
} catch { } catch {
// Legacy compatibility: early web-created file sends uploaded plaintext bytes. // Legacy compatibility: early web-created file sends uploaded plaintext bytes.
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' }); blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
} }
} else { } else {
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' }); blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
} }
downloadBytesAsFile( downloadBytesAsFile(
new Uint8Array(await blob.arrayBuffer()), new Uint8Array(await blob.arrayBuffer()),
+79 -11
View File
@@ -1,4 +1,6 @@
import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact'; import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import type { AuthorizedDevice } from '@/lib/types'; import type { AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -6,6 +8,7 @@ interface SecurityDevicesPageProps {
devices: AuthorizedDevice[]; devices: AuthorizedDevice[];
loading: boolean; loading: boolean;
onRefresh: () => void; onRefresh: () => void;
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeTrust: (device: AuthorizedDevice) => void; onRevokeTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAll: () => void; onRevokeAll: () => void;
@@ -41,9 +44,26 @@ function mapDeviceTypeName(type: number): string {
} }
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) { export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
const [deviceNote, setDeviceNote] = useState('');
const [savingNote, setSavingNote] = useState(false);
async function handleSaveDeviceNote(): Promise<void> {
if (!editingDevice || savingNote) return;
setSavingNote(true);
try {
await props.onRenameDevice(editingDevice, deviceNote);
setEditingDevice(null);
setDeviceNote('');
} finally {
setSavingNote(false);
}
}
return ( return (
<div className="stack"> <>
<section className="card"> <div className="stack">
<section className="card">
<div className="section-head"> <div className="section-head">
<div> <div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3> <h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
@@ -66,9 +86,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</button> </button>
</div> </div>
</div> </div>
</section> </section>
<section className="card"> <section className="card">
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3> <h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
<table className="table"> <table className="table">
<thead> <thead>
@@ -87,6 +107,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<tr key={device.identifier}> <tr key={device.identifier}>
<td data-label={t('txt_device')}> <td data-label={t('txt_device')}>
<div>{device.name || t('txt_unknown_device')}</div> <div>{device.name || t('txt_unknown_device')}</div>
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
<div className="muted-inline">{device.systemName}</div>
)}
<div className="muted-inline">{device.identifier}</div> <div className="muted-inline">{device.identifier}</div>
</td> </td>
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td> <td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
@@ -96,7 +119,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</span> </span>
</td> </td>
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td> <td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
<td data-label={t('txt_last_seen')}>{formatDateTime(device.revisionDate)}</td> <td data-label={t('txt_last_seen')}>{formatDateTime(device.lastSeenAt || device.revisionDate)}</td>
<td data-label={t('txt_trusted_until')}> <td data-label={t('txt_trusted_until')}>
{device.trusted ? ( {device.trusted ? (
<div className="trusted-cell"> <div className="trusted-cell">
@@ -116,11 +139,28 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
onClick={() => props.onRevokeTrust(device)} onClick={() => props.onRevokeTrust(device)}
> >
<ShieldOff size={14} className="btn-icon" /> <ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_trust')} {t('txt_untrust')}
</button> </button>
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}> <button
type="button"
className="btn btn-secondary small"
disabled={device.hasStoredDevice === false}
onClick={() => {
setEditingDevice(device);
setDeviceNote(device.deviceNote || device.name || '');
}}
>
<Pencil size={14} className="btn-icon" />
{t('txt_device_note')}
</button>
<button
type="button"
className="btn btn-danger small"
disabled={device.hasStoredDevice === false}
onClick={() => props.onRemoveDevice(device)}
>
<Trash2 size={14} className="btn-icon" /> <Trash2 size={14} className="btn-icon" />
{t('txt_remove_device_2')} {t('txt_delete')}
</button> </button>
</div> </div>
</td> </td>
@@ -135,7 +175,35 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
)} )}
</tbody> </tbody>
</table> </table>
</section> </section>
</div> </div>
<ConfirmDialog
open={!!editingDevice}
title={t('txt_device_note')}
message={t('txt_replace_device_name_with_note')}
confirmText={t('txt_save')}
cancelText={t('txt_cancel')}
showIcon={false}
confirmDisabled={savingNote}
cancelDisabled={savingNote}
onConfirm={() => void handleSaveDeviceNote()}
onCancel={() => {
if (savingNote) return;
setEditingDevice(null);
setDeviceNote('');
}}
>
<label className="field">
<span>{t('txt_device_note')}</span>
<input
className="input"
maxLength={128}
value={deviceNote}
onInput={(e) => setDeviceNote((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
</>
); );
} }
+10 -1
View File
@@ -4,6 +4,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator'; import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types'; import type { Profile } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog';
interface SettingsPageProps { interface SettingsPageProps {
profile: Profile; profile: Profile;
@@ -64,7 +65,8 @@ export default function SettingsPage(props: SettingsPageProps) {
const qr = qrcode(0, 'M'); const qr = qrcode(0, 'M');
qr.addData(buildOtpUri(props.profile.email, secret)); qr.addData(buildOtpUri(props.profile.email, secret));
qr.make(); qr.make();
const svg = qr.createSvgTag({ scalable: true, margin: 0 }); // Keep a visible quiet zone so authenticator apps can scan reliably in both themes.
const svg = qr.createSvgTag({ scalable: true, margin: 4 });
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}, [props.profile.email, secret]); }, [props.profile.email, secret]);
@@ -85,6 +87,13 @@ export default function SettingsPage(props: SettingsPageProps) {
props.onNotify?.('success', t('txt_recovery_code_loaded')); props.onNotify?.('success', t('txt_recovery_code_loaded'));
} }
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString();
}
return ( return (
<div className="stack"> <div className="stack">
<section className="card"> <section className="card">
+3 -1
View File
@@ -1,3 +1,4 @@
import type { JSX } from 'preact';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Clipboard, Globe, GripVertical } from 'lucide-preact'; import { Clipboard, Globe, GripVertical } from 'lucide-preact';
import { import {
@@ -96,6 +97,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.cipher.id, id: props.cipher.id,
}); });
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@@ -113,7 +115,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
className="btn btn-secondary small totp-drag-btn" className="btn btn-secondary small totp-drag-btn"
title={t('txt_drag_to_reorder')} title={t('txt_drag_to_reorder')}
aria-label={t('txt_drag_to_reorder')} aria-label={t('txt_drag_to_reorder')}
{...attributes} {...dragButtonAttributes}
{...listeners} {...listeners}
> >
<GripVertical size={14} className="btn-icon" /> <GripVertical size={14} className="btn-icon" />
+52 -6
View File
@@ -50,6 +50,7 @@ interface VaultPageProps {
onVerifyMasterPassword: (email: string, password: string) => Promise<void>; onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
onCreateFolder: (name: string) => Promise<void>; onCreateFolder: (name: string) => Promise<void>;
onRenameFolder: (folderId: string, name: string) => Promise<void>;
onDeleteFolder: (folderId: string) => Promise<void>; onDeleteFolder: (folderId: string) => Promise<void>;
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>; onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>; onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
@@ -91,6 +92,8 @@ export default function VaultPage(props: VaultPageProps) {
const [moveFolderId, setMoveFolderId] = useState('__none__'); const [moveFolderId, setMoveFolderId] = useState('__none__');
const [createFolderOpen, setCreateFolderOpen] = useState(false); const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState(''); const [newFolderName, setNewFolderName] = useState('');
const [pendingRenameFolder, setPendingRenameFolder] = useState<Folder | null>(null);
const [renameFolderName, setRenameFolderName] = useState('');
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null); const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false); const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null); const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
@@ -101,6 +104,7 @@ export default function VaultPage(props: VaultPageProps) {
const [repromptOpen, setRepromptOpen] = useState(false); const [repromptOpen, setRepromptOpen] = useState(false);
const [repromptPassword, setRepromptPassword] = useState(''); const [repromptPassword, setRepromptPassword] = useState('');
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null); const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
const [pendingDeletePasskeyIndex, setPendingDeletePasskeyIndex] = useState<number | null>(null);
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout); const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list'); const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
@@ -349,7 +353,6 @@ export default function VaultPage(props: VaultPageProps) {
() => filteredCiphers.slice(virtualRange.start, virtualRange.end), () => filteredCiphers.slice(virtualRange.start, virtualRange.end),
[filteredCiphers, virtualRange.start, virtualRange.end] [filteredCiphers, virtualRange.start, virtualRange.end]
); );
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
const selectedAttachments = useMemo( const selectedAttachments = useMemo(
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []), () => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
[selectedCipher] [selectedCipher]
@@ -443,6 +446,7 @@ function folderName(id: string | null | undefined): string {
setLocalError(''); setLocalError('');
setAttachmentQueue([]); setAttachmentQueue([]);
setRemovedAttachmentIds({}); setRemovedAttachmentIds({});
setPendingDeletePasskeyIndex(null);
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list'); if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
} }
@@ -450,6 +454,18 @@ function folderName(id: string | null | undefined): string {
setDraft((prev) => (prev ? { ...prev, ...patch } : prev)); setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
} }
function confirmDeleteLoginPasskey(): void {
if (pendingDeletePasskeyIndex == null) return;
setDraft((prev) => {
if (!prev) return prev;
return {
...prev,
loginFido2Credentials: prev.loginFido2Credentials.filter((_, index) => index !== pendingDeletePasskeyIndex),
};
});
setPendingDeletePasskeyIndex(null);
}
async function seedSshDefaults(force = false): Promise<void> { async function seedSshDefaults(force = false): Promise<void> {
const ticket = ++sshSeedTicketRef.current; const ticket = ++sshSeedTicketRef.current;
try { try {
@@ -699,6 +715,23 @@ function folderName(id: string | null | undefined): string {
} }
} }
async function confirmRenameFolder(): Promise<void> {
if (!pendingRenameFolder) return;
const nextName = renameFolderName.trim();
if (!nextName) {
props.onNotify('error', t('txt_folder_name_is_required'));
return;
}
setBusy(true);
try {
await props.onRenameFolder(pendingRenameFolder.id, nextName);
setPendingRenameFolder(null);
setRenameFolderName('');
} finally {
setBusy(false);
}
}
async function confirmBulkRestore(): Promise<void> { async function confirmBulkRestore(): Promise<void> {
const ids = Object.entries(selectedMap) const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected) .filter(([, selected]) => selected)
@@ -806,6 +839,10 @@ function folderName(id: string | null | undefined): string {
onChangeFilter={setSidebarFilter} onChangeFilter={setSidebarFilter}
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)} onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
onOpenCreateFolder={() => setCreateFolderOpen(true)} onOpenCreateFolder={() => setCreateFolderOpen(true)}
onOpenRenameFolder={(folder) => {
setPendingRenameFolder(folder);
setRenameFolderName(folder.decName || folder.name || '');
}}
onOpenDeleteFolder={setPendingDeleteFolder} onOpenDeleteFolder={setPendingDeleteFolder}
/> />
@@ -924,6 +961,7 @@ function folderName(id: string | null | undefined): string {
onUpdateDraftLoginUri={updateDraftLoginUri} onUpdateDraftLoginUri={updateDraftLoginUri}
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch} onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
onReorderDraftLoginUri={reorderDraftLoginUri} onReorderDraftLoginUri={reorderDraftLoginUri}
onRequestDeleteLoginPasskey={setPendingDeletePasskeyIndex}
onQueueAttachmentFiles={queueAttachmentFiles} onQueueAttachmentFiles={queueAttachmentFiles}
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval} onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
onRemoveQueuedAttachment={removeQueuedAttachment} onRemoveQueuedAttachment={removeQueuedAttachment}
@@ -949,7 +987,7 @@ function folderName(id: string | null | undefined): string {
repromptApprovedCipherId={repromptApprovedCipherId} repromptApprovedCipherId={repromptApprovedCipherId}
showPassword={showPassword} showPassword={showPassword}
totpLive={totpLive} totpLive={totpLive}
passkeyCreatedAt={passkeyCreatedAt} passkeyCreatedAt={firstPasskeyCreationTime(selectedCipher)}
hiddenFieldVisibleMap={hiddenFieldVisibleMap} hiddenFieldVisibleMap={hiddenFieldVisibleMap}
folderName={folderName} folderName={folderName}
onOpenReprompt={() => setRepromptOpen(true)} onOpenReprompt={() => setRepromptOpen(true)}
@@ -971,6 +1009,7 @@ function folderName(id: string | null | undefined): string {
</div> </div>
<VaultDialogs <VaultDialogs
busy={busy}
fieldModalOpen={fieldModalOpen} fieldModalOpen={fieldModalOpen}
fieldType={fieldType} fieldType={fieldType}
fieldLabel={fieldLabel} fieldLabel={fieldLabel}
@@ -986,10 +1025,13 @@ function folderName(id: string | null | undefined): string {
folders={props.folders} folders={props.folders}
createFolderOpen={createFolderOpen} createFolderOpen={createFolderOpen}
newFolderName={newFolderName} newFolderName={newFolderName}
renameFolderOpen={!!pendingRenameFolder}
renameFolderName={renameFolderName}
pendingDeleteFolder={pendingDeleteFolder} pendingDeleteFolder={pendingDeleteFolder}
deleteAllFoldersOpen={deleteAllFoldersOpen} deleteAllFoldersOpen={deleteAllFoldersOpen}
repromptOpen={repromptOpen} repromptOpen={repromptOpen}
repromptPassword={repromptPassword} repromptPassword={repromptPassword}
deletePasskeyOpen={pendingDeletePasskeyIndex != null}
onConfirmAddField={() => { onConfirmAddField={() => {
if (!draft) return; if (!draft) return;
if (!fieldLabel.trim()) { if (!fieldLabel.trim()) {
@@ -1036,6 +1078,12 @@ function folderName(id: string | null | undefined): string {
setNewFolderName(''); setNewFolderName('');
}} }}
onNewFolderNameChange={setNewFolderName} onNewFolderNameChange={setNewFolderName}
onConfirmRenameFolder={() => void confirmRenameFolder()}
onCancelRenameFolder={() => {
setPendingRenameFolder(null);
setRenameFolderName('');
}}
onRenameFolderNameChange={setRenameFolderName}
onConfirmDeleteFolder={() => void confirmDeleteFolder()} onConfirmDeleteFolder={() => void confirmDeleteFolder()}
onCancelDeleteFolder={() => setPendingDeleteFolder(null)} onCancelDeleteFolder={() => setPendingDeleteFolder(null)}
onConfirmDeleteAllFolders={() => void confirmDeleteAllFolders()} onConfirmDeleteAllFolders={() => void confirmDeleteAllFolders()}
@@ -1046,12 +1094,10 @@ function folderName(id: string | null | undefined): string {
setRepromptPassword(''); setRepromptPassword('');
}} }}
onRepromptPasswordChange={setRepromptPassword} onRepromptPasswordChange={setRepromptPassword}
onConfirmDeletePasskey={confirmDeleteLoginPasskey}
onCancelDeletePasskey={() => setPendingDeletePasskeyIndex(null)}
/> />
</> </>
); );
} }
@@ -134,6 +134,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
...COMMON_TIME_ZONES, ...COMMON_TIME_ZONES,
...props.availableTimeZones, ...props.availableTimeZones,
])); ]));
const selectedIntervalHours = props.selectedDestination?.schedule.intervalHours ?? 24;
if (props.selectedRecommendedProvider) { if (props.selectedRecommendedProvider) {
return ( return (
@@ -216,7 +217,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
type="text" type="text"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
value={String(props.selectedDestination.schedule.intervalHours || 24)} value={String(selectedIntervalHours)}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => { onInput={(event) => {
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, ''); const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
@@ -234,7 +235,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
</div> </div>
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}> <div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
{INTERVAL_HOUR_PRESETS.map((preset) => { {INTERVAL_HOUR_PRESETS.map((preset) => {
const active = preset === props.selectedDestination.schedule.intervalHours; const active = preset === selectedIntervalHours;
return ( return (
<button <button
key={preset} key={preset}
@@ -1,5 +1,7 @@
import { useState } from 'preact/hooks'; import { createPortal } from 'preact/compat';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact'; import { useMemo, useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { import {
@@ -35,10 +37,60 @@ interface VaultDetailViewProps {
onUnarchive: (cipher: Cipher) => void | Promise<void>; onUnarchive: (cipher: Cipher) => void | Promise<void>;
} }
function PasswordHistoryDialog(props: {
open: boolean;
entries: Array<{ password: string; lastUsedDate: string | null }>;
onClose: () => void;
}) {
useDialogLifecycle(props.open, props.onClose);
if (!props.open || typeof document === 'undefined') return null;
return createPortal(
<div className="dialog-mask open" onClick={(event) => event.target === event.currentTarget && props.onClose()}>
<section className="dialog-card password-history-dialog open" role="dialog" aria-modal="true" aria-label={t('txt_password_history')}>
<div className="password-history-head">
<h3 className="dialog-title">{t('txt_password_history')}</h3>
<button type="button" className="password-history-close" aria-label={t('txt_close')} onClick={props.onClose}>
<X size={18} />
</button>
</div>
<div className="password-history-list">
{props.entries.map((entry, index) => (
<div key={`password-history-${index}-${entry.lastUsedDate || 'none'}`} className="password-history-item">
<div className="password-history-copy">
<button type="button" className="btn btn-secondary small password-history-copy-btn" onClick={() => copyToClipboard(entry.password)}>
<Clipboard size={16} />
</button>
</div>
<div className="password-history-value">{entry.password}</div>
<div className="password-history-time">{formatHistoryTime(entry.lastUsedDate)}</div>
</div>
))}
</div>
<button type="button" className="btn btn-primary dialog-btn" onClick={props.onClose}>
{t('txt_close')}
</button>
</section>
</div>,
document.body
);
}
export default function VaultDetailView(props: VaultDetailViewProps) { export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : []; const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false); const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt); const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
const passwordHistoryEntries = useMemo(
() =>
(props.selectedCipher.passwordHistory || [])
.map((entry) => ({
password: String(entry?.decPassword || entry?.password || ''),
lastUsedDate: entry?.lastUsedDate ?? null,
}))
.filter((entry) => entry.password.trim()),
[props.selectedCipher.passwordHistory]
);
const formatDownloadLabel = (attachmentId: string) => { const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`; const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -355,6 +407,14 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<h4>{t('txt_item_history')}</h4> <h4>{t('txt_item_history')}</h4>
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div> <div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div> <div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
{!!props.selectedCipher.login?.passwordRevisionDate && (
<div className="detail-sub">{t('txt_password_updated_value', { value: formatHistoryTime(props.selectedCipher.login.passwordRevisionDate) })}</div>
)}
{passwordHistoryEntries.length > 0 && (
<button type="button" className="password-history-link" onClick={() => setPasswordHistoryOpen(true)}>
{t('txt_password_history')}
</button>
)}
</div> </div>
)} )}
@@ -379,6 +439,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
</div> </div>
</> </>
)} )}
<PasswordHistoryDialog
open={passwordHistoryOpen}
entries={passwordHistoryEntries}
onClose={() => setPasswordHistoryOpen(false)}
/>
</> </>
); );
} }
+101 -5
View File
@@ -4,6 +4,7 @@ import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vaul
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface VaultDialogsProps { interface VaultDialogsProps {
busy: boolean;
fieldModalOpen: boolean; fieldModalOpen: boolean;
fieldType: CustomFieldType; fieldType: CustomFieldType;
fieldLabel: string; fieldLabel: string;
@@ -19,10 +20,13 @@ interface VaultDialogsProps {
folders: Folder[]; folders: Folder[];
createFolderOpen: boolean; createFolderOpen: boolean;
newFolderName: string; newFolderName: string;
renameFolderOpen: boolean;
renameFolderName: string;
pendingDeleteFolder: Folder | null; pendingDeleteFolder: Folder | null;
deleteAllFoldersOpen: boolean; deleteAllFoldersOpen: boolean;
repromptOpen: boolean; repromptOpen: boolean;
repromptPassword: string; repromptPassword: string;
deletePasskeyOpen: boolean;
onConfirmAddField: () => void; onConfirmAddField: () => void;
onCancelFieldModal: () => void; onCancelFieldModal: () => void;
onFieldTypeChange: (value: CustomFieldType) => void; onFieldTypeChange: (value: CustomFieldType) => void;
@@ -42,6 +46,9 @@ interface VaultDialogsProps {
onConfirmCreateFolder: () => void; onConfirmCreateFolder: () => void;
onCancelCreateFolder: () => void; onCancelCreateFolder: () => void;
onNewFolderNameChange: (value: string) => void; onNewFolderNameChange: (value: string) => void;
onConfirmRenameFolder: () => void;
onCancelRenameFolder: () => void;
onRenameFolderNameChange: (value: string) => void;
onConfirmDeleteFolder: () => void; onConfirmDeleteFolder: () => void;
onCancelDeleteFolder: () => void; onCancelDeleteFolder: () => void;
onConfirmDeleteAllFolders: () => void; onConfirmDeleteAllFolders: () => void;
@@ -49,6 +56,8 @@ interface VaultDialogsProps {
onConfirmReprompt: () => void; onConfirmReprompt: () => void;
onCancelReprompt: () => void; onCancelReprompt: () => void;
onRepromptPasswordChange: (value: string) => void; onRepromptPasswordChange: (value: string) => void;
onConfirmDeletePasskey: () => void;
onCancelDeletePasskey: () => void;
} }
export default function VaultDialogs(props: VaultDialogsProps) { export default function VaultDialogs(props: VaultDialogsProps) {
@@ -100,6 +109,8 @@ export default function VaultDialogs(props: VaultDialogsProps) {
message={t('txt_archive_item_message')} message={t('txt_archive_item_message')}
confirmText={t('txt_archive')} confirmText={t('txt_archive')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmArchive} onConfirm={props.onConfirmArchive}
onCancel={props.onCancelArchive} onCancel={props.onCancelArchive}
/> />
@@ -110,11 +121,22 @@ export default function VaultDialogs(props: VaultDialogsProps) {
message={t('txt_archive_selected_items_message', { count: props.selectedCount })} message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
confirmText={t('txt_archive')} confirmText={t('txt_archive')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmBulkArchive} onConfirm={props.onConfirmBulkArchive}
onCancel={props.onCancelBulkArchive} onCancel={props.onCancelBulkArchive}
/> />
<ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} /> <ConfirmDialog
open={props.pendingDeleteOpen}
title={t('txt_delete_item')}
message={t('txt_are_you_sure_you_want_to_delete_this_item')}
danger
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmDelete}
onCancel={props.onCancelDelete}
/>
<ConfirmDialog <ConfirmDialog
open={props.bulkDeleteOpen} open={props.bulkDeleteOpen}
@@ -125,11 +147,23 @@ export default function VaultDialogs(props: VaultDialogsProps) {
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount }) : t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
} }
danger danger
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmBulkDelete} onConfirm={props.onConfirmBulkDelete}
onCancel={props.onCancelBulkDelete} onCancel={props.onCancelBulkDelete}
/> />
<ConfirmDialog open={props.moveOpen} title={t('txt_move_selected_items')} message={t('txt_choose_destination_folder')} confirmText={t('txt_move')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmMove} onCancel={props.onCancelMove}> <ConfirmDialog
open={props.moveOpen}
title={t('txt_move_selected_items')}
message={t('txt_choose_destination_folder')}
confirmText={t('txt_move')}
cancelText={t('txt_cancel')}
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmMove}
onCancel={props.onCancelMove}
>
<label className="field"> <label className="field">
<span>{t('txt_folder')}</span> <span>{t('txt_folder')}</span>
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}> <select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}>
@@ -143,13 +177,40 @@ export default function VaultDialogs(props: VaultDialogsProps) {
</label> </label>
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog open={props.createFolderOpen} title={t('txt_create_folder')} message={t('txt_enter_a_folder_name')} confirmText={t('txt_create')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmCreateFolder} onCancel={props.onCancelCreateFolder}> <ConfirmDialog
open={props.createFolderOpen}
title={t('txt_create_folder')}
message={t('txt_enter_a_folder_name')}
confirmText={t('txt_create')}
cancelText={t('txt_cancel')}
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmCreateFolder}
onCancel={props.onCancelCreateFolder}
>
<label className="field"> <label className="field">
<span>{t('txt_folder_name')}</span> <span>{t('txt_folder_name')}</span>
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} /> <input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
</label> </label>
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog
open={props.renameFolderOpen}
title={t('txt_edit')}
message={t('txt_enter_a_folder_name')}
confirmText={t('txt_save')}
cancelText={t('txt_cancel')}
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmRenameFolder}
onCancel={props.onCancelRenameFolder}
>
<label className="field">
<span>{t('txt_folder_name')}</span>
<input className="input" value={props.renameFolderName} onInput={(e) => props.onRenameFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
</label>
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={!!props.pendingDeleteFolder} open={!!props.pendingDeleteFolder}
title={t('txt_delete_folder')} title={t('txt_delete_folder')}
@@ -157,18 +218,53 @@ export default function VaultDialogs(props: VaultDialogsProps) {
confirmText={t('txt_delete')} confirmText={t('txt_delete')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
danger danger
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmDeleteFolder} onConfirm={props.onConfirmDeleteFolder}
onCancel={props.onCancelDeleteFolder} onCancel={props.onCancelDeleteFolder}
/> />
<ConfirmDialog open={props.deleteAllFoldersOpen} title={t('txt_delete_all_folders')} message={t('txt_delete_all_folders_message')} confirmText={t('txt_delete')} cancelText={t('txt_cancel')} danger onConfirm={props.onConfirmDeleteAllFolders} onCancel={props.onCancelDeleteAllFolders} /> <ConfirmDialog
open={props.deleteAllFoldersOpen}
title={t('txt_delete_all_folders')}
message={t('txt_delete_all_folders_message')}
confirmText={t('txt_delete')}
cancelText={t('txt_cancel')}
danger
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmDeleteAllFolders}
onCancel={props.onCancelDeleteAllFolders}
/>
<ConfirmDialog open={props.repromptOpen} title={t('txt_unlock_item')} message={t('txt_enter_master_password_to_view_this_item')} confirmText={t('txt_unlock')} cancelText={t('txt_cancel')} showIcon={false} onConfirm={props.onConfirmReprompt} onCancel={props.onCancelReprompt}> <ConfirmDialog
open={props.repromptOpen}
title={t('txt_unlock_item')}
message={t('txt_enter_master_password_to_view_this_item')}
confirmText={t('txt_unlock')}
cancelText={t('txt_cancel')}
showIcon={false}
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmReprompt}
onCancel={props.onCancelReprompt}
>
<label className="field"> <label className="field">
<span>{t('txt_master_password')}</span> <span>{t('txt_master_password')}</span>
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} /> <input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
</label> </label>
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog
open={props.deletePasskeyOpen}
title={t('txt_delete_passkey')}
message={t('txt_are_you_sure_you_want_to_delete_this_passkey')}
confirmText={t('txt_delete')}
cancelText={t('txt_cancel')}
danger
onConfirm={props.onConfirmDeletePasskey}
onCancel={props.onCancelDeletePasskey}
/>
</> </>
); );
} }
+49 -3
View File
@@ -1,4 +1,4 @@
import type { RefObject } from 'preact'; import type { JSX, RefObject } from 'preact';
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact'; import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { import {
@@ -20,7 +20,15 @@ import {
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers'; import {
CREATE_TYPE_OPTIONS,
cipherTypeLabel,
createEmptyLoginUri,
formatAttachmentSize,
formatHistoryTime,
toBooleanFieldValue,
WEBSITE_MATCH_OPTIONS,
} from '@/components/vault/vault-page-helpers';
interface VaultEditorProps { interface VaultEditorProps {
draft: VaultDraft; draft: VaultDraft;
@@ -44,6 +52,7 @@ interface VaultEditorProps {
onUpdateDraftLoginUri: (index: number, value: string) => void; onUpdateDraftLoginUri: (index: number, value: string) => void;
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void; onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void; onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
onRequestDeleteLoginPasskey: (index: number) => void;
onQueueAttachmentFiles: (list: FileList | null) => void; onQueueAttachmentFiles: (list: FileList | null) => void;
onToggleExistingAttachmentRemoval: (attachmentId: string) => void; onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
onRemoveQueuedAttachment: (index: number) => void; onRemoveQueuedAttachment: (index: number) => void;
@@ -71,6 +80,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id, id: props.id,
}); });
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@@ -89,7 +99,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
className="btn btn-secondary small website-drag-btn" className="btn btn-secondary small website-drag-btn"
title={t('txt_drag_to_reorder')} title={t('txt_drag_to_reorder')}
aria-label={t('txt_drag_to_reorder')} aria-label={t('txt_drag_to_reorder')}
{...attributes} {...dragButtonAttributes}
{...listeners} {...listeners}
> >
<GripVertical size={14} className="btn-icon" /> <GripVertical size={14} className="btn-icon" />
@@ -287,6 +297,42 @@ export default function VaultEditor(props: VaultEditorProps) {
))} ))}
</SortableContext> </SortableContext>
</DndContext> </DndContext>
{props.draft.loginFido2Credentials.length > 0 && (
<>
<div className="section-head" style={{ marginTop: '18px' }}>
<h4>{t('txt_passkeys')}</h4>
</div>
<div className="attachment-list">
{props.draft.loginFido2Credentials.map((credential, index) => {
const createdAt = String(credential?.creationDate || '').trim();
const label = createdAt
? t('txt_passkey_created_at_value', { value: formatHistoryTime(createdAt) })
: t('txt_passkey');
return (
<div key={`login-passkey-${index}`} className="attachment-row">
<div className="attachment-main">
<div className="attachment-text">
<strong>{t('txt_passkey')}</strong>
<span>{label}</span>
</div>
</div>
<div className="kv-actions">
<button
type="button"
className="btn btn-secondary small"
disabled={props.busy}
onClick={() => props.onRequestDeleteLoginPasskey(index)}
>
<X size={14} className="btn-icon" />
{t('txt_remove')}
</button>
</div>
</div>
);
})}
</div>
</>
)}
</div> </div>
)} )}
@@ -8,6 +8,7 @@ import {
Globe, Globe,
KeyRound, KeyRound,
LayoutGrid, LayoutGrid,
Pencil,
ShieldUser, ShieldUser,
Star, Star,
StickyNote, StickyNote,
@@ -28,6 +29,7 @@ interface VaultSidebarProps {
onChangeFilter: (filter: SidebarFilter) => void; onChangeFilter: (filter: SidebarFilter) => void;
onOpenDeleteAllFolders: () => void; onOpenDeleteAllFolders: () => void;
onOpenCreateFolder: () => void; onOpenCreateFolder: () => void;
onOpenRenameFolder: (folder: Folder) => void;
onOpenDeleteFolder: (folder: Folder) => void; onOpenDeleteFolder: (folder: Folder) => void;
} }
@@ -113,6 +115,20 @@ export default function VaultSidebar(props: VaultSidebarProps) {
{folder.decName || folder.name || folder.id} {folder.decName || folder.name || folder.id}
</span> </span>
</button> </button>
<button
type="button"
className="folder-delete-btn folder-edit-btn"
title={t('txt_edit')}
aria-label={t('txt_edit')}
disabled={props.busy}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.onOpenRenameFolder(folder);
}}
>
<Pencil size={12} />
</button>
<button <button
type="button" type="button"
className="folder-delete-btn" className="folder-delete-btn"
@@ -165,7 +165,7 @@ export function websiteIconUrl(host: string): string {
} }
export function createEmptyLoginUri(): VaultDraftLoginUri { export function createEmptyLoginUri(): VaultDraftLoginUri {
return { uri: '', match: null }; return { uri: '', match: null, originalUri: '', extra: {} };
} }
export function websiteMatchLabel(value: number | null | undefined): string { export function websiteMatchLabel(value: number | null | undefined): string {
@@ -313,6 +313,10 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
draft.loginUris = (cipher.login.uris || []).map((x) => ({ draft.loginUris = (cipher.login.uris || []).map((x) => ({
uri: x.decUri || x.uri || '', uri: x.decUri || x.uri || '',
match: x.match ?? null, match: x.match ?? null,
originalUri: x.decUri || x.uri || '',
extra: Object.fromEntries(
Object.entries(x as Record<string, unknown>).filter(([key]) => !['uri', 'match', 'decUri'].includes(key))
),
})); }));
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials) draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
? cipher.login.fido2Credentials.map((credential) => ({ ...credential })) ? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
+47 -15
View File
@@ -9,6 +9,7 @@ import {
revokeAuthorizedDeviceTrust, revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust,
setTotp, setTotp,
updateAuthorizedDeviceName,
updateProfile, updateProfile,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -151,6 +152,21 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
await refetchAuthorizedDevices(); await refetchAuthorizedDevices();
}, },
async renameAuthorizedDevice(device: AuthorizedDevice, name: string) {
const normalized = String(name || '').trim();
if (!normalized) {
onNotify('error', t('txt_device_note_required'));
return;
}
try {
await updateAuthorizedDeviceName(authedFetch, device.identifier, normalized);
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_note_updated'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_device_note_failed'));
}
},
openRevokeDeviceTrust(device: AuthorizedDevice) { openRevokeDeviceTrust(device: AuthorizedDevice) {
onSetConfirm({ onSetConfirm({
title: t('txt_revoke_device_authorization'), title: t('txt_revoke_device_authorization'),
@@ -159,9 +175,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier); try {
await refetchAuthorizedDevices(); await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
onNotify('success', t('txt_device_authorization_revoked')); await refetchAuthorizedDevices();
onNotify('success', t('txt_device_authorization_revoked'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_device_trust_failed'));
}
})(); })();
}, },
}); });
@@ -175,14 +195,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await deleteAuthorizedDevice(authedFetch, device.identifier); try {
if (device.identifier === getCurrentDeviceIdentifier()) { await deleteAuthorizedDevice(authedFetch, device.identifier);
if (device.identifier === getCurrentDeviceIdentifier()) {
onNotify('success', t('txt_device_removed'));
onLogoutNow();
return;
}
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_removed')); onNotify('success', t('txt_device_removed'));
onLogoutNow(); } catch (error) {
return; onNotify('error', error instanceof Error ? error.message : t('txt_remove_device_failed'));
} }
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_removed'));
})(); })();
}, },
}); });
@@ -196,9 +220,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await revokeAllAuthorizedDeviceTrust(authedFetch); try {
await refetchAuthorizedDevices(); await revokeAllAuthorizedDeviceTrust(authedFetch);
onNotify('success', t('txt_all_device_authorizations_revoked')); await refetchAuthorizedDevices();
onNotify('success', t('txt_all_device_authorizations_revoked'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_all_device_trust_failed'));
}
})(); })();
}, },
}); });
@@ -212,9 +240,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => { onConfirm: () => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
await deleteAllAuthorizedDevices(authedFetch); try {
onNotify('success', t('txt_all_devices_removed')); await deleteAllAuthorizedDevices(authedFetch);
onLogoutNow(); onNotify('success', t('txt_all_devices_removed'));
onLogoutNow();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_remove_all_devices_failed'));
}
})(); })();
}, },
}); });
+23
View File
@@ -43,6 +43,7 @@ import {
type CiphersImportPayload, type CiphersImportPayload,
type ImportedCipherMapEntry, type ImportedCipherMapEntry,
updateCipher, updateCipher,
updateFolder,
unarchiveCipher, unarchiveCipher,
uploadCipherAttachment, uploadCipherAttachment,
} from '@/lib/api/vault'; } from '@/lib/api/vault';
@@ -340,6 +341,28 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
} }
}, },
async renameFolder(folderId: string, name: string) {
const id = String(folderId || '').trim();
const nextName = String(name || '').trim();
if (!id) {
onNotify('error', t('txt_folder_not_found'));
return;
}
if (!nextName) {
onNotify('error', t('txt_folder_name_is_required'));
return;
}
try {
if (!session) throw new Error(t('txt_vault_key_unavailable'));
await updateFolder(authedFetch, session, id, nextName);
await refetchFolders();
onNotify('success', t('txt_folder_updated'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
throw error;
}
},
async bulkRestoreVaultItems(ids: string[]) { async bulkRestoreVaultItems(ids: string[]) {
try { try {
await bulkRestoreCiphers(authedFetch, ids); await bulkRestoreCiphers(authedFetch, ids);
+6 -6
View File
@@ -1,4 +1,4 @@
import { base64ToBytes, decryptBw } from './crypto'; import { base64ToBytes, decryptBw, toBufferSource } from './crypto';
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup'; import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
import type { Profile, SessionState } from './types'; import type { Profile, SessionState } from './types';
@@ -9,7 +9,7 @@ const AES_GCM_ALGORITHM = 'AES-GCM';
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> { async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey( return crypto.subtle.importKey(
'pkcs8', 'pkcs8',
pkcs8, toBufferSource(pkcs8),
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH }, { name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
false, false,
['decrypt'] ['decrypt']
@@ -17,7 +17,7 @@ async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
} }
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> { async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', keyBytes, { name: AES_GCM_ALGORITHM }, false, ['decrypt']); return crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
} }
export async function decryptPortableBackupSettings( export async function decryptPortableBackupSettings(
@@ -50,15 +50,15 @@ export async function decryptPortableBackupSettings(
await crypto.subtle.decrypt( await crypto.subtle.decrypt(
{ name: PORTABLE_ALGORITHM }, { name: PORTABLE_ALGORITHM },
privateKey, privateKey,
base64ToBytes(wrap.wrappedKey) toBufferSource(base64ToBytes(wrap.wrappedKey))
) )
); );
const aesKey = await importPortableAesKey(portableDek); const aesKey = await importPortableAesKey(portableDek);
const plaintext = new Uint8Array( const plaintext = new Uint8Array(
await crypto.subtle.decrypt( await crypto.subtle.decrypt(
{ name: AES_GCM_ALGORITHM, iv: base64ToBytes(portable.iv) }, { name: AES_GCM_ALGORITHM, iv: toBufferSource(base64ToBytes(portable.iv)) },
aesKey, aesKey,
base64ToBytes(portable.ciphertext) toBufferSource(base64ToBytes(portable.ciphertext))
) )
); );
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings; return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
+131 -19
View File
@@ -10,8 +10,10 @@ import type {
import { parseJson, type AuthedFetch, type SessionSetter } from './shared'; import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
const SESSION_KEY = 'nodewarden.web.session.v4'; const SESSION_KEY = 'nodewarden.web.session.v4';
const PROFILE_SNAPSHOT_KEY = 'nodewarden.web.profile-snapshot.v1';
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1'; const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1'; const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
const WEB_SESSION_HEADER = 'X-NodeWarden-Web-Session';
export interface PreloginResult { export interface PreloginResult {
hash: string; hash: string;
@@ -26,6 +28,24 @@ export interface PreloginKdfConfig {
kdfParallelism: number | null; kdfParallelism: number | null;
} }
interface PersistedSessionState {
email: string;
authMode: 'token' | 'web-cookie';
}
interface RefreshFailure {
ok: false;
transient: boolean;
error: string;
}
interface RefreshSuccess {
ok: true;
token: TokenSuccess;
}
type RefreshResult = RefreshFailure | RefreshSuccess;
function randomHex(length: number): string { function randomHex(length: number): string {
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2)))); const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length); return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
@@ -66,12 +86,19 @@ export function loadSession(): SessionState | null {
try { try {
const raw = localStorage.getItem(SESSION_KEY); const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as SessionState; const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
if (!parsed.accessToken || !parsed.refreshToken) return null; if (parsed.authMode === 'web-cookie' && parsed.email) {
return {
email: parsed.email,
authMode: 'web-cookie',
};
}
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
return { return {
accessToken: parsed.accessToken, accessToken: parsed.accessToken,
refreshToken: parsed.refreshToken, refreshToken: parsed.refreshToken,
email: parsed.email, email: parsed.email,
authMode: 'token',
}; };
} catch { } catch {
return null; return null;
@@ -83,14 +110,35 @@ export function saveSession(session: SessionState | null): void {
localStorage.removeItem(SESSION_KEY); localStorage.removeItem(SESSION_KEY);
return; return;
} }
const persisted: SessionState = { const persisted: PersistedSessionState = {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
email: session.email, email: session.email,
authMode: session.authMode === 'token' ? 'token' : 'web-cookie',
}; };
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted)); localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
} }
export function loadProfileSnapshot(email?: string | null): Profile | null {
try {
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Profile;
if (!parsed?.email || !parsed?.key) return null;
if (email && parsed.email !== email) return null;
return parsed;
} catch {
return null;
}
}
export function saveProfileSnapshot(profile: Profile | null): void {
if (!profile) return;
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile));
}
export function clearProfileSnapshot(): void {
localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
}
export function getCurrentDeviceIdentifier(): string { export function getCurrentDeviceIdentifier(): string {
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim(); return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
} }
@@ -170,7 +218,10 @@ export async function loginWithPassword(
} }
const resp = await fetch('/identity/connect/token', { const resp = await fetch('/identity/connect/token', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
'Content-Type': 'application/x-www-form-urlencoded',
[WEB_SESSION_HEADER]: '1',
},
body: body.toString(), body: body.toString(),
}); });
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {}; const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
@@ -183,18 +234,60 @@ export async function loginWithPassword(
return json; return json;
} }
export async function refreshAccessToken(refreshToken: string): Promise<TokenSuccess | null> { function isTransientRefreshStatus(status: number): boolean {
return status === 0 || status === 429 || status >= 500;
}
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
const body = new URLSearchParams(); const body = new URLSearchParams();
body.set('grant_type', 'refresh_token'); body.set('grant_type', 'refresh_token');
body.set('refresh_token', refreshToken); if (session.authMode !== 'web-cookie' && session.refreshToken) {
const resp = await fetch('/identity/connect/token', { body.set('refresh_token', session.refreshToken);
}
try {
const resp = await fetch('/identity/connect/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
},
body: body.toString(),
});
if (!resp.ok) {
const json = await parseJson<TokenError>(resp);
return {
ok: false,
transient: isTransientRefreshStatus(resp.status),
error: json?.error_description || json?.error || 'Session refresh failed',
};
}
const json = await parseJson<TokenSuccess>(resp);
if (!json?.access_token) {
return { ok: false, transient: false, error: 'Session refresh failed' };
}
return { ok: true, token: json };
} catch (error) {
return {
ok: false,
transient: true,
error: error instanceof Error ? error.message : 'Network error',
};
}
}
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
const body = new URLSearchParams();
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
body.set('token', session.refreshToken);
}
await fetch('/identity/connect/revocation', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
},
body: body.toString(), body: body.toString(),
}); }).catch(() => undefined);
if (!resp.ok) return null;
const json = await parseJson<TokenSuccess>(resp);
return json || null;
} }
export async function registerAccount(args: { export async function registerAccount(args: {
@@ -279,18 +372,22 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
headers.set('Authorization', `Bearer ${session.accessToken}`); headers.set('Authorization', `Bearer ${session.accessToken}`);
let resp = await fetch(input, { ...init, headers }); let resp = await fetch(input, { ...init, headers });
if (resp.status !== 401 || !session.refreshToken) return resp; if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
const refreshed = await refreshAccessToken(session.refreshToken); const refreshed = await refreshAccessToken(session);
if (!refreshed?.access_token) { if (!refreshed.ok) {
if (refreshed.transient) {
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
}
setSession(null); setSession(null);
throw new Error('Session expired'); throw new Error('Session expired');
} }
const nextSession: SessionState = { const nextSession: SessionState = {
...session, ...session,
accessToken: refreshed.access_token, accessToken: refreshed.token.access_token,
refreshToken: refreshed.refresh_token || session.refreshToken, refreshToken: refreshed.token.refresh_token || session.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
}; };
setSession(nextSession); setSession(nextSession);
saveSession(nextSession); saveSession(nextSession);
@@ -478,6 +575,21 @@ export async function deleteAuthorizedDevice(
if (!resp.ok) throw new Error(t('txt_remove_device_failed')); if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
} }
export async function updateAuthorizedDeviceName(
authedFetch: AuthedFetch,
deviceIdentifier: string,
name: string
): Promise<void> {
const normalized = String(name || '').trim();
if (!normalized) throw new Error(t('txt_device_note_required'));
const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}/name`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: normalized }),
});
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
}
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
const resp = await authedFetch('/api/devices', { method: 'DELETE' }); const resp = await authedFetch('/api/devices', { method: 'DELETE' });
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
+9 -19
View File
@@ -16,6 +16,7 @@ import {
type AuthedFetch, type AuthedFetch,
} from './shared'; } from './shared';
import { readResponseBytesWithProgress } from '../download'; import { readResponseBytesWithProgress } from '../download';
import { toBufferSource } from '../crypto';
import { unzipSync, zipSync } from 'fflate'; import { unzipSync, zipSync } from 'fflate';
export type { export type {
@@ -148,32 +149,21 @@ interface BackupExportManifest {
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5; const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
function parseBackupTimestampFromFileName(fileName: string): Date | null { function extractBackupTimestampFromFileName(fileName: string): string | null {
const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i); const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i);
if (!match) return null; if (!match) return null;
const datePart = match[1]; return `${match[1]}_${match[2]}`;
const timePart = match[2];
const iso = `${datePart.slice(0, 4)}-${datePart.slice(4, 6)}-${datePart.slice(6, 8)}T${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}.000Z`;
const parsed = new Date(iso);
return Number.isFinite(parsed.getTime()) ? parsed : null;
} }
function buildBackupFileName(date: Date, checksumPrefix: string): string { function buildBackupFileName(timestamp: string, checksumPrefix: string): string {
const parts = [ return `nodewarden_backup_${timestamp}_${checksumPrefix}.zip`;
date.getUTCFullYear().toString().padStart(4, '0'),
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
date.getUTCDate().toString().padStart(2, '0'),
date.getUTCHours().toString().padStart(2, '0'),
date.getUTCMinutes().toString().padStart(2, '0'),
date.getUTCSeconds().toString().padStart(2, '0'),
];
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}_${checksumPrefix}.zip`;
} }
async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> { async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> {
const integrity = await verifyBackupFileIntegrity(bytes, fileName); const integrity = await verifyBackupFileIntegrity(bytes, fileName);
const effectiveDate = parseBackupTimestampFromFileName(fileName) || new Date(); const timestamp = extractBackupTimestampFromFileName(fileName);
return buildBackupFileName(effectiveDate, integrity.actualPrefix); if (!timestamp) return fileName;
return buildBackupFileName(timestamp, integrity.actualPrefix);
} }
export async function exportAdminBackup( export async function exportAdminBackup(
@@ -378,7 +368,7 @@ export function extractBackupFileChecksumPrefix(fileName: string): string | null
} }
async function sha256Hex(bytes: Uint8Array): Promise<string> { async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes); const digest = await crypto.subtle.digest('SHA-256', toBufferSource(bytes));
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join(''); return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
} }
+7 -5
View File
@@ -1,6 +1,7 @@
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto'; import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
import type { Send, SendDraft, SessionState } from '../types'; import type { Send, SendDraft, SessionState } from '../types';
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared'; import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
import { loadVaultSyncSnapshot } from './vault-sync';
function toIsoDateFromDays(value: string, required: boolean): string | null { function toIsoDateFromDays(value: string, required: boolean): string | null {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
@@ -61,10 +62,8 @@ function parseMaxAccessCountRaw(value: string): number | null {
} }
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> { export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
const resp = await authedFetch('/api/sends'); const body = await loadVaultSyncSnapshot(authedFetch);
if (!resp.ok) throw new Error('Failed to load sends'); return body.sends || [];
const body = await parseJson<{ object: 'list'; data: Send[] }>(resp);
return body?.data || [];
} }
export async function createSend( export async function createSend(
@@ -152,10 +151,13 @@ export async function createSend(
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp); const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
const uploadUrl = uploadInfo?.url; const uploadUrl = uploadInfo?.url;
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL'); if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
if (!session.accessToken) throw new Error('Unauthorized');
const payload = new ArrayBuffer(encryptedFileBytes.byteLength);
new Uint8Array(payload).set(encryptedFileBytes);
const uploadResp = await uploadDirectEncryptedPayload({ const uploadResp = await uploadDirectEncryptedPayload({
accessToken: session.accessToken, accessToken: session.accessToken,
uploadUrl, uploadUrl,
payload: encryptedFileBytes, payload,
fileUploadType: uploadInfo?.fileUploadType, fileUploadType: uploadInfo?.fileUploadType,
unsupportedMessage: 'Unsupported send upload type', unsupportedMessage: 'Unsupported send upload type',
onProgress, onProgress,
+2 -2
View File
@@ -63,14 +63,14 @@ interface UploadWithProgressOptions {
accessToken?: string; accessToken?: string;
method?: string; method?: string;
headers?: HeadersInit; headers?: HeadersInit;
body?: Document | XMLHttpRequestBodyInit | null; body?: XMLHttpRequestBodyInit | null;
onProgress?: (percent: number | null) => void; onProgress?: (percent: number | null) => void;
} }
interface DirectEncryptedUploadOptions { interface DirectEncryptedUploadOptions {
accessToken: string; accessToken: string;
uploadUrl: string; uploadUrl: string;
payload: ArrayBuffer | Uint8Array; payload: XMLHttpRequestBodyInit;
fileUploadType: number | null | undefined; fileUploadType: number | null | undefined;
unsupportedMessage: string; unsupportedMessage: string;
onProgress?: (percent: number | null) => void; onProgress?: (percent: number | null) => void;
+31
View File
@@ -0,0 +1,31 @@
import type { Cipher, Folder, Send } from '../types';
import { parseJson, type AuthedFetch } from './shared';
interface VaultSyncResponse {
ciphers?: Cipher[];
folders?: Folder[];
sends?: Send[];
}
const pendingSyncRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>();
export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> {
const existing = pendingSyncRequests.get(authedFetch);
if (existing) return existing;
const request = (async () => {
const resp = await authedFetch('/api/sync');
if (!resp.ok) throw new Error('Failed to load vault');
const body = await parseJson<VaultSyncResponse>(resp);
return body || {};
})();
pendingSyncRequests.set(authedFetch, request);
try {
return await request;
} finally {
if (pendingSyncRequests.get(authedFetch) === request) {
pendingSyncRequests.delete(authedFetch);
}
}
}
+82 -11
View File
@@ -1,8 +1,8 @@
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto'; import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto';
import type { import type {
Cipher, Cipher,
CipherPasswordHistoryEntry,
Folder, Folder,
ListResponse,
SessionState, SessionState,
VaultDraft, VaultDraft,
VaultDraftField, VaultDraftField,
@@ -16,12 +16,11 @@ import {
type AuthedFetch, type AuthedFetch,
} from './shared'; } from './shared';
import { readResponseBytesWithProgress } from '../download'; import { readResponseBytesWithProgress } from '../download';
import { loadVaultSyncSnapshot } from './vault-sync';
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> { export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
const resp = await authedFetch('/api/folders'); const body = await loadVaultSyncSnapshot(authedFetch);
if (!resp.ok) throw new Error('Failed to load folders'); return body.folders || [];
const body = await parseJson<ListResponse<Folder>>(resp);
return body?.data || [];
} }
export async function createFolder( export async function createFolder(
@@ -93,10 +92,8 @@ export async function updateFolder(
} }
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> { export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
const resp = await authedFetch('/api/ciphers?deleted=true'); const body = await loadVaultSyncSnapshot(authedFetch);
if (!resp.ok) throw new Error('Failed to load ciphers'); return body.ciphers || [];
const body = await parseJson<ListResponse<Cipher>>(resp);
return body?.data || [];
} }
export interface CiphersImportPayload { export interface CiphersImportPayload {
@@ -240,6 +237,7 @@ export async function uploadCipherAttachment(
const attachmentId = String(meta.attachmentId || '').trim(); const attachmentId = String(meta.attachmentId || '').trim();
const uploadUrl = String(meta.url || '').trim(); const uploadUrl = String(meta.url || '').trim();
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed'); if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
if (!session.accessToken) throw new Error('Unauthorized');
const payload = new ArrayBuffer(encryptedBytes.byteLength); const payload = new ArrayBuffer(encryptedBytes.byteLength);
new Uint8Array(payload).set(encryptedBytes); new Uint8Array(payload).set(encryptedBytes);
@@ -349,6 +347,61 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array)
return encryptBw(new TextEncoder().encode(s), enc, mac); return encryptBw(new TextEncoder().encode(s), enc, mac);
} }
async function encryptPasswordHistory(
entries: CipherPasswordHistoryEntry[] | null | undefined,
enc: Uint8Array,
mac: Uint8Array
): Promise<CipherPasswordHistoryEntry[] | null> {
if (!Array.isArray(entries) || entries.length === 0) return null;
const out: CipherPasswordHistoryEntry[] = [];
for (const entry of entries) {
const rawPassword = String(entry?.password || '');
const plainPassword = entry?.decPassword ?? rawPassword;
const encryptedPassword = looksLikeCipherString(rawPassword)
? rawPassword
: await encryptTextValue(plainPassword, enc, mac);
if (!encryptedPassword) continue;
out.push({
password: encryptedPassword,
lastUsedDate: toIsoDateOrNow(entry?.lastUsedDate),
});
}
return out.length ? out : null;
}
async function buildUpdatedPasswordHistory(
cipher: Cipher | null,
draft: VaultDraft,
enc: Uint8Array,
mac: Uint8Array
): Promise<CipherPasswordHistoryEntry[] | null> {
const existingHistory = Array.isArray(cipher?.passwordHistory) ? cipher.passwordHistory : [];
const currentPassword = String(cipher?.login?.decPassword || '');
const nextPassword = String(draft.loginPassword || '');
const passwordChanged = currentPassword !== nextPassword;
const history = await encryptPasswordHistory(existingHistory, enc, mac);
if (!passwordChanged || !currentPassword.trim()) {
return history;
}
const encryptedCurrentPassword = await encryptTextValue(currentPassword, enc, mac);
if (!encryptedCurrentPassword) {
return history;
}
const nextEntries: CipherPasswordHistoryEntry[] = [
{
password: encryptedCurrentPassword,
lastUsedDate: new Date().toISOString(),
},
...(history || []),
];
return nextEntries.slice(0, 5);
}
async function encryptCustomFields( async function encryptCustomFields(
fields: VaultDraftField[], fields: VaultDraftField[],
enc: Uint8Array, enc: Uint8Array,
@@ -371,12 +424,20 @@ async function encryptUris(
uris: VaultDraft['loginUris'], uris: VaultDraft['loginUris'],
enc: Uint8Array, enc: Uint8Array,
mac: Uint8Array mac: Uint8Array
): Promise<Array<{ uri: string | null; match: number | null }>> { ): Promise<Array<Record<string, unknown>>> {
const out: Array<{ uri: string | null; match: number | null }> = []; const out: Array<Record<string, unknown>> = [];
for (const entry of uris || []) { for (const entry of uris || []) {
const trimmed = String(entry?.uri || '').trim(); const trimmed = String(entry?.uri || '').trim();
if (!trimmed) continue; if (!trimmed) continue;
const preservedExtra =
entry?.extra && typeof entry.extra === 'object'
? { ...entry.extra }
: {};
if (String(entry?.originalUri || '').trim() !== trimmed) {
delete preservedExtra.uriChecksum;
}
out.push({ out.push({
...preservedExtra,
uri: await encryptTextValue(trimmed, enc, mac), uri: await encryptTextValue(trimmed, enc, mac),
match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null, match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
}); });
@@ -468,6 +529,7 @@ async function buildCipherPayload(
const userMac = base64ToBytes(session.symMacKey); const userMac = base64ToBytes(session.symMacKey);
const keys = await getCipherKeys(cipher, userEnc, userMac); const keys = await getCipherKeys(cipher, userEnc, userMac);
const type = Number(draft.type || cipher?.type || 1); const type = Number(draft.type || cipher?.type || 1);
const now = new Date().toISOString();
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
type, type,
@@ -482,6 +544,7 @@ async function buildCipherPayload(
secureNote: null, secureNote: null,
sshKey: null, sshKey: null,
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac), fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
}; };
if (cipher?.id) { if (cipher?.id) {
@@ -490,17 +553,25 @@ async function buildCipherPayload(
} }
if (type === 1) { if (type === 1) {
const passwordChanged = String(cipher?.login?.decPassword || '') !== String(draft.loginPassword || '');
const existingFido2 = const existingFido2 =
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials) cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
? (cipher.login as any).fido2Credentials ? (cipher.login as any).fido2Credentials
: draft.loginFido2Credentials; : draft.loginFido2Credentials;
const existingLogin =
cipher?.login && typeof cipher.login === 'object'
? { ...(cipher.login as Record<string, unknown>) }
: {};
payload.login = { payload.login = {
...existingLogin,
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac), username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac), password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac), totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
passwordRevisionDate: passwordChanged ? now : existingLogin.passwordRevisionDate ?? null,
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac), fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac), uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
}; };
payload.passwordHistory = await buildUpdatedPasswordHistory(cipher, draft, keys.enc, keys.mac);
} else if (type === 3) { } else if (type === 3) {
payload.card = { payload.card = {
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac), cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
+48 -22
View File
@@ -2,6 +2,7 @@ import {
createAuthedFetch, createAuthedFetch,
deriveLoginHashLocally, deriveLoginHashLocally,
getProfile, getProfile,
loadProfileSnapshot,
loadSession, loadSession,
loginWithPassword, loginWithPassword,
refreshAccessToken, refreshAccessToken,
@@ -26,6 +27,7 @@ export interface BootstrapAppResult {
session: SessionState | null; session: SessionState | null;
profile: Profile | null; profile: Profile | null;
phase: AppPhase; phase: AppPhase;
needsBackgroundHydration?: boolean;
} }
export interface InitialAppBootstrapState { export interface InitialAppBootstrapState {
@@ -51,8 +53,9 @@ export interface RecoverTwoFactorResult {
newRecoveryCode: string | null; newRecoveryCode: string | null;
} }
function decodeJwtExp(accessToken: string): number | null { function decodeJwtExp(accessToken: string | undefined): number | null {
try { try {
if (!accessToken) return null;
const parts = accessToken.split('.'); const parts = accessToken.split('.');
if (parts.length < 2) return null; if (parts.length < 2) return null;
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
@@ -66,23 +69,24 @@ function decodeJwtExp(accessToken: string): number | null {
} }
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> { async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> {
if (!session.refreshToken) return session; if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null;
const exp = decodeJwtExp(session.accessToken); const exp = decodeJwtExp(session.accessToken);
const nowSeconds = Math.floor(Date.now() / 1000); const nowSeconds = Math.floor(Date.now() / 1000);
if (exp !== null && exp - nowSeconds > 60) { if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
return session; return session;
} }
const refreshed = await refreshAccessToken(session.refreshToken); const refreshed = await refreshAccessToken(session);
if (!refreshed?.access_token) { if (!refreshed.ok) {
return exp !== null && exp > nowSeconds ? session : null; return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
} }
return { return {
...session, ...session,
accessToken: refreshed.access_token, accessToken: refreshed.token.access_token,
refreshToken: refreshed.refresh_token || session.refreshToken, refreshToken: refreshed.token.refresh_token || session.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
}; };
} }
@@ -197,31 +201,51 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
}; };
} }
const cachedProfile = loadProfileSnapshot(loaded.email);
if (cachedProfile) {
return {
defaultKdfIterations,
jwtWarning: null,
session: loaded,
profile: cachedProfile,
phase: 'locked',
needsBackgroundHydration: true,
};
}
return {
defaultKdfIterations,
jwtWarning: null,
session: loaded,
profile: null,
phase: 'locked',
needsBackgroundHydration: true,
};
}
export async function hydrateLockedSession(
session: SessionState,
fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> {
const refreshedSession = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) {
return { session: null, profile: null };
}
try { try {
const session = await maybeRefreshSession(loaded);
if (!session) {
throw new Error('Session expired');
}
const profile = await getProfile( const profile = await getProfile(
createAuthedFetch( createAuthedFetch(
() => session, () => refreshedSession,
() => {} () => {}
) )
); );
return { return {
defaultKdfIterations, session: refreshedSession,
jwtWarning: null,
session,
profile, profile,
phase: 'locked',
}; };
} catch { } catch {
return { return {
defaultKdfIterations, session: refreshedSession,
jwtWarning: null, profile: fallbackProfile,
session: null,
profile: null,
phase: initial.phase === 'register' ? 'register' : 'login',
}; };
} }
} }
@@ -236,6 +260,7 @@ export async function completeLogin(
accessToken: token.access_token, accessToken: token.access_token,
refreshToken: token.refresh_token, refreshToken: token.refresh_token,
email: normalizedEmail, email: normalizedEmail,
authMode: token.web_session ? 'web-cookie' : 'token',
}; };
const tempFetch = createAuthedFetch( const tempFetch = createAuthedFetch(
() => baseSession, () => baseSession,
@@ -359,3 +384,4 @@ export async function performUnlock(
} }
return { ...refreshedSession, ...keys }; return { ...refreshedSession, ...keys };
} }
+8 -6
View File
@@ -161,11 +161,6 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
draft.loginUsername = asText(login.username); draft.loginUsername = asText(login.username);
draft.loginPassword = asText(login.password); draft.loginPassword = asText(login.password);
draft.loginTotp = asText(login.totp); draft.loginTotp = asText(login.totp);
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
? login.fido2Credentials
.filter((credential): credential is Record<string, unknown> => !!credential && typeof credential === 'object')
.map((credential) => ({ ...credential }))
: [];
const urisRaw = Array.isArray(login.uris) ? login.uris : []; const urisRaw = Array.isArray(login.uris) ? login.uris : [];
const uris = urisRaw const uris = urisRaw
.map((u) => { .map((u) => {
@@ -175,10 +170,17 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
return { return {
uri, uri,
match: typeof matchRaw === 'number' && Number.isFinite(matchRaw) ? matchRaw : null, match: typeof matchRaw === 'number' && Number.isFinite(matchRaw) ? matchRaw : null,
originalUri: uri,
extra: Object.fromEntries(
Object.entries(row).filter(([key]) => !['uri', 'match'].includes(key))
),
}; };
}) })
.filter((u) => !!u.uri); .filter((u) => !!u.uri);
draft.loginUris = uris.length ? uris : [{ uri: '', match: null }]; draft.loginUris = uris.length ? uris : [{ uri: '', match: null, originalUri: '', extra: {} }];
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
? login.fido2Credentials.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
: [];
} else if (type === 3) { } else if (type === 3) {
const card = (cipher.card || {}) as Record<string, unknown>; const card = (cipher.card || {}) as Record<string, unknown>;
draft.cardholderName = asText(card.cardholderName); draft.cardholderName = asText(card.cardholderName);
+1 -1
View File
@@ -18,7 +18,7 @@ export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
return out; return out;
} }
function toBufferSource(bytes: Uint8Array): ArrayBuffer { export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
return new Uint8Array(bytes).buffer; return new Uint8Array(bytes).buffer;
} }
+3 -1
View File
@@ -293,7 +293,9 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
) )
: [], : [],
fido2Credentials: Array.isArray(cipher.login.fido2Credentials) fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
? await Promise.all(cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac))) ? await Promise.all(
cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac))
)
: [], : [],
}; };
} else { } else {
+30 -5
View File
@@ -293,6 +293,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?", txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?",
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: "Are you sure you want to permanently delete {count} selected items?", txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: "Are you sure you want to permanently delete {count} selected items?",
txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?", txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?",
txt_are_you_sure_you_want_to_delete_this_passkey: "Are you sure you want to delete this passkey?",
txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?", txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?",
txt_authenticator_key: "Authenticator Key", txt_authenticator_key: "Authenticator Key",
txt_authorized_devices: "Authorized Devices", txt_authorized_devices: "Authorized Devices",
@@ -352,6 +353,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_delete_all_invite_codes_active_inactive: "Delete all invite codes (active/inactive)?", txt_delete_all_invite_codes_active_inactive: "Delete all invite codes (active/inactive)?",
txt_delete_all_invites: "Delete all invites", txt_delete_all_invites: "Delete all invites",
txt_delete_item: "Delete Item", txt_delete_item: "Delete Item",
txt_delete_passkey: "Delete Passkey",
txt_delete_item_failed: "Delete item failed", txt_delete_item_failed: "Delete item failed",
txt_delete_permanently: "Delete Permanently", txt_delete_permanently: "Delete Permanently",
txt_archive: "Archive", txt_archive: "Archive",
@@ -385,6 +387,9 @@ const messages: Record<Locale, Record<string, string>> = {
txt_device: "Device", txt_device: "Device",
txt_device_authorization_revoked: "Device trust revoked", txt_device_authorization_revoked: "Device trust revoked",
txt_device_management: "Device Management", txt_device_management: "Device Management",
txt_device_note: "Device Note",
txt_device_note_required: "Device name is required",
txt_device_note_updated: "Device name updated",
txt_device_removed: "Device removed", txt_device_removed: "Device removed",
txt_load_devices_failed: "Failed to load devices", txt_load_devices_failed: "Failed to load devices",
txt_disable_this_send: "Disable this send", txt_disable_this_send: "Disable this send",
@@ -457,6 +462,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_item_created: "Item created", txt_item_created: "Item created",
txt_item_deleted: "Item deleted", txt_item_deleted: "Item deleted",
txt_item_history: "Item History", txt_item_history: "Item History",
txt_password_history: "Password History",
txt_password_updated_value: "Password updated: {value}",
txt_item_name_is_required: "Item name is required.", txt_item_name_is_required: "Item name is required.",
txt_item_updated: "Item updated", txt_item_updated: "Item updated",
txt_last_edited_value: "Last edited: {value}", txt_last_edited_value: "Last edited: {value}",
@@ -548,6 +555,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_not_trusted: "Not trusted", txt_not_trusted: "Not trusted",
txt_note: "Note", txt_note: "Note",
txt_notes: "Notes", txt_notes: "Notes",
txt_replace_device_name_with_note: "Set a custom name for this device without changing its detected system type.",
txt_number: "Number", txt_number: "Number",
txt_open: "Open", txt_open: "Open",
txt_opera_browser: "Opera Browser", txt_opera_browser: "Opera Browser",
@@ -571,6 +579,9 @@ const messages: Record<Locale, Record<string, string>> = {
txt_password_hint_not_set: "No password hint is available for this email.", txt_password_hint_not_set: "No password hint is available for this email.",
txt_password_hint_load_failed: "Failed to load password hint", txt_password_hint_load_failed: "Failed to load password hint",
txt_password_hint_too_long: "Password hint must be 120 characters or fewer", txt_password_hint_too_long: "Password hint must be 120 characters or fewer",
txt_passkey: "Passkey",
txt_passkeys: "Passkeys",
txt_passkey_created_at_value: "Created on {value}",
txt_phone: "Phone", txt_phone: "Phone",
txt_please_input_email_and_password: "Please input email and password", txt_please_input_email_and_password: "Please input email and password",
txt_please_input_master_password: "Please input master password", txt_please_input_master_password: "Please input master password",
@@ -613,6 +624,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_revoke_device_trust_failed: "Failed to revoke device trust", txt_revoke_device_trust_failed: "Failed to revoke device trust",
txt_revoke_all_device_trust_failed: "Failed to revoke all device trust", txt_revoke_all_device_trust_failed: "Failed to revoke all device trust",
txt_revoke_trust: "Revoke Trust", txt_revoke_trust: "Revoke Trust",
txt_untrust: "Untrust",
txt_update_device_note_failed: "Update device note failed",
txt_role: "Role", txt_role: "Role",
txt_save: "Save", txt_save: "Save",
txt_save_profile: "Save Profile", txt_save_profile: "Save Profile",
@@ -671,8 +684,6 @@ const messages: Record<Locale, Record<string, string>> = {
txt_total_items_count: "{count} items", txt_total_items_count: "{count} items",
txt_totp_secret: "TOTP Secret", txt_totp_secret: "TOTP Secret",
txt_totp_verify_failed: "TOTP verify failed", txt_totp_verify_failed: "TOTP verify failed",
txt_passkey: "Passkey",
txt_passkey_created_at_value: "Created at {value}",
txt_attachments: "Attachments", txt_attachments: "Attachments",
txt_upload_attachments: "Upload attachments", txt_upload_attachments: "Upload attachments",
txt_new_attachments: "New attachments", txt_new_attachments: "New attachments",
@@ -1064,7 +1075,10 @@ const zhCNOverrides: Record<string, string> = {
txt_additional_options: '附加选项', txt_additional_options: '附加选项',
txt_custom_fields: '自定义字段', txt_custom_fields: '自定义字段',
txt_notes: '备注', txt_notes: '备注',
txt_replace_device_name_with_note: '为这台设备设置自定义名称,不会改变系统识别到的设备类型。',
txt_item_history: '项目历史', txt_item_history: '项目历史',
txt_password_history: '密码历史记录',
txt_password_updated_value: '密码新于: {value}',
txt_last_edited_value: '最后编辑:{value}', txt_last_edited_value: '最后编辑:{value}',
txt_created_value: '创建于:{value}', txt_created_value: '创建于:{value}',
txt_username: '用户名', txt_username: '用户名',
@@ -1110,12 +1124,17 @@ const zhCNOverrides: Record<string, string> = {
txt_view_recovery_code: '查看恢复代码', txt_view_recovery_code: '查看恢复代码',
txt_copy_code: '复制代码', txt_copy_code: '复制代码',
txt_device_management: '设备管理', txt_device_management: '设备管理',
txt_device_note: '备注',
txt_device_note_required: '设备名称不能为空',
txt_device_note_updated: '设备名称已更新',
txt_authorized_devices: '已授权设备', txt_authorized_devices: '已授权设备',
txt_device: '设备', txt_device: '设备',
txt_last_seen: '最后在线', txt_last_seen: '最后在线',
txt_trusted_until: '信任至', txt_trusted_until: '信任至',
txt_revoke_trust: '撤销信任', txt_revoke_trust: '撤销信任',
txt_untrust: '不信任',
txt_remove_device_2: '移除设备', txt_remove_device_2: '移除设备',
txt_update_device_note_failed: '更新设备备注失败',
txt_not_trusted: '未信任', txt_not_trusted: '未信任',
txt_unknown_device: '未知设备', txt_unknown_device: '未知设备',
txt_users: '用户', txt_users: '用户',
@@ -1163,6 +1182,7 @@ const zhCNOverrides: Record<string, string> = {
txt_no_name: '(无名称)', txt_no_name: '(无名称)',
txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?', txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?',
txt_delete_item: '删除项目', txt_delete_item: '删除项目',
txt_delete_passkey: '删除通行密钥',
txt_delete_selected_items: '删除所选项目', txt_delete_selected_items: '删除所选项目',
txt_move_selected_items: '移动所选项目', txt_move_selected_items: '移动所选项目',
txt_create_folder: '创建文件夹', txt_create_folder: '创建文件夹',
@@ -1226,6 +1246,7 @@ const zhCNOverrides: Record<string, string> = {
txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?', txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?',
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: '确认永久删除所选的 {count} 个项目?', txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: '确认永久删除所选的 {count} 个项目?',
txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?', txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?',
txt_are_you_sure_you_want_to_delete_this_passkey: '确认删除这个通行密钥?',
txt_authenticator_key: '验证器密钥', txt_authenticator_key: '验证器密钥',
txt_brand: '品牌', txt_brand: '品牌',
txt_bulk_delete_failed: '批量删除失败', txt_bulk_delete_failed: '批量删除失败',
@@ -1326,6 +1347,9 @@ const zhCNOverrides: Record<string, string> = {
txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。', txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。',
txt_password_hint_load_failed: '加载密码提示失败', txt_password_hint_load_failed: '加载密码提示失败',
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符', txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
txt_passkey: '通行密钥',
txt_passkeys: '通行密钥',
txt_passkey_created_at_value: '创建于 {value}',
txt_phone: '电话', txt_phone: '电话',
txt_please_input_email_and_password: '请输入邮箱和密码', txt_please_input_email_and_password: '请输入邮箱和密码',
txt_please_input_master_password: '请输入主密码', txt_please_input_master_password: '请输入主密码',
@@ -1431,8 +1455,6 @@ zhCNOverrides.txt_lock = '锁定';
zhCNOverrides.txt_menu = '菜单'; zhCNOverrides.txt_menu = '菜单';
zhCNOverrides.txt_settings = '设置'; zhCNOverrides.txt_settings = '设置';
zhCNOverrides.txt_back = '返回'; zhCNOverrides.txt_back = '返回';
zhCNOverrides.txt_passkey = 'Passkey';
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
zhCNOverrides.txt_attachments = '附件'; zhCNOverrides.txt_attachments = '附件';
zhCNOverrides.txt_upload_attachments = '上传附件'; zhCNOverrides.txt_upload_attachments = '上传附件';
zhCNOverrides.txt_new_attachments = '待上传附件'; zhCNOverrides.txt_new_attachments = '待上传附件';
@@ -1493,7 +1515,9 @@ messages.en.txt_delete_all_folders = 'Delete All Folders';
messages.en.txt_delete_all_folders_message = 'Delete all folders? Items inside will move to No Folder.'; messages.en.txt_delete_all_folders_message = 'Delete all folders? Items inside will move to No Folder.';
messages.en.txt_folder_not_found = 'Folder not found'; messages.en.txt_folder_not_found = 'Folder not found';
messages.en.txt_folder_deleted = 'Folder deleted'; messages.en.txt_folder_deleted = 'Folder deleted';
messages.en.txt_folder_updated = 'Folder updated';
messages.en.txt_folders_deleted = 'Folders deleted'; messages.en.txt_folders_deleted = 'Folders deleted';
messages.en.txt_update_folder_failed = 'Update folder failed';
messages.en.txt_delete_folder_failed = 'Delete folder failed'; messages.en.txt_delete_folder_failed = 'Delete folder failed';
messages.en.txt_delete_all_folders_failed = 'Delete all folders failed'; messages.en.txt_delete_all_folders_failed = 'Delete all folders failed';
messages.en.txt_other = 'Other'; messages.en.txt_other = 'Other';
@@ -1575,7 +1599,9 @@ zhCNOverrides.txt_delete_all_folders = '删除全部文件夹';
zhCNOverrides.txt_delete_all_folders_message = '确认删除全部文件夹吗?其中的项目将移至无文件夹。'; zhCNOverrides.txt_delete_all_folders_message = '确认删除全部文件夹吗?其中的项目将移至无文件夹。';
zhCNOverrides.txt_folder_not_found = '文件夹不存在'; zhCNOverrides.txt_folder_not_found = '文件夹不存在';
zhCNOverrides.txt_folder_deleted = '文件夹已删除'; zhCNOverrides.txt_folder_deleted = '文件夹已删除';
zhCNOverrides.txt_folder_updated = '文件夹已重命名';
zhCNOverrides.txt_folders_deleted = '文件夹已删除'; zhCNOverrides.txt_folders_deleted = '文件夹已删除';
zhCNOverrides.txt_update_folder_failed = '重命名文件夹失败';
zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败'; zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败';
zhCNOverrides.txt_delete_all_folders_failed = '删除全部文件夹失败'; zhCNOverrides.txt_delete_all_folders_failed = '删除全部文件夹失败';
zhCNOverrides.txt_other = '其他'; zhCNOverrides.txt_other = '其他';
@@ -1629,4 +1655,3 @@ export function setLocale(next: Locale): void {
// ignore storage errors // ignore storage errors
} }
} }
+1 -1
View File
@@ -223,7 +223,7 @@ export function makeLoginCipher(): Record<string, unknown> {
favorite: false, favorite: false,
reprompt: 0, reprompt: 0,
key: null, key: null,
login: { username: null, password: null, totp: null, fido2Credentials: null, uris: null }, login: { username: null, password: null, totp: null, uris: null },
card: null, card: null,
identity: null, identity: null,
secureNote: null, secureNote: null,
+30 -4
View File
@@ -1,9 +1,10 @@
export type AppPhase = 'register' | 'login' | 'locked' | 'app'; export type AppPhase = 'register' | 'login' | 'locked' | 'app';
export interface SessionState { export interface SessionState {
accessToken: string; accessToken?: string;
refreshToken: string; refreshToken?: string;
email: string; email: string;
authMode?: 'token' | 'web-cookie';
symEncKey?: string; symEncKey?: string;
symMacKey?: string; symMacKey?: string;
} }
@@ -28,13 +29,18 @@ export interface Folder {
export interface CipherLoginUri { export interface CipherLoginUri {
uri?: string | null; uri?: string | null;
uriChecksum?: string | null;
match?: number | null; match?: number | null;
response?: unknown | null;
decUri?: string; decUri?: string;
[key: string]: unknown;
} }
export interface VaultDraftLoginUri { export interface VaultDraftLoginUri {
uri: string; uri: string;
match: number | null; match: number | null;
originalUri?: string;
extra?: Record<string, unknown>;
} }
export interface CipherAttachment { export interface CipherAttachment {
@@ -59,9 +65,14 @@ export interface CipherLogin {
totp?: string | null; totp?: string | null;
uris?: CipherLoginUri[] | null; uris?: CipherLoginUri[] | null;
fido2Credentials?: CipherLoginPasskey[] | null; fido2Credentials?: CipherLoginPasskey[] | null;
autofillOnPageLoad?: boolean | null;
uri?: string | null;
passwordRevisionDate?: string | null;
response?: unknown | null;
decUsername?: string; decUsername?: string;
decPassword?: string; decPassword?: string;
decTotp?: string; decTotp?: string;
[key: string]: unknown;
} }
export interface CipherCard { export interface CipherCard {
@@ -137,6 +148,12 @@ export interface CipherField {
decValue?: string; decValue?: string;
} }
export interface CipherPasswordHistoryEntry {
password?: string | null;
lastUsedDate?: string | null;
decPassword?: string;
}
export interface Cipher { export interface Cipher {
id: string; id: string;
type: number; type: number;
@@ -156,7 +173,7 @@ export interface Cipher {
identity?: CipherIdentity | null; identity?: CipherIdentity | null;
sshKey?: CipherSshKey | null; sshKey?: CipherSshKey | null;
secureNote?: { type?: number | null } | null; secureNote?: { type?: number | null } | null;
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null; passwordHistory?: CipherPasswordHistoryEntry[] | null;
fields?: CipherField[] | null; fields?: CipherField[] | null;
decName?: string; decName?: string;
decNotes?: string; decNotes?: string;
@@ -272,7 +289,8 @@ export interface WebBootstrapResponse {
export interface TokenSuccess { export interface TokenSuccess {
access_token: string; access_token: string;
refresh_token: string; refresh_token?: string;
web_session?: boolean;
expires_in?: number; expires_in?: number;
token_type?: string; token_type?: string;
TwoFactorToken?: string; TwoFactorToken?: string;
@@ -290,6 +308,10 @@ export interface TokenSuccess {
unofficialServer?: boolean; unofficialServer?: boolean;
UserDecryptionOptions?: unknown; UserDecryptionOptions?: unknown;
userDecryptionOptions?: unknown; userDecryptionOptions?: unknown;
VaultKeys?: {
symEncKey?: string;
symMacKey?: string;
};
} }
export interface TokenError { export interface TokenError {
@@ -322,10 +344,14 @@ export interface AdminInvite {
export interface AuthorizedDevice { export interface AuthorizedDevice {
id: string; id: string;
name: string; name: string;
systemName?: string | null;
deviceNote?: string | null;
identifier: string; identifier: string;
type: number; type: number;
creationDate: string | null; creationDate: string | null;
revisionDate: string | null; revisionDate: string | null;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
online: boolean; online: boolean;
trusted: boolean; trusted: boolean;
trustedTokenCount: number; trustedTokenCount: number;
+103 -1
View File
@@ -1164,6 +1164,11 @@ input[type='file'].input::file-selector-button:hover {
transform: scale(1.06); transform: scale(1.06);
} }
.folder-edit-btn:hover {
color: #1d4ed8;
background: #dbeafe;
}
.list-col { .list-col {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1556,6 +1561,22 @@ input[type='file'].input::file-selector-button:hover {
margin-top: 8px; margin-top: 8px;
} }
.password-history-link {
margin-top: 10px;
padding: 0;
border: none;
background: transparent;
color: var(--primary);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.password-history-link:hover {
color: var(--primary-hover);
text-decoration: underline;
}
.kv-line { .kv-line {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1586,6 +1607,81 @@ input[type='file'].input::file-selector-button:hover {
border-bottom: none; border-bottom: none;
} }
.password-history-dialog {
width: min(560px, calc(100vw - 32px));
}
.password-history-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.password-history-head .dialog-title {
margin: 0;
}
.password-history-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: none;
border-radius: 999px;
background: transparent;
color: var(--muted-strong);
cursor: pointer;
}
.password-history-close:hover {
background: var(--panel-soft);
color: var(--text);
}
.password-history-list {
display: grid;
gap: 12px;
margin: 10px 0 18px;
}
.password-history-item {
position: relative;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel-soft);
padding: 16px 54px 14px 16px;
box-shadow: var(--shadow-sm);
}
.password-history-value {
color: var(--primary);
font-size: 22px;
line-height: 1.15;
letter-spacing: 0.01em;
word-break: break-all;
}
.password-history-time {
margin-top: 8px;
color: var(--muted);
}
.password-history-copy {
position: absolute;
top: 12px;
right: 12px;
}
.password-history-copy-btn {
min-width: 36px;
padding: 0;
width: 36px;
height: 36px;
}
.kv-label { .kv-label {
color: #64748b; color: #64748b;
min-width: 0; min-width: 0;
@@ -2669,6 +2765,8 @@ input[type='file'].input::file-selector-button:hover {
.totp-qr img { .totp-qr img {
width: 180px; width: 180px;
height: 180px; height: 180px;
background: #fff;
border-radius: 8px;
} }
.section-head { .section-head {
@@ -4768,7 +4866,6 @@ input[type='file'].input::file-selector-button:hover {
:root[data-theme='dark'] .backup-browser-list, :root[data-theme='dark'] .backup-browser-list,
:root[data-theme='dark'] .backup-schedule-current, :root[data-theme='dark'] .backup-schedule-current,
:root[data-theme='dark'] .backup-status-card, :root[data-theme='dark'] .backup-status-card,
:root[data-theme='dark'] .totp-qr,
:root[data-theme='dark'] .create-menu, :root[data-theme='dark'] .create-menu,
:root[data-theme='dark'] .create-menu-item, :root[data-theme='dark'] .create-menu-item,
:root[data-theme='dark'] .sort-menu, :root[data-theme='dark'] .sort-menu,
@@ -4926,6 +5023,11 @@ input[type='file'].input::file-selector-button:hover {
color: #9be2bd; color: #9be2bd;
} }
:root[data-theme='dark'] .totp-qr {
background: #ffffff;
border-color: rgba(15, 23, 42, 0.12);
}
:root[data-theme='dark'] .totp-qr svg, :root[data-theme='dark'] .totp-qr svg,
:root[data-theme='dark'] .totp-qr img { :root[data-theme='dark'] .totp-qr img {
background: #ffffff; background: #ffffff;
+1 -2
View File
@@ -6,9 +6,8 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "preact", "jsxImportSource": "preact",
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["./src/*"],
"@shared/*": ["../shared/*"] "@shared/*": ["../shared/*"]
}, },
"strict": true, "strict": true,