28 Commits

Author SHA1 Message Date
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
61 changed files with 1469 additions and 595 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/
+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
--- ---
+2
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,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.2", "version": "1.4.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodewarden", "name": "nodewarden",
"version": "1.4.2", "version": "1.4.3",
"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.3",
"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.3';
+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);
} });
} }
+14 -1
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') {
+44 -100
View File
@@ -13,6 +13,26 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function mergeCipherNestedObject<T>(
existingValue: T | null | undefined,
incomingValue: unknown
): T | null {
if (incomingValue === undefined) {
return (existingValue ?? null) as T | null;
}
if (incomingValue === null || typeof incomingValue !== 'object' || Array.isArray(incomingValue)) {
return incomingValue as T | null;
}
const existingObject =
existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue)
? (existingValue as Record<string, unknown>)
: {};
return {
...existingObject,
...(incomingValue as Record<string, unknown>),
} as T;
}
async function notifyVaultSyncForRequest( async function notifyVaultSyncForRequest(
request: Request, request: Request,
env: Env, env: Env,
@@ -61,80 +81,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 +138,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 {
@@ -221,7 +178,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 +198,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 +227,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),
})
); );
} }
@@ -327,9 +283,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
); );
} }
@@ -370,6 +324,11 @@ 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,
}; };
cipher.login = mergeCipherNestedObject(existingCipher.login, cipherData.login);
cipher.card = mergeCipherNestedObject(existingCipher.card, cipherData.card);
cipher.identity = mergeCipherNestedObject(existingCipher.identity, cipherData.identity);
cipher.secureNote = mergeCipherNestedObject(existingCipher.secureNote, cipherData.secureNote);
cipher.sshKey = mergeCipherNestedObject(existingCipher.sshKey, cipherData.sshKey);
// Custom fields deletion compatibility: // Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields". // - Accept both camelCase "fields" and PascalCase "Fields".
@@ -394,9 +353,7 @@ export async function handleUpdateCipher(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),
})
); );
} }
@@ -418,9 +375,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 +439,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 +478,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 +519,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 +555,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 +577,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),
})
); );
} }
+89 -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
@@ -390,16 +451,18 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const { accessToken, user, device } = result; const { accessToken, user, device } = result;
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 +475,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 +536,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;
} }
+2 -3
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;
@@ -159,7 +158,7 @@ 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) || c.folderId || null;
const sourceIdRaw = String(c?.id ?? '').trim(); const sourceIdRaw = String(c?.id ?? '').trim();
const sourceId = sourceIdRaw || null; const sourceId = sourceIdRaw || null;
@@ -184,7 +183,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
})) || null, })) || null,
totp: c.login.totp ?? null, totp: c.login.totp ?? null,
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null, autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
fido2Credentials: c.login.fido2Credentials ?? null, fido2Credentials: Array.isArray(c.login.fido2Credentials) ? c.login.fido2Credentials : null,
uri: c.login.uri ?? null, uri: c.login.uri ?? null,
passwordRevisionDate: c.login.passwordRevisionDate ?? null, passwordRevisionDate: c.login.passwordRevisionDate ?? null,
} : null, } : 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> {
+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,
+2
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',
+1 -1
View File
@@ -106,7 +106,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-03-30.1';
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
+6 -1
View File
@@ -347,7 +347,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 +368,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';
} }
+116 -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,
@@ -874,9 +938,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 +950,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 +966,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 +996,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 +1008,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 +1027,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 +1172,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,
@@ -1178,7 +1256,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 +1296,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 +1353,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}
> >
+2
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>;
@@ -192,6 +193,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}
+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()),
+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}
+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 }))
+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;
+116 -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);
+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);
}
}
}
+21 -11
View File
@@ -2,7 +2,6 @@ import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, enc
import type { import type {
Cipher, Cipher,
Folder, Folder,
ListResponse,
SessionState, SessionState,
VaultDraft, VaultDraft,
VaultDraftField, VaultDraftField,
@@ -16,12 +15,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 +91,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 +236,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);
@@ -371,12 +368,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,
}); });
@@ -494,7 +499,12 @@ async function buildCipherPayload(
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),
+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 {
+14 -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",
@@ -571,6 +573,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",
@@ -671,8 +676,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",
@@ -1163,6 +1166,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 +1230,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 +1331,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 +1439,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 +1499,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 +1583,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 +1639,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,
+19 -3
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 {
@@ -272,7 +283,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 +302,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 {
+12 -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;
@@ -2669,6 +2674,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 +4775,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 +4932,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,