48 Commits

Author SHA1 Message Date
shuaiplus 4246e179f1 Merge branch 'pr-200' 2026-04-23 23:22:01 +08:00
shuaiplus fe8d9e0b7d fix: harden API key authentication 2026-04-23 23:17:25 +08:00
maooyer 1147c1e013 feat(web): Add api key components 2026-04-23 23:17:25 +08:00
maooyer 31ffd98166 feat(server): Add api key handler 2026-04-23 23:17:25 +08:00
maooyer 7d7562d191 feat(server): Add api_key in backup repo 2026-04-23 23:17:25 +08:00
maooyer d6e5a1c40b feat(server): Add the field api_key at the database 2026-04-23 23:17:25 +08:00
shuaiplus 77794e43ce feat: remove unused styles for select input in dark theme 2026-04-22 23:50:25 +08:00
shuaiplus b990f17a3e Add new styles for app shell, tokens, and vault components
- Introduced `shell.css` for the main application layout, including styles for the app shell, top bar, and user interactions.
- Created `tokens.css` to define CSS variables for theming, including colors, shadows, and transition durations for light and dark modes.
- Developed `vault.css` for the vault component, implementing grid layouts, sidebar styles, search inputs, and list item designs.
2026-04-22 23:44:51 +08:00
shuaiplus 31b8ec6f7d feat: update VaultListPanel styles for improved item display and adjust row height for better layout 2026-04-22 21:39:15 +08:00
shuaiplus ef47597be5 feat: update website branding with new logo and wordmark, enhance styles for better responsiveness 2026-04-18 21:44:27 +08:00
shuaiplus 408874ac05 feat: update version to 1.4.4 in package.json, package-lock.json, and app-version.ts 2026-04-18 04:02:49 +08:00
shuaiplus dabd2c923e feat: optimize attachment handling in backup process 2026-04-18 03:55:27 +08:00
shuaiplus 08414d7cf2 feat: add support for new cipher properties and enhance import functionality 2026-04-18 03:44:17 +08:00
shuaiplus 38b33df719 feat: add password history feature with dialog and encryption handling 2026-04-18 02:05:01 +08:00
shuaiplus 7ebd12fa07 feat: add device note and last seen tracking to devices, enhance device management features 2026-04-18 01:43:21 +08:00
entsalze f7cbdaf730 feat: update NodeWarden logo image 2026-04-17 15:19:54 +08:00
shuaiplus 6cae5cb218 feat: update version to 1.4.3 in package.json, package-lock.json, and app-version.ts 2026-04-16 23:01:20 +08:00
shuaiplus d96ad9bb1c Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-04-16 22:30:01 +08:00
shuaiplus 92d1f07998 feat: enhance cipher handling with nested object merging and additional fields 2026-04-16 22:29:55 +08:00
maooyer a8432ab94b feat: add issue templates 2026-04-13 00:31:21 +08:00
shuaiplus 2230f75d8a feat: add loading state management for TOTP and import/export operations 2026-04-09 23:27:40 +08:00
shuaiplus a982a5a57b feat: enhance database indexing and optimize sync response handling 2026-04-09 23:05:00 +08:00
github-actions[bot] 4d7ee2164a chore: restore sync-upstream workflow after sync 2026-04-09 17:23:42 +08:00
shuaiplus 34d4851981 feat: add links to documentation homepage and quick start in README 2026-04-09 17:05:52 +08:00
shuaiplus 4827a4958e Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-04-09 16:50:49 +08:00
shuaiplus 70463d3fc7 feat: add Telegram channel and group links to README files 2026-04-09 16:50:43 +08:00
shuaiplus 681705ee13 feat: add passkey deletion functionality and related UI components 2026-04-08 14:47:53 +08:00
shuaiplus 5bf7c79ada feat: add FIDO2 credentials support in cipher handling and UI components 2026-04-08 14:40:49 +08:00
shuaiplus c516194d54 feat: implement web session handling and enhance token management 2026-04-07 22:14:26 +08:00
shuaiplus 53231a4878 feat: enhance backup progress handling and improve user status toggling 2026-04-07 20:58:23 +08:00
shuaiplus c9e7417825 feat: add timezone support for backup file naming and extraction 2026-04-07 20:24:28 +08:00
shuaiplus 76623d7201 Refactor: Remove passkey-related functionality and types
- Deleted passkey-related interfaces and types from index.ts and types.ts.
- Removed passkey handling from App component, including related state and functions.
- Cleaned up API calls in auth.ts, removing passkey registration and login functions.
- Updated vault and import formats to eliminate passkey references.
- Removed passkey support checks and UI elements from AuthViews and SettingsPage.
- Cleaned up unused passkey helper functions and constants.
- Adjusted related components and hooks to ensure consistent functionality without passkey support.
2026-04-06 00:46:13 +08:00
shuaiplus 90a7731351 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-04-01 23:05:47 +08:00
shuaiplus f4adeb8ec9 fix: enhance QR code visibility with background and border adjustments 2026-04-01 23:05:44 +08:00
saleacy bb0b82f838 Update folderId assignment to include c.folderId
修复导入数据时选择指定文件夹未生效的BUG。
2026-04-01 22:54:56 +08:00
qaz741wsd856 be82c953d6 feat: add request URL normalization 2026-03-31 10:42:57 +08:00
Shuai edd2ba2e44 refine passkey settings list, rename and delete UX 2026-03-31 01:24:12 +08:00
Shuai 0f6da7d147 feat: add passkey-first login and management flow 2026-03-31 01:24:12 +08:00
Shuai 1184cb8d9a feat(vault): add folder rename action in sidebar 2026-03-31 00:29:34 +08:00
shuaiplus 882fa2e8c8 chore: ignore local wiki repo 2026-03-29 01:07:28 +08:00
shuaiplus b6b7e46f79 feat: Update README 2026-03-29 01:00:33 +08:00
shuaiplus 144d3d9406 feat: add decodeIncomingMessage function and improve webSocketMessage handling 2026-03-28 15:28:46 +08:00
qaz741wsd856 10707cf902 refactor: refactor NotificationsHub to use hibernation api
- Updated NotificationsHub class to extend DurableObject.
- Persisted connection state into attachment instead of memory.
- Removed unnecessary ping functions & server-side periodic ping logic and added auto response which integrated into the WebSocket lifecycle.
- Added echo for binary ws messages (for keeplive of MessagePack).
- Added ping timer functionality in the App component to manage WebSocket connections more effectively.
2026-03-28 14:56:40 +08:00
shuaiplus 3bd4f6a9fe feat: enhance error handling for remote attachment index loading 2026-03-28 14:51:58 +08:00
shuaiplus 3d4e95ef66 feat: update version to 1.4.2 across package files 2026-03-28 06:01:07 +08:00
shuaiplus 2a7879efaa feat: enhance backup and restore functionality with integrity checks and progress tracking
- Added support for backup integrity verification during export and restore processes.
- Introduced progress dispatching for backup export and restore operations.
- Implemented new API endpoints for inspecting remote backup integrity.
- Enhanced user interface with progress indicators and warning dialogs for integrity issues.
- Updated localization strings for new features and user feedback.
- Refactored backup-related functions for better clarity and maintainability.
2026-03-28 05:52:47 +08:00
shuaiplus bd8e26d2ab feat: add search clear functionality and improve search input styling 2026-03-28 01:58:47 +08:00
shuaiplus 783fcbbe4b feat: add normalization functions for optional IDs and public keys in cipher and user decryption handling 2026-03-28 01:18:40 +08:00
102 changed files with 9211 additions and 5579 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:
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
target_commit:
description: 'Commit hash (leave blank to use latest commit)'
required: false
type: string
permissions:
contents: write
@@ -11,9 +16,8 @@ permissions:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -22,13 +26,118 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Sync main from upstream
- name: Add upstream
run: |
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
git fetch upstream
git checkout main
git merge upstream/main
git fetch upstream --tags
- name: Push synced main
- name: Resolve target commit
id: resolve
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/
nodewarden.wiki/
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 472 KiB

+22 -11
View File
@@ -13,6 +13,10 @@
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
[文档首页](./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)
> **免责声明**
@@ -52,19 +56,26 @@ English: [`README_EN.md`](./README_EN.md)
## 网页部署
1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可用 KV 来代替,将**部署命令**改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接
| 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|---|---|---|---|
| R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
| KV | 不需要 | 25 MiBCloudflare限制) | 1 GB |
1. Fork `NodeWarden` 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers 创建页面](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 `Continue with GitHub`
4. 选择你刚刚 Fork 的仓库
5. 保持默认配置继续部署
6. 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
7. 等部署完成后,打开生成的 Workers 域名
8. 根据页面提示设置`JWT_SECRET` ,不建议临时乱填。这个值直接关系到令牌签发安全,正式环境至少使用 32 个字符以上的随机字符串。
> [!TIP]
> 同步方法(更新仓库)
>- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
>- 自动:进入你的 Fork 仓库 ➜ `Actions``Sync upstream``Enable workflow`,会在每天凌晨 3 点自动同步上游。
> 默认R2与可选KV的区别
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
> |---|---|---|---|
> | 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">
A third-party Bitwarden-compatible server running on Cloudflare Workers.
</p>
[![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)
[![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)
[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)
English: [`README.md`](./README.md)
[Telegram Channel](https://t.me/NodeWarden_News) | [Telegram Group](https://t.me/NodeWarden_Official)
中文说明:[`README.md`](./README.md)
> **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.
---
## Feature Comparison with Official Bitwarden Server
## Feature Comparison with the Official Bitwarden Server
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| 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 |
| Send | ✅ | ✅ | Supports both text and file Sends |
| 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** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration |
@@ -46,19 +46,20 @@ English: [`README.md`](./README.md)
- ✅ Mobile app
- ✅ Browser extension
- ✅ Linux desktop client
- ⚠️ macOS desktop client not fully verified
- ⚠️ macOS desktop client has not been fully verified yet
---
## Web Deploy
1. Fork this repository. If this project helps you, please 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.
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`.
1. Fork this repository. If this project helps you, consider giving it a Star.
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
4. Deploy and open the generated URL.
| Storage | Card required | Single attachment / Send file limit | Free tier |
|---|---|---|---|
| R2 | Yes | 100 MB (soft limit, can be adjusted) | 10 GB |
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
> [!TIP]
@@ -71,7 +72,6 @@ English: [`README.md`](./README.md)
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm install
npx wrangler login
@@ -92,13 +92,13 @@ npm run dev:kv
- Remote backup supports **WebDAV** and **E3**
- When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json`
- real attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of uploading everything again
- the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- During remote restore:
- required attachment files are loaded from `attachments/`
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
- required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
---
@@ -110,7 +110,7 @@ Current supported import sources include:
- Bitwarden CSV
- Bitwarden vault + attachments ZIP
- 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:
@@ -130,9 +130,9 @@ LGPL-3.0 License
## Credits
- [Bitwarden](https://bitwarden.com/) - original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
- [Bitwarden](https://bitwarden.com/) - Original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
---
+6
View File
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS users (
verify_devices INTEGER NOT NULL DEFAULT 1,
totp_secret TEXT,
totp_recovery_code TEXT,
api_key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -59,6 +60,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_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_updated ON ciphers(user_id, deleted_at, updated_at);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
@@ -106,6 +108,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_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 (
token TEXT PRIMARY KEY,
@@ -151,12 +154,15 @@ CREATE TABLE IF NOT EXISTS devices (
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
device_note TEXT,
last_seen_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_identifier),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
token TEXT PRIMARY KEY,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "nodewarden",
"version": "1.4.1",
"version": "1.4.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodewarden",
"version": "1.4.1",
"version": "1.4.4",
"license": "LGPL-3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.4.1",
"version": "1.4.4",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.4.1';
export const APP_VERSION = '1.4.4';
+6
View File
@@ -24,6 +24,9 @@
// Default PBKDF2 iterations for account creation/prelogin fallback.
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
defaultKdfIterations: 600000,
// clientSecret length
// clientSecret 长度
clientSecretLength: 30,
},
rateLimit: {
// Max failed login attempts before temporary lock.
@@ -130,6 +133,9 @@
// Max total items (folders + ciphers) allowed in a single import.
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
importItemLimit: 5000,
// Small fixed concurrency for blob/attachment batch cleanup work.
// 附件 / blob 批量清理时的保守并发数。
attachmentDeleteConcurrency: 4,
},
request: {
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
+174 -135
View File
@@ -1,3 +1,4 @@
import { DurableObject } from 'cloudflare:workers';
import type { Env } from '../types';
const SIGNALR_RECORD_SEPARATOR = 0x1e;
@@ -5,11 +6,12 @@ const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARAT
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
const SIGNALR_PING_INTERVAL_MS = 15_000;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
type HubProtocol = 'json' | 'messagepack';
interface ConnectionState {
interface WsAttachment {
userId: string;
handshakeComplete: boolean;
protocol: HubProtocol;
deviceIdentifier: string | null;
@@ -30,6 +32,12 @@ function encodeUtf8(value: string): Uint8Array {
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 {
const normalized = Math.trunc(value);
if (normalized >= 0 && normalized <= 0x7f) {
@@ -127,40 +135,31 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array {
}
function buildSignalRJsonInvocation(
userId: string,
updateType: number,
revisionDate: string,
payload: Record<string, unknown>,
contextId: string | null
): string {
return JSON.stringify({
type: 1,
target: 'ReceiveMessage',
arguments: [
{
ContextId: contextId,
Type: updateType,
Payload: {
UserId: userId,
Date: revisionDate,
{
ContextId: contextId,
Type: updateType,
Payload: payload,
},
},
],
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
}
function buildSignalRJsonPing(): string {
return JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
],
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
}
function buildSignalRMessagePackInvocation(
userId: string,
updateType: number,
revisionDate: string,
messagePayload: Record<string, unknown>,
contextId: string | null
): Uint8Array {
// SignalR MessagePack hub protocol uses an array-based invocation shape:
// [type, headers, invocationId, target, arguments]
const payload = encodeMsgPack([
const encodedPayload = encodeMsgPack([
1,
{},
null,
@@ -169,34 +168,22 @@ function buildSignalRMessagePackInvocation(
{
ContextId: contextId,
Type: updateType,
Payload: {
UserId: userId,
Date: new Date(revisionDate),
},
Payload: messagePayload,
},
],
]);
return frameSignalRBinary(payload);
return frameSignalRBinary(encodedPayload);
}
function buildSignalRMessagePackPing(): Uint8Array {
return frameSignalRBinary(encodeMsgPack([6]));
}
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));
}
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;
export class NotificationsHub extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx.setWebSocketAutoResponse(
new WebSocketRequestResponsePair(
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR),
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR)
)
);
}
async fetch(request: Request): Promise<Response> {
@@ -209,13 +196,20 @@ export class NotificationsHub {
contextId?: string | null;
updateType?: number;
targetDeviceIdentifier?: string | null;
payload?: Record<string, unknown> | null;
} | null;
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 updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
this.broadcastMessage(updateType, revisionDate, contextId, targetDeviceIdentifier);
const payload = body?.payload && typeof body.payload === 'object'
? body.payload
: {
UserId: userId,
Date: revisionDate,
};
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
return new Response(null, { status: 204 });
}
@@ -238,46 +232,27 @@ export class NotificationsHub {
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
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 });
}
const pair = new WebSocketPair();
const client = pair[0];
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,
protocol: 'messagepack',
deviceIdentifier: requestDeviceIdentifier,
});
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
}
});
} satisfies WsAttachment);
return new Response(null, {
status: 101,
@@ -285,21 +260,21 @@ export class NotificationsHub {
});
}
private async handleSocketMessage(socket: WebSocket, rawData: string | ArrayBuffer | ArrayBufferView): Promise<void> {
const connection = this.connections.get(socket);
if (!connection) return;
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!attachment) return;
if (!connection.handshakeComplete) {
const text = decodeIncomingMessage(rawData);
if (!attachment.handshakeComplete) {
const text = decodeIncomingMessage(message);
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
for (const frame of frames) {
try {
const handshake = JSON.parse(frame) as { protocol?: string };
const protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
connection.protocol = protocol;
connection.handshakeComplete = true;
socket.send(SIGNALR_HANDSHAKE_ACK);
this.broadcastDeviceStatus();
attachment.protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
attachment.handshakeComplete = true;
ws.serializeAttachment(attachment);
ws.send(SIGNALR_HANDSHAKE_ACK);
this.broadcastDeviceStatus(attachment.userId);
return;
} catch {
// Ignore malformed pre-handshake payloads.
@@ -307,89 +282,83 @@ export class NotificationsHub {
}
return;
}
}
private ensurePingLoop(): void {
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;
if (typeof message !== 'string') {
try {
if (connection.protocol === 'json') {
socket.send(buildSignalRJsonPing());
} else {
socket.send(buildSignalRMessagePackPing());
}
ws.send(message);
} catch {
this.connections.delete(socket);
try {
socket.close(1011, 'Ping send failed');
} catch {
// ignore close races
}
// ignore send errors on echo
}
}
}
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[] {
const out = new Set<string>();
for (const connection of this.connections.values()) {
if (!connection.handshakeComplete || !connection.deviceIdentifier) continue;
out.add(connection.deviceIdentifier);
for (const ws of this.ctx.getWebSockets()) {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
out.add(attachment.deviceIdentifier);
}
return Array.from(out);
}
private broadcastMessage(
updateType: number,
revisionDate: string,
payload: Record<string, unknown>,
contextId: string | null,
targetDeviceIdentifier: string | null
): 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 (!connection.handshakeComplete) continue;
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue;
if (sockets.length === 0) return;
for (const ws of sockets) {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!attachment?.handshakeComplete) continue;
try {
if (connection.protocol === 'json') {
socket.send(buildSignalRJsonInvocation(this.userId, updateType, revisionDate, contextId));
if (attachment.protocol === 'json') {
ws.send(buildSignalRJsonInvocation(updateType, payload, contextId));
} else {
socket.send(buildSignalRMessagePackInvocation(this.userId, updateType, revisionDate, contextId));
ws.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
}
} catch {
this.connections.delete(socket);
try {
socket.close(1011, 'Notification send failed');
ws.close(1011, 'Notification send failed');
} catch {
// ignore close races
}
}
}
this.stopPingLoopIfIdle();
}
private broadcastDeviceStatus(): void {
this.broadcastMessage(SIGNALR_UPDATE_TYPE_DEVICE_STATUS, new Date().toISOString(), null, null);
private broadcastDeviceStatus(userId: string): void {
this.broadcastMessage(
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
{
UserId: userId,
Date: new Date().toISOString(),
},
null,
null
);
}
}
@@ -445,9 +414,79 @@ async function notifyUserUpdate(
contextId: contextId || null,
updateType,
targetDeviceIdentifier: targetDeviceIdentifier || null,
payload: {
UserId: userId,
Date: revisionDate,
},
}),
});
} catch (error) {
console.error('Failed to broadcast realtime notification:', error);
}
}
export async function notifyUserBackupProgress(
env: Env,
userId: string,
progress: {
operation: 'backup-restore' | 'backup-export' | 'backup-remote-run';
source?: 'local' | 'remote';
step: string;
fileName: string;
stageTitle?: string;
stageDetail?: string;
replaceExisting?: boolean;
done?: boolean;
ok?: boolean;
error?: string | null;
timestamp?: string;
},
targetDeviceIdentifier?: string | null
): Promise<void> {
const revisionDate = progress.timestamp || new Date().toISOString();
try {
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
const stub = env.NOTIFICATIONS_HUB.get(id);
await stub.fetch('https://notifications/internal/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-NodeWarden-UserId': userId,
},
body: JSON.stringify({
revisionDate,
contextId: null,
updateType: SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS,
targetDeviceIdentifier: targetDeviceIdentifier || null,
payload: {
UserId: userId,
Date: revisionDate,
...progress,
},
}),
});
} catch (error) {
console.error('Failed to broadcast backup progress:', error);
}
}
export async function notifyUserBackupRestoreProgress(
env: Env,
userId: string,
progress: {
operation: 'backup-restore';
source: 'local' | 'remote';
step: string;
fileName: string;
stageTitle?: string;
stageDetail?: string;
replaceExisting?: boolean;
done?: boolean;
ok?: boolean;
error?: string | null;
timestamp?: string;
},
targetDeviceIdentifier?: string | null
): Promise<void> {
return notifyUserBackupProgress(env, userId, progress, targetDeviceIdentifier);
}
+68 -1
View File
@@ -87,6 +87,7 @@ async function verifyUserSecret(
function toProfile(user: User, env: Env): ProfileResponse {
void env;
const accountKeys = buildAccountKeys(user);
return {
id: user.id,
name: user.name,
@@ -100,7 +101,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys: buildAccountKeys(user),
accountKeys,
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
@@ -208,6 +209,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
verifyDevices: true,
totpSecret: null,
totpRecoveryCode: null,
apiKey: null,
createdAt: now,
updatedAt: now,
};
@@ -750,3 +752,68 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
return new Response(null, { status: 200 });
}
// POST /api/accounts/api-key
export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise<Response> {
return apiKey(request, env, userId, false);
}
// POST /api/accounts/rotate-api-key
export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise<Response> {
return apiKey(request, env, userId, true);
}
async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, string | undefined>;
try {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries()) as Record<string, string>;
} else {
body = await request.json();
}
} catch {
return errorResponse('Invalid JSON', 400);
}
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
if (rotate || user.apiKey === null) {
// Upstream apikeys are 30-character random alphanumeric strings
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
if (rotate) {
user.securityStamp = generateUUID();
await storage.deleteRefreshTokensByUserId(user.id);
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
}
return jsonResponse({
apiKey: user.apiKey,
revisionDate: user.updatedAt,
object: 'apiKey',
});
}
// Generate a random alphanumeric string of the given length using crypto.getRandomValues.
function randomStringAlphanum(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const array = new Uint8Array(length);
crypto.getRandomValues(array);
let result = '';
for (let i = 0; i < length; i++) {
result += chars[array[i] % chars.length];
}
return result;
}
+17 -10
View File
@@ -10,7 +10,7 @@ import {
verifyAttachmentUploadToken,
verifyFileDownloadToken,
} from '../utils/jwt';
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
import { cipherToResponse } from './ciphers';
import { LIMITS } from '../config/limits';
import { readActingDeviceIdentifier } from '../utils/device';
import {
@@ -38,6 +38,18 @@ function formatSize(bytes: number): string {
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(
request: Request,
env: Env,
@@ -158,9 +170,7 @@ export async function handleCreateAttachment(
attachmentId: attachmentId,
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
fileUploadType: 1,
cipherResponse: cipherToResponse(updatedCipher!, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
}),
cipherResponse: cipherToResponse(updatedCipher!, attachments),
});
}
@@ -372,9 +382,7 @@ export async function handleDeleteAttachment(
const attachments = await storage.getAttachmentsByCipher(cipherId);
return jsonResponse({
cipher: cipherToResponse(updatedCipher!, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
}),
cipher: cipherToResponse(updatedCipher!, attachments),
});
}
@@ -385,10 +393,9 @@ export async function deleteAllAttachmentsForCipher(
): Promise<void> {
const storage = new StorageService(env.DB);
const attachments = await storage.getAttachmentsByCipher(cipherId);
for (const attachment of attachments) {
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => {
const path = getAttachmentObjectKey(cipherId, attachment.id);
await deleteBlobObject(env, path);
await storage.deleteAttachment(attachment.id);
}
});
}
+356 -26
View File
@@ -1,7 +1,12 @@
import type { Env, User } from '../types';
import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { type BackupArchiveBundle, buildBackupArchive } from '../services/backup-archive';
import {
type BackupArchiveBundle,
buildBackupArchive,
inspectBackupArchiveFileNameChecksum,
verifyBackupArchiveFileNameChecksum,
} from '../services/backup-archive';
import {
type BackupDestinationRecord,
type BackupSettingsInput,
@@ -17,19 +22,25 @@ import {
requireBackupDestination,
saveBackupSettings,
} from '../services/backup-config';
import { type BackupImportExecutionResult, importBackupArchiveBytes, importRemoteBackupArchiveBytes } from '../services/backup-import';
import {
type BackupImportExecutionResult,
type BackupRestoreProgressReporter,
importBackupArchiveBytes,
importRemoteBackupArchiveBytes,
} from '../services/backup-import';
import {
type RemoteBackupTransferSession,
createRemoteBackupTransferSession,
deleteRemoteBackupFile,
downloadRemoteBackupFile,
ensureRemoteRestoreCandidate,
listRemoteBackupEntries,
pruneRemoteBackupArchives,
remoteBackupFileExists,
uploadRemoteBackupFile,
uploadBackupArchive,
} from '../services/backup-uploader';
import { StorageService } from '../services/storage';
import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
@@ -81,13 +92,86 @@ function ensureBackupBlobName(value: string): string {
return parts.join('/');
}
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
interface RemoteAttachmentIndexPayload {
version: 1;
blobs: Record<string, { sizeBytes: number; updatedAt: string }>;
}
async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession): Promise<Map<string, number>> {
try {
const file = await session.download(REMOTE_ATTACHMENT_INDEX_PATH);
const payload = JSON.parse(new TextDecoder().decode(file.bytes)) as RemoteAttachmentIndexPayload;
if (payload?.version !== 1 || !payload.blobs || typeof payload.blobs !== 'object') {
return new Map<string, number>();
}
return new Map(
Object.entries(payload.blobs)
.filter(([key, value]) => !!String(key || '').trim() && Number.isFinite(Number(value?.sizeBytes || 0)))
.map(([key, value]) => [key, Number(value.sizeBytes || 0)])
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
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>();
}
throw error;
}
}
async function saveRemoteAttachmentIndex(
session: RemoteBackupTransferSession,
index: Map<string, number>
): Promise<void> {
const payload: RemoteAttachmentIndexPayload = {
version: 1,
blobs: Object.fromEntries(
Array.from(index.entries()).map(([blobName, sizeBytes]) => [
blobName,
{
sizeBytes,
updatedAt: new Date().toISOString(),
},
])
),
};
const bytes = new TextEncoder().encode(JSON.stringify(payload));
await session.putFile(REMOTE_ATTACHMENT_INDEX_PATH, bytes, {
contentType: 'application/json; charset=utf-8',
});
}
async function executeConfiguredBackup(
env: Env,
storage: StorageService,
actorUserId: string | null,
trigger: 'manual' | 'scheduled',
destinationId?: string | null
destinationId?: string | null,
progress?: ((event: {
operation: 'backup-remote-run';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
done?: boolean;
ok?: boolean;
error?: string | null;
}) => Promise<void>) | null
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
const maxArchiveUploadAttempts = 3;
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
const destination = requireBackupDestination(currentSettings, destinationId);
@@ -99,25 +183,112 @@ async function executeConfiguredBackup(
await saveBackupSettings(storage, env, currentSettings);
try {
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_prepare',
fileName: '',
stageTitle: 'txt_backup_remote_run_progress_prepare_title',
stageDetail: 'txt_backup_remote_run_progress_prepare_detail',
});
const archive = await buildBackupArchive(env, now, {
includeAttachments: destination.includeAttachments,
timeZone: destination.schedule.timezone,
progress: progress
? async (event) => {
if (event.step === 'archive_ready') {
return;
}
await progress({
operation: 'backup-remote-run',
step: `remote_run_${event.step}`,
fileName: event.fileName || '',
stageTitle: event.stageTitle,
stageDetail: event.stageDetail,
});
}
: undefined,
});
for (const attachment of archive.manifest.attachmentBlobs || []) {
const remotePath = `attachments/${attachment.blobName}`;
if (await remoteBackupFileExists(destination, remotePath)) continue;
const object = await getBlobObject(env, attachment.blobName);
if (!object) {
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_sync_attachments',
fileName: archive.fileName,
stageTitle: 'txt_backup_remote_run_progress_sync_attachments_title',
stageDetail: destination.includeAttachments
? 'txt_backup_remote_run_progress_sync_attachments_detail'
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
});
const remoteSession = createRemoteBackupTransferSession(destination);
if (destination.includeAttachments) {
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
let attachmentIndexChanged = false;
for (const attachment of archive.manifest.attachmentBlobs || []) {
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
continue;
}
const remotePath = `attachments/${attachment.blobName}`;
const object = await getBlobObject(env, attachment.blobName);
if (!object) {
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
}
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
await remoteSession.putFile(remotePath, bytes, {
contentType: object.contentType,
});
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
attachmentIndexChanged = true;
}
if (attachmentIndexChanged) {
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
}
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
await uploadRemoteBackupFile(destination, remotePath, bytes, {
contentType: object.contentType,
});
}
const upload = await uploadBackupArchive(destination, archive.bytes, archive.fileName);
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_upload_archive',
fileName: archive.fileName,
stageTitle: 'txt_backup_remote_run_progress_upload_title',
stageDetail: 'txt_backup_remote_run_progress_upload_detail',
});
upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName);
try {
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_verify_archive',
fileName: archive.fileName,
stageTitle: 'txt_backup_remote_run_progress_verify_title',
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
});
const remoteFile = await remoteSession.download(archive.fileName);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
if (!checksumOk) {
throw new Error('Remote backup ZIP checksum verification failed');
}
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
throw new Error('Remote backup ZIP size verification failed');
}
break;
} catch (error) {
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
if (attempt === maxArchiveUploadAttempts) {
const message = error instanceof Error ? error.message : 'Remote backup ZIP verification failed';
throw new Error(`Backup archive upload verification failed after ${maxArchiveUploadAttempts} attempts: ${message}`);
}
}
}
if (!upload) {
throw new Error('Backup archive upload failed');
}
let prunedFileCount = 0;
let pruneErrorMessage: string | null = null;
try {
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_cleanup',
fileName: archive.fileName,
stageTitle: 'txt_backup_remote_run_progress_cleanup_title',
stageDetail: 'txt_backup_remote_run_progress_cleanup_detail',
});
prunedFileCount = await pruneRemoteBackupArchives(destination, destination.schedule.retentionCount, archive.fileName);
} catch (error) {
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
@@ -137,10 +308,21 @@ async function executeConfiguredBackup(
remotePath: upload.remotePath,
fileName: archive.fileName,
fileBytes: archive.bytes.byteLength,
uploadVerificationAttempts: maxArchiveUploadAttempts,
prunedFileCount,
pruneError: pruneErrorMessage,
});
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_complete',
fileName: archive.fileName,
stageTitle: 'txt_backup_remote_run_progress_complete_title',
stageDetail: 'txt_backup_remote_run_progress_complete_detail',
done: true,
ok: true,
});
return {
fileName: archive.fileName,
fileSize: archive.bytes.byteLength,
@@ -156,6 +338,16 @@ async function executeConfiguredBackup(
...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage,
});
await progress?.({
operation: 'backup-remote-run',
step: 'remote_run_failed',
fileName: '',
stageTitle: 'txt_backup_remote_run_progress_failed_title',
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
done: true,
ok: false,
error: destination.runtime.lastErrorMessage,
});
throw error;
}
}
@@ -170,13 +362,35 @@ function toImportStatusCode(message: string): number {
async function runImportAndAudit(
env: Env,
request: Request,
actorUser: User,
archiveBytes: Uint8Array,
fileName: string,
replaceExisting: boolean,
metadata: Record<string, unknown>
): Promise<BackupImportExecutionResult> {
const storage = new StorageService(env.DB);
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting);
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
const progress: BackupRestoreProgressReporter = async (event) => {
await notifyUserBackupRestoreProgress(
env,
actorUser.id,
{
operation: 'backup-restore',
...event,
},
targetDeviceIdentifier
);
};
await progress({
source: 'local',
step: 'local_upload_received',
fileName,
stageTitle: 'txt_backup_restore_progress_local_upload_title',
stageDetail: 'txt_backup_restore_progress_local_upload_detail',
replaceExisting,
});
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting, progress, fileName);
await writeAuditLog(storage, imported.auditActorUserId, 'admin.backup.import', 'backup', null, {
users: imported.result.imported.users,
ciphers: imported.result.imported.ciphers,
@@ -309,7 +523,20 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
return errorResponse('Backup run payload is invalid', 400);
}
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null);
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
const progress = async (event: {
operation: 'backup-remote-run';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
done?: boolean;
ok?: boolean;
error?: string | null;
}) => {
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
};
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null, progress);
const settings = await loadBackupSettings(storage, env, 'UTC');
return jsonResponse({
object: 'backup-run',
@@ -369,6 +596,29 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
}
}
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const remoteFile = await downloadRemoteBackupFile(destination, path);
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
return jsonResponse({
object: 'backup-remote-integrity',
destinationId: destination.id,
path,
fileName: remoteFile.fileName || path.split('/').pop() || path,
integrity,
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Remote backup integrity inspection failed', 409);
}
}
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
@@ -392,7 +642,7 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; replaceExisting?: boolean };
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
try {
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
} catch {
@@ -404,7 +654,39 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
const settings = await loadBackupSettings(storage, env, 'UTC');
const destination = requireBackupDestination(settings, body.destinationId || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
const restoreFileNameFromPath = path.split('/').pop() || path;
await notifyUserBackupRestoreProgress(
env,
actorUser.id,
{
operation: 'backup-restore',
source: 'remote',
step: 'remote_fetch_archive',
fileName: restoreFileNameFromPath,
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
replaceExisting: !!body.replaceExisting,
},
targetDeviceIdentifier
);
const remoteFile = await downloadRemoteBackupFile(destination, path);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
if (!checksumOk && !body.allowChecksumMismatch) {
return errorResponse('Remote backup file checksum does not match its filename', 400);
}
const restoreFileName = remoteFile.fileName || path.split('/').pop() || path;
const progress: BackupRestoreProgressReporter = async (event) => {
await notifyUserBackupRestoreProgress(
env,
actorUser.id,
{
operation: 'backup-restore',
...event,
},
targetDeviceIdentifier
);
};
const imported = await (async () => {
const storage = new StorageService(env.DB);
const result = await importRemoteBackupArchiveBytes(
@@ -413,12 +695,13 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
actorUser.id,
!!body.replaceExisting,
{
hasAttachment: async (blobName) => remoteBackupFileExists(destination, `attachments/${blobName}`),
loadAttachment: async (blobName) => {
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
return file?.bytes || null;
},
}
loadAttachment: async (blobName) => {
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
return file?.bytes || null;
},
},
progress,
restoreFileName
);
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
users: result.result.imported.users,
@@ -431,6 +714,7 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
remotePath: path,
bytes: remoteFile.bytes.byteLength,
trigger: 'remote',
checksumMismatchAccepted: !checksumOk,
});
return result;
})();
@@ -445,6 +729,7 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const storage = new StorageService(env.DB);
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
let body: { includeAttachments?: boolean } | null = null;
try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
@@ -455,11 +740,49 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
}
let archive: BackupArchiveBundle;
try {
const progress = async (event: {
step: string;
fileName?: string;
stageTitle: string;
stageDetail: string;
includeAttachments: boolean;
}) => {
await notifyUserBackupProgress(
env,
actorUser.id,
{
operation: 'backup-export',
source: 'local',
step: `export_${event.step}`,
fileName: event.fileName || '',
stageTitle: event.stageTitle,
stageDetail: event.stageDetail,
},
targetDeviceIdentifier
);
};
archive = await buildBackupArchive(env, new Date(), {
includeAttachments: !!body?.includeAttachments,
progress,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Backup export failed';
await notifyUserBackupProgress(
env,
actorUser.id,
{
operation: 'backup-export',
source: 'local',
step: 'export_failed',
fileName: '',
stageTitle: 'txt_backup_export_progress_failed_title',
stageDetail: 'txt_backup_export_progress_failed_detail',
done: true,
ok: false,
error: message,
},
targetDeviceIdentifier
);
return errorResponse(message, message.includes('blob missing') ? 409 : 500);
}
@@ -520,6 +843,7 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
}
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
let archiveBytes: Uint8Array;
try {
archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise<ArrayBuffer> }).arrayBuffer());
@@ -528,9 +852,15 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
}
try {
const imported = await runImportAndAudit(env, actorUser, archiveBytes, replaceExisting, {
const fileName = 'name' in file ? String((file as File).name || '') : '';
const checksumOk = await verifyBackupArchiveFileNameChecksum(archiveBytes, fileName);
if (!checksumOk && !allowChecksumMismatch) {
return errorResponse('Backup file checksum does not match its filename', 400);
}
const imported = await runImportAndAudit(env, request, actorUser, archiveBytes, fileName || 'nodewarden_backup.zip', replaceExisting, {
trigger: 'local',
bytes: archiveBytes.byteLength,
checksumMismatchAccepted: !checksumOk,
});
return jsonResponse(imported.result);
} catch (error) {
+116 -113
View File
@@ -1,4 +1,15 @@
import { Env, Cipher, CipherResponse, Attachment } from '../types';
import {
Env,
Cipher,
CipherCard,
CipherIdentity,
CipherLogin,
CipherResponse,
CipherSecureNote,
CipherSshKey,
Attachment,
PasswordHistory,
} from '../types';
import { StorageService } from '../services/storage';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { jsonResponse, errorResponse } from '../utils/response';
@@ -7,6 +18,12 @@ import { deleteAllAttachmentsForCipher } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device';
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
async function notifyVaultSyncForRequest(
request: Request,
env: Env,
@@ -26,6 +43,10 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
return { present: false, value: undefined };
}
function readCipherProp<T = unknown>(source: any, aliases: string[]): { present: boolean; value: T | undefined } {
return getAliasedProp(source, aliases) as { present: boolean; value: T | undefined };
}
function normalizeCipherTimestamp(value: unknown): string | null {
if (value == null || value === '') return null;
const parsed = new Date(String(value));
@@ -38,6 +59,19 @@ function readCipherArchivedAt(source: any, fallback: string | null = null): stri
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
}
function readCipherRevisionDate(source: any): string | null {
const revision = getAliasedProp(source, ['lastKnownRevisionDate', 'LastKnownRevisionDate']);
return revision.present ? normalizeCipherTimestamp(revision.value) : null;
}
function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: string | null): boolean {
if (!clientRevisionDate) return false;
const existingTs = Date.parse(existingUpdatedAt);
const clientTs = Date.parse(clientRevisionDate);
if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false;
return existingTs - clientTs > 1000;
}
function syncCipherComputedAliases(cipher: Cipher): Cipher {
cipher.archivedDate = cipher.archivedAt ?? null;
cipher.deletedDate = cipher.deletedAt ?? null;
@@ -47,6 +81,7 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher {
function normalizeCipherForStorage(cipher: Cipher): Cipher {
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
cipher.folderId = normalizeOptionalId(cipher.folderId);
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
cipher.archivedAt = hasArchivedAt
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
@@ -54,80 +89,18 @@ function normalizeCipherForStorage(cipher: Cipher): 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 {
if (!login || typeof login !== 'object') return login ?? null;
return {
...login,
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
};
}
export function normalizeCipherLoginForCompatibility(
login: any,
options?: { omitFido2Credentials?: boolean }
): any {
export function normalizeCipherLoginForCompatibility(login: any): any {
const normalized = normalizeCipherLoginForStorage(login);
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
if (!options?.omitFido2Credentials) 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;
return normalized;
}
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
@@ -173,21 +146,21 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
// survive a round-trip without code changes.
export function cipherToResponse(
cipher: Cipher,
attachments: Attachment[] = [],
options?: { omitFido2Credentials?: boolean }
attachments: Attachment[] = []
): CipherResponse {
// Strip internal-only fields that must not appear in the API response
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);
return {
// Pass through ALL stored cipher fields (known + unknown)
...passthrough,
// Server-computed / enforced fields (always override)
folderId: normalizeOptionalId(cipher.folderId),
type: Number(cipher.type) || 1,
organizationId: null,
organizationUseTotp: false,
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
creationDate: createdAt,
revisionDate: updatedAt,
deletedDate: deletedAt,
@@ -198,12 +171,12 @@ export function cipherToResponse(
delete: true,
restore: true,
},
object: 'cipher',
collectionIds: [],
object: 'cipherDetails',
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
attachments: formatAttachments(attachments),
login: normalizedLogin,
sshKey: normalizedSshKey,
encryptedFor: null,
encryptedFor: (passthrough as any).encryptedFor ?? null,
};
}
@@ -213,7 +186,6 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const url = new URL(request.url);
const includeDeleted = url.searchParams.get('deleted') === 'true';
const pagination = parsePagination(url);
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
let filteredCiphers: Cipher[];
let continuationToken: string | null = null;
@@ -234,13 +206,15 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
: 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
const cipherResponses = [];
// Build responses only for the current page to keep pagination cheap.
const cipherResponses: CipherResponse[] = [];
for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
cipherResponses.push(cipherToResponse(cipher, attachments));
}
return jsonResponse({
@@ -261,9 +235,7 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
cipherToResponse(cipher, attachments)
);
}
@@ -287,6 +259,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
// Handle nested cipher object (from some clients)
// Android client sends PascalCase "Cipher" for organization ciphers
const cipherData = body.Cipher || body.cipher || body;
const createFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
const createKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
const createLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
const createCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const now = new Date().toISOString();
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
@@ -304,6 +284,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
archivedAt: readCipherArchivedAt(cipherData, null),
deletedAt: null,
};
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null);
cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
normalizeCipherForStorage(cipher);
@@ -319,9 +307,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
}),
cipherToResponse(cipher, []),
200
);
}
@@ -345,6 +331,21 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Handle nested cipher object
// Android client sends PascalCase "Cipher" for organization ciphers
const cipherData = body.Cipher || body.cipher || body;
const incomingFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
const incomingKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
const incomingLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
const incomingCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData);
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
}
const nextType = Number(cipherData.type) || existingCipher.type;
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
@@ -354,7 +355,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Server-controlled fields (never from client)
id: existingCipher.id,
userId: existingCipher.userId,
type: Number(cipherData.type) || existingCipher.type,
type: nextType,
favorite: cipherData.favorite ?? existingCipher.favorite,
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt,
@@ -362,6 +363,20 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt,
};
if (incomingFolderId.present) {
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
}
if (incomingKey.present) {
cipher.key = incomingKey.value ?? null;
}
cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null;
cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
if (incomingPasswordHistory.present) {
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
}
// Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields".
@@ -384,11 +399,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
cipherToResponse(cipher, attachments)
);
}
@@ -410,9 +424,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
cipherToResponse(cipher, [])
);
}
@@ -476,9 +488,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
cipherToResponse(cipher, [])
);
}
@@ -499,11 +509,12 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
}
if (body.folderId !== undefined) {
if (body.folderId) {
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
const folderId = normalizeOptionalId(body.folderId);
if (folderId) {
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
cipher.folderId = body.folderId;
cipher.folderId = folderId;
}
if (body.favorite !== undefined) {
cipher.favorite = body.favorite;
@@ -516,9 +527,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [], {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
cipherToResponse(cipher, [])
);
}
@@ -537,12 +546,13 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
return errorResponse('ids array is required', 400);
}
if (body.folderId) {
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
const folderId = normalizeOptionalId(body.folderId);
if (folderId) {
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
const revisionDate = await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
const revisionDate = await storage.bulkMoveCiphers(body.ids, folderId, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
@@ -558,13 +568,10 @@ async function buildCipherListResponse(
): Promise<Response> {
const ciphers = await storage.getCiphersByIds(ids, userId);
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
return jsonResponse({
data: ciphers.map((cipher) =>
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], {
omitFido2Credentials,
})
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])
),
object: 'list',
continuationToken: null,
@@ -597,9 +604,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
cipherToResponse(cipher, attachments)
);
}
@@ -621,9 +626,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
cipherToResponse(cipher, attachments, {
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
})
cipherToResponse(cipher, attachments)
);
}
+47 -4
View File
@@ -23,13 +23,18 @@ function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPub
}
function buildDeviceResponse(device: Device): DeviceResponse {
const displayName = String(device.deviceNote || '').trim() || device.name;
const response = {
Id: device.deviceIdentifier,
id: device.deviceIdentifier,
UserId: device.userId,
userId: device.userId,
Name: device.name,
name: device.name,
Name: displayName,
name: displayName,
SystemName: device.name,
systemName: device.name,
DeviceNote: device.deviceNote,
deviceNote: device.deviceNote,
Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier,
Type: device.type,
@@ -38,6 +43,10 @@ function buildDeviceResponse(device: Device): DeviceResponse {
creationDate: device.createdAt,
RevisionDate: device.updatedAt,
revisionDate: device.updatedAt,
LastSeenAt: device.lastSeenAt,
lastSeenAt: device.lastSeenAt,
HasStoredDevice: true,
hasStoredDevice: true,
IsTrusted: isTrustedDevice(device),
isTrusted: isTrustedDevice(device),
EncryptedUserKey: device.encryptedUserKey,
@@ -55,8 +64,12 @@ function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireRespon
const response = {
Id: device.deviceIdentifier,
id: device.deviceIdentifier,
Name: device.name,
name: device.name,
Name: String(device.deviceNote || '').trim() || device.name,
name: String(device.deviceNote || '').trim() || device.name,
SystemName: device.name,
systemName: device.name,
DeviceNote: device.deviceNote,
deviceNote: device.deviceNote,
Identifier: device.deviceIdentifier,
identifier: device.deviceIdentifier,
Type: device.type,
@@ -101,6 +114,10 @@ async function readJsonBody(request: Request): Promise<any> {
}
}
function parseDeviceName(value: unknown): string {
return String(value || '').trim().slice(0, 128);
}
// GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding
@@ -203,12 +220,15 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
encryptedPublicKey: null,
encryptedPrivateKey: null,
devicePendingAuthRequest: null,
deviceNote: null,
lastSeenAt: null,
createdAt: '',
updatedAt: '',
};
data.push({
...buildDeviceResponse(placeholderDevice),
isTrusted: true,
hasStoredDevice: false,
online: onlineSet.has(row.deviceIdentifier),
trusted: true,
trustedTokenCount: row.tokenCount,
@@ -269,6 +289,29 @@ export async function handleDeleteDevice(
return jsonResponse({ success: deleted });
}
// PUT /api/devices/:deviceIdentifier/name
export async function handleUpdateDeviceName(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const body = await readJsonBody(request);
const name = parseDeviceName(body?.name);
if (!name) return errorResponse('Device name is required', 400);
const storage = new StorageService(env.DB);
const updated = await storage.updateDeviceName(userId, normalized, name);
if (!updated) return errorResponse('Device not found', 404);
const device = await storage.getDevice(userId, normalized);
if (!device) return errorResponse('Device not found', 404);
return jsonResponse(buildDeviceResponse(device));
}
// DELETE /api/devices
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
+209 -16
View File
@@ -18,6 +18,7 @@ import {
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
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.
// Keep request parsing backward-compatible with historical provider values (8 / 100).
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
@@ -31,6 +32,66 @@ function resolveTotpSecret(userSecret: string | null): string | 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 constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0;
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
}
return diff === 0;
}
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(
email: string,
kdfType: number,
@@ -278,17 +339,19 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
const response: TokenResponse = {
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
refresh_token: refreshToken,
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: buildAccountKeys(user),
accountKeys: buildAccountKeys(user),
AccountKeys: accountKeys,
accountKeys: accountKeys,
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
@@ -301,11 +364,106 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: buildUserDecryptionOptions(user),
userDecryptionOptions: buildUserDecryptionOptions(user),
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
return jsonResponse(response);
const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'client_credentials') {
// Login with client credentials
const clientId = body.client_id;
const clientSecret = body.client_secret;
const scope = body.scope;
const deviceInfo = readAuthRequestDeviceInfo(body, request);
const loginIdentifier = `${clientIdentifier}:${clientId}`;
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
if (!parmValid) {
return identityErrorResponse('Parameter error', 'invalid_request', 400);
}
// Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) {
return identityErrorResponse(
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
}
const uid = clientId.slice(5);
const user = await storage.getUserById(uid);
if (!user) {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
}
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
}
// Persist device only after successful client credential verification.
const deviceSession =
deviceInfo.deviceIdentifier
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
: null;
if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
}
// Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
const response: TokenResponse = {
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
accountKeys: accountKeys,
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'send_access') {
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
@@ -371,14 +529,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
// Refresh token
const refreshToken = body.refresh_token;
const refreshToken = String(body.refresh_token || '').trim() || (
shouldUseWebSession(request)
? parseCookieValue(request, WEB_REFRESH_COOKIE)
: null
);
if (!refreshToken) {
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
}
const result = await auth.refreshAccessToken(refreshToken);
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
@@ -389,17 +554,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
);
const { accessToken, user, device } = result;
if (device?.identifier) {
await storage.touchDeviceLastSeen(user.id, device.identifier);
}
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
const response: TokenResponse = {
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
refresh_token: newRefreshToken,
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: buildAccountKeys(user),
accountKeys: buildAccountKeys(user),
AccountKeys: accountKeys,
accountKeys: accountKeys,
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
@@ -412,11 +582,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: buildUserDecryptionOptions(user),
userDecryptionOptions: buildUserDecryptionOptions(user),
UserDecryptionOptions: userDecryptionOptions,
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);
@@ -470,10 +643,30 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
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) {
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;
}
export function checkClientCredentialsParam(clientId: string, clientSecret: string, scope: string): boolean {
if (scope !== 'api') {
return false;
}
if (!clientId.startsWith('user.')) {
return false;
}
if (!clientSecret) {
return false;
}
return true;
}
+60 -44
View File
@@ -24,7 +24,6 @@ interface CiphersImportRequest {
password?: string | null;
totp?: string | null;
autofillOnPageLoad?: boolean | null;
fido2Credentials?: any[] | null;
uri?: string | null;
passwordRevisionDate?: string | null;
[key: string]: any;
@@ -83,6 +82,16 @@ function bindNull(v: any): any {
return v === undefined ? null : v;
}
function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T | undefined {
if (!source || typeof source !== 'object') return undefined;
for (const key of aliases) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
return source[key] as T;
}
}
return undefined;
}
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
for (let i = 0; i < statements.length; i += chunkSize) {
const chunk = statements.slice(i, i + chunkSize);
@@ -159,9 +168,16 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
for (let i = 0; i < ciphers.length; i++) {
const c = ciphers[i];
const folderId = cipherFolderMap.get(i) || null;
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
const sourceIdRaw = String(c?.id ?? '').trim();
const sourceId = sourceIdRaw || null;
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
const cipher: Cipher = {
...c,
@@ -172,64 +188,64 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
name: c.name ?? 'Untitled',
notes: c.notes ?? null,
favorite: c.favorite ?? false,
login: c.login ? {
...c.login,
username: c.login.username ?? null,
password: c.login.password ?? null,
uris: c.login.uris?.map(u => ({
login: login ? {
...login,
username: login.username ?? null,
password: login.password ?? null,
uris: login.uris?.map((u: any) => ({
...u,
uri: u.uri ?? null,
uriChecksum: null,
match: u.match ?? null,
})) || null,
totp: c.login.totp ?? null,
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
fido2Credentials: c.login.fido2Credentials ?? null,
uri: c.login.uri ?? null,
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
totp: login.totp ?? null,
autofillOnPageLoad: login.autofillOnPageLoad ?? null,
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
uri: login.uri ?? null,
passwordRevisionDate: login.passwordRevisionDate ?? null,
} : null,
card: c.card ? {
...c.card,
cardholderName: c.card.cardholderName ?? null,
brand: c.card.brand ?? null,
number: c.card.number ?? null,
expMonth: c.card.expMonth ?? null,
expYear: c.card.expYear ?? null,
code: c.card.code ?? null,
card: card ? {
...card,
cardholderName: card.cardholderName ?? null,
brand: card.brand ?? null,
number: card.number ?? null,
expMonth: card.expMonth ?? null,
expYear: card.expYear ?? null,
code: card.code ?? null,
} : null,
identity: c.identity ? {
...c.identity,
title: c.identity.title ?? null,
firstName: c.identity.firstName ?? null,
middleName: c.identity.middleName ?? null,
lastName: c.identity.lastName ?? null,
address1: c.identity.address1 ?? null,
address2: c.identity.address2 ?? null,
address3: c.identity.address3 ?? null,
city: c.identity.city ?? null,
state: c.identity.state ?? null,
postalCode: c.identity.postalCode ?? null,
country: c.identity.country ?? null,
company: c.identity.company ?? null,
email: c.identity.email ?? null,
phone: c.identity.phone ?? null,
ssn: c.identity.ssn ?? null,
username: c.identity.username ?? null,
passportNumber: c.identity.passportNumber ?? null,
licenseNumber: c.identity.licenseNumber ?? null,
identity: identity ? {
...identity,
title: identity.title ?? null,
firstName: identity.firstName ?? null,
middleName: identity.middleName ?? null,
lastName: identity.lastName ?? null,
address1: identity.address1 ?? null,
address2: identity.address2 ?? null,
address3: identity.address3 ?? null,
city: identity.city ?? null,
state: identity.state ?? null,
postalCode: identity.postalCode ?? null,
country: identity.country ?? null,
company: identity.company ?? null,
email: identity.email ?? null,
phone: identity.phone ?? null,
ssn: identity.ssn ?? null,
username: identity.username ?? null,
passportNumber: identity.passportNumber ?? null,
licenseNumber: identity.licenseNumber ?? null,
} : null,
secureNote: c.secureNote ?? null,
fields: c.fields?.map(f => ({
secureNote: secureNote ?? null,
fields: fields?.map((f: any) => ({
...f,
name: f.name ?? null,
value: f.value ?? null,
type: f.type,
linkedId: f.linkedId ?? null,
})) || null,
passwordHistory: c.passwordHistory ?? null,
passwordHistory: passwordHistory ?? null,
reprompt: c.reprompt ?? 0,
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
key: (c as any).key ?? null,
key: key ?? null,
createdAt: now,
updatedAt: now,
archivedAt: null,
+2 -1
View File
@@ -97,8 +97,9 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
sends = await storage.getAllSends(userId);
}
const sendResponses = sends.map(sendToResponse);
return jsonResponse({
data: sends.map(sendToResponse),
data: sendResponses,
object: 'list',
continuationToken,
});
+47 -116
View File
@@ -10,87 +10,23 @@ import {
buildUserDecryptionOptions,
} from '../utils/user-decryption';
interface SyncCacheEntry {
userId: string;
revisionDate: string;
body: string;
expiresAt: number;
bytes: number;
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request {
const url = new URL(request.url);
const cacheUrl = new URL(
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`,
url.origin
);
return new Request(cacheUrl.toString(), { method: 'GET' });
}
const syncResponseCache = new Map<string, SyncCacheEntry>();
let syncResponseCacheTotalBytes = 0;
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);
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
const hit = await caches.default.match(cacheRequest);
if (!hit) return null;
if (hit.expiresAt <= Date.now()) {
deleteSyncCacheEntry(key, hit);
return null;
}
return hit.body;
return new Response(hit.body, hit);
}
function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void {
const existing = entry ?? syncResponseCache.get(key);
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;
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
await caches.default.put(cacheRequest, response.clone());
}
// 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 excludeDomainsParam = url.searchParams.get('excludeDomains');
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);
if (!user) {
@@ -112,21 +42,21 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
}
const revisionDate = await storage.getRevisionDate(userId);
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
const cachedBody = readSyncCache(cacheKey);
if (cachedBody) {
return new Response(cachedBody, {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains);
const cachedResponse = await readSyncCache(cacheRequest);
if (cachedResponse) {
return cachedResponse;
}
const ciphers = await storage.getAllCiphers(userId);
const folders = await storage.getAllFolders(userId);
const sends = await storage.getAllSends(userId);
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
storage.getAllCiphers(userId),
storage.getAllFolders(userId),
storage.getAllSends(userId),
storage.getAttachmentsByUserId(userId),
]);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
// Build profile response
const profile: ProfileResponse = {
id: user.id,
name: user.name,
@@ -140,7 +70,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys: buildAccountKeys(user),
accountKeys,
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
@@ -152,23 +82,24 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
object: 'profile',
};
// Build cipher responses with attachments
const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) {
const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []));
}
// Build folder responses
const folderResponses: FolderResponse[] = folders.map(folder => ({
id: folder.id,
name: folder.name,
revisionDate: folder.updatedAt,
object: 'folder',
}));
const folderResponses: FolderResponse[] = [];
for (const folder of folders) {
folderResponses.push({
id: folder.id,
name: folder.name,
revisionDate: folder.updatedAt,
object: 'folder',
});
}
const sendResponses = sends.map(sendToResponse);
const syncResponse: SyncResponse = {
profile: profile,
profile,
folders: folderResponses,
collections: [],
ciphers: cipherResponses,
@@ -180,25 +111,25 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
object: 'domains',
},
policies: [],
sends: sends.map(sendToResponse),
sends: sendResponses,
UserDecryption: {
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
TrustedDeviceOption: null,
KeyConnectorOption: null,
Object: 'userDecryption',
},
// PascalCase for desktop/browser clients
UserDecryptionOptions: buildUserDecryptionOptions(user),
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
UserDecryptionOptions: userDecryptionOptions,
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
object: 'sync',
};
const body = JSON.stringify(syncResponse);
writeSyncCache(userId, revisionDate, cacheKey, body);
return new Response(body, {
const response = new Response(JSON.stringify(syncResponse), {
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 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 {
return (
path.startsWith('/api/') ||
@@ -56,9 +65,10 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
void ctx;
const assetResponse = await maybeServeAsset(request, env);
const normalizedRequest = normalizeRequestUrl(request);
const assetResponse = await maybeServeAsset(normalizedRequest, env);
if (assetResponse) {
return applyCors(request, assetResponse);
return applyCors(normalizedRequest, assetResponse);
}
await ensureDatabaseInitialized(env);
@@ -76,11 +86,11 @@ export default {
},
500
);
return applyCors(request, resp);
return applyCors(normalizedRequest, resp);
}
const resp = await handleRequest(request, env);
return applyCors(request, resp);
const resp = await handleRequest(normalizedRequest, env);
return applyCors(normalizedRequest, resp);
},
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
+5
View File
@@ -6,6 +6,7 @@ import {
handleDownloadAdminBackupAttachment,
handleGetAdminBackupSettings,
handleGetAdminBackupSettingsRepairState,
handleInspectAdminRemoteBackup,
handleAdminImportBackup,
handleListAdminRemoteBackups,
handleRepairAdminBackupSettings,
@@ -53,6 +54,10 @@ export async function handleAdminBackupRoute(
return handleDownloadAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
return handleInspectAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
return handleDeleteAdminRemoteBackup(request, env, actorUser);
}
+10
View File
@@ -11,6 +11,8 @@ import {
handleGetTotpStatus,
handleSetTotpStatus,
handleGetTotpRecoveryCode,
handleGetApiKey,
handleRotateApiKey,
} from './handlers/accounts';
import {
handleGetCiphers,
@@ -119,6 +121,14 @@ export async function handleAuthenticatedRoute(
return handleSetVerifyDevices(request, env, userId);
}
if ((path === '/api/accounts/api-key' || path === '/api/accounts/api_key') && method === 'POST') {
return handleGetApiKey(request, env, userId);
}
if ((path === '/api/accounts/rotate-api-key' || path === '/api/accounts/rotate_api_key') && method === 'POST') {
return handleRotateApiKey(request, env, userId);
}
if (path === '/api/sync' && method === 'GET') {
return handleSync(request, env, userId);
}
+7
View File
@@ -13,6 +13,7 @@ import {
handleRevokeTrustedDevice,
handleDeleteAllDevices,
handleDeleteDevice,
handleUpdateDeviceName,
handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth,
handleClearDeviceToken,
@@ -53,6 +54,12 @@ export async function handleAuthenticatedDeviceRoute(
return handleDeleteDevice(request, env, userId, deviceIdentifier);
}
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
if (updateDeviceNameMatch && method === 'PUT') {
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
}
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
if (identifierMatch && method === 'GET') {
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
+19 -8
View File
@@ -52,12 +52,12 @@ function isSameOriginWriteRequest(request: Request): boolean {
return false;
}
function getNwIconSvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
function getDefaultWebsiteIconSvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Globe icon"><circle cx="48" cy="48" r="34" fill="none" stroke="#8ea9c7" stroke-width="6"/><path d="M14 48h68M48 14c10 10 16 21.5 16 34s-6 24-16 34c-10-10-16-21.5-16-34s6-24 16-34zm-24 10c8 5 17 8 24 8s16-3 24-8m-48 48c8-5 17-8 24-8s16 3 24 8" fill="none" stroke="#8ea9c7" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
function handleNwFavicon(): Response {
return new Response(getNwIconSvg(), {
return new Response(getDefaultWebsiteIconSvg(), {
status: 200,
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
@@ -66,6 +66,15 @@ function handleNwFavicon(): Response {
});
}
function handleMissingWebsiteIcon(): Response {
return new Response(null, {
status: 404,
headers: {
'Cache-Control': 'public, max-age=300',
},
});
}
function buildIconServiceBase(origin: string): string {
return `${origin}/icons`;
}
@@ -104,6 +113,7 @@ function buildConfigResponse(origin: string) {
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'cipher-key-encryption': true,
'duo-redirect': true,
'email-verification': true,
'pm-19051-send-email-verification': false,
@@ -126,9 +136,9 @@ function normalizeIconHost(rawHost: string): string | null {
}
}
async function handleWebsiteIcon(host: string): Promise<Response> {
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return handleNwFavicon();
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
const encodedHost = encodeURIComponent(normalizedHost);
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
@@ -171,9 +181,9 @@ async function handleWebsiteIcon(host: string): Promise<Response> {
});
}
return handleNwFavicon();
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
} catch {
return handleNwFavicon();
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
}
}
@@ -220,7 +230,8 @@ export async function handlePublicRoute(
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') {
return handleWebsiteIcon(iconMatch[1]);
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
return handleWebsiteIcon(iconMatch[1], fallbackMode);
}
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
+110 -15
View File
@@ -9,6 +9,7 @@ import {
type SqlRow = Record<string, string | number | null>;
const BACKUP_FORMAT_VERSION = 1;
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
// Worker-side backup export must stay well below Cloudflare CPU limits.
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
@@ -60,25 +61,89 @@ export interface BackupArchiveBundle {
manifest: BackupManifest;
}
export interface BackupFileIntegrityCheckResult {
hasChecksumPrefix: boolean;
expectedPrefix: string | null;
actualPrefix: string;
matches: boolean;
}
export interface BuildBackupArchiveOptions {
includeAttachments?: boolean;
progress?: BackupArchiveBuildProgressReporter;
timeZone?: string;
}
export interface BackupArchiveBuildProgressEvent {
step: string;
fileName?: string;
stageTitle: string;
stageDetail: string;
includeAttachments: boolean;
}
export type BackupArchiveBuildProgressReporter = (event: BackupArchiveBuildProgressEvent) => Promise<void>;
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
return (result.results || []).map((row) => ({ ...row }));
}
function buildBackupFileName(date: Date = new Date()): string {
const parts = [
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]}.zip`;
async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function getDateParts(date: Date, timeZone: string): string {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
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}` : '';
return `nodewarden_backup_${parts}${suffix}.zip`;
}
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
const normalized = String(fileName || '').trim();
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
return match ? match[1].toLowerCase() : null;
}
export async function inspectBackupArchiveFileNameChecksum(
bytes: Uint8Array,
fileName: string
): Promise<BackupFileIntegrityCheckResult> {
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
const actualHash = await sha256Hex(bytes);
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
return {
hasChecksumPrefix: !!expectedPrefix,
expectedPrefix,
actualPrefix,
matches: !expectedPrefix || actualPrefix === expectedPrefix,
};
}
export async function verifyBackupArchiveFileNameChecksum(bytes: Uint8Array, fileName: string): Promise<boolean> {
const result = await inspectBackupArchiveFileNameChecksum(bytes, fileName);
return result.matches;
}
function validateArchiveSize(bytes: Uint8Array): void {
@@ -269,16 +334,25 @@ export async function buildBackupArchive(
date: Date = new Date(),
options: BuildBackupArchiveOptions = {}
): Promise<BackupArchiveBundle> {
const includeAttachments = options.includeAttachments !== false;
await options.progress?.({
step: 'collect_data',
fileName: '',
stageTitle: 'txt_backup_archive_progress_collect_title',
stageDetail: includeAttachments
? 'txt_backup_archive_progress_collect_with_attachments_detail'
: 'txt_backup_archive_progress_collect_detail',
includeAttachments,
});
const encoder = new TextEncoder();
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
]);
const includeAttachments = options.includeAttachments !== false;
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
const cipherId = String(row.cipher_id || '').trim();
@@ -327,9 +401,30 @@ export async function buildBackupArchive(
}, null, BACKUP_JSON_INDENT)),
};
await options.progress?.({
step: 'package_archive',
fileName: '',
stageTitle: 'txt_backup_archive_progress_package_title',
stageDetail: includeAttachments
? 'txt_backup_archive_progress_package_with_attachments_detail'
: 'txt_backup_archive_progress_package_detail',
includeAttachments,
});
const bytes = zipSync(createZipEntries(files));
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
const backupTimeZone = options.timeZone || 'UTC';
const fileName = buildBackupFileNameInTimeZone(date, fileHashPrefix, backupTimeZone);
await options.progress?.({
step: 'archive_ready',
fileName,
stageTitle: 'txt_backup_archive_progress_ready_title',
stageDetail: 'txt_backup_archive_progress_ready_detail',
includeAttachments,
});
return {
bytes: zipSync(createZipEntries(files)),
fileName: buildBackupFileName(date),
bytes,
fileName,
manifest: manifestBase,
};
}
+30 -5
View File
@@ -1,4 +1,4 @@
import type { Env } from '../types';
import type { Env, User } from '../types';
import { StorageService } from './storage';
import {
type BackupSettingsPortableEnvelope,
@@ -422,20 +422,45 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
if (!raw) return;
const users = await storage.getAllUsers();
const normalized = await normalizeImportedBackupSettingsValue(raw, env, users, fallbackTimezone);
if (normalized !== null) {
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, normalized);
}
}
export async function normalizeImportedBackupSettingsValue(
raw: string | null,
env: Env,
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[],
fallbackTimezone: string = 'UTC'
): Promise<string | null> {
if (!raw) return null;
const envelope = parseBackupSettingsEnvelope(raw);
if (envelope) {
try {
const decrypted = await decryptBackupSettingsRuntime(raw, env);
const settings = parseBackupSettings(decrypted, fallbackTimezone);
await saveBackupSettings(storage, env, settings);
return;
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
} catch {
// Keep imported portable recovery data intact until an admin signs in and repairs it.
return;
return raw;
}
}
const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings);
const hasPortableAdmins = users.some(
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
);
if (!hasPortableAdmins) {
return serializeBackupSettings(settings);
}
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
}
export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettingsRepairState> {
+464 -110
View File
@@ -1,7 +1,6 @@
import type { Env } from '../types';
import { StorageService } from './storage';
import type { Env, User } from '../types';
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
import { normalizeImportedBackupSettings } from './backup-config';
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
import {
type BackupManifestAttachmentBlob,
type BackupPayload,
@@ -10,6 +9,26 @@ import {
} from './backup-archive';
type SqlRow = Record<string, string | number | null>;
type BackupTableName =
| 'config'
| 'users'
| 'user_revisions'
| 'folders'
| 'ciphers'
| 'attachments';
const BACKUP_TABLES: BackupTableName[] = [
'config',
'users',
'user_revisions',
'folders',
'ciphers',
'attachments',
];
function shadowTableName(table: BackupTableName): string {
return `${table}__restore`;
}
export interface BackupImportResultBody {
object: 'instance-backup-import';
@@ -43,6 +62,81 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro
return (response.results || []).map((row) => ({ ...row }));
}
async function getTableCreateSql(db: D1Database, table: BackupTableName): Promise<string> {
const row = await db
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
.bind(table)
.first<{ sql: string | null }>();
const sql = String(row?.sql || '').trim();
if (!sql) {
throw new Error(`Restore shadow schema is missing table definition for ${table}`);
}
return sql;
}
function buildShadowTableCreateSql(createSql: string, table: BackupTableName): string {
const tablePattern = new RegExp(`^CREATE TABLE(?:\\s+IF NOT EXISTS)?\\s+(?:\"${table}\"|${table})(?=\\s*\\()`, 'i');
let next = createSql.replace(tablePattern, `CREATE TABLE "${shadowTableName(table)}"`);
if (next === createSql) {
throw new Error(`Restore shadow schema could not rewrite CREATE TABLE statement for ${table}`);
}
for (const currentTable of BACKUP_TABLES) {
const referencePattern = new RegExp(`\\bREFERENCES\\s+(?:\"${currentTable}\"|${currentTable})(?=\\s*\\()`, 'gi');
next = next.replace(
referencePattern,
`REFERENCES "${shadowTableName(currentTable)}"`
);
}
return next;
}
async function resetRestoreArtifacts(db: D1Database): Promise<void> {
const dropStatements = BACKUP_TABLES
.slice()
.reverse()
.map((table) => db.prepare(`DROP TABLE IF EXISTS ${shadowTableName(table)}`));
if (dropStatements.length) {
await db.batch(dropStatements);
}
}
async function createShadowTables(db: D1Database): Promise<void> {
const createStatements: D1PreparedStatement[] = [];
for (const table of BACKUP_TABLES) {
const createSql = await getTableCreateSql(db, table);
createStatements.push(db.prepare(buildShadowTableCreateSql(createSql, table)));
}
await db.batch(createStatements);
}
async function validateShadowTableCounts(
db: D1Database,
expectedCounts: Partial<Record<BackupTableName, number>>
): Promise<void> {
await Promise.all(BACKUP_TABLES.map(async (table) => {
const expected = expectedCounts[table] ?? 0;
const row = await db.prepare(`SELECT COUNT(*) AS count FROM ${shadowTableName(table)}`).first<{ count: number }>();
const actual = Number(row?.count || 0);
if (actual !== expected) {
throw new Error(`Restore shadow validation failed for ${table}: expected ${expected}, received ${actual}`);
}
}));
}
async function swapShadowTablesIntoPlace(db: D1Database): Promise<void> {
const statements: D1PreparedStatement[] = [];
// Commit by replacing live table contents from validated shadow tables.
// This avoids D1 schema-rename edge cases while keeping current data intact
// until the final batch succeeds.
for (const sql of buildResetImportTargetStatements(db)) {
statements.push(sql);
}
for (const table of BACKUP_TABLES) {
statements.push(db.prepare(`INSERT INTO ${table} SELECT * FROM ${shadowTableName(table)}`));
}
await db.batch(statements);
}
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
const counts = await Promise.all([
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
@@ -61,18 +155,9 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
'DELETE FROM attachments',
'DELETE FROM ciphers',
'DELETE FROM folders',
'DELETE FROM sends',
'DELETE FROM trusted_two_factor_device_tokens',
'DELETE FROM devices',
'DELETE FROM refresh_tokens',
'DELETE FROM invites',
'DELETE FROM audit_logs',
'DELETE FROM user_revisions',
'DELETE FROM users',
'DELETE FROM config',
'DELETE FROM login_attempts_ip',
'DELETE FROM api_rate_limits',
'DELETE FROM used_attachment_download_tokens',
].map((sql) => db.prepare(sql));
}
@@ -119,10 +204,90 @@ interface AttachmentRestoreResult {
}
interface RemoteAttachmentSource {
hasAttachment(blobName: string): Promise<boolean>;
loadAttachment(blobName: string): Promise<Uint8Array | null>;
}
export interface BackupRestoreProgressEvent {
source: 'local' | 'remote';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
replaceExisting: boolean;
done?: boolean;
ok?: boolean;
error?: string | null;
}
export type BackupRestoreProgressReporter = (event: BackupRestoreProgressEvent) => Promise<void> | void;
function attachmentRowKey(row: SqlRow): string {
const attachmentId = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim();
return `${cipherId}/${attachmentId}`;
}
function cloneRows(rows: SqlRow[]): SqlRow[] {
return rows.map((row) => ({ ...row }));
}
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
let replaced = false;
const nextRows = rows.map((row) => {
if (String(row.key || '').trim() !== key) return { ...row };
replaced = true;
return { ...row, key, value };
});
if (!replaced) {
nextRows.push({ key, value });
}
return nextRows;
}
async function prepareImportedConfigRows(
env: Env,
configRows: SqlRow[],
userRows: SqlRow[]
): Promise<SqlRow[]> {
let nextConfigRows = cloneRows(configRows || []);
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
env,
userRows.map((row) => ({
id: String(row.id || '').trim(),
publicKey: typeof row.public_key === 'string' ? row.public_key : null,
role: String(row.role || '').trim() as User['role'],
status: String(row.status || '').trim() as User['status'],
})),
'UTC'
);
if (normalizedBackupSettings !== null) {
nextConfigRows = upsertConfigRow(nextConfigRows, BACKUP_SETTINGS_CONFIG_KEY, normalizedBackupSettings);
}
nextConfigRows = upsertConfigRow(nextConfigRows, 'registered', 'true');
return nextConfigRows;
}
async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['db'], env: Env): Promise<BackupPayload['db']> {
const preparedDb: BackupPayload['db'] = {
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
users: cloneRows(payload.users || []).map((row) => ({
...row,
verify_devices: row.verify_devices ?? 1,
})),
user_revisions: cloneRows(payload.user_revisions || []),
folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
...row,
archived_at: row.archived_at ?? null,
})),
attachments: cloneRows(payload.attachments || []),
};
await importBackupRows(db, preparedDb, true);
return preparedDb;
}
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
const storageKind = getBlobStorageKind(env);
if (storageKind === 'r2') {
@@ -147,7 +312,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
};
});
return {
const result = {
payload: {
...payload,
db: {
@@ -161,6 +326,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
items: skippedItems,
},
};
return result;
}
const oversizedAttachmentPaths = new Set<string>();
@@ -197,7 +363,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
}
return {
const result = {
payload: nextPayload,
skipped: {
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
@@ -205,6 +371,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
items: skippedItems,
},
};
return result;
}
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
@@ -214,6 +381,16 @@ function buildInsertStatements(db: D1Database, table: string, columns: string[],
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
}
async function runInsertBatch(db: D1Database, table: string, statements: D1PreparedStatement[]): Promise<void> {
if (!statements.length) return;
try {
await db.batch(statements);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Restore insert failed for ${table}: ${message}`);
}
}
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<AttachmentRestoreResult> {
const restoredAttachments: SqlRow[] = [];
const skippedItems: BackupImportSkipSummary['items'] = [];
@@ -300,14 +477,10 @@ async function prepareRemoteAttachmentPayload(
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
if (!(await source.hasAttachment(ref.blobName))) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
nextAttachments.push(row);
}
return {
const result = {
payload: {
...payload,
db: {
@@ -321,16 +494,18 @@ async function prepareRemoteAttachmentPayload(
items: skippedItems,
},
};
return result;
}
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[]): Promise<void> {
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[], useShadowTable: boolean = false): Promise<void> {
if (!attachmentRows.length) return;
const tableName = useShadowTable ? shadowTableName('attachments') : 'attachments';
const statements = attachmentRows
.map((row) => {
const attachmentId = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim();
if (!attachmentId || !cipherId) return null;
return db.prepare('DELETE FROM attachments WHERE id = ? AND cipher_id = ?').bind(attachmentId, cipherId);
return db.prepare(`DELETE FROM ${tableName} WHERE id = ? AND cipher_id = ?`).bind(attachmentId, cipherId);
})
.filter((statement): statement is D1PreparedStatement => !!statement);
if (!statements.length) return;
@@ -406,36 +581,58 @@ async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set<string>, after
}
}
async function importBackupRows(db: D1Database, payload: BackupPayload['db']): Promise<void> {
const statements: D1PreparedStatement[] = [
...buildResetImportTargetStatements(db),
...buildInsertStatements(db, 'config', ['key', 'value'], payload.config || [], true),
...buildInsertStatements(
async function importBackupRows(db: D1Database, payload: BackupPayload['db'], useShadowTables: boolean = false): Promise<void> {
const tableName = (table: BackupTableName): string => (useShadowTables ? shadowTableName(table) : table);
await runInsertBatch(
db,
tableName('config'),
buildInsertStatements(db, tableName('config'), ['key', 'value'], payload.config || [], true)
);
await runInsertBatch(
db,
tableName('users'),
buildInsertStatements(
db,
'users',
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
tableName('users'),
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'api_key', 'created_at', 'updated_at'],
payload.users || []
),
...buildInsertStatements(db, 'user_revisions', ['user_id', 'revision_date'], payload.user_revisions || [], true),
...buildInsertStatements(db, 'folders', ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || []),
...buildInsertStatements(
)
);
await runInsertBatch(
db,
tableName('user_revisions'),
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
);
await runInsertBatch(
db,
tableName('folders'),
buildInsertStatements(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || [])
);
await runInsertBatch(
db,
tableName('ciphers'),
buildInsertStatements(
db,
'ciphers',
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'],
tableName('ciphers'),
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'],
payload.ciphers || []
),
...buildInsertStatements(db, 'attachments', ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []),
];
await db.batch(statements);
)
);
await runInsertBatch(
db,
tableName('attachments'),
buildInsertStatements(db, tableName('attachments'), ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || [])
);
}
export async function importBackupArchiveBytes(
archiveBytes: Uint8Array,
env: Env,
actorUserId: string,
replaceExisting: boolean
replaceExisting: boolean,
progress?: BackupRestoreProgressReporter,
fileName: string = 'nodewarden_backup.zip'
): Promise<BackupImportExecutionResult> {
const storage = new StorageService(env.DB);
const parsed = parseBackupArchive(archiveBytes);
validateBackupPayloadContents(parsed.payload, parsed.files);
const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
@@ -448,40 +645,118 @@ export async function importBackupArchiveBytes(
}
}
await resetRestoreArtifacts(env.DB);
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
const { db } = prepared.payload;
await importBackupRows(env.DB, db);
await normalizeImportedBackupSettings(storage, env, 'UTC');
try {
await progress?.({
source: 'local',
step: 'local_create_shadow',
fileName,
stageTitle: 'txt_backup_restore_progress_local_shadow_title',
stageDetail: 'txt_backup_restore_progress_local_shadow_detail',
replaceExisting,
});
await createShadowTables(env.DB);
await progress?.({
source: 'local',
step: 'local_import_data',
fileName,
stageTitle: 'txt_backup_restore_progress_local_data_title',
stageDetail: 'txt_backup_restore_progress_local_data_detail',
replaceExisting,
});
const db = await importPreparedBackupRows(env.DB, prepared.payload.db, env);
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
user_revisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length,
});
const restored = await restoreBlobFiles(env, db, parsed.files);
const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row));
await removeAttachmentRows(env.DB, failedRestoreRows);
if (replaceExisting && previousBlobKeys.size) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
await progress?.({
source: 'local',
step: 'local_restore_files',
fileName,
stageTitle: 'txt_backup_restore_progress_local_files_title',
stageDetail: 'txt_backup_restore_progress_local_files_detail',
replaceExisting,
});
const restored = await restoreBlobFiles(env, db, parsed.files);
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
user_revisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
});
await progress?.({
source: 'local',
step: 'local_finalize',
fileName,
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
replaceExisting,
});
await swapShadowTablesIntoPlace(env.DB);
await resetRestoreArtifacts(env.DB).catch(() => undefined);
if (replaceExisting && previousBlobKeys.size) {
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
if (nextBlobKeys) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
}
}
await progress?.({
source: 'local',
step: 'local_complete',
fileName,
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
replaceExisting,
done: true,
ok: true,
});
return {
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
result: {
object: 'instance-backup-import',
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
userRevisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
attachmentFiles: restored.imported,
},
skipped: {
reason: restored.skipped.reason || prepared.skipped.reason,
attachments: prepared.skipped.attachments + restored.skipped.attachments,
items: [...prepared.skipped.items, ...restored.skipped.items],
},
},
};
} catch (error) {
await progress?.({
source: 'local',
step: 'local_failed',
fileName,
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
replaceExisting,
done: true,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
await resetRestoreArtifacts(env.DB).catch(() => undefined);
throw error;
}
await storage.setRegistered();
return {
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
result: {
object: 'instance-backup-import',
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
userRevisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
attachmentFiles: restored.imported,
},
skipped: {
reason: restored.skipped.reason || prepared.skipped.reason,
attachments: prepared.skipped.attachments + restored.skipped.attachments,
items: [...prepared.skipped.items, ...restored.skipped.items],
},
},
};
}
export async function importRemoteBackupArchiveBytes(
@@ -489,9 +764,10 @@ export async function importRemoteBackupArchiveBytes(
env: Env,
actorUserId: string,
replaceExisting: boolean,
source: RemoteAttachmentSource
source: RemoteAttachmentSource,
progress?: BackupRestoreProgressReporter,
fileName: string = 'nodewarden_backup.zip'
): Promise<BackupImportExecutionResult> {
const storage = new StorageService(env.DB);
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source);
validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true });
@@ -504,44 +780,122 @@ export async function importRemoteBackupArchiveBytes(
}
}
await resetRestoreArtifacts(env.DB);
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
const { db } = preparedRemote.payload;
await importBackupRows(env.DB, db);
await normalizeImportedBackupSettings(storage, env, 'UTC');
try {
await progress?.({
source: 'remote',
step: 'remote_create_shadow',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_shadow_title',
stageDetail: 'txt_backup_restore_progress_remote_shadow_detail',
replaceExisting,
});
await createShadowTables(env.DB);
await progress?.({
source: 'remote',
step: 'remote_import_data',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_data_title',
stageDetail: 'txt_backup_restore_progress_remote_data_detail',
replaceExisting,
});
const db = await importPreparedBackupRows(env.DB, preparedRemote.payload.db, env);
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
user_revisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length,
});
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row));
await removeAttachmentRows(env.DB, failedRestoreRows);
await progress?.({
source: 'remote',
step: 'remote_restore_files',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_files_title',
stageDetail: 'txt_backup_restore_progress_remote_files_detail',
replaceExisting,
});
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
await validateShadowTableCounts(env.DB, {
config: (db.config || []).length,
users: (db.users || []).length,
user_revisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
});
await progress?.({
source: 'remote',
step: 'remote_finalize',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
replaceExisting,
});
await swapShadowTablesIntoPlace(env.DB);
await resetRestoreArtifacts(env.DB).catch(() => undefined);
if (replaceExisting && previousBlobKeys.size) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
if (replaceExisting && previousBlobKeys.size) {
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
if (nextBlobKeys) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
}
}
await progress?.({
source: 'remote',
step: 'remote_complete',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
replaceExisting,
done: true,
ok: true,
});
const finalSkippedItems = [...preparedRemote.skipped.items, ...restored.skipped.items];
const finalSkippedReason = finalSkippedItems.length
? restored.skipped.reason || preparedRemote.skipped.reason
: null;
return {
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
result: {
object: 'instance-backup-import',
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
userRevisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
attachmentFiles: restored.imported,
},
skipped: {
reason: finalSkippedReason,
attachments: finalSkippedItems.length,
items: finalSkippedItems,
},
},
};
} catch (error) {
await progress?.({
source: 'remote',
step: 'remote_failed',
fileName,
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
replaceExisting,
done: true,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
await resetRestoreArtifacts(env.DB).catch(() => undefined);
throw error;
}
await storage.setRegistered();
const finalSkippedItems = [...preparedRemote.skipped.items, ...restored.skipped.items];
const finalSkippedReason = finalSkippedItems.length
? restored.skipped.reason || preparedRemote.skipped.reason
: null;
return {
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
result: {
object: 'instance-backup-import',
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
userRevisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
attachmentFiles: restored.imported,
},
skipped: {
reason: finalSkippedReason,
attachments: finalSkippedItems.length,
items: finalSkippedItems,
},
},
};
}
+81 -14
View File
@@ -250,18 +250,49 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut
}
}
async function ensureWebDavDirectoryCached(
baseUrl: string,
directoryPath: string,
authHeader: string,
ensuredDirectories: Set<string>
): Promise<void> {
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
let current = '';
for (const segment of segments) {
current = buildJoinedPath(current, segment);
if (ensuredDirectories.has(current)) continue;
const url = buildWebDavUrl(baseUrl, current);
const response = await fetch(url, {
method: 'MKCOL',
headers: {
Authorization: authHeader,
},
});
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
ensuredDirectories.add(current);
continue;
}
throw new Error(`WebDAV directory creation failed: ${response.status}`);
}
}
async function putToWebDav(
config: WebDavBackupDestination,
relativePath: string,
bytes: Uint8Array,
options: RemoteBackupFilePutOptions = {}
options: RemoteBackupFilePutOptions = {},
ensuredDirectories?: Set<string>
): Promise<void> {
const authHeader = toBasicAuthHeader(config.username, config.password);
const remoteFilePath = buildJoinedPath(config.remotePath, relativePath);
const remoteDir = parentPath(remoteFilePath);
if (remoteDir) {
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
if (ensuredDirectories) {
await ensureWebDavDirectoryCached(config.baseUrl, remoteDir, authHeader, ensuredDirectories);
} else {
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
}
}
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
@@ -608,6 +639,16 @@ interface ConfiguredDestinationAdapter {
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
}
export interface RemoteBackupTransferSession {
provider: BackupDestinationType;
uploadArchive(archive: Uint8Array, fileName: string): Promise<BackupUploadResult>;
putFile(relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions): Promise<void>;
list(relativePath: string): Promise<RemoteBackupListResult>;
download(relativePath: string): Promise<RemoteBackupFile>;
deleteFile(relativePath: string): Promise<void>;
exists(relativePath: string): Promise<boolean>;
}
function resolveConfiguredDestinationAdapter(
destination: BackupDestinationRecord
): ConfiguredDestinationAdapter {
@@ -641,35 +682,62 @@ function resolveConfiguredDestinationAdapter(
throw new Error('Unsupported backup destination type');
}
export function createRemoteBackupTransferSession(destination: BackupDestinationRecord): RemoteBackupTransferSession {
const adapter = resolveConfiguredDestinationAdapter(destination);
const ensuredDirectories = adapter.provider === 'webdav' ? new Set<string>() : null;
const putFile = async (relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}): Promise<void> => {
const normalized = normalizeRelativePath(relativePath);
if (adapter.provider === 'webdav' && ensuredDirectories) {
await putToWebDav(adapter.config as WebDavBackupDestination, normalized, bytes, options, ensuredDirectories);
return;
}
await adapter.putFile(adapter.config, normalized, bytes, options);
};
return {
provider: adapter.provider,
uploadArchive: async (archive: Uint8Array, fileName: string) => {
await putFile(fileName, archive, { contentType: 'application/zip' });
return {
provider: adapter.provider,
remotePath: adapter.provider === 'webdav'
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
: normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName),
};
},
putFile,
list: async (relativePath: string) => adapter.list(adapter.config, relativePath),
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
};
}
export async function uploadBackupArchive(
destination: BackupDestinationRecord,
archive: Uint8Array,
fileName: string
): Promise<BackupUploadResult> {
const adapter = resolveConfiguredDestinationAdapter(destination);
return adapter.upload(adapter.config, archive, fileName);
return createRemoteBackupTransferSession(destination).uploadArchive(archive, fileName);
}
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
const adapter = resolveConfiguredDestinationAdapter(destination);
return adapter.list(adapter.config, relativePath);
return createRemoteBackupTransferSession(destination).list(relativePath);
}
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
const adapter = resolveConfiguredDestinationAdapter(destination);
return adapter.download(adapter.config, relativePath);
return createRemoteBackupTransferSession(destination).download(relativePath);
}
export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<void> {
const normalized = ensureRemoteRestoreCandidate(relativePath);
const adapter = resolveConfiguredDestinationAdapter(destination);
await adapter.deleteFile(adapter.config, normalized);
await createRemoteBackupTransferSession(destination).deleteFile(normalized);
}
export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise<boolean> {
const normalized = normalizeRelativePath(relativePath);
const adapter = resolveConfiguredDestinationAdapter(destination);
return adapter.exists(adapter.config, normalized);
return createRemoteBackupTransferSession(destination).exists(normalized);
}
export async function uploadRemoteBackupFile(
@@ -679,8 +747,7 @@ export async function uploadRemoteBackupFile(
options: RemoteBackupFilePutOptions = {}
): Promise<void> {
const normalized = normalizeRelativePath(relativePath);
const adapter = resolveConfiguredDestinationAdapter(destination);
await adapter.putFile(adapter.config, normalized, bytes, options);
await createRemoteBackupTransferSession(destination).putFile(normalized, bytes, options);
}
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
+17 -5
View File
@@ -1,5 +1,11 @@
import type { Cipher } from '../types';
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
type SqlChunkSize = (fixedBindCount: number) => number;
type UpdateRevisionDate = (userId: string) => Promise<string>;
@@ -25,12 +31,13 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
if (!row?.data) return null;
try {
const parsed = JSON.parse(row.data) as Cipher;
const folderId = normalizeOptionalId(row.folder_id ?? parsed.folderId ?? null);
return {
...parsed,
id: row.id,
userId: row.user_id,
type: Number(row.type) || Number(parsed.type) || 1,
folderId: row.folder_id ?? parsed.folderId ?? null,
folderId,
name: row.name ?? parsed.name ?? null,
notes: row.notes ?? parsed.notes ?? null,
favorite: row.favorite != null ? !!row.favorite : !!parsed.favorite,
@@ -60,7 +67,11 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
}
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
const data = JSON.stringify(cipher);
const folderId = normalizeOptionalId(cipher.folderId);
const data = JSON.stringify({
...cipher,
folderId,
});
const stmt = db.prepare(
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
@@ -72,7 +83,7 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
cipher.id,
cipher.userId,
Number(cipher.type) || 1,
cipher.folderId,
folderId,
cipher.name,
cipher.notes,
cipher.favorite ? 1 : 0,
@@ -249,8 +260,9 @@ export async function bulkMoveCiphers(
): Promise<string | null> {
if (ids.length === 0) return null;
const now = new Date().toISOString();
const normalizedFolderId = normalizeOptionalId(folderId);
const uniqueIds = sanitizeIds(ids);
const patch = JSON.stringify({ folderId, updatedAt: now });
const patch = JSON.stringify({ folderId: normalizedFolderId, updatedAt: now });
const chunkSize = sqlChunkSize(4);
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
@@ -262,7 +274,7 @@ export async function bulkMoveCiphers(
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
WHERE user_id = ? AND id IN (${placeholders})`
)
.bind(folderId, now, patch, userId, ...chunk)
.bind(normalizedFolderId, now, patch, userId, ...chunk)
.run();
}
+39 -6
View File
@@ -8,11 +8,13 @@ function mapDeviceRow(row: any): Device {
userId: row.user_id,
deviceIdentifier: row.device_identifier,
name: row.name,
deviceNote: row.device_note ?? null,
type: row.type,
sessionStamp: row.session_stamp || '',
encryptedUserKey: row.encrypted_user_key ?? null,
encryptedPublicKey: row.encrypted_public_key ?? null,
encryptedPrivateKey: row.encrypted_private_key ?? null,
lastSeenAt: row.last_seen_at ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
@@ -33,31 +35,62 @@ export async function upsertDevice(
}
): Promise<void> {
const now = new Date().toISOString();
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
const existingDevice = await getDeviceById(userId, deviceIdentifier);
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
await db
.prepare(
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
'last_seen_at=excluded.last_seen_at, ' +
'updated_at=excluded.updated_at'
)
.bind(
userId,
deviceIdentifier,
name,
effectiveName,
type,
effectiveSessionStamp,
keys?.encryptedUserKey ?? null,
keys?.encryptedPublicKey ?? null,
keys?.encryptedPrivateKey ?? null,
existingDevice?.deviceNote ?? null,
now,
now,
now
)
.run();
}
export async function updateDeviceName(
db: D1Database,
userId: string,
deviceIdentifier: string,
name: string
): Promise<boolean> {
const result = await db
.prepare('UPDATE devices SET device_note = ? WHERE user_id = ? AND device_identifier = ?')
.bind(String(name || '').trim(), userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function touchDeviceLastSeen(
db: D1Database,
userId: string,
deviceIdentifier: string
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare('UPDATE devices SET last_seen_at = ? WHERE user_id = ? AND device_identifier = ?')
.bind(now, userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function updateDeviceKeys(
db: D1Database,
userId: string,
@@ -133,8 +166,8 @@ export async function isKnownDeviceByEmail(
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
const res = await db
.prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
)
.bind(userId)
.all<any>();
@@ -144,7 +177,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
const row = await db
.prepare(
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
)
.bind(userId, deviceIdentifier)
+8 -2
View File
@@ -6,13 +6,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN api_key TEXT',
'CREATE TABLE IF NOT EXISTS user_revisions (' +
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
@@ -27,6 +28,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_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_updated ON ciphers(user_id, deleted_at, updated_at)',
'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, ' +
@@ -47,6 +49,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'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_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 emails TEXT',
@@ -71,7 +74,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'PRIMARY KEY (user_id, device_identifier), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
@@ -82,6 +85,9 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
'ALTER TABLE devices ADD COLUMN device_note TEXT',
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
+9 -6
View File
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
const USER_SELECT_COLUMNS =
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
'totp_secret, totp_recovery_code, created_at, updated_at';
'totp_secret, totp_recovery_code, api_key, created_at, updated_at';
function mapUserRow(row: any): User {
return {
@@ -26,6 +26,7 @@ function mapUserRow(row: any): User {
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null,
apiKey: row.api_key ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
@@ -64,11 +65,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
const email = user.email.toLowerCase();
const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at'
);
await safeBind(
stmt,
@@ -90,6 +91,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
user.verifyDevices ? 1 : 0,
user.totpSecret,
user.totpRecoveryCode,
user.apiKey,
user.createdAt,
user.updatedAt
).run();
@@ -102,8 +104,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
const email = user.email.toLowerCase();
const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
);
const result = await safeBind(
@@ -126,6 +128,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
user.verifyDevices ? 1 : 0,
user.totpSecret,
user.totpRecoveryCode,
user.apiKey,
user.createdAt,
user.updatedAt
).run();
+11 -1
View File
@@ -92,7 +92,9 @@ import {
isKnownDevice as getKnownStoredDevice,
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
touchDeviceLastSeen as touchStoredDeviceLastSeen,
upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName,
updateDeviceKeys as updateStoredDeviceKeys,
} from './storage-device-repo';
import {
@@ -106,7 +108,7 @@ import {
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
const STORAGE_SCHEMA_VERSION = '2026-03-23.1';
const STORAGE_SCHEMA_VERSION = '2026-04-22';
// D1-backed storage.
// Contract:
@@ -550,6 +552,14 @@ export class StorageService {
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
}
async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise<boolean> {
return updateStoredDeviceName(this.db, userId, deviceIdentifier, name);
}
async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise<boolean> {
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
}
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
}
+13 -1
View File
@@ -50,6 +50,7 @@ export interface User {
verifyDevices?: boolean;
totpSecret: string | null;
totpRecoveryCode: string | null;
apiKey: string | null;
createdAt: string;
updatedAt: string;
}
@@ -189,12 +190,14 @@ export interface Device {
userId: string;
deviceIdentifier: string;
name: string;
deviceNote: string | null;
type: number;
sessionStamp: string;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
lastSeenAt: string | null;
createdAt: string;
updatedAt: string;
}
@@ -208,10 +211,14 @@ export interface DeviceResponse {
id: string;
userId?: string | null;
name: string;
systemName?: string | null;
deviceNote?: string | null;
identifier: string;
type: number;
creationDate: string;
revisionDate: string;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
isTrusted: boolean;
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
@@ -347,7 +354,8 @@ export interface TokenResponse {
access_token: string;
expires_in: number;
token_type: string;
refresh_token: string;
refresh_token?: string;
web_session?: boolean;
TwoFactorToken?: string;
Key: string;
PrivateKey: string | null;
@@ -367,6 +375,10 @@ export interface TokenResponse {
accountKeys?: any | null;
UserDecryptionOptions: UserDecryptionOptions;
userDecryptionOptions?: UserDecryptionOptions;
VaultKeys?: {
symEncKey: string;
symMacKey: string;
};
}
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-Device-Identifier',
'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');
if (!origin) return '*';
return origin;
if (isWildcardCorsPath(url.pathname)) {
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> {
@@ -35,13 +65,14 @@ function buildCorsHeaders(request: Request): Record<string, string> {
'Access-Control-Allow-Headers': allowHeaders.join(', '),
'Access-Control-Expose-Headers': '*',
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
'Access-Control-Allow-Private-Network': 'true',
};
const allowedOrigin = getAllowedOrigin(request);
if (allowedOrigin) {
headers['Access-Control-Allow-Origin'] = allowedOrigin;
headers['Access-Control-Allow-Credentials'] = 'true';
const corsPolicy = getCorsPolicy(request);
if (corsPolicy.allowOrigin) {
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
if (corsPolicy.allowCredentials) {
headers['Access-Control-Allow-Credentials'] = 'true';
}
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
}
+9 -2
View File
@@ -1,14 +1,21 @@
import { User, UserDecryptionOptions } from '../types';
function normalizeOptionalPublicKey(value: unknown): string {
if (value == null) return '';
return String(value);
}
export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>): Record<string, unknown> | null {
if (!user.privateKey || !user.publicKey) {
if (!user.privateKey) {
return null;
}
const publicKey = normalizeOptionalPublicKey(user.publicKey);
return {
publicKeyEncryptionKeyPair: {
wrappedPrivateKey: user.privateKey,
publicKey: user.publicKey,
publicKey,
Object: 'publicKeyEncryptionKeyPair',
},
Object: 'privateKeys',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg width="862" height="101" viewBox="0 0 8620 1017" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z" fill="#006DF4"/>
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z" fill="#006DF4"/>
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z" fill="#006DF4"/>
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z" fill="#006DF4"/>
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z" fill="#006DF4"/>
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z" fill="#006DF4"/>
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z" fill="#006DF4"/>
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z" fill="#006DF4"/>
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z" fill="#006DF4"/>
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z" fill="#006DF4"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+136 -9
View File
@@ -10,8 +10,12 @@ import JwtWarningPage from '@/components/JwtWarningPage';
import {
createAuthedFetch,
getAuthorizedDevices,
clearProfileSnapshot,
getCurrentDeviceIdentifier,
getPasswordHint,
loadProfileSnapshot,
saveProfileSnapshot,
revokeCurrentSession,
getTotpStatus,
saveSession,
} from '@/lib/api/auth';
@@ -39,6 +43,7 @@ import {
performRecoverTwoFactorLogin,
performRegistration,
performTotpLogin,
hydrateLockedSession,
performUnlock,
type JwtUnsafeReason,
type PendingTotp,
@@ -50,8 +55,20 @@ import useVaultSendActions from '@/hooks/useVaultSendActions';
import { useToastManager } from '@/hooks/useToastManager';
import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
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_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));
@@ -62,6 +79,7 @@ const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
type ThemePreference = 'system' | 'light' | 'dark';
const MAGNETIC_SELECTOR = '.topbar .btn, .topbar .user-chip, .side-link, .mobile-tab';
@@ -122,11 +140,12 @@ function resolveSystemTheme(): 'light' | 'dark' {
export default function App() {
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]);
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
const [location, navigate] = useLocation();
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
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 [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
@@ -153,12 +172,15 @@ export default function App() {
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [totpCode, setTotpCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(true);
const [totpSubmitting, setTotpSubmitting] = useState(false);
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key);
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false);
@@ -260,6 +282,16 @@ export default function App() {
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
}, [themePreference]);
useEffect(() => {
saveProfileSnapshot(profile);
}, [profile]);
useEffect(() => {
if (phase === 'locked' && profile?.key && session) {
setUnlockPreparing(false);
}
}, [phase, profile, session]);
useEffect(() => installMagneticUiFeedback(), []);
function handleToggleTheme() {
@@ -321,6 +353,7 @@ export default function App() {
setSession(boot.session);
setProfile(boot.profile);
setPhase(boot.phase);
setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key);
})();
return () => {
@@ -328,9 +361,34 @@ export default function App() {
};
}, [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) {
setSession(login.session);
setProfile(login.profile);
setUnlockPreparing(false);
setPendingTotp(null);
setTotpCode('');
setPhase('app');
@@ -377,16 +435,20 @@ export default function App() {
}
async function handleTotpVerify() {
if (totpSubmitting) return;
if (!pendingTotp) return;
if (!totpCode.trim()) {
pushToast('error', t('txt_please_input_totp_code'));
return;
}
setTotpSubmitting(true);
try {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
await finalizeLogin(login);
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
} finally {
setTotpSubmitting(false);
}
}
@@ -515,6 +577,7 @@ export default function App() {
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
setSession(nextSession);
setUnlockPassword('');
setUnlockPreparing(false);
setPhase('app');
if (location === '/' || location === '/lock') navigate('/vault');
pushToast('success', t('txt_unlocked'));
@@ -531,14 +594,18 @@ export default function App() {
delete nextSession.symEncKey;
delete nextSession.symMacKey;
setSession(nextSession);
setUnlockPreparing(false);
setPhase('locked');
navigate('/lock');
}
function logoutNow() {
void revokeCurrentSession(sessionRef.current);
setConfirm(null);
setSession(null);
clearProfileSnapshot();
setProfile(null);
setUnlockPreparing(false);
setPendingTotp(null);
setPhase('login');
navigate('/login');
@@ -570,11 +637,13 @@ export default function App() {
onConfirmTotp={() => {}}
onCancelTotp={() => {}}
onUseRecoveryCode={() => {}}
totpSubmitting={false}
disableTotpOpen={false}
disableTotpPassword=""
onDisableTotpPasswordChange={() => {}}
onConfirmDisableTotp={() => {}}
onCancelDisableTotp={() => {}}
disableTotpSubmitting={false}
/>
);
}
@@ -689,9 +758,6 @@ export default function App() {
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
decPassword: await decryptField(cipher.login.password || '', 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(
(cipher.login.uris || []).map(async (u) => ({
...u,
@@ -700,6 +766,14 @@ export default function App() {
),
};
}
if (Array.isArray(cipher.passwordHistory)) {
nextCipher.passwordHistory = await Promise.all(
cipher.passwordHistory.map(async (entry) => ({
...entry,
decPassword: await decryptField(entry?.password || '', itemEnc, itemMac),
}))
);
}
if (cipher.card) {
nextCipher.card = {
...cipher.card,
@@ -872,9 +946,11 @@ export default function App() {
const connect = () => {
if (disposed) return;
const accessToken = session.accessToken;
if (!accessToken) return;
try {
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:';
socket = new WebSocket(hubUrl.toString());
} catch {
@@ -882,6 +958,15 @@ export default function App() {
return;
}
let pingTimer: number | null = null;
const clearPingTimer = () => {
if (pingTimer !== null) {
window.clearInterval(pingTimer);
pingTimer = null;
}
};
socket.addEventListener('open', () => {
reconnectAttempts = 0;
void refreshAuthorizedDevicesRef.current();
@@ -889,7 +974,16 @@ export default function App() {
socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`);
} catch {
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) => {
@@ -908,6 +1002,11 @@ export default function App() {
void refreshAuthorizedDevicesRef.current();
continue;
}
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
const payload = frame.arguments?.[0]?.Payload;
if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
continue;
}
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
@@ -917,6 +1016,7 @@ export default function App() {
socket.addEventListener('close', () => {
socket = null;
clearPingTimer();
void refreshAuthorizedDevicesRef.current();
scheduleReconnect();
});
@@ -935,9 +1035,11 @@ export default function App() {
return () => {
disposed = true;
clearReconnectTimer();
if (socket && socket.readyState === WebSocket.OPEN) {
if (socket) {
const s = socket;
socket = null;
try {
socket.close();
s.close();
} catch {
// ignore close races
}
@@ -1078,6 +1180,7 @@ export default function App() {
onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems,
onVerifyMasterPassword: vaultSendActions.verifyMasterPassword,
onCreateFolder: vaultSendActions.createFolder,
onRenameFolder: vaultSendActions.renameFolder,
onDeleteFolder: vaultSendActions.deleteFolder,
onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders,
onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment,
@@ -1100,7 +1203,10 @@ export default function App() {
},
onOpenDisableTotp: () => setDisableTotpOpen(true),
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey,
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onRemoveDevice: accountSecurityActions.openRemoveDevice,
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
@@ -1113,13 +1219,16 @@ export default function App() {
onRevokeInvite: adminActions.revokeInvite,
onExportBackup: backupActions.exportBackup,
onImportBackup: backupActions.importBackup,
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
onLoadBackupSettings: backupActions.loadSettings,
onSaveBackupSettings: backupActions.saveSettings,
onRunRemoteBackup: backupActions.runRemoteBackup,
onListRemoteBackups: backupActions.listRemoteBackups,
onDownloadRemoteBackup: backupActions.downloadRemoteBackup,
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
};
if (jwtWarning) {
@@ -1158,7 +1267,8 @@ export default function App() {
<AuthViews
mode={phase}
pendingAction={pendingAuthAction}
unlockReady={!!profile}
unlockReady={!!profile?.key && !!session}
unlockPreparing={unlockPreparing}
loginValues={loginValues}
registerValues={registerValues}
unlockPassword={unlockPassword}
@@ -1197,21 +1307,25 @@ export default function App() {
onRememberDeviceChange={setRememberDevice}
onConfirmTotp={() => void handleTotpVerify()}
onCancelTotp={() => {
if (totpSubmitting) return;
setPendingTotp(null);
setTotpCode('');
setRememberDevice(true);
}}
onUseRecoveryCode={() => {
if (totpSubmitting) return;
setPendingTotp(null);
setTotpCode('');
setRememberDevice(true);
navigate('/recover-2fa');
}}
totpSubmitting={totpSubmitting}
disableTotpOpen={false}
disableTotpPassword=""
onDisableTotpPasswordChange={() => {}}
onConfirmDisableTotp={() => {}}
onCancelDisableTotp={() => {}}
disableTotpSubmitting={false}
/>
</>
);
@@ -1250,14 +1364,27 @@ export default function App() {
onConfirmTotp={() => {}}
onCancelTotp={() => {}}
onUseRecoveryCode={() => {}}
totpSubmitting={false}
disableTotpOpen={disableTotpOpen}
disableTotpPassword={disableTotpPassword}
onDisableTotpPasswordChange={setDisableTotpPassword}
onConfirmDisableTotp={() => void accountSecurityActions.disableTotp()}
onConfirmDisableTotp={() => {
if (disableTotpSubmitting) return;
void (async () => {
setDisableTotpSubmitting(true);
try {
await accountSecurityActions.disableTotp();
} finally {
setDisableTotpSubmitting(false);
}
})();
}}
onCancelDisableTotp={() => {
if (disableTotpSubmitting) return;
setDisableTotpOpen(false);
setDisableTotpPassword('');
}}
disableTotpSubmitting={disableTotpSubmitting}
/>
</>
);
+18 -6
View File
@@ -40,6 +40,12 @@ export default function AdminPage(props: AdminPageProps) {
return status || '-';
};
const normalizeToggleableStatus = (status: string): 'active' | 'banned' | null => {
const normalized = String(status || '').toLowerCase();
if (normalized === 'active' || normalized === 'banned') return normalized;
return null;
};
return (
<div className="stack">
<section className="card">
@@ -55,8 +61,10 @@ export default function AdminPage(props: AdminPageProps) {
</tr>
</thead>
<tbody>
{props.users.map((user) => (
<tr key={user.id}>
{props.users.map((user) => {
const toggleableStatus = normalizeToggleableStatus(user.status);
return (
<tr key={user.id}>
<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_role')}>{roleText(user.role)}</td>
@@ -66,8 +74,11 @@ export default function AdminPage(props: AdminPageProps) {
<button
type="button"
className="btn btn-secondary"
disabled={user.id === props.currentUserId}
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
disabled={user.id === props.currentUserId || !toggleableStatus}
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' ? t('txt_ban') : t('txt_unban')}
@@ -80,8 +91,9 @@ export default function AdminPage(props: AdminPageProps) {
)}
</div>
</td>
</tr>
))}
</tr>
);
})}
</tbody>
</table>
</section>
@@ -33,7 +33,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<header className="topbar">
<div className="brand">
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
<span className="brand-name">NodeWarden</span>
<img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="brand-wordmark" />
<span className="mobile-page-title">{props.currentPageTitle}</span>
</div>
<div className="topbar-actions">
+7 -1
View File
@@ -27,11 +27,13 @@ interface AppGlobalOverlaysProps {
onConfirmTotp: () => void;
onCancelTotp: () => void;
onUseRecoveryCode: () => void;
totpSubmitting: boolean;
disableTotpOpen: boolean;
disableTotpPassword: string;
onDisableTotpPasswordChange: (value: string) => void;
onConfirmDisableTotp: () => void;
onCancelDisableTotp: () => void;
disableTotpSubmitting: boolean;
}
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
@@ -57,12 +59,14 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
confirmText={t('txt_verify')}
cancelText={t('txt_cancel')}
showIcon={false}
confirmDisabled={props.totpSubmitting}
cancelDisabled={props.totpSubmitting}
onConfirm={props.onConfirmTotp}
onCancel={props.onCancelTotp}
afterActions={(
<div className="dialog-extra">
<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')}
</button>
</div>
@@ -86,6 +90,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
cancelText={t('txt_cancel')}
danger
showIcon={false}
confirmDisabled={props.disableTotpSubmitting}
cancelDisabled={props.disableTotpSubmitting}
onConfirm={props.onConfirmDisableTotp}
onCancel={props.onCancelDisableTotp}
>
+14
View File
@@ -74,6 +74,7 @@ export interface AppMainRoutesProps {
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onCreateFolder: (name: string) => Promise<void>;
onRenameFolder: (folderId: string, name: string) => Promise<void>;
onDeleteFolder: (folderId: string) => Promise<void>;
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
@@ -93,7 +94,10 @@ export interface AppMainRoutesProps {
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onRefreshAuthorizedDevices: () => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAllDeviceTrust: () => void;
@@ -106,13 +110,16 @@ export interface AppMainRoutesProps {
onRevokeInvite: (code: string) => Promise<void>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
}
export default function AppMainRoutes(props: AppMainRoutesProps) {
@@ -189,6 +196,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onVerifyMasterPassword={props.onVerifyMasterPassword}
onNotify={props.onNotify}
onCreateFolder={props.onCreateFolder}
onRenameFolder={props.onRenameFolder}
onDeleteFolder={props.onDeleteFolder}
onBulkDeleteFolders={props.onBulkDeleteFolders}
onDownloadAttachment={props.onDownloadVaultAttachment}
@@ -219,6 +227,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onEnableTotp={props.onEnableTotp}
onOpenDisableTotp={props.onOpenDisableTotp}
onGetRecoveryCode={props.onGetRecoveryCode}
onGetApiKey={props.onGetApiKey}
onRotateApiKey={props.onRotateApiKey}
onNotify={props.onNotify}
/>
</Suspense>
@@ -276,6 +286,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
devices={props.authorizedDevices}
loading={props.authorizedDevicesLoading}
onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRenameDevice={props.onRenameAuthorizedDevice}
onRevokeTrust={props.onRevokeDeviceTrust}
onRemoveDevice={props.onRemoveDevice}
onRevokeAll={props.onRevokeAllDeviceTrust}
@@ -333,11 +344,14 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
currentUserId={props.profile?.id || null}
onExport={props.onExportBackup}
onImport={props.onImportBackup}
onImportAllowingChecksumMismatch={props.onImportBackupAllowingChecksumMismatch}
onLoadSettings={props.onLoadBackupSettings}
onListRemoteBackups={props.onListRemoteBackups}
onDownloadRemoteBackup={props.onDownloadRemoteBackup}
onInspectRemoteBackup={props.onInspectRemoteBackup}
onDeleteRemoteBackup={props.onDeleteRemoteBackup}
onRestoreRemoteBackup={props.onRestoreRemoteBackup}
onRestoreRemoteBackupAllowingChecksumMismatch={props.onRestoreRemoteBackupAllowingChecksumMismatch}
onSaveSettings={props.onSaveBackupSettings}
onRunRemoteBackup={props.onRunRemoteBackup}
onNotify={props.onNotify}
+7 -3
View File
@@ -21,6 +21,7 @@ interface AuthViewsProps {
mode: 'login' | 'register' | 'locked';
pendingAction: 'login' | 'register' | 'unlock' | null;
unlockReady: boolean;
unlockPreparing: boolean;
loginValues: LoginValues;
registerValues: RegisterValues;
unlockPassword: string;
@@ -97,14 +98,17 @@ export default function AuthViews(props: AuthViewsProps) {
type="button"
className="auth-link-btn"
onClick={props.onShowLockedPasswordHint}
disabled={unlockBusy}
disabled={unlockBusy || props.unlockPreparing}
>
{t('txt_show_password_hint')}
</button>
</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" />
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
+426 -19
View File
@@ -1,12 +1,15 @@
import { createPortal } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import ConfirmDialog from '@/components/ConfirmDialog';
import {
type AdminBackupImportResponse,
type AdminBackupRunResponse,
type AdminBackupSettings,
type BackupFileIntegrityCheckResult,
type BackupDestinationRecord,
type BackupDestinationType,
type RemoteBackupBrowserResponse,
verifyBackupFileIntegrity,
} from '@/lib/api/backup';
import {
REMOTE_BROWSER_ITEMS_PER_PAGE,
@@ -22,6 +25,7 @@ import {
loadPersistedRemoteBrowserState,
persistRemoteBrowserState,
} from '@/lib/backup-center';
import { BACKUP_PROGRESS_EVENT, type BackupProgressDetail, type BackupProgressOperation } from '@/lib/backup-restore-progress';
import { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations';
import { t } from '@/lib/i18n';
import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail';
@@ -32,16 +36,82 @@ interface BackupCenterPageProps {
currentUserId: string | null;
onExport: (includeAttachments?: boolean) => Promise<void>;
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadSettings: () => Promise<AdminBackupSettings>;
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
}
type PendingRestoreIntegrity =
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
interface BackupProgressPhase {
titleKey: string;
detailKey: string;
}
interface BackupProgressState {
operation: BackupProgressOperation;
source: 'local' | 'remote' | null;
includeAttachments: boolean;
fileLabel: string;
startedAt: number;
phaseIndex: number;
phases: BackupProgressPhase[];
currentTitleKey: string;
currentDetailKey: string;
}
const LOCAL_RESTORE_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_restore_progress_local_upload_title', detailKey: 'txt_backup_restore_progress_local_upload_detail' },
{ titleKey: 'txt_backup_restore_progress_local_shadow_title', detailKey: 'txt_backup_restore_progress_local_shadow_detail' },
{ titleKey: 'txt_backup_restore_progress_local_data_title', detailKey: 'txt_backup_restore_progress_local_data_detail' },
{ titleKey: 'txt_backup_restore_progress_local_files_title', detailKey: 'txt_backup_restore_progress_local_files_detail' },
{ titleKey: 'txt_backup_restore_progress_local_finalize_title', detailKey: 'txt_backup_restore_progress_local_finalize_detail' },
];
const REMOTE_RESTORE_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_restore_progress_remote_fetch_title', detailKey: 'txt_backup_restore_progress_remote_fetch_detail' },
{ titleKey: 'txt_backup_restore_progress_remote_shadow_title', detailKey: 'txt_backup_restore_progress_remote_shadow_detail' },
{ titleKey: 'txt_backup_restore_progress_remote_data_title', detailKey: 'txt_backup_restore_progress_remote_data_detail' },
{ titleKey: 'txt_backup_restore_progress_remote_files_title', detailKey: 'txt_backup_restore_progress_remote_files_detail' },
{ titleKey: 'txt_backup_restore_progress_remote_finalize_title', detailKey: 'txt_backup_restore_progress_remote_finalize_detail' },
];
const EXPORT_PROGRESS_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_detail' },
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_detail' },
{ titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' },
{ titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' },
];
const EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' },
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' },
{ titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' },
{ titleKey: 'txt_backup_export_progress_fetch_attachments_title', detailKey: 'txt_backup_export_progress_fetch_attachments_detail' },
{ titleKey: 'txt_backup_export_progress_rebuild_title', detailKey: 'txt_backup_export_progress_rebuild_detail' },
{ titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' },
];
const REMOTE_RUN_PROGRESS_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_remote_run_progress_prepare_title', detailKey: 'txt_backup_remote_run_progress_prepare_detail' },
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' },
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' },
{ titleKey: 'txt_backup_remote_run_progress_sync_attachments_title', detailKey: 'txt_backup_remote_run_progress_sync_attachments_detail' },
{ titleKey: 'txt_backup_remote_run_progress_upload_title', detailKey: 'txt_backup_remote_run_progress_upload_detail' },
{ titleKey: 'txt_backup_remote_run_progress_verify_title', detailKey: 'txt_backup_remote_run_progress_verify_detail' },
{ titleKey: 'txt_backup_remote_run_progress_cleanup_title', detailKey: 'txt_backup_remote_run_progress_cleanup_detail' },
];
function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null {
const skipped = result.skipped;
if (!skipped || !skipped.attachments) return null;
@@ -51,10 +121,56 @@ function buildSkippedImportMessage(result: AdminBackupImportResponse): string |
});
}
function buildIntegrityStatusMessage(result: BackupFileIntegrityCheckResult, options?: { remote?: boolean }): string {
if (!result.hasChecksumPrefix) {
return t(options?.remote ? 'txt_backup_remote_restore_completed_without_checksum' : 'txt_backup_restore_completed_without_checksum');
}
return t(options?.remote ? 'txt_backup_remote_restore_completed_verified' : 'txt_backup_restore_completed_verified');
}
function buildIntegrityWarningMessage(entry: PendingRestoreIntegrity): string {
if (entry.source === 'remote') {
return t('txt_backup_remote_restore_checksum_warning_message', {
name: entry.fileName,
expected: entry.result.expectedPrefix || '-----',
actual: entry.result.actualPrefix,
});
}
return t('txt_backup_restore_checksum_warning_message', {
name: entry.fileName,
expected: entry.result.expectedPrefix || '-----',
actual: entry.result.actualPrefix,
});
}
function getBackupProgressPhases(
operation: BackupProgressOperation,
source: 'local' | 'remote' | null,
includeAttachments: boolean
): BackupProgressPhase[] {
if (operation === 'backup-restore') {
return source === 'remote' ? REMOTE_RESTORE_PHASES : LOCAL_RESTORE_PHASES;
}
if (operation === 'backup-export') {
return includeAttachments ? EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES : EXPORT_PROGRESS_PHASES;
}
return REMOTE_RUN_PROGRESS_PHASES;
}
function getBackupProgressTitleKey(state: BackupProgressState): string {
if (state.operation === 'backup-export') return 'txt_backup_export_progress_title';
if (state.operation === 'backup-remote-run') return 'txt_backup_remote_run_progress_title';
return state.source === 'remote'
? 'txt_backup_restore_progress_remote_title'
: 'txt_backup_restore_progress_local_title';
}
export default function BackupCenterPage(props: BackupCenterPageProps) {
const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState(props.currentUserId));
const persistedRemoteState = persistedRemoteStateRef.current;
const fileInputRef = useRef<HTMLInputElement | null>(null);
const restoreProgressTimerRef = useRef<number | null>(null);
const restoreProgressPendingRef = useRef<BackupProgressState | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [exporting, setExporting] = useState(false);
@@ -67,14 +183,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
const [restoringRemotePath, setRestoringRemotePath] = useState('');
const [remoteRestoreStatusText, setRemoteRestoreStatusText] = useState('');
const [deletingRemotePath, setDeletingRemotePath] = useState('');
const [localError, setLocalError] = useState('');
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false);
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
const [savedSettings, setSavedSettings] = useState<AdminBackupSettings | null>(null);
@@ -148,6 +267,59 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
});
}, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]);
useEffect(() => {
if (!restoreProgress) {
setRestoreElapsedSeconds(0);
return;
}
setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000)));
const tickTimer = window.setInterval(() => {
setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000)));
}, 1000);
return () => window.clearInterval(tickTimer);
}, [restoreProgress]);
useEffect(() => {
const handleProgress = (event: Event) => {
const detail = (event as CustomEvent<BackupProgressDetail>).detail;
if (!detail) return;
const pending = restoreProgressPendingRef.current;
const operation = detail.operation || pending?.operation || 'backup-restore';
const source = (detail.source || pending?.source || null) as 'local' | 'remote' | null;
const includeAttachments = pending?.includeAttachments || false;
const phases = getBackupProgressPhases(operation, source, includeAttachments);
const matchedPhaseIndex = phases.findIndex((phase) => phase.titleKey === detail.stageTitle);
const phaseIndex = matchedPhaseIndex >= 0 ? matchedPhaseIndex : 0;
const nextState: BackupProgressState = {
operation,
source,
includeAttachments,
fileLabel: detail.fileName || pending?.fileLabel || '',
startedAt: pending?.operation === operation
? pending.startedAt
: Date.now(),
phaseIndex,
phases,
currentTitleKey: detail.stageTitle || phases[Math.max(0, phaseIndex)].titleKey,
currentDetailKey: detail.stageDetail || phases[Math.max(0, phaseIndex)].detailKey,
};
restoreProgressPendingRef.current = nextState;
if (restoreProgressTimerRef.current === null) {
setRestoreProgress(nextState);
}
if (detail.done) {
window.setTimeout(() => {
setRestoreProgress((current) => (
current && current.fileLabel === (detail.fileName || current.fileLabel) ? null : current
));
setRestoreElapsedSeconds(0);
}, detail.ok === false ? 1200 : 900);
}
};
window.addEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener);
return () => window.removeEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener);
}, []);
function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) {
setSettings((current) => {
const next = mutator(current);
@@ -225,6 +397,67 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (fileInputRef.current) fileInputRef.current.value = '';
}
function resetPendingIntegrityWarning() {
setPendingRestoreIntegrity(null);
setConfirmIntegrityWarningOpen(false);
}
function startRestoreProgress(
operation: BackupProgressOperation,
fileLabel: string,
options?: { source?: 'local' | 'remote' | null; includeAttachments?: boolean; delayMs?: number }
) {
if (restoreProgressTimerRef.current !== null) {
window.clearTimeout(restoreProgressTimerRef.current);
restoreProgressTimerRef.current = null;
}
setRestoreElapsedSeconds(0);
const source = options?.source || null;
const includeAttachments = !!options?.includeAttachments;
const phases = getBackupProgressPhases(operation, source, includeAttachments);
restoreProgressPendingRef.current = {
operation,
source,
includeAttachments,
fileLabel,
startedAt: Date.now(),
phaseIndex: 0,
phases,
currentTitleKey: phases[0].titleKey,
currentDetailKey: phases[0].detailKey,
};
restoreProgressTimerRef.current = window.setTimeout(() => {
restoreProgressTimerRef.current = null;
if (!restoreProgressPendingRef.current) return;
setRestoreProgress(restoreProgressPendingRef.current);
}, options?.delayMs ?? 480);
}
function clearRestoreProgress() {
if (restoreProgressTimerRef.current !== null) {
window.clearTimeout(restoreProgressTimerRef.current);
restoreProgressTimerRef.current = null;
}
restoreProgressPendingRef.current = null;
setRestoreProgress(null);
setRestoreElapsedSeconds(0);
}
async function inspectLocalBackupFile(file: File): Promise<BackupFileIntegrityCheckResult> {
const bytes = new Uint8Array(await file.arrayBuffer());
return verifyBackupFileIntegrity(bytes, file.name || '');
}
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
const payload = await props.onInspectRemoteBackup(destinationId, path);
return {
source: 'remote',
path,
fileName: payload.fileName || path.split('/').pop() || path,
result: payload.integrity,
};
}
function handleAddDestination(type: BackupDestinationType) {
updateSettings((current) => {
const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1);
@@ -277,18 +510,25 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setLocalError('');
setExporting(true);
try {
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
await props.onExport(exportIncludeAttachments);
props.onNotify('success', t('txt_backup_export_success'));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200);
} finally {
setExporting(false);
}
}
async function runLocalRestore(replaceExisting: boolean) {
async function runLocalRestore(
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (importing) return;
if (!selectedFile) {
const message = t('txt_backup_file_required');
setLocalError(message);
@@ -296,17 +536,29 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
return;
}
setLocalError('');
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false);
setConfirmIntegrityWarningOpen(false);
setImporting(true);
try {
const result = await props.onImport(selectedFile, replaceExisting);
props.onNotify('success', t('txt_backup_restore_success_relogin'));
const integrity = knownIntegrity || await inspectLocalBackupFile(selectedFile);
startRestoreProgress('backup-restore', selectedFile.name || t('txt_backup_import'), {
source: 'local',
delayMs: replaceExisting ? 480 : 1400,
});
const result = allowChecksumMismatch
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting)
: await props.onImport(selectedFile, replaceExisting);
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
resetSelectedFile();
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false);
resetPendingIntegrityWarning();
} catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) {
clearRestoreProgress();
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(true);
return;
@@ -314,6 +566,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200);
} finally {
setImporting(false);
}
@@ -364,16 +617,21 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setRunningRemoteBackup(true);
setLocalError('');
try {
startRestoreProgress('backup-remote-run', selectedDestination.name || t('txt_backup_run_now'), {
source: 'remote',
includeAttachments: !!selectedDestination.includeAttachments,
});
const result = await props.onRunRemoteBackup(selectedDestination.id);
setSavedSettings(result.settings);
setSettings(result.settings);
setSelectedDestinationId(selectedDestination.id);
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
props.onNotify('success', t('txt_backup_remote_run_success'));
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200);
} finally {
setRunningRemoteBackup(false);
}
@@ -397,6 +655,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}
async function handleDeleteRemote(path: string) {
if (deletingRemotePath) return;
if (!savedSelectedDestination) return;
setDeletingRemotePath(path);
setLocalError('');
@@ -415,30 +674,89 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}
}
async function runRemoteRestore(path: string, replaceExisting: boolean) {
async function handleSelectedLocalFile(nextFile: File | null) {
setSelectedFile(nextFile);
setLocalError('');
resetPendingIntegrityWarning();
setConfirmLocalRestoreOpen(false);
if (!nextFile) return;
try {
const integrity = await inspectLocalBackupFile(nextFile);
if (!integrity.matches) {
setPendingRestoreIntegrity({
source: 'local',
fileName: nextFile.name,
result: integrity,
});
setConfirmIntegrityWarningOpen(true);
return;
}
setConfirmLocalRestoreOpen(true);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
setLocalError(message);
props.onNotify('error', message);
}
}
async function handlePromptRemoteRestore(path: string) {
if (!savedSelectedDestination) return;
setLocalError('');
resetPendingIntegrityWarning();
try {
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
if (!integrity.result.matches) {
setPendingRestoreIntegrity(integrity);
setConfirmIntegrityWarningOpen(true);
return;
}
await runRemoteRestore(path, false, false, integrity.result);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
setLocalError(message);
props.onNotify('error', message);
}
}
async function runRemoteRestore(
path: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (restoringRemotePath) return;
if (!savedSelectedDestination) return;
setConfirmRemoteReplaceOpen(false);
setConfirmIntegrityWarningOpen(false);
setRestoringRemotePath(path);
setRemoteRestoreStatusText(replaceExisting ? t('txt_backup_remote_restore_stage_replace') : t('txt_backup_remote_restore_stage_prepare'));
setLocalError('');
try {
const result = await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path);
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
source: 'remote',
delayMs: replaceExisting ? 480 : 1400,
});
const result = allowChecksumMismatch
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting)
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath('');
setRemoteRestoreStatusText('');
props.onNotify('success', t('txt_backup_restore_success_relogin'));
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
resetPendingIntegrityWarning();
} catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) {
setPendingRemoteRestorePath(path);
setConfirmRemoteReplaceOpen(true);
setRemoteRestoreStatusText('');
clearRestoreProgress();
return;
}
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
setRemoteRestoreStatusText('');
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200);
} finally {
setRestoringRemotePath('');
}
@@ -454,9 +772,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
disabled={disableWhileBusy}
onChange={(event) => {
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
setSelectedFile(nextFile);
setLocalError('');
if (nextFile) setConfirmLocalRestoreOpen(true);
void handleSelectedLocalFile(nextFile);
}}
/>
@@ -521,7 +837,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path);
}}
onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)}
onRestoreRemoteBackup={(path) => void runRemoteRestore(path, false)}
onRestoreRemoteBackup={(path) => void handlePromptRemoteRestore(path)}
onPromptDeleteRemoteBackup={(path) => {
setPendingRemoteDeletePath(path);
setConfirmRemoteDeleteOpen(true);
@@ -533,7 +849,49 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
/>
{localError ? <div className="local-error">{localError}</div> : null}
{!localError && remoteRestoreStatusText ? <div className="status-ok">{remoteRestoreStatusText}</div> : null}
{restoreProgress && typeof document !== 'undefined' ? createPortal((
<div className="restore-progress-overlay" aria-live="polite">
<section className="restore-progress-card restore-progress-modal">
<div className="restore-progress-head">
<div>
<div className="restore-progress-kicker">{t('txt_backup_progress_kicker')}</div>
<h3 className="restore-progress-title">
{t(getBackupProgressTitleKey(restoreProgress))}
</h3>
<p className="restore-progress-subtitle">
{t('txt_backup_progress_subject', { name: restoreProgress.fileLabel })}
</p>
</div>
<div className="restore-progress-elapsed">
{t('txt_backup_restore_progress_elapsed', { seconds: String(restoreElapsedSeconds) })}
</div>
</div>
<div className="restore-progress-meter">
<span
className="restore-progress-meter-bar"
style={{
width: `${((restoreProgress.phaseIndex + 1) / restoreProgress.phases.length) * 100}%`,
}}
/>
</div>
<div className="restore-progress-current">
<strong>{t(restoreProgress.currentTitleKey)}</strong>
<p>{t(restoreProgress.currentDetailKey)}</p>
</div>
<ol className="restore-progress-list">
{restoreProgress.phases.map((phase, index) => {
const status = index < restoreProgress.phaseIndex ? 'done' : index === restoreProgress.phaseIndex ? 'active' : 'pending';
return (
<li key={phase.titleKey} className={`restore-progress-item ${status}`}>
<span className="restore-progress-dot" />
<span className="restore-progress-item-text">{t(phase.titleKey)}</span>
</li>
);
})}
</ol>
</section>
</div>
), document.body) : null}
<ConfirmDialog
open={confirmLocalRestoreOpen}
@@ -541,11 +899,15 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')}
confirmText={t('txt_backup_import')}
cancelText={t('txt_cancel')}
confirmDisabled={importing}
cancelDisabled={importing}
danger
onConfirm={() => void runLocalRestore(false)}
onCancel={() => {
if (importing) return;
setConfirmLocalRestoreOpen(false);
resetSelectedFile();
resetPendingIntegrityWarning();
}}
/>
@@ -558,11 +920,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
confirmDisabled={importing}
cancelDisabled={importing}
danger
onConfirm={() => void runLocalRestore(true)}
onConfirm={() => void runLocalRestore(
true,
pendingRestoreIntegrity?.source === 'local',
pendingRestoreIntegrity?.source === 'local' ? pendingRestoreIntegrity.result : undefined
)}
onCancel={() => {
if (importing) return;
setConfirmReplaceOpen(false);
resetSelectedFile();
resetPendingIntegrityWarning();
}}
/>
@@ -575,11 +942,47 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
confirmDisabled={!!restoringRemotePath}
cancelDisabled={!!restoringRemotePath}
danger
onConfirm={() => void runRemoteRestore(pendingRemoteRestorePath, true)}
onConfirm={() => void runRemoteRestore(
pendingRemoteRestorePath,
true,
pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath,
pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath
? pendingRestoreIntegrity.result
: undefined
)}
onCancel={() => {
if (restoringRemotePath) return;
setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath('');
resetPendingIntegrityWarning();
}}
/>
<ConfirmDialog
open={confirmIntegrityWarningOpen}
title={t('txt_backup_restore_checksum_warning_title')}
message={pendingRestoreIntegrity ? buildIntegrityWarningMessage(pendingRestoreIntegrity) : t('txt_backup_restore_checksum_warning_message_fallback')}
variant="warning"
confirmText={t('txt_backup_restore_checksum_warning_confirm')}
cancelText={t('txt_cancel')}
confirmDisabled={importing || !!restoringRemotePath}
cancelDisabled={importing || !!restoringRemotePath}
danger
onConfirm={() => {
if (!pendingRestoreIntegrity) return;
setConfirmIntegrityWarningOpen(false);
if (pendingRestoreIntegrity.source === 'local') {
void runLocalRestore(false, true, pendingRestoreIntegrity.result);
return;
}
void runRemoteRestore(pendingRestoreIntegrity.path, false, true, pendingRestoreIntegrity.result);
}}
onCancel={() => {
if (importing || restoringRemotePath) return;
resetPendingIntegrityWarning();
setPendingRemoteRestorePath('');
setConfirmLocalRestoreOpen(false);
resetSelectedFile();
}}
/>
@@ -589,6 +992,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
confirmText={t('txt_delete')}
cancelText={t('txt_cancel')}
confirmDisabled={!!deletingRemotePath}
cancelDisabled={!!deletingRemotePath}
danger
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
onCancel={() => {
@@ -606,6 +1011,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
})}
confirmText={t('txt_delete')}
cancelText={t('txt_cancel')}
confirmDisabled={savingSettings}
cancelDisabled={savingSettings}
danger
onConfirm={() => void handleDeleteDestination()}
onCancel={() => {
+71 -6
View File
@@ -1,11 +1,14 @@
import { createPortal } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { TriangleAlert } from 'lucide-preact';
import { t } from '@/lib/i18n';
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
variant?: 'default' | 'warning';
showIcon?: boolean;
confirmText?: string;
cancelText?: string;
@@ -19,9 +22,49 @@ interface ConfirmDialogProps {
afterActions?: ComponentChildren;
}
function incrementDialogBodyLock() {
if (typeof document === 'undefined') return;
const body = document.body;
const nextCount = Number(body.dataset.dialogCount || '0') + 1;
body.dataset.dialogCount = String(nextCount);
body.classList.add('dialog-open');
}
function decrementDialogBodyLock() {
if (typeof document === 'undefined') return;
const body = document.body;
const nextCount = Math.max(0, Number(body.dataset.dialogCount || '0') - 1);
if (nextCount === 0) {
delete body.dataset.dialogCount;
body.classList.remove('dialog-open');
return;
}
body.dataset.dialogCount = String(nextCount);
}
export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) {
useEffect(() => {
if (!active) return;
incrementDialogBodyLock();
return () => decrementDialogBodyLock();
}, [active]);
useEffect(() => {
if (!active || !onCancel || typeof window === 'undefined') return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
onCancel();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [active, onCancel]);
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false);
const canDismiss = !props.cancelDisabled && !closing && !props.hideCancel;
useEffect(() => {
if (props.open) {
@@ -38,19 +81,41 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
return () => window.clearTimeout(timer);
}, [props.open, present]);
if (!present) return null;
return (
<div className={`dialog-mask ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}>
useDialogLifecycle(present, canDismiss ? props.onCancel : null);
if (!present || typeof document === 'undefined') return null;
return createPortal((
<div
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
onClick={(event) => {
if (event.target !== event.currentTarget || !canDismiss) return;
props.onCancel();
}}
>
<form
className={`dialog-card ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
role="dialog"
aria-modal="true"
aria-label={props.title}
onSubmit={(e) => {
e.preventDefault();
if (props.confirmDisabled || closing) return;
props.onConfirm();
}}
>
{props.variant === 'warning' ? (
<>
<div className="dialog-warning-strip" aria-hidden="true" />
<div className="dialog-warning-head">
<div className="dialog-warning-badge" aria-hidden="true">
<TriangleAlert size={24} />
</div>
<div className="dialog-warning-kicker">{t('txt_warning')}</div>
</div>
</>
) : null}
<h3 className="dialog-title">{props.title}</h3>
<div className="dialog-message">{props.message}</div>
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
{props.children}
<button
type="submit"
@@ -75,5 +140,5 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
{props.afterActions}
</form>
</div>
);
), document.body);
}
+23 -5
View File
@@ -1,9 +1,10 @@
import { useState } from 'preact/hooks';
import { argon2idAsync } from '@noble/hashes/argon2.js';
import { createPortal } from 'preact/compat';
import { strFromU8, unzipSync } from 'fflate';
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
import { Download, FileUp } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { CiphersImportPayload } from '@/lib/api/vault';
import {
type EncryptedJsonMode,
@@ -311,6 +312,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
const [exportAuthPassword, setExportAuthPassword] = useState('');
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
useDialogLifecycle(!!importSummary, importSummary ? () => setImportSummary(null) : null);
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
@@ -465,6 +468,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
}
async function handlePasswordImportConfirm() {
if (isPasswordSubmitting) return;
if (!pendingPasswordImport) return;
setIsPasswordSubmitting(true);
try {
@@ -483,6 +487,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
}
async function handleZipPasswordImportConfirm() {
if (isZipPasswordSubmitting) return;
if (!pendingZipFile) return;
setIsZipPasswordSubmitting(true);
try {
@@ -555,6 +560,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
}
async function handleExportConfirmPassword() {
if (isExporting) return;
const masterPassword = String(exportAuthPassword || '').trim();
if (!masterPassword) {
onNotify('error', t('txt_master_password_is_required'));
@@ -733,6 +739,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
cancelText={t('txt_cancel')}
showIcon={false}
confirmDisabled={isExporting}
cancelDisabled={isExporting}
onConfirm={() => void handleExportConfirmPassword()}
onCancel={() => {
if (isExporting) return;
@@ -758,6 +766,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
cancelText={t('txt_cancel')}
showIcon={false}
confirmDisabled={isPasswordSubmitting}
cancelDisabled={isPasswordSubmitting}
onConfirm={() => void handlePasswordImportConfirm()}
onCancel={() => {
if (isPasswordSubmitting) return;
@@ -784,6 +794,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
cancelText={t('txt_cancel')}
showIcon={false}
confirmDisabled={isZipPasswordSubmitting}
cancelDisabled={isZipPasswordSubmitting}
onConfirm={() => void handleZipPasswordImportConfirm()}
onCancel={() => {
if (isZipPasswordSubmitting) return;
@@ -803,9 +815,15 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
</label>
</ConfirmDialog>
{importSummary && (
<div className="dialog-mask">
<section className="dialog-card import-summary-dialog">
{importSummary && typeof document !== 'undefined' ? createPortal((
<div
className="dialog-mask"
onClick={(event) => {
if (event.target !== event.currentTarget) return;
setImportSummary(null);
}}
>
<section className="dialog-card import-summary-dialog" role="dialog" aria-modal="true" aria-label={t('txt_import_success')}>
<button
type="button"
className="import-summary-close"
@@ -866,7 +884,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
</button>
</section>
</div>
)}
), document.body) : null}
</div>
);
}
+4 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
import { toBufferSource } from '@/lib/crypto';
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
@@ -61,13 +62,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
if (props.keyPart) {
try {
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 {
// 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 {
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
}
downloadBytesAsFile(
new Uint8Array(await blob.arrayBuffer()),
+79 -11
View File
@@ -1,4 +1,6 @@
import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import type { AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n';
@@ -6,6 +8,7 @@ interface SecurityDevicesPageProps {
devices: AuthorizedDevice[];
loading: boolean;
onRefresh: () => void;
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAll: () => void;
@@ -41,9 +44,26 @@ function mapDeviceTypeName(type: number): string {
}
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
const [deviceNote, setDeviceNote] = useState('');
const [savingNote, setSavingNote] = useState(false);
async function handleSaveDeviceNote(): Promise<void> {
if (!editingDevice || savingNote) return;
setSavingNote(true);
try {
await props.onRenameDevice(editingDevice, deviceNote);
setEditingDevice(null);
setDeviceNote('');
} finally {
setSavingNote(false);
}
}
return (
<div className="stack">
<section className="card">
<>
<div className="stack">
<section className="card">
<div className="section-head">
<div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
@@ -66,9 +86,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</button>
</div>
</div>
</section>
</section>
<section className="card">
<section className="card">
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
<table className="table">
<thead>
@@ -87,6 +107,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<tr key={device.identifier}>
<td data-label={t('txt_device')}>
<div>{device.name || t('txt_unknown_device')}</div>
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
<div className="muted-inline">{device.systemName}</div>
)}
<div className="muted-inline">{device.identifier}</div>
</td>
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
@@ -96,7 +119,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</span>
</td>
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
<td data-label={t('txt_last_seen')}>{formatDateTime(device.revisionDate)}</td>
<td data-label={t('txt_last_seen')}>{formatDateTime(device.lastSeenAt || device.revisionDate)}</td>
<td data-label={t('txt_trusted_until')}>
{device.trusted ? (
<div className="trusted-cell">
@@ -116,11 +139,28 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
onClick={() => props.onRevokeTrust(device)}
>
<ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_trust')}
{t('txt_untrust')}
</button>
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}>
<button
type="button"
className="btn btn-secondary small"
disabled={device.hasStoredDevice === false}
onClick={() => {
setEditingDevice(device);
setDeviceNote(device.deviceNote || device.name || '');
}}
>
<Pencil size={14} className="btn-icon" />
{t('txt_device_note')}
</button>
<button
type="button"
className="btn btn-danger small"
disabled={device.hasStoredDevice === false}
onClick={() => props.onRemoveDevice(device)}
>
<Trash2 size={14} className="btn-icon" />
{t('txt_remove_device_2')}
{t('txt_delete')}
</button>
</div>
</td>
@@ -135,7 +175,35 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
)}
</tbody>
</table>
</section>
</div>
</section>
</div>
<ConfirmDialog
open={!!editingDevice}
title={t('txt_device_note')}
message={t('txt_replace_device_name_with_note')}
confirmText={t('txt_save')}
cancelText={t('txt_cancel')}
showIcon={false}
confirmDisabled={savingNote}
cancelDisabled={savingNote}
onConfirm={() => void handleSaveDeviceNote()}
onCancel={() => {
if (savingNote) return;
setEditingDevice(null);
setDeviceNote('');
}}
>
<label className="field">
<span>{t('txt_device_note')}</span>
<input
className="input"
maxLength={128}
value={deviceNote}
onInput={(e) => setDeviceNote((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
</>
);
}
+134 -1
View File
@@ -4,6 +4,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types';
import { t } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog';
interface SettingsPageProps {
profile: Profile;
@@ -13,6 +14,8 @@ interface SettingsPageProps {
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onNotify?: (type: 'success' | 'error', text: string) => void;
}
@@ -47,6 +50,10 @@ export default function SettingsPage(props: SettingsPageProps) {
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
const [recoveryCode, setRecoveryCode] = useState('');
const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState('');
const [apiKey, setApiKey] = useState('');
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
useEffect(() => {
if (!props.totpEnabled) {
@@ -64,7 +71,8 @@ export default function SettingsPage(props: SettingsPageProps) {
const qr = qrcode(0, 'M');
qr.addData(buildOtpUri(props.profile.email, secret));
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)}`;
}, [props.profile.email, secret]);
@@ -85,6 +93,34 @@ export default function SettingsPage(props: SettingsPageProps) {
props.onNotify?.('success', t('txt_recovery_code_loaded'));
}
async function loadApiKey(): Promise<void> {
try {
const key = await props.onGetApiKey(apiKeyMasterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty'));
}
}
async function doRotateApiKey(): Promise<void> {
try {
const key = await props.onRotateApiKey(apiKeyMasterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
props.onNotify?.('success', t('txt_api_key_rotated'));
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty'));
}
}
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 (
<div className="stack">
<section className="card">
@@ -226,8 +262,105 @@ export default function SettingsPage(props: SettingsPageProps) {
</div>
)}
</div>
<div className="settings-subcard">
<h3>{t('txt_api_key')}</h3>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
value={apiKeyMasterPassword}
onInput={(e) => setApiKeyMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadApiKey()}>
<KeyRound size={14} className="btn-icon" />
{t('txt_view_api_key')}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setRotateApiKeyConfirmOpen(true)}
>
<RefreshCw size={14} className="btn-icon" />
{t('txt_rotate_api_key')}
</button>
</div>
</div>
</div>
</section>
<ConfirmDialog
open={apiKeyDialogOpen}
title={t('txt_api_key')}
message={t('txt_api_key_dialog_intro')}
hideCancel
confirmText={t('txt_close')}
onConfirm={() => setApiKeyDialogOpen(false)}
onCancel={() => setApiKeyDialogOpen(false)}
>
<div
style={{
border: '1px solid color-mix(in srgb, var(--danger) 24%, transparent)',
background: 'color-mix(in srgb, var(--danger) 7%, var(--surface))',
borderRadius: 8,
padding: 14,
marginTop: 12,
marginBottom: 14,
}}
>
<div style={{ fontWeight: 800, color: 'var(--danger)', marginBottom: 8 }}>{t('txt_warning')}</div>
<div style={{ color: 'var(--text)', lineHeight: 1.55 }}>{t('txt_api_key_warning_body')}</div>
</div>
<div
style={{
border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)',
background: 'color-mix(in srgb, var(--primary) 7%, var(--surface))',
borderRadius: 8,
padding: 14,
marginBottom: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 800, color: 'var(--primary)', marginBottom: 10 }}>
<KeyRound size={15} />
<span>{t('txt_oauth_client_credentials')}</span>
</div>
{([
[t('txt_client_id'), `user.${props.profile.id}`],
[t('txt_client_secret'), apiKey],
[t('txt_scope'), 'api'],
[t('txt_grant_type'), 'client_credentials'],
] as [string, string][]).map(([label, value]) => (
<label key={label} className="field">
<span>{label}</span>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', gap: 8 }}>
<input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} />
<button
type="button"
className="btn btn-secondary small"
onClick={() => void copyTextToClipboard(value, { successMessage: t('txt_copied') })}
>
<Clipboard size={14} className="btn-icon" />
{t('txt_copy')}
</button>
</div>
</label>
))}
</div>
</ConfirmDialog>
<ConfirmDialog
open={rotateApiKeyConfirmOpen}
title={t('txt_rotate_api_key')}
message={t('txt_rotate_api_key_confirm')}
danger
onConfirm={() => {
setRotateApiKeyConfirmOpen(false);
void doRotateApiKey();
}}
onCancel={() => setRotateApiKeyConfirmOpen(false)}
/>
</div>
);
}
@@ -12,7 +12,7 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
<div className="standalone-brand standalone-brand-outside">
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
<div>
<div className="standalone-brand-title">NodeWarden</div>
<img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="standalone-brand-wordmark" />
</div>
</div>
+7 -1
View File
@@ -1,3 +1,4 @@
import type { JSX } from 'preact';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
import {
@@ -64,6 +65,10 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher);
const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
}, [host]);
if (host && !errored) {
return (
<img
@@ -96,6 +101,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.cipher.id,
});
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
const style = {
transform: CSS.Transform.toString(transform),
@@ -113,7 +119,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
className="btn btn-secondary small totp-drag-btn"
title={t('txt_drag_to_reorder')}
aria-label={t('txt_drag_to_reorder')}
{...attributes}
{...dragButtonAttributes}
{...listeners}
>
<GripVertical size={14} className="btn-icon" />
+53 -6
View File
@@ -50,6 +50,7 @@ interface VaultPageProps {
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
onCreateFolder: (name: string) => Promise<void>;
onRenameFolder: (folderId: string, name: string) => Promise<void>;
onDeleteFolder: (folderId: string) => Promise<void>;
onBulkDeleteFolders: (folderIds: 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 [createFolderOpen, setCreateFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [pendingRenameFolder, setPendingRenameFolder] = useState<Folder | null>(null);
const [renameFolderName, setRenameFolderName] = useState('');
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
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 [repromptPassword, setRepromptPassword] = useState('');
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
const [pendingDeletePasskeyIndex, setPendingDeletePasskeyIndex] = useState<number | null>(null);
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
@@ -349,7 +353,6 @@ export default function VaultPage(props: VaultPageProps) {
() => filteredCiphers.slice(virtualRange.start, virtualRange.end),
[filteredCiphers, virtualRange.start, virtualRange.end]
);
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
const selectedAttachments = useMemo(
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
[selectedCipher]
@@ -443,6 +446,7 @@ function folderName(id: string | null | undefined): string {
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
setPendingDeletePasskeyIndex(null);
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
}
@@ -450,6 +454,18 @@ function folderName(id: string | null | undefined): string {
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> {
const ticket = ++sshSeedTicketRef.current;
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> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
@@ -806,6 +839,10 @@ function folderName(id: string | null | undefined): string {
onChangeFilter={setSidebarFilter}
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
onOpenCreateFolder={() => setCreateFolderOpen(true)}
onOpenRenameFolder={(folder) => {
setPendingRenameFolder(folder);
setRenameFolderName(folder.decName || folder.name || '');
}}
onOpenDeleteFolder={setPendingDeleteFolder}
/>
@@ -828,6 +865,7 @@ function folderName(id: string | null | undefined): string {
sortMenuRef={sortMenuRef}
listPanelRef={listPanelRef}
onSearchInput={setSearchInput}
onClearSearch={() => setSearchInput('')}
onSearchCompositionStart={() => setSearchComposing(true)}
onSearchCompositionEnd={(value) => {
setSearchComposing(false);
@@ -923,6 +961,7 @@ function folderName(id: string | null | undefined): string {
onUpdateDraftLoginUri={updateDraftLoginUri}
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
onReorderDraftLoginUri={reorderDraftLoginUri}
onRequestDeleteLoginPasskey={setPendingDeletePasskeyIndex}
onQueueAttachmentFiles={queueAttachmentFiles}
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
onRemoveQueuedAttachment={removeQueuedAttachment}
@@ -948,7 +987,7 @@ function folderName(id: string | null | undefined): string {
repromptApprovedCipherId={repromptApprovedCipherId}
showPassword={showPassword}
totpLive={totpLive}
passkeyCreatedAt={passkeyCreatedAt}
passkeyCreatedAt={firstPasskeyCreationTime(selectedCipher)}
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
folderName={folderName}
onOpenReprompt={() => setRepromptOpen(true)}
@@ -970,6 +1009,7 @@ function folderName(id: string | null | undefined): string {
</div>
<VaultDialogs
busy={busy}
fieldModalOpen={fieldModalOpen}
fieldType={fieldType}
fieldLabel={fieldLabel}
@@ -985,10 +1025,13 @@ function folderName(id: string | null | undefined): string {
folders={props.folders}
createFolderOpen={createFolderOpen}
newFolderName={newFolderName}
renameFolderOpen={!!pendingRenameFolder}
renameFolderName={renameFolderName}
pendingDeleteFolder={pendingDeleteFolder}
deleteAllFoldersOpen={deleteAllFoldersOpen}
repromptOpen={repromptOpen}
repromptPassword={repromptPassword}
deletePasskeyOpen={pendingDeletePasskeyIndex != null}
onConfirmAddField={() => {
if (!draft) return;
if (!fieldLabel.trim()) {
@@ -1035,6 +1078,12 @@ function folderName(id: string | null | undefined): string {
setNewFolderName('');
}}
onNewFolderNameChange={setNewFolderName}
onConfirmRenameFolder={() => void confirmRenameFolder()}
onCancelRenameFolder={() => {
setPendingRenameFolder(null);
setRenameFolderName('');
}}
onRenameFolderNameChange={setRenameFolderName}
onConfirmDeleteFolder={() => void confirmDeleteFolder()}
onCancelDeleteFolder={() => setPendingDeleteFolder(null)}
onConfirmDeleteAllFolders={() => void confirmDeleteAllFolders()}
@@ -1045,12 +1094,10 @@ function folderName(id: string | null | undefined): string {
setRepromptPassword('');
}}
onRepromptPasswordChange={setRepromptPassword}
onConfirmDeletePasskey={confirmDeleteLoginPasskey}
onCancelDeletePasskey={() => setPendingDeletePasskeyIndex(null)}
/>
</>
);
}
@@ -134,6 +134,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
...COMMON_TIME_ZONES,
...props.availableTimeZones,
]));
const selectedIntervalHours = props.selectedDestination?.schedule.intervalHours ?? 24;
if (props.selectedRecommendedProvider) {
return (
@@ -216,7 +217,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={String(props.selectedDestination.schedule.intervalHours || 24)}
value={String(selectedIntervalHours)}
disabled={props.loadingSettings || props.disableWhileBusy}
onInput={(event) => {
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
@@ -234,7 +235,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
</div>
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
{INTERVAL_HOUR_PRESETS.map((preset) => {
const active = preset === props.selectedDestination.schedule.intervalHours;
const active = preset === selectedIntervalHours;
return (
<button
key={preset}
@@ -1,5 +1,7 @@
import { useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact';
import { createPortal } from 'preact/compat';
import { useMemo, useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
@@ -35,10 +37,60 @@ interface VaultDetailViewProps {
onUnarchive: (cipher: Cipher) => void | Promise<void>;
}
function PasswordHistoryDialog(props: {
open: boolean;
entries: Array<{ password: string; lastUsedDate: string | null }>;
onClose: () => void;
}) {
useDialogLifecycle(props.open, props.onClose);
if (!props.open || typeof document === 'undefined') return null;
return createPortal(
<div className="dialog-mask open" onClick={(event) => event.target === event.currentTarget && props.onClose()}>
<section className="dialog-card password-history-dialog open" role="dialog" aria-modal="true" aria-label={t('txt_password_history')}>
<div className="password-history-head">
<h3 className="dialog-title">{t('txt_password_history')}</h3>
<button type="button" className="password-history-close" aria-label={t('txt_close')} onClick={props.onClose}>
<X size={18} />
</button>
</div>
<div className="password-history-list">
{props.entries.map((entry, index) => (
<div key={`password-history-${index}-${entry.lastUsedDate || 'none'}`} className="password-history-item">
<div className="password-history-copy">
<button type="button" className="btn btn-secondary small password-history-copy-btn" onClick={() => copyToClipboard(entry.password)}>
<Clipboard size={16} />
</button>
</div>
<div className="password-history-value">{entry.password}</div>
<div className="password-history-time">{formatHistoryTime(entry.lastUsedDate)}</div>
</div>
))}
</div>
<button type="button" className="btn btn-primary dialog-btn" onClick={props.onClose}>
{t('txt_close')}
</button>
</section>
</div>,
document.body
);
}
export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
const passwordHistoryEntries = useMemo(
() =>
(props.selectedCipher.passwordHistory || [])
.map((entry) => ({
password: String(entry?.decPassword || entry?.password || ''),
lastUsedDate: entry?.lastUsedDate ?? null,
}))
.filter((entry) => entry.password.trim()),
[props.selectedCipher.passwordHistory]
);
const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -355,6 +407,14 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<h4>{t('txt_item_history')}</h4>
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
{!!props.selectedCipher.login?.passwordRevisionDate && (
<div className="detail-sub">{t('txt_password_updated_value', { value: formatHistoryTime(props.selectedCipher.login.passwordRevisionDate) })}</div>
)}
{passwordHistoryEntries.length > 0 && (
<button type="button" className="password-history-link" onClick={() => setPasswordHistoryOpen(true)}>
{t('txt_password_history')}
</button>
)}
</div>
)}
@@ -379,6 +439,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
</div>
</>
)}
<PasswordHistoryDialog
open={passwordHistoryOpen}
entries={passwordHistoryEntries}
onClose={() => setPasswordHistoryOpen(false)}
/>
</>
);
}
+101 -5
View File
@@ -4,6 +4,7 @@ import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vaul
import { t } from '@/lib/i18n';
interface VaultDialogsProps {
busy: boolean;
fieldModalOpen: boolean;
fieldType: CustomFieldType;
fieldLabel: string;
@@ -19,10 +20,13 @@ interface VaultDialogsProps {
folders: Folder[];
createFolderOpen: boolean;
newFolderName: string;
renameFolderOpen: boolean;
renameFolderName: string;
pendingDeleteFolder: Folder | null;
deleteAllFoldersOpen: boolean;
repromptOpen: boolean;
repromptPassword: string;
deletePasskeyOpen: boolean;
onConfirmAddField: () => void;
onCancelFieldModal: () => void;
onFieldTypeChange: (value: CustomFieldType) => void;
@@ -42,6 +46,9 @@ interface VaultDialogsProps {
onConfirmCreateFolder: () => void;
onCancelCreateFolder: () => void;
onNewFolderNameChange: (value: string) => void;
onConfirmRenameFolder: () => void;
onCancelRenameFolder: () => void;
onRenameFolderNameChange: (value: string) => void;
onConfirmDeleteFolder: () => void;
onCancelDeleteFolder: () => void;
onConfirmDeleteAllFolders: () => void;
@@ -49,6 +56,8 @@ interface VaultDialogsProps {
onConfirmReprompt: () => void;
onCancelReprompt: () => void;
onRepromptPasswordChange: (value: string) => void;
onConfirmDeletePasskey: () => void;
onCancelDeletePasskey: () => void;
}
export default function VaultDialogs(props: VaultDialogsProps) {
@@ -100,6 +109,8 @@ export default function VaultDialogs(props: VaultDialogsProps) {
message={t('txt_archive_item_message')}
confirmText={t('txt_archive')}
cancelText={t('txt_cancel')}
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmArchive}
onCancel={props.onCancelArchive}
/>
@@ -110,11 +121,22 @@ export default function VaultDialogs(props: VaultDialogsProps) {
message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
confirmText={t('txt_archive')}
cancelText={t('txt_cancel')}
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmBulkArchive}
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
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 })
}
danger
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmBulkDelete}
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">
<span>{t('txt_folder')}</span>
<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>
</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">
<span>{t('txt_folder_name')}</span>
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
</label>
</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
open={!!props.pendingDeleteFolder}
title={t('txt_delete_folder')}
@@ -157,18 +218,53 @@ export default function VaultDialogs(props: VaultDialogsProps) {
confirmText={t('txt_delete')}
cancelText={t('txt_cancel')}
danger
confirmDisabled={props.busy}
cancelDisabled={props.busy}
onConfirm={props.onConfirmDeleteFolder}
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">
<span>{t('txt_master_password')}</span>
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
</label>
</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 { useEffect, useRef, useState } from 'preact/hooks';
import {
@@ -20,7 +20,15 @@ import {
import { CSS } from '@dnd-kit/utilities';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
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 {
draft: VaultDraft;
@@ -44,6 +52,7 @@ interface VaultEditorProps {
onUpdateDraftLoginUri: (index: number, value: string) => void;
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
onRequestDeleteLoginPasskey: (index: number) => void;
onQueueAttachmentFiles: (list: FileList | null) => void;
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
onRemoveQueuedAttachment: (index: number) => void;
@@ -71,6 +80,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id,
});
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
const style = {
transform: CSS.Transform.toString(transform),
@@ -89,7 +99,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
className="btn btn-secondary small website-drag-btn"
title={t('txt_drag_to_reorder')}
aria-label={t('txt_drag_to_reorder')}
{...attributes}
{...dragButtonAttributes}
{...listeners}
>
<GripVertical size={14} className="btn-icon" />
@@ -287,6 +297,42 @@ export default function VaultEditor(props: VaultEditorProps) {
))}
</SortableContext>
</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>
)}
+29 -11
View File
@@ -37,6 +37,7 @@ interface VaultListPanelProps {
sortMenuRef: RefObject<HTMLDivElement>;
listPanelRef: RefObject<HTMLDivElement>;
onSearchInput: (value: string) => void;
onClearSearch: () => void;
onSearchCompositionStart: () => void;
onSearchCompositionEnd: (value: string) => void;
onToggleSortMenu: () => void;
@@ -62,14 +63,32 @@ export default function VaultListPanel(props: VaultListPanelProps) {
return (
<section className="list-col">
<div className="list-head">
<input
className="search-input"
placeholder={t('txt_search_your_secure_vault')}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
/>
<div className="search-input-wrap">
<input
className="search-input"
placeholder={t('txt_search_your_secure_vault')}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !props.searchInput) return;
e.preventDefault();
props.onClearSearch();
}}
/>
{!!props.searchInput && (
<button
type="button"
className="search-clear-btn"
aria-label={t('txt_clear_search')}
title={t('txt_clear_search_esc')}
onClick={props.onClearSearch}
>
<X size={14} />
</button>
)}
</div>
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
<button
type="button"
@@ -166,11 +185,10 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
{!!props.filteredCiphers.length && (
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
{props.visibleCiphers.map((cipher, index) => (
{props.visibleCiphers.map((cipher) => (
<div
key={cipher.id}
className={`list-item stagger-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
@@ -8,6 +8,7 @@ import {
Globe,
KeyRound,
LayoutGrid,
Pencil,
ShieldUser,
Star,
StickyNote,
@@ -28,6 +29,7 @@ interface VaultSidebarProps {
onChangeFilter: (filter: SidebarFilter) => void;
onOpenDeleteAllFolders: () => void;
onOpenCreateFolder: () => void;
onOpenRenameFolder: (folder: Folder) => void;
onOpenDeleteFolder: (folder: Folder) => void;
}
@@ -113,6 +115,20 @@ export default function VaultSidebar(props: VaultSidebarProps) {
{folder.decName || folder.name || folder.id}
</span>
</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
type="button"
className="folder-delete-btn"
@@ -1,4 +1,4 @@
import { useState } from 'preact/hooks';
import { useEffect, useState } from 'preact/hooks';
import {
CreditCard,
FileKey2,
@@ -37,7 +37,7 @@ export const CREATE_TYPE_OPTIONS: TypeOption[] = [
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
export const VAULT_LIST_ROW_HEIGHT = 66;
export const VAULT_LIST_ROW_HEIGHT = 74;
export const VAULT_LIST_OVERSCAN = 10;
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
{ value: 'edited', label: t('txt_sort_last_edited') },
@@ -161,11 +161,11 @@ export function hostFromUri(uri: string): string {
}
export function websiteIconUrl(host: string): string {
return `/icons/${encodeURIComponent(host)}/icon.png`;
return `/icons/${encodeURIComponent(host)}/icon.png?fallback=404`;
}
export function createEmptyLoginUri(): VaultDraftLoginUri {
return { uri: '', match: null };
return { uri: '', match: null, originalUri: '', extra: {} };
}
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) => ({
uri: x.decUri || x.uri || '',
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)
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
@@ -429,6 +433,10 @@ export function VaultListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher);
const host = hostFromUri(uri);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
useEffect(() => {
setErrored(host ? failedIconHosts.has(host) : false);
}, [host]);
if (host && !errored) {
return (
<img
+69 -15
View File
@@ -5,10 +5,13 @@ import {
deleteAuthorizedDevice,
deriveLoginHash,
getCurrentDeviceIdentifier,
getApiKey,
getTotpRecoveryCode,
rotateApiKey,
revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust,
setTotp,
updateAuthorizedDeviceName,
updateProfile,
} from '@/lib/api/auth';
import { t } from '@/lib/i18n';
@@ -147,10 +150,45 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
return code;
},
async getApiKey(masterPassword: string): Promise<string> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const key = await getApiKey(authedFetch, derived.hash);
if (!key) throw new Error(t('txt_api_key_is_empty'));
return key;
},
async rotateApiKey(masterPassword: string): Promise<string> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const key = await rotateApiKey(authedFetch, derived.hash);
if (!key) throw new Error(t('txt_api_key_is_empty'));
return key;
},
async refreshAuthorizedDevices() {
await refetchAuthorizedDevices();
},
async renameAuthorizedDevice(device: AuthorizedDevice, name: string) {
const normalized = String(name || '').trim();
if (!normalized) {
onNotify('error', t('txt_device_note_required'));
return;
}
try {
await updateAuthorizedDeviceName(authedFetch, device.identifier, normalized);
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_note_updated'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_device_note_failed'));
}
},
openRevokeDeviceTrust(device: AuthorizedDevice) {
onSetConfirm({
title: t('txt_revoke_device_authorization'),
@@ -159,9 +197,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => {
onSetConfirm(null);
void (async () => {
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_authorization_revoked'));
try {
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_authorization_revoked'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_device_trust_failed'));
}
})();
},
});
@@ -175,14 +217,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => {
onSetConfirm(null);
void (async () => {
await deleteAuthorizedDevice(authedFetch, device.identifier);
if (device.identifier === getCurrentDeviceIdentifier()) {
try {
await deleteAuthorizedDevice(authedFetch, device.identifier);
if (device.identifier === getCurrentDeviceIdentifier()) {
onNotify('success', t('txt_device_removed'));
onLogoutNow();
return;
}
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_removed'));
onLogoutNow();
return;
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_remove_device_failed'));
}
await refetchAuthorizedDevices();
onNotify('success', t('txt_device_removed'));
})();
},
});
@@ -196,9 +242,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => {
onSetConfirm(null);
void (async () => {
await revokeAllAuthorizedDeviceTrust(authedFetch);
await refetchAuthorizedDevices();
onNotify('success', t('txt_all_device_authorizations_revoked'));
try {
await revokeAllAuthorizedDeviceTrust(authedFetch);
await refetchAuthorizedDevices();
onNotify('success', t('txt_all_device_authorizations_revoked'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_all_device_trust_failed'));
}
})();
},
});
@@ -212,9 +262,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onConfirm: () => {
onSetConfirm(null);
void (async () => {
await deleteAllAuthorizedDevices(authedFetch);
onNotify('success', t('txt_all_devices_removed'));
onLogoutNow();
try {
await deleteAllAuthorizedDevices(authedFetch);
onNotify('success', t('txt_all_devices_removed'));
onLogoutNow();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_remove_all_devices_failed'));
}
})();
},
});
+40 -5
View File
@@ -1,16 +1,19 @@
import { useMemo } from 'preact/hooks';
import {
type BackupExportClientProgressEvent,
buildCompleteAdminBackupExport,
deleteRemoteBackup,
downloadRemoteBackup,
downloadRemoteBackup as fetchRemoteBackupPayload,
getAdminBackupSettings,
importAdminBackup,
inspectRemoteBackupIntegrity,
listRemoteBackups,
restoreRemoteBackup,
restoreRemoteBackup as restoreRemoteBackupRequest,
runAdminBackupNow,
saveAdminBackupSettings,
} from '@/lib/api/backup';
import { downloadBytesAsFile } from '@/lib/download';
import { dispatchBackupProgress } from '@/lib/backup-restore-progress';
import type { AuthedFetch } from '@/lib/api/shared';
interface UseBackupActionsOptions {
@@ -25,8 +28,24 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return useMemo(
() => ({
async exportBackup(includeAttachments: boolean = false) {
const payload = await buildCompleteAdminBackupExport(authedFetch, includeAttachments);
const payload = await buildCompleteAdminBackupExport(
authedFetch,
includeAttachments,
async (event: BackupExportClientProgressEvent) => {
dispatchBackupProgress(event);
}
);
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
dispatchBackupProgress({
operation: 'backup-export',
source: 'local',
step: 'export_complete',
fileName: payload.fileName,
stageTitle: 'txt_backup_export_progress_complete_title',
stageDetail: 'txt_backup_export_progress_complete_detail',
done: true,
ok: true,
});
},
async importBackup(file: File, replaceExisting: boolean = false) {
@@ -35,6 +54,12 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return result;
},
async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, file, replaceExisting, true);
onImported?.();
return result;
},
async loadSettings() {
return getAdminBackupSettings(authedFetch);
},
@@ -52,16 +77,26 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
},
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
const payload = await downloadRemoteBackup(authedFetch, destinationId, path, onProgress);
const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress);
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
},
async inspectRemoteBackup(destinationId: string, path: string) {
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
},
async deleteRemoteBackup(destinationId: string, path: string) {
await deleteRemoteBackup(authedFetch, destinationId, path);
},
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting);
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting);
onRestored?.();
return result;
},
async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true);
onRestored?.();
return result;
},
+23
View File
@@ -43,6 +43,7 @@ import {
type CiphersImportPayload,
type ImportedCipherMapEntry,
updateCipher,
updateFolder,
unarchiveCipher,
uploadCipherAttachment,
} 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[]) {
try {
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 { Profile, SessionState } from './types';
@@ -9,7 +9,7 @@ const AES_GCM_ALGORITHM = 'AES-GCM';
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey(
'pkcs8',
pkcs8,
toBufferSource(pkcs8),
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
false,
['decrypt']
@@ -17,7 +17,7 @@ async function importPortablePrivateKey(pkcs8: 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(
@@ -50,15 +50,15 @@ export async function decryptPortableBackupSettings(
await crypto.subtle.decrypt(
{ name: PORTABLE_ALGORITHM },
privateKey,
base64ToBytes(wrap.wrappedKey)
toBufferSource(base64ToBytes(wrap.wrappedKey))
)
);
const aesKey = await importPortableAesKey(portableDek);
const plaintext = new Uint8Array(
await crypto.subtle.decrypt(
{ name: AES_GCM_ALGORITHM, iv: base64ToBytes(portable.iv) },
{ name: AES_GCM_ALGORITHM, iv: toBufferSource(base64ToBytes(portable.iv)) },
aesKey,
base64ToBytes(portable.ciphertext)
toBufferSource(base64ToBytes(portable.ciphertext))
)
);
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
+159 -19
View File
@@ -10,8 +10,10 @@ import type {
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
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 TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
const WEB_SESSION_HEADER = 'X-NodeWarden-Web-Session';
export interface PreloginResult {
hash: string;
@@ -26,6 +28,24 @@ export interface PreloginKdfConfig {
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 {
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);
@@ -66,12 +86,19 @@ export function loadSession(): SessionState | null {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as SessionState;
if (!parsed.accessToken || !parsed.refreshToken) return null;
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
if (parsed.authMode === 'web-cookie' && parsed.email) {
return {
email: parsed.email,
authMode: 'web-cookie',
};
}
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
return {
accessToken: parsed.accessToken,
refreshToken: parsed.refreshToken,
email: parsed.email,
authMode: 'token',
};
} catch {
return null;
@@ -83,14 +110,35 @@ export function saveSession(session: SessionState | null): void {
localStorage.removeItem(SESSION_KEY);
return;
}
const persisted: SessionState = {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
const persisted: PersistedSessionState = {
email: session.email,
authMode: session.authMode === 'token' ? 'token' : 'web-cookie',
};
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 {
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
}
@@ -170,7 +218,10 @@ export async function loginWithPassword(
}
const resp = await fetch('/identity/connect/token', {
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(),
});
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
@@ -183,18 +234,60 @@ export async function loginWithPassword(
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();
body.set('grant_type', 'refresh_token');
body.set('refresh_token', refreshToken);
const resp = await fetch('/identity/connect/token', {
if (session.authMode !== 'web-cookie' && session.refreshToken) {
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',
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(),
});
if (!resp.ok) return null;
const json = await parseJson<TokenSuccess>(resp);
return json || null;
}).catch(() => undefined);
}
export async function registerAccount(args: {
@@ -279,18 +372,22 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
headers.set('Authorization', `Bearer ${session.accessToken}`);
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);
if (!refreshed?.access_token) {
const refreshed = await refreshAccessToken(session);
if (!refreshed.ok) {
if (refreshed.transient) {
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
}
setSession(null);
throw new Error('Session expired');
}
const nextSession: SessionState = {
...session,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token || session.refreshToken,
accessToken: refreshed.token.access_token,
refreshToken: refreshed.token.refresh_token || session.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
};
setSession(nextSession);
saveSession(nextSession);
@@ -478,7 +575,50 @@ export async function deleteAuthorizedDevice(
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
}
export async function updateAuthorizedDeviceName(
authedFetch: AuthedFetch,
deviceIdentifier: string,
name: string
): Promise<void> {
const normalized = String(name || '').trim();
if (!normalized) throw new Error(t('txt_device_note_required'));
const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}/name`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: normalized }),
});
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
}
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
}
export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
const resp = await authedFetch('/api/accounts/api-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(body?.error_description || body?.error || 'Failed to get API key');
}
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
return String(body.apiKey || '');
}
export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
const resp = await authedFetch('/api/accounts/rotate-api-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(body?.error_description || body?.error || 'Failed to rotate API key');
}
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
return String(body.apiKey || '');
}
+134 -6
View File
@@ -16,6 +16,7 @@ import {
type AuthedFetch,
} from './shared';
import { readResponseBytesWithProgress } from '../download';
import { toBufferSource } from '../crypto';
import { unzipSync, zipSync } from 'fflate';
export type {
@@ -57,6 +58,21 @@ export interface AdminBackupRunResponse {
settings: AdminBackupSettings;
}
export interface BackupFileIntegrityCheckResult {
hasChecksumPrefix: boolean;
expectedPrefix: string | null;
actualPrefix: string;
matches: boolean;
}
export interface RemoteBackupIntegrityResponse {
object: 'backup-remote-integrity';
destinationId: string;
path: string;
fileName: string;
integrity: BackupFileIntegrityCheckResult;
}
export interface RemoteBackupItem {
path: string;
name: string;
@@ -109,6 +125,18 @@ export interface AdminBackupExportPayload {
bytes: Uint8Array;
}
export interface BackupExportClientProgressEvent {
operation: 'backup-export';
source: 'local';
step: string;
fileName: string;
stageTitle: string;
stageDetail: string;
done?: boolean;
ok?: boolean;
error?: string | null;
}
interface BackupExportManifestAttachmentBlob {
cipherId: string;
attachmentId: string;
@@ -119,6 +147,25 @@ interface BackupExportManifest {
attachmentBlobs?: BackupExportManifestAttachmentBlob[];
}
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
function extractBackupTimestampFromFileName(fileName: string): string | null {
const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i);
if (!match) return null;
return `${match[1]}_${match[2]}`;
}
function buildBackupFileName(timestamp: string, checksumPrefix: string): string {
return `nodewarden_backup_${timestamp}_${checksumPrefix}.zip`;
}
async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> {
const integrity = await verifyBackupFileIntegrity(bytes, fileName);
const timestamp = extractBackupTimestampFromFileName(fileName);
if (!timestamp) return fileName;
return buildBackupFileName(timestamp, integrity.actualPrefix);
}
export async function exportAdminBackup(
authedFetch: AuthedFetch,
includeAttachments: boolean = false
@@ -149,10 +196,21 @@ export async function downloadAdminBackupAttachmentBlob(
export async function buildCompleteAdminBackupExport(
authedFetch: AuthedFetch,
includeAttachments: boolean = false
includeAttachments: boolean = false,
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
): Promise<AdminBackupExportPayload> {
const payload = await exportAdminBackup(authedFetch, includeAttachments);
if (!includeAttachments) return payload;
if (!includeAttachments) {
await onProgress?.({
operation: 'backup-export',
source: 'local',
step: 'export_client_save',
fileName: payload.fileName,
stageTitle: 'txt_backup_export_progress_save_title',
stageDetail: 'txt_backup_export_progress_save_detail',
});
return payload;
}
const zipped = unzipSync(payload.bytes);
const manifestBytes = zipped['manifest.json'];
@@ -167,14 +225,41 @@ export async function buildCompleteAdminBackupExport(
throw new Error(t('txt_backup_export_failed'));
}
await onProgress?.({
operation: 'backup-export',
source: 'local',
step: 'export_client_fetch_attachments',
fileName: payload.fileName,
stageTitle: 'txt_backup_export_progress_fetch_attachments_title',
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
});
for (const attachment of manifest.attachmentBlobs || []) {
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
}
await onProgress?.({
operation: 'backup-export',
source: 'local',
step: 'export_client_rebuild',
fileName: payload.fileName,
stageTitle: 'txt_backup_export_progress_rebuild_title',
stageDetail: 'txt_backup_export_progress_rebuild_detail',
});
const rebuiltBytes = zipSync(zipped, { level: 0 });
const rebuiltFileName = await applyBackupFileIntegrityName(payload.fileName, rebuiltBytes);
await onProgress?.({
operation: 'backup-export',
source: 'local',
step: 'export_client_save',
fileName: rebuiltFileName,
stageTitle: 'txt_backup_export_progress_save_title',
stageDetail: 'txt_backup_export_progress_save_detail',
});
return {
...payload,
bytes: zipSync(zipped, { level: 0 }),
bytes: rebuiltBytes,
fileName: rebuiltFileName,
};
}
@@ -276,6 +361,29 @@ export async function downloadRemoteBackup(
return { fileName, mimeType, bytes };
}
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
const normalized = String(fileName || '').trim();
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
return match ? match[1].toLowerCase() : null;
}
async function sha256Hex(bytes: Uint8Array): Promise<string> {
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('');
}
export async function verifyBackupFileIntegrity(bytes: Uint8Array, fileName: string): Promise<BackupFileIntegrityCheckResult> {
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
const actualHash = await sha256Hex(bytes);
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
return {
hasChecksumPrefix: !!expectedPrefix,
expectedPrefix,
actualPrefix,
matches: !expectedPrefix || expectedPrefix === actualPrefix,
};
}
export async function deleteRemoteBackup(
authedFetch: AuthedFetch,
destinationId: string,
@@ -288,16 +396,32 @@ export async function deleteRemoteBackup(
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
}
export async function inspectRemoteBackupIntegrity(
authedFetch: AuthedFetch,
destinationId: string,
path: string
): Promise<RemoteBackupIntegrityResponse> {
const params = new URLSearchParams();
params.set('destinationId', destinationId);
params.set('path', path);
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
return body;
}
export async function restoreRemoteBackup(
authedFetch: AuthedFetch,
destinationId: string,
path: string,
replaceExisting: boolean = false
replaceExisting: boolean = false,
allowChecksumMismatch: boolean = false
): Promise<AdminBackupImportResponse> {
const resp = await authedFetch('/api/admin/backup/remote/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId, path, replaceExisting }),
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
const body = await parseJson<AdminBackupImportResponse>(resp);
@@ -308,13 +432,17 @@ export async function restoreRemoteBackup(
export async function importAdminBackup(
authedFetch: AuthedFetch,
file: File,
replaceExisting: boolean = false
replaceExisting: boolean = false,
allowChecksumMismatch: boolean = false
): Promise<AdminBackupImportResponse> {
const formData = new FormData();
formData.set('file', file, file.name || 'nodewarden_backup.zip');
if (replaceExisting) {
formData.set('replaceExisting', '1');
}
if (allowChecksumMismatch) {
formData.set('allowChecksumMismatch', '1');
}
const resp = await authedFetch('/api/admin/backup/import', {
method: 'POST',
+7 -5
View File
@@ -1,6 +1,7 @@
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
import type { Send, SendDraft, SessionState } from '../types';
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
import { loadVaultSyncSnapshot } from './vault-sync';
function toIsoDateFromDays(value: string, required: boolean): string | null {
const raw = String(value || '').trim();
@@ -61,10 +62,8 @@ function parseMaxAccessCountRaw(value: string): number | null {
}
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
const resp = await authedFetch('/api/sends');
if (!resp.ok) throw new Error('Failed to load sends');
const body = await parseJson<{ object: 'list'; data: Send[] }>(resp);
return body?.data || [];
const body = await loadVaultSyncSnapshot(authedFetch);
return body.sends || [];
}
export async function createSend(
@@ -152,10 +151,13 @@ export async function createSend(
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
const uploadUrl = uploadInfo?.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({
accessToken: session.accessToken,
uploadUrl,
payload: encryptedFileBytes,
payload,
fileUploadType: uploadInfo?.fileUploadType,
unsupportedMessage: 'Unsupported send upload type',
onProgress,
+2 -2
View File
@@ -63,14 +63,14 @@ interface UploadWithProgressOptions {
accessToken?: string;
method?: string;
headers?: HeadersInit;
body?: Document | XMLHttpRequestBodyInit | null;
body?: XMLHttpRequestBodyInit | null;
onProgress?: (percent: number | null) => void;
}
interface DirectEncryptedUploadOptions {
accessToken: string;
uploadUrl: string;
payload: ArrayBuffer | Uint8Array;
payload: XMLHttpRequestBodyInit;
fileUploadType: number | null | undefined;
unsupportedMessage: string;
onProgress?: (percent: number | null) => void;
+37
View File
@@ -0,0 +1,37 @@
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', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
},
});
if (!resp.ok) throw new Error('Failed to load vault');
const body = await parseJson<VaultSyncResponse>(resp);
return body || {};
})();
pendingSyncRequests.set(authedFetch, request);
try {
return await request;
} finally {
if (pendingSyncRequests.get(authedFetch) === request) {
pendingSyncRequests.delete(authedFetch);
}
}
}
+82 -11
View File
@@ -1,8 +1,8 @@
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto';
import type {
Cipher,
CipherPasswordHistoryEntry,
Folder,
ListResponse,
SessionState,
VaultDraft,
VaultDraftField,
@@ -16,12 +16,11 @@ import {
type AuthedFetch,
} from './shared';
import { readResponseBytesWithProgress } from '../download';
import { loadVaultSyncSnapshot } from './vault-sync';
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
const resp = await authedFetch('/api/folders');
if (!resp.ok) throw new Error('Failed to load folders');
const body = await parseJson<ListResponse<Folder>>(resp);
return body?.data || [];
const body = await loadVaultSyncSnapshot(authedFetch);
return body.folders || [];
}
export async function createFolder(
@@ -93,10 +92,8 @@ export async function updateFolder(
}
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
const resp = await authedFetch('/api/ciphers?deleted=true');
if (!resp.ok) throw new Error('Failed to load ciphers');
const body = await parseJson<ListResponse<Cipher>>(resp);
return body?.data || [];
const body = await loadVaultSyncSnapshot(authedFetch);
return body.ciphers || [];
}
export interface CiphersImportPayload {
@@ -240,6 +237,7 @@ export async function uploadCipherAttachment(
const attachmentId = String(meta.attachmentId || '').trim();
const uploadUrl = String(meta.url || '').trim();
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
if (!session.accessToken) throw new Error('Unauthorized');
const payload = new ArrayBuffer(encryptedBytes.byteLength);
new Uint8Array(payload).set(encryptedBytes);
@@ -349,6 +347,61 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array)
return encryptBw(new TextEncoder().encode(s), enc, mac);
}
async function encryptPasswordHistory(
entries: CipherPasswordHistoryEntry[] | null | undefined,
enc: Uint8Array,
mac: Uint8Array
): Promise<CipherPasswordHistoryEntry[] | null> {
if (!Array.isArray(entries) || entries.length === 0) return null;
const out: CipherPasswordHistoryEntry[] = [];
for (const entry of entries) {
const rawPassword = String(entry?.password || '');
const plainPassword = entry?.decPassword ?? rawPassword;
const encryptedPassword = looksLikeCipherString(rawPassword)
? rawPassword
: await encryptTextValue(plainPassword, enc, mac);
if (!encryptedPassword) continue;
out.push({
password: encryptedPassword,
lastUsedDate: toIsoDateOrNow(entry?.lastUsedDate),
});
}
return out.length ? out : null;
}
async function buildUpdatedPasswordHistory(
cipher: Cipher | null,
draft: VaultDraft,
enc: Uint8Array,
mac: Uint8Array
): Promise<CipherPasswordHistoryEntry[] | null> {
const existingHistory = Array.isArray(cipher?.passwordHistory) ? cipher.passwordHistory : [];
const currentPassword = String(cipher?.login?.decPassword || '');
const nextPassword = String(draft.loginPassword || '');
const passwordChanged = currentPassword !== nextPassword;
const history = await encryptPasswordHistory(existingHistory, enc, mac);
if (!passwordChanged || !currentPassword.trim()) {
return history;
}
const encryptedCurrentPassword = await encryptTextValue(currentPassword, enc, mac);
if (!encryptedCurrentPassword) {
return history;
}
const nextEntries: CipherPasswordHistoryEntry[] = [
{
password: encryptedCurrentPassword,
lastUsedDate: new Date().toISOString(),
},
...(history || []),
];
return nextEntries.slice(0, 5);
}
async function encryptCustomFields(
fields: VaultDraftField[],
enc: Uint8Array,
@@ -371,12 +424,20 @@ async function encryptUris(
uris: VaultDraft['loginUris'],
enc: Uint8Array,
mac: Uint8Array
): Promise<Array<{ uri: string | null; match: number | null }>> {
const out: Array<{ uri: string | null; match: number | null }> = [];
): Promise<Array<Record<string, unknown>>> {
const out: Array<Record<string, unknown>> = [];
for (const entry of uris || []) {
const trimmed = String(entry?.uri || '').trim();
if (!trimmed) continue;
const preservedExtra =
entry?.extra && typeof entry.extra === 'object'
? { ...entry.extra }
: {};
if (String(entry?.originalUri || '').trim() !== trimmed) {
delete preservedExtra.uriChecksum;
}
out.push({
...preservedExtra,
uri: await encryptTextValue(trimmed, enc, mac),
match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
});
@@ -468,6 +529,7 @@ async function buildCipherPayload(
const userMac = base64ToBytes(session.symMacKey);
const keys = await getCipherKeys(cipher, userEnc, userMac);
const type = Number(draft.type || cipher?.type || 1);
const now = new Date().toISOString();
const payload: Record<string, unknown> = {
type,
@@ -482,6 +544,7 @@ async function buildCipherPayload(
secureNote: null,
sshKey: null,
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
};
if (cipher?.id) {
@@ -490,17 +553,25 @@ async function buildCipherPayload(
}
if (type === 1) {
const passwordChanged = String(cipher?.login?.decPassword || '') !== String(draft.loginPassword || '');
const existingFido2 =
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
? (cipher.login as any).fido2Credentials
: draft.loginFido2Credentials;
const existingLogin =
cipher?.login && typeof cipher.login === 'object'
? { ...(cipher.login as Record<string, unknown>) }
: {};
payload.login = {
...existingLogin,
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
passwordRevisionDate: passwordChanged ? now : existingLogin.passwordRevisionDate ?? null,
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
};
payload.passwordHistory = await buildUpdatedPasswordHistory(cipher, draft, keys.enc, keys.mac);
} else if (type === 3) {
payload.card = {
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
+48 -22
View File
@@ -2,6 +2,7 @@ import {
createAuthedFetch,
deriveLoginHashLocally,
getProfile,
loadProfileSnapshot,
loadSession,
loginWithPassword,
refreshAccessToken,
@@ -26,6 +27,7 @@ export interface BootstrapAppResult {
session: SessionState | null;
profile: Profile | null;
phase: AppPhase;
needsBackgroundHydration?: boolean;
}
export interface InitialAppBootstrapState {
@@ -51,8 +53,9 @@ export interface RecoverTwoFactorResult {
newRecoveryCode: string | null;
}
function decodeJwtExp(accessToken: string): number | null {
function decodeJwtExp(accessToken: string | undefined): number | null {
try {
if (!accessToken) return null;
const parts = accessToken.split('.');
if (parts.length < 2) return null;
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> {
if (!session.refreshToken) return session;
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null;
const exp = decodeJwtExp(session.accessToken);
const nowSeconds = Math.floor(Date.now() / 1000);
if (exp !== null && exp - nowSeconds > 60) {
if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
return session;
}
const refreshed = await refreshAccessToken(session.refreshToken);
if (!refreshed?.access_token) {
return exp !== null && exp > nowSeconds ? session : null;
const refreshed = await refreshAccessToken(session);
if (!refreshed.ok) {
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
}
return {
...session,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token || session.refreshToken,
accessToken: refreshed.token.access_token,
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 {
const session = await maybeRefreshSession(loaded);
if (!session) {
throw new Error('Session expired');
}
const profile = await getProfile(
createAuthedFetch(
() => session,
() => refreshedSession,
() => {}
)
);
return {
defaultKdfIterations,
jwtWarning: null,
session,
session: refreshedSession,
profile,
phase: 'locked',
};
} catch {
return {
defaultKdfIterations,
jwtWarning: null,
session: null,
profile: null,
phase: initial.phase === 'register' ? 'register' : 'login',
session: refreshedSession,
profile: fallbackProfile,
};
}
}
@@ -236,6 +260,7 @@ export async function completeLogin(
accessToken: token.access_token,
refreshToken: token.refresh_token,
email: normalizedEmail,
authMode: token.web_session ? 'web-cookie' : 'token',
};
const tempFetch = createAuthedFetch(
() => baseSession,
@@ -359,3 +384,4 @@ export async function performUnlock(
}
return { ...refreshedSession, ...keys };
}
+9 -6
View File
@@ -17,6 +17,7 @@ export interface WebVaultSignalRInvocation {
UserId?: string;
Date?: string;
RevisionDate?: string;
[key: string]: unknown;
};
}>;
}
@@ -160,11 +161,6 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
draft.loginUsername = asText(login.username);
draft.loginPassword = asText(login.password);
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 uris = urisRaw
.map((u) => {
@@ -174,10 +170,17 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
return {
uri,
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);
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) {
const card = (cipher.card || {}) as Record<string, unknown>;
draft.cardholderName = asText(card.cardholderName);
+27
View File
@@ -0,0 +1,27 @@
export type BackupProgressOperation = 'backup-restore' | 'backup-export' | 'backup-remote-run';
export interface BackupProgressDetail {
operation: BackupProgressOperation;
source?: 'local' | 'remote';
step: string;
fileName: string;
stageTitle?: string;
stageDetail?: string;
replaceExisting?: boolean;
done?: boolean;
ok?: boolean;
error?: string | null;
Date?: string;
}
export type BackupRestoreProgressDetail = BackupProgressDetail;
export const BACKUP_PROGRESS_EVENT = 'nodewarden:backup-progress';
export const BACKUP_RESTORE_PROGRESS_EVENT = BACKUP_PROGRESS_EVENT;
export function dispatchBackupProgress(detail: BackupProgressDetail): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<BackupProgressDetail>(BACKUP_PROGRESS_EVENT, { detail }));
}
export const dispatchBackupRestoreProgress = dispatchBackupProgress;
+1 -1
View File
@@ -18,7 +18,7 @@ export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
return out;
}
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
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)
? 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 {
+218 -11
View File
@@ -26,11 +26,16 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_export_success: "Backup exported",
txt_backup_import_success_relogin: "Backup restored. Please sign in again.",
txt_backup_restore_success_relogin: "Backup restored. Please sign in again.",
txt_backup_restore_completed_verified: "Backup file integrity verification passed.",
txt_backup_restore_completed_without_checksum: "Backup restored. No filename integrity marker was available for verification.",
txt_backup_remote_restore_completed_verified: "Remote backup integrity verification passed.",
txt_backup_remote_restore_completed_without_checksum: "Remote backup restored. No filename integrity marker was available for verification.",
txt_backup_restore_skipped_summary: "{reason}. Skipped {attachments} attachment(s).",
txt_backup_restore_skipped_reason_default: "Some files could not be restored",
txt_backup_export_failed: "Backup export failed",
txt_backup_import_failed: "Backup restore failed",
txt_backup_restore_failed: "Backup restore failed",
txt_backup_integrity_check_failed: "Backup integrity verification failed",
txt_backup_center_title: "Instance Backup",
txt_backup_center_description: "Keep local exports for manual restore, and configure one daily remote backup target for unattended protection.",
txt_backup_restore_note: "Restoring will overwrite the current instance if you choose the replace flow.",
@@ -99,6 +104,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_run_manual: "Run Manually",
txt_backup_running_now: "Running...",
txt_backup_remote_run_success: "Remote backup completed",
txt_backup_remote_run_success_verified: "Remote backup completed and integrity verification passed.",
txt_backup_remote_run_failed: "Remote backup failed",
txt_backup_remote_title: "Remote Backups",
txt_backup_remote_note: "Browse the saved destination and choose a backup ZIP to download or restore.",
@@ -112,6 +118,68 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_remote_restore: "Restore",
txt_backup_remote_restore_stage_prepare: "Preparing remote backup restore...",
txt_backup_remote_restore_stage_replace: "Clearing current data and restoring remote backup...",
txt_backup_progress_kicker: "Backup Task",
txt_backup_progress_subject: "Current item: {name}",
txt_backup_restore_progress_kicker: "Restore Progress",
txt_backup_restore_progress_local_title: "Restoring local backup",
txt_backup_restore_progress_remote_title: "Restoring remote backup",
txt_backup_export_progress_title: "Exporting backup",
txt_backup_remote_run_progress_title: "Running remote backup",
txt_backup_restore_progress_file: "Current file: {name}",
txt_backup_restore_progress_elapsed: "{seconds}s elapsed",
txt_backup_archive_progress_collect_title: "Collecting vault data",
txt_backup_archive_progress_collect_detail: "The server is reading database tables and assembling the backup payload.",
txt_backup_archive_progress_collect_with_attachments_detail: "The server is reading database tables and collecting attachment metadata for the backup payload.",
txt_backup_archive_progress_package_title: "Packaging backup archive",
txt_backup_archive_progress_package_detail: "The server is generating the backup ZIP and computing its checksum prefix.",
txt_backup_archive_progress_package_with_attachments_detail: "The server is generating the backup ZIP metadata and computing its checksum prefix for the attachment-aware export.",
txt_backup_archive_progress_ready_title: "Preparing download",
txt_backup_archive_progress_ready_detail: "The backup archive is ready and is being returned to the browser.",
txt_backup_export_progress_fetch_attachments_title: "Downloading attachment files",
txt_backup_export_progress_fetch_attachments_detail: "The browser is fetching attachment objects and adding them into the export package.",
txt_backup_export_progress_rebuild_title: "Rebuilding export archive",
txt_backup_export_progress_rebuild_detail: "The browser is rebuilding the final ZIP and refreshing its checksum suffix.",
txt_backup_export_progress_save_title: "Saving export file",
txt_backup_export_progress_save_detail: "The browser is preparing the final backup file for download.",
txt_backup_export_progress_complete_title: "Export completed",
txt_backup_export_progress_complete_detail: "The backup export is ready.",
txt_backup_export_progress_failed_title: "Export failed",
txt_backup_export_progress_failed_detail: "The backup export could not be completed.",
txt_backup_remote_run_progress_prepare_title: "Preparing remote backup",
txt_backup_remote_run_progress_prepare_detail: "The server is loading the selected destination and preparing this backup run.",
txt_backup_remote_run_progress_sync_attachments_title: "Checking attachment index",
txt_backup_remote_run_progress_sync_attachments_detail: "The server is comparing attachment metadata so only missing attachment objects are uploaded.",
txt_backup_remote_run_progress_sync_attachments_skipped_detail: "This backup does not include attachments, so attachment synchronization is skipped.",
txt_backup_remote_run_progress_upload_title: "Uploading backup archive",
txt_backup_remote_run_progress_upload_detail: "The server is uploading the backup ZIP to the remote destination.",
txt_backup_remote_run_progress_verify_title: "Verifying uploaded archive",
txt_backup_remote_run_progress_verify_detail: "The server is downloading the uploaded ZIP back and verifying its checksum and size.",
txt_backup_remote_run_progress_cleanup_title: "Cleaning older backups",
txt_backup_remote_run_progress_cleanup_detail: "The server is pruning older backup files according to the retention policy.",
txt_backup_remote_run_progress_complete_title: "Remote backup completed",
txt_backup_remote_run_progress_complete_detail: "The remote backup has been uploaded and verified successfully.",
txt_backup_remote_run_progress_failed_title: "Remote backup failed",
txt_backup_remote_run_progress_failed_detail: "The remote backup could not be completed.",
txt_backup_restore_progress_local_upload_title: "Uploading backup archive",
txt_backup_restore_progress_local_upload_detail: "The selected ZIP is being sent to the server for processing.",
txt_backup_restore_progress_local_shadow_title: "Creating shadow workspace",
txt_backup_restore_progress_local_shadow_detail: "The server is preparing an isolated restore area so the current data remains untouched until validation passes.",
txt_backup_restore_progress_local_data_title: "Writing vault data",
txt_backup_restore_progress_local_data_detail: "The server is importing users, folders, vault items, and related metadata into shadow tables.",
txt_backup_restore_progress_local_files_title: "Restoring attachment files",
txt_backup_restore_progress_local_files_detail: "The server is writing attachment objects back to storage and removing any attachment rows that cannot be restored.",
txt_backup_restore_progress_local_finalize_title: "Validating and switching data",
txt_backup_restore_progress_local_finalize_detail: "The server is performing final validation and then swapping the verified restore data into the live tables.",
txt_backup_restore_progress_remote_fetch_title: "Reading remote backup",
txt_backup_restore_progress_remote_fetch_detail: "The server is downloading the selected backup package from the remote destination.",
txt_backup_restore_progress_remote_shadow_title: "Creating shadow workspace",
txt_backup_restore_progress_remote_shadow_detail: "The server is preparing an isolated restore area so the current data remains untouched until validation passes.",
txt_backup_restore_progress_remote_data_title: "Writing vault data",
txt_backup_restore_progress_remote_data_detail: "The server is importing users, folders, vault items, and related metadata into shadow tables.",
txt_backup_restore_progress_remote_files_title: "Restoring remote attachments",
txt_backup_restore_progress_remote_files_detail: "The server is fetching required attachment objects from remote storage and writing them back into local storage.",
txt_backup_restore_progress_remote_finalize_title: "Validating and switching data",
txt_backup_restore_progress_remote_finalize_detail: "The server is performing final validation and then switching the verified restore data into the live tables.",
txt_backup_remote_loading: "Loading remote backups...",
txt_backup_remote_cached_empty: "Click Refresh to load this destination.",
txt_backup_remote_empty: "No backup files found in this folder.",
@@ -126,6 +194,11 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_remote_delete_confirm_message: "Delete backup file \"{name}\"? This cannot be undone.",
txt_backup_remote_deleting: "Deleting...",
txt_backup_remote_restore_failed: "Restoring remote backup failed",
txt_backup_restore_checksum_warning_title: "Backup Integrity Warning",
txt_backup_restore_checksum_warning_message: "The selected backup file \"{name}\" failed filename integrity verification. Expected prefix {expected}, actual prefix {actual}. The file may be incomplete or corrupted. Continuing may restore damaged data.",
txt_backup_remote_restore_checksum_warning_message: "The remote backup file \"{name}\" failed filename integrity verification. Expected prefix {expected}, actual prefix {actual}. The file may be corrupted during upload or storage. Continuing may restore damaged data and may cause serious data loss.",
txt_backup_restore_checksum_warning_message_fallback: "The selected backup file failed integrity verification. Continuing may restore damaged data.",
txt_backup_restore_checksum_warning_confirm: "Continue Restore",
txt_backup_remote_restore_invalid_response: "Invalid remote backup restore response",
txt_backup_remote_run_invalid_response: "Invalid remote backup run response",
txt_backup_settings_invalid_response: "Invalid backup settings response",
@@ -197,9 +270,9 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_no_file_selected: "No backup file selected",
txt_backup_selected_file_name: "Selected file: {name}",
txt_backup_replace_confirm_title: "Replace Current Instance Data",
txt_backup_replace_confirm_message: "The current instance already contains data. Clear it and restore the selected backup?",
txt_backup_clear_and_import: "Clear and Import",
txt_backup_clear_and_restore: "Clear and Restore",
txt_backup_replace_confirm_message: "The current instance already contains data. Continue restoring and replace the current instance data with the selected backup after verification succeeds?",
txt_backup_clear_and_import: "Replace and Import",
txt_backup_clear_and_restore: "Replace and Restore",
txt_access_count: "Access Count",
txt_accessed_count_times: "Accessed {count} times",
txt_actions: "Actions",
@@ -220,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_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_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_authenticator_key: "Authenticator Key",
txt_authorized_devices: "Authorized Devices",
@@ -279,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_invites: "Delete all invites",
txt_delete_item: "Delete Item",
txt_delete_passkey: "Delete Passkey",
txt_delete_item_failed: "Delete item failed",
txt_delete_permanently: "Delete Permanently",
txt_archive: "Archive",
@@ -312,6 +387,9 @@ const messages: Record<Locale, Record<string, string>> = {
txt_device: "Device",
txt_device_authorization_revoked: "Device trust revoked",
txt_device_management: "Device Management",
txt_device_note: "Device Note",
txt_device_note_required: "Device name is required",
txt_device_note_updated: "Device name updated",
txt_device_removed: "Device removed",
txt_load_devices_failed: "Failed to load devices",
txt_disable_this_send: "Disable this send",
@@ -384,6 +462,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_item_created: "Item created",
txt_item_deleted: "Item deleted",
txt_item_history: "Item History",
txt_password_history: "Password History",
txt_password_updated_value: "Password updated: {value}",
txt_item_name_is_required: "Item name is required.",
txt_item_updated: "Item updated",
txt_last_edited_value: "Last edited: {value}",
@@ -475,6 +555,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_not_trusted: "Not trusted",
txt_note: "Note",
txt_notes: "Notes",
txt_replace_device_name_with_note: "Set a custom name for this device without changing its detected system type.",
txt_number: "Number",
txt_open: "Open",
txt_opera_browser: "Opera Browser",
@@ -498,6 +579,9 @@ const messages: Record<Locale, Record<string, string>> = {
txt_password_hint_not_set: "No password hint is available for this email.",
txt_password_hint_load_failed: "Failed to load password hint",
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_please_input_email_and_password: "Please input email and password",
txt_please_input_master_password: "Please input master password",
@@ -517,6 +601,21 @@ const messages: Record<Locale, Record<string, string>> = {
txt_recovery_code_copied: "Recovery code copied",
txt_recovery_code_is_empty: "Recovery code is empty",
txt_recovery_code_loaded: "Recovery code loaded",
txt_api_key: "API Key",
txt_view_api_key: "View API Key",
txt_rotate_api_key: "Rotate API Key",
txt_api_key_copied: "API key copied",
txt_api_key_loaded: "API key loaded",
txt_api_key_rotated: "API key rotated",
txt_rotate_api_key_confirm: "Rotate API key? The current key will stop working immediately.",
txt_api_key_is_empty: "API key is empty",
txt_api_key_dialog_intro: "Your API key can be used to authenticate with the Bitwarden CLI.",
txt_api_key_warning_body: "Your API key is an alternative authentication mechanism. Keep it secret.",
txt_oauth_client_credentials: "OAuth 2.0 Client Credentials",
txt_client_id: "client_id",
txt_client_secret: "client_secret",
txt_scope: "scope",
txt_grant_type: "grant_type",
txt_refresh: "Refresh",
txt_refresh_in_seconds_s: "Refresh in {seconds}s",
txt_regenerate: "Regenerate",
@@ -540,12 +639,16 @@ const messages: Record<Locale, Record<string, string>> = {
txt_revoke_device_trust_failed: "Failed to revoke device trust",
txt_revoke_all_device_trust_failed: "Failed to revoke all device trust",
txt_revoke_trust: "Revoke Trust",
txt_untrust: "Untrust",
txt_update_device_note_failed: "Update device note failed",
txt_role: "Role",
txt_save: "Save",
txt_save_profile: "Save Profile",
txt_save_profile_failed: "Save profile failed",
txt_search_sends: "Search sends...",
txt_search_your_secure_vault: "Search your secure vault...",
txt_clear_search: "Clear search",
txt_clear_search_esc: "Clear search (Esc)",
txt_sort: "Sort",
txt_sort_last_edited: "Modified",
txt_sort_created: "Created",
@@ -596,8 +699,6 @@ const messages: Record<Locale, Record<string, string>> = {
txt_total_items_count: "{count} items",
txt_totp_secret: "TOTP Secret",
txt_totp_verify_failed: "TOTP verify failed",
txt_passkey: "Passkey",
txt_passkey_created_at_value: "Created at {value}",
txt_attachments: "Attachments",
txt_upload_attachments: "Upload attachments",
txt_new_attachments: "New attachments",
@@ -641,6 +742,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_vault_synced: "Vault synced",
txt_verification_code: "Verification Code",
txt_verify: "Verify",
txt_warning: "Warning",
txt_view_recovery_code: "View Recovery Code",
txt_web: "Web",
txt_website: "Website",
@@ -674,11 +776,16 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_export_success: '备份已导出',
txt_backup_import_success_relogin: '备份已还原,请重新登录',
txt_backup_restore_success_relogin: '备份已还原,请重新登录',
txt_backup_restore_completed_verified: '备份文件完整性校验已通过。',
txt_backup_restore_completed_without_checksum: '备份已还原,但文件名中未提供可校验的完整性标记。',
txt_backup_remote_restore_completed_verified: '远程备份完整性校验已通过。',
txt_backup_remote_restore_completed_without_checksum: '远程备份已还原,但文件名中未提供可校验的完整性标记。',
txt_backup_restore_skipped_summary: '{reason},已跳过 {attachments} 个附件',
txt_backup_restore_skipped_reason_default: '部分文件无法还原',
txt_backup_export_failed: '备份导出失败',
txt_backup_import_failed: '备份还原失败',
txt_backup_restore_failed: '备份还原失败',
txt_backup_integrity_check_failed: '备份完整性校验失败',
txt_backup_center_title: '实例备份',
txt_backup_center_description: '把本地导出和远程自动备份放在一起管理,既方便手动恢复,也能每天自动留一份。',
txt_backup_restore_note: '还原会覆盖当前实例;如果当前已有数据,系统会要求你确认“清空后还原”。',
@@ -747,6 +854,7 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_run_manual: '手动执行',
txt_backup_running_now: '执行中...',
txt_backup_remote_run_success: '远程备份已完成',
txt_backup_remote_run_success_verified: '远程备份已完成,且完整性校验已通过。',
txt_backup_remote_run_failed: '远程备份失败',
txt_backup_remote_title: '远端备份',
txt_backup_remote_note: '浏览已保存的备份地点,选择某个备份 ZIP 后可以下载,也可以直接还原。',
@@ -760,6 +868,68 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_remote_restore: '还原',
txt_backup_remote_restore_stage_prepare: '正在读取远端备份并检查可恢复内容...',
txt_backup_remote_restore_stage_replace: '正在清空当前数据并还原远端备份,请稍候...',
txt_backup_progress_kicker: '备份任务',
txt_backup_progress_subject: '当前对象:{name}',
txt_backup_restore_progress_kicker: '还原进度',
txt_backup_restore_progress_local_title: '正在还原本地备份',
txt_backup_restore_progress_remote_title: '正在还原远端备份',
txt_backup_export_progress_title: '正在导出备份',
txt_backup_remote_run_progress_title: '正在执行远程备份',
txt_backup_restore_progress_file: '当前文件:{name}',
txt_backup_restore_progress_elapsed: '已耗时 {seconds} 秒',
txt_backup_archive_progress_collect_title: '正在收集密码库数据',
txt_backup_archive_progress_collect_detail: '服务器正在读取数据库表,并整理备份所需的数据内容。',
txt_backup_archive_progress_collect_with_attachments_detail: '服务器正在读取数据库表,并整理附件元数据与备份内容。',
txt_backup_archive_progress_package_title: '正在打包备份压缩包',
txt_backup_archive_progress_package_detail: '服务器正在生成备份 ZIP,并计算文件名校验前缀。',
txt_backup_archive_progress_package_with_attachments_detail: '服务器正在生成带附件信息的备份 ZIP 元数据,并计算文件名校验前缀。',
txt_backup_archive_progress_ready_title: '正在准备下载',
txt_backup_archive_progress_ready_detail: '备份压缩包已经生成,服务器正在把它返回给浏览器。',
txt_backup_export_progress_fetch_attachments_title: '正在下载附件文件',
txt_backup_export_progress_fetch_attachments_detail: '浏览器正在读取附件对象,并把它们补入导出备份包。',
txt_backup_export_progress_rebuild_title: '正在重建导出压缩包',
txt_backup_export_progress_rebuild_detail: '浏览器正在重建最终 ZIP,并刷新文件名里的校验后缀。',
txt_backup_export_progress_save_title: '正在保存导出文件',
txt_backup_export_progress_save_detail: '浏览器正在准备最终的备份文件下载。',
txt_backup_export_progress_complete_title: '备份导出已完成',
txt_backup_export_progress_complete_detail: '导出备份已经准备完成。',
txt_backup_export_progress_failed_title: '备份导出失败',
txt_backup_export_progress_failed_detail: '导出备份未能完成。',
txt_backup_remote_run_progress_prepare_title: '正在准备远程备份',
txt_backup_remote_run_progress_prepare_detail: '服务器正在读取当前备份目标,并准备执行这次远程备份。',
txt_backup_remote_run_progress_sync_attachments_title: '正在检查附件索引',
txt_backup_remote_run_progress_sync_attachments_detail: '服务器正在比对附件索引,只会上传缺失或不一致的附件对象。',
txt_backup_remote_run_progress_sync_attachments_skipped_detail: '当前备份未包含附件,因此跳过附件同步。',
txt_backup_remote_run_progress_upload_title: '正在上传备份压缩包',
txt_backup_remote_run_progress_upload_detail: '服务器正在把备份 ZIP 上传到远程备份目标。',
txt_backup_remote_run_progress_verify_title: '正在校验已上传压缩包',
txt_backup_remote_run_progress_verify_detail: '服务器正在回读刚上传的 ZIP,并校验它的哈希和大小。',
txt_backup_remote_run_progress_cleanup_title: '正在清理旧备份',
txt_backup_remote_run_progress_cleanup_detail: '服务器正在按保留策略清理旧备份文件。',
txt_backup_remote_run_progress_complete_title: '远程备份已完成',
txt_backup_remote_run_progress_complete_detail: '远程备份已上传完成,并通过完整性校验。',
txt_backup_remote_run_progress_failed_title: '远程备份失败',
txt_backup_remote_run_progress_failed_detail: '远程备份未能完成。',
txt_backup_restore_progress_local_upload_title: '正在上传备份包',
txt_backup_restore_progress_local_upload_detail: '已选 ZIP 正在发送到服务器,服务器收到后会开始执行还原。',
txt_backup_restore_progress_local_shadow_title: '正在创建影子恢复区',
txt_backup_restore_progress_local_shadow_detail: '服务器正在准备独立的影子数据区,只有校验通过后才会替换正式数据。',
txt_backup_restore_progress_local_data_title: '正在写入密码库数据',
txt_backup_restore_progress_local_data_detail: '服务器正在把用户、文件夹、密码条目和相关元数据写入影子表。',
txt_backup_restore_progress_local_files_title: '正在恢复附件文件',
txt_backup_restore_progress_local_files_detail: '服务器正在把附件对象写回存储,并剔除无法恢复的附件记录。',
txt_backup_restore_progress_local_finalize_title: '正在校验并完成切换',
txt_backup_restore_progress_local_finalize_detail: '服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。',
txt_backup_restore_progress_remote_fetch_title: '正在读取远端备份包',
txt_backup_restore_progress_remote_fetch_detail: '服务器正在从远端备份目标下载你选中的备份包。',
txt_backup_restore_progress_remote_shadow_title: '正在创建影子恢复区',
txt_backup_restore_progress_remote_shadow_detail: '服务器正在准备独立的影子数据区,只有校验通过后才会替换正式数据。',
txt_backup_restore_progress_remote_data_title: '正在写入密码库数据',
txt_backup_restore_progress_remote_data_detail: '服务器正在把用户、文件夹、密码条目和相关元数据写入影子表。',
txt_backup_restore_progress_remote_files_title: '正在恢复远端附件',
txt_backup_restore_progress_remote_files_detail: '服务器正在从远端存储读取所需附件,并写回到当前实例的附件存储。',
txt_backup_restore_progress_remote_finalize_title: '正在校验并完成切换',
txt_backup_restore_progress_remote_finalize_detail: '服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。',
txt_backup_remote_loading: '正在读取远端备份...',
txt_backup_remote_cached_empty: '点击“刷新”后读取',
txt_backup_remote_empty: '这个目录下还没有备份文件',
@@ -774,6 +944,11 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_remote_delete_confirm_message: '删除备份文件“{name}”?此操作不可撤销。',
txt_backup_remote_deleting: '删除中...',
txt_backup_remote_restore_failed: '还原远端备份失败',
txt_backup_restore_checksum_warning_title: '备份完整性警告',
txt_backup_restore_checksum_warning_message: '所选备份文件“{name}”未通过文件名完整性校验。期望前缀为 {expected},实际计算结果为 {actual}。该文件可能不完整或已经损坏。继续还原可能会导入受损数据。',
txt_backup_remote_restore_checksum_warning_message: '远程备份文件“{name}”未通过文件名完整性校验。期望前缀为 {expected},实际计算结果为 {actual}。该文件可能在上传或存储过程中损坏。继续还原可能会导入受损数据,并可能造成严重后果。',
txt_backup_restore_checksum_warning_message_fallback: '所选备份文件未通过完整性校验。继续还原可能会导入受损数据。',
txt_backup_restore_checksum_warning_confirm: '继续还原',
txt_backup_remote_restore_invalid_response: '远端备份还原响应无效',
txt_backup_remote_run_invalid_response: '远端备份执行响应无效',
txt_backup_settings_invalid_response: '备份设置响应无效',
@@ -845,9 +1020,9 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_no_file_selected: '尚未选择备份文件',
txt_backup_selected_file_name: '已选择文件:{name}',
txt_backup_replace_confirm_title: '替换当前实例数据',
txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再还原所选备份吗',
txt_backup_clear_and_import: '清空后导入',
txt_backup_clear_and_restore: '清空后还原',
txt_backup_replace_confirm_message: '当前实例里已经有数据。确认后,系统会先完成校验与恢复准备,只有在恢复成功后才会用所选备份替换当前实例数据。是否继续',
txt_backup_clear_and_import: '替换并导入',
txt_backup_clear_and_restore: '替换并还原',
txt_sign_out: '退出登录',
txt_log_in: '登录',
txt_logging_in: '正在登录...',
@@ -872,6 +1047,8 @@ const zhCNOverrides: Record<string, string> = {
txt_loading_nodewarden: '正在加载 NodeWarden...',
txt_search_sends: '搜索发送...',
txt_search_your_secure_vault: '搜索你的密码库...',
txt_clear_search: '清空搜索',
txt_clear_search_esc: '清空搜索(Esc',
txt_refresh: '刷新',
txt_sync: '同步',
txt_sync_vault: '同步',
@@ -913,7 +1090,10 @@ const zhCNOverrides: Record<string, string> = {
txt_additional_options: '附加选项',
txt_custom_fields: '自定义字段',
txt_notes: '备注',
txt_replace_device_name_with_note: '为这台设备设置自定义名称,不会改变系统识别到的设备类型。',
txt_item_history: '项目历史',
txt_password_history: '密码历史记录',
txt_password_updated_value: '密码新于: {value}',
txt_last_edited_value: '最后编辑:{value}',
txt_created_value: '创建于:{value}',
txt_username: '用户名',
@@ -959,12 +1139,17 @@ const zhCNOverrides: Record<string, string> = {
txt_view_recovery_code: '查看恢复代码',
txt_copy_code: '复制代码',
txt_device_management: '设备管理',
txt_device_note: '备注',
txt_device_note_required: '设备名称不能为空',
txt_device_note_updated: '设备名称已更新',
txt_authorized_devices: '已授权设备',
txt_device: '设备',
txt_last_seen: '最后在线',
txt_trusted_until: '信任至',
txt_revoke_trust: '撤销信任',
txt_untrust: '不信任',
txt_remove_device_2: '移除设备',
txt_update_device_note_failed: '更新设备备注失败',
txt_not_trusted: '未信任',
txt_unknown_device: '未知设备',
txt_users: '用户',
@@ -1012,6 +1197,7 @@ const zhCNOverrides: Record<string, string> = {
txt_no_name: '(无名称)',
txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?',
txt_delete_item: '删除项目',
txt_delete_passkey: '删除通行密钥',
txt_delete_selected_items: '删除所选项目',
txt_move_selected_items: '移动所选项目',
txt_create_folder: '创建文件夹',
@@ -1075,6 +1261,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_permanently: '确认永久删除所选的 {count} 个项目?',
txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?',
txt_are_you_sure_you_want_to_delete_this_passkey: '确认删除这个通行密钥?',
txt_authenticator_key: '验证器密钥',
txt_brand: '品牌',
txt_bulk_delete_failed: '批量删除失败',
@@ -1175,6 +1362,9 @@ const zhCNOverrides: Record<string, string> = {
txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。',
txt_password_hint_load_failed: '加载密码提示失败',
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
txt_passkey: '通行密钥',
txt_passkeys: '通行密钥',
txt_passkey_created_at_value: '创建于 {value}',
txt_phone: '电话',
txt_please_input_email_and_password: '请输入邮箱和密码',
txt_please_input_master_password: '请输入主密码',
@@ -1188,6 +1378,21 @@ const zhCNOverrides: Record<string, string> = {
txt_recovery_code_copied: '恢复代码已复制',
txt_recovery_code_is_empty: '恢复代码为空',
txt_recovery_code_loaded: '恢复代码已加载',
txt_api_key: 'API 密钥',
txt_view_api_key: '查看 API 密钥',
txt_rotate_api_key: '轮换 API 密钥',
txt_api_key_copied: 'API 密钥已复制',
txt_api_key_loaded: 'API 密钥已加载',
txt_api_key_rotated: 'API 密钥已轮换',
txt_rotate_api_key_confirm: '轮换 API 密钥?当前密钥将立即失效。',
txt_api_key_is_empty: 'API 密钥为空',
txt_api_key_dialog_intro: '您的 API 密钥可用于在 Bitwarden CLI 中进行身份验证。',
txt_api_key_warning_body: '您的 API 密钥是一种替代身份验证机制。请严格保密。',
txt_oauth_client_credentials: 'OAuth 2.0 客户端凭据',
txt_client_id: 'client_id',
txt_client_secret: 'client_secret',
txt_scope: 'scope',
txt_grant_type: 'grant_type',
txt_refresh_in_seconds_s: '{seconds} 秒后刷新',
txt_registration_succeeded_please_sign_in: '注册成功,请登录',
txt_remove_device: '移除设备',
@@ -1239,6 +1444,7 @@ const zhCNOverrides: Record<string, string> = {
txt_user_status_updated: '用户状态已更新',
txt_vault_synced: '密码库已同步',
txt_verify: '验证',
txt_warning: '警告',
txt_web: '网页',
txt_windows_desktop: 'Windows 桌面端',
txt_jwt_warning_title: 'JWT_SECRET 配置警告',
@@ -1279,8 +1485,6 @@ zhCNOverrides.txt_lock = '锁定';
zhCNOverrides.txt_menu = '菜单';
zhCNOverrides.txt_settings = '设置';
zhCNOverrides.txt_back = '返回';
zhCNOverrides.txt_passkey = 'Passkey';
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
zhCNOverrides.txt_attachments = '附件';
zhCNOverrides.txt_upload_attachments = '上传附件';
zhCNOverrides.txt_new_attachments = '待上传附件';
@@ -1341,7 +1545,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_folder_not_found = 'Folder not found';
messages.en.txt_folder_deleted = 'Folder deleted';
messages.en.txt_folder_updated = 'Folder updated';
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_all_folders_failed = 'Delete all folders failed';
messages.en.txt_other = 'Other';
@@ -1423,7 +1629,9 @@ zhCNOverrides.txt_delete_all_folders = '删除全部文件夹';
zhCNOverrides.txt_delete_all_folders_message = '确认删除全部文件夹吗?其中的项目将移至无文件夹。';
zhCNOverrides.txt_folder_not_found = '文件夹不存在';
zhCNOverrides.txt_folder_deleted = '文件夹已删除';
zhCNOverrides.txt_folder_updated = '文件夹已重命名';
zhCNOverrides.txt_folders_deleted = '文件夹已删除';
zhCNOverrides.txt_update_folder_failed = '重命名文件夹失败';
zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败';
zhCNOverrides.txt_delete_all_folders_failed = '删除全部文件夹失败';
zhCNOverrides.txt_other = '其他';
@@ -1477,4 +1685,3 @@ export function setLocale(next: Locale): void {
// ignore storage errors
}
}
+1 -1
View File
@@ -223,7 +223,7 @@ export function makeLoginCipher(): Record<string, unknown> {
favorite: false,
reprompt: 0,
key: null,
login: { username: null, password: null, totp: null, fido2Credentials: null, uris: null },
login: { username: null, password: null, totp: null, uris: null },
card: null,
identity: null,
secureNote: null,
+30 -4
View File
@@ -1,9 +1,10 @@
export type AppPhase = 'register' | 'login' | 'locked' | 'app';
export interface SessionState {
accessToken: string;
refreshToken: string;
accessToken?: string;
refreshToken?: string;
email: string;
authMode?: 'token' | 'web-cookie';
symEncKey?: string;
symMacKey?: string;
}
@@ -28,13 +29,18 @@ export interface Folder {
export interface CipherLoginUri {
uri?: string | null;
uriChecksum?: string | null;
match?: number | null;
response?: unknown | null;
decUri?: string;
[key: string]: unknown;
}
export interface VaultDraftLoginUri {
uri: string;
match: number | null;
originalUri?: string;
extra?: Record<string, unknown>;
}
export interface CipherAttachment {
@@ -59,9 +65,14 @@ export interface CipherLogin {
totp?: string | null;
uris?: CipherLoginUri[] | null;
fido2Credentials?: CipherLoginPasskey[] | null;
autofillOnPageLoad?: boolean | null;
uri?: string | null;
passwordRevisionDate?: string | null;
response?: unknown | null;
decUsername?: string;
decPassword?: string;
decTotp?: string;
[key: string]: unknown;
}
export interface CipherCard {
@@ -137,6 +148,12 @@ export interface CipherField {
decValue?: string;
}
export interface CipherPasswordHistoryEntry {
password?: string | null;
lastUsedDate?: string | null;
decPassword?: string;
}
export interface Cipher {
id: string;
type: number;
@@ -156,7 +173,7 @@ export interface Cipher {
identity?: CipherIdentity | null;
sshKey?: CipherSshKey | null;
secureNote?: { type?: number | null } | null;
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null;
passwordHistory?: CipherPasswordHistoryEntry[] | null;
fields?: CipherField[] | null;
decName?: string;
decNotes?: string;
@@ -272,7 +289,8 @@ export interface WebBootstrapResponse {
export interface TokenSuccess {
access_token: string;
refresh_token: string;
refresh_token?: string;
web_session?: boolean;
expires_in?: number;
token_type?: string;
TwoFactorToken?: string;
@@ -290,6 +308,10 @@ export interface TokenSuccess {
unofficialServer?: boolean;
UserDecryptionOptions?: unknown;
userDecryptionOptions?: unknown;
VaultKeys?: {
symEncKey?: string;
symMacKey?: string;
};
}
export interface TokenError {
@@ -322,10 +344,14 @@ export interface AdminInvite {
export interface AuthorizedDevice {
id: string;
name: string;
systemName?: string | null;
deviceNote?: string | null;
identifier: string;
type: number;
creationDate: string | null;
revisionDate: string | null;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
online: boolean;
trusted: boolean;
trustedTokenCount: number;
+12 -4659
View File
File diff suppressed because it is too large Load Diff
+193
View File
@@ -0,0 +1,193 @@
.loading-screen {
height: 100%;
display: grid;
place-items: center;
color: var(--muted);
font-size: 18px;
animation: fade-in-up var(--dur-panel) var(--ease-out-strong) both;
}
.auth-page {
min-height: 100%;
display: grid;
place-items: center;
padding: 24px;
position: relative;
background: transparent;
}
.public-send-page {
min-height: 80vh;
align-items: center;
justify-items: center;
}
.auth-card {
width: 100%;
position: relative;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: var(--shadow-lg);
padding: 30px;
overflow: hidden;
transform-origin: 50% 24%;
animation: surface-enter 520ms var(--ease-out-strong) both;
}
.auth-card h1 {
margin: 0 0 4px 0;
text-align: center;
}
.standalone-shell {
width: min(640px, 100%);
display: grid;
gap: 14px;
animation: fade-in-up 420ms var(--ease-out-strong) both;
}
.standalone-brand {
display: inline-flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
}
.standalone-brand-outside {
justify-content: center;
width: 100%;
margin-bottom: 2px;
}
.standalone-brand-logo {
width: 56px;
height: 56px;
object-fit: contain;
flex-shrink: 0;
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
}
.standalone-brand-wordmark {
display: block;
height: auto;
width: clamp(200px, 30vw, 360px);
max-width: 100%;
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.18));
}
.standalone-title {
margin: 0 0 4px 0;
text-align: left;
font-size: 31px;
line-height: 1.15;
letter-spacing: -0.035em;
}
.standalone-muted {
text-align: left;
}
.jwt-warning-head {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 10px;
color: #b45309;
text-align: center;
}
.jwt-warning-box {
border: 1px solid #f1d8a5;
border-radius: 12px;
background: #fffaf0;
padding: 12px 14px;
}
.jwt-warning-label {
font-size: 13px;
font-weight: 700;
color: #92400e;
margin-bottom: 6px;
}
.jwt-warning-copy {
margin: 0 0 14px;
color: #475569;
line-height: 1.6;
}
.jwt-warning-list {
margin: 0;
padding-left: 18px;
color: #334155;
line-height: 1.55;
}
.jwt-inline-link {
color: #1d4ed8;
font-weight: 700;
text-decoration: none;
}
.jwt-inline-link:hover {
text-decoration: underline;
}
.jwt-secret-fields {
margin-top: 8px;
display: grid;
gap: 6px;
}
.jwt-secret-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.jwt-secret-row > span {
color: #64748b;
}
.jwt-generator {
margin-top: 14px;
}
.jwt-generator-actions {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.jwt-copy-hint {
color: #15803d;
font-size: 13px;
font-weight: 700;
}
.standalone-footer {
width: 100%;
text-align: center;
font-size: 13px;
color: #64748b;
}
.standalone-footer a {
color: #1d4ed8;
font-weight: 700;
text-decoration: none;
}
.standalone-footer a:hover {
text-decoration: underline;
}
.standalone-version {
font-weight: 700;
color: #1d4ed8;
}
+27
View File
@@ -0,0 +1,27 @@
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
color: var(--text);
background: var(--bg-accent);
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
}
body {
position: relative;
transition:
background-color var(--dur-medium) var(--ease-smooth),
color var(--dur-medium) var(--ease-smooth);
}
body.dialog-open {
overflow: hidden;
overscroll-behavior: contain;
}
+451
View File
@@ -0,0 +1,451 @@
:root[data-theme='dark'] body,
:root[data-theme='dark'] #root,
:root[data-theme='dark'] .app-page,
:root[data-theme='dark'] .auth-page {
background: transparent;
color: var(--text);
}
:root[data-theme='dark'] .app-shell,
:root[data-theme='dark'] .auth-card,
:root[data-theme='dark'] .dialog,
:root[data-theme='dark'] .jwt-warning-box,
:root[data-theme='dark'] .backup-operations-sidebar,
:root[data-theme='dark'] .backup-destination-sidebar,
:root[data-theme='dark'] .backup-detail-panel,
:root[data-theme='dark'] .settings-subcard,
:root[data-theme='dark'] .list-panel,
:root[data-theme='dark'] .card,
:root[data-theme='dark'] .sidebar-block,
:root[data-theme='dark'] .empty {
background: var(--panel);
border-color: var(--line);
color: var(--text);
box-shadow: var(--shadow-lg);
}
:root[data-theme='dark'] .topbar,
:root[data-theme='dark'] .mobile-tabbar,
:root[data-theme='dark'] .sort-menu,
:root[data-theme='dark'] .create-menu,
:root[data-theme='dark'] .dialog-card,
:root[data-theme='dark'] .mobile-sidebar-sheet,
:root[data-theme='dark'] .mobile-detail-sheet {
background: var(--panel-soft);
border-color: var(--line);
color: var(--text);
}
:root[data-theme='dark'] .dialog-card.warning {
border-color: rgba(248, 113, 113, 0.36);
background: linear-gradient(180deg, rgba(39, 16, 16, 0.98), rgba(27, 12, 12, 0.98));
box-shadow:
0 36px 90px rgba(5, 5, 5, 0.56),
0 0 0 1px rgba(248, 113, 113, 0.12) inset;
}
:root[data-theme='dark'] .dialog-mask.warning {
background:
radial-gradient(circle at top, rgba(127, 29, 29, 0.28), transparent 34%),
linear-gradient(180deg, rgba(20, 12, 12, 0.64), rgba(2, 6, 23, 0.82));
}
:root[data-theme='dark'] .dialog-warning-badge {
background: linear-gradient(180deg, rgba(127, 29, 29, 0.8), rgba(69, 10, 10, 0.86));
color: #fda4af;
box-shadow:
0 12px 30px rgba(0, 0, 0, 0.32),
0 0 0 1px rgba(248, 113, 113, 0.14) inset;
}
:root[data-theme='dark'] .dialog-warning-kicker,
:root[data-theme='dark'] .dialog-card.warning .dialog-title {
color: #fecaca;
}
:root[data-theme='dark'] .dialog-message.warning {
border-color: rgba(248, 113, 113, 0.18);
background: linear-gradient(180deg, rgba(69, 10, 10, 0.54), rgba(67, 20, 7, 0.46));
color: #fecdd3;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18) inset;
}
:root[data-theme='dark'] .app-side,
:root[data-theme='dark'] .sidebar,
:root[data-theme='dark'] .mobile-sidebar-sheet .sidebar-block {
background: var(--panel-muted);
border-color: var(--line);
}
:root[data-theme='dark'] .auth-card {
background: var(--panel);
}
:root[data-theme='dark'] .brand,
:root[data-theme='dark'] .mobile-page-title,
:root[data-theme='dark'] .detail-title,
:root[data-theme='dark'] .dialog-title,
:root[data-theme='dark'] .standalone-title,
:root[data-theme='dark'] .kv-main strong,
:root[data-theme='dark'] .list-title,
:root[data-theme='dark'] .sidebar-title,
:root[data-theme='dark'] h1,
:root[data-theme='dark'] h2,
:root[data-theme='dark'] h3,
:root[data-theme='dark'] h4 {
color: var(--text);
}
:root[data-theme='dark'] .standalone-brand-wordmark,
:root[data-theme='dark'] .brand-wordmark {
text-shadow: 0 16px 28px rgba(2, 6, 23, 0.32);
}
:root[data-theme='dark'] .muted,
:root[data-theme='dark'] .detail-sub,
:root[data-theme='dark'] .field-help,
:root[data-theme='dark'] .list-sub,
:root[data-theme='dark'] .kv-label,
:root[data-theme='dark'] .standalone-muted,
:root[data-theme='dark'] .standalone-footer,
:root[data-theme='dark'] .backup-inline-note,
:root[data-theme='dark'] .backup-browser-empty,
:root[data-theme='dark'] .or,
:root[data-theme='dark'] .mobile-tab,
:root[data-theme='dark'] .side-link,
:root[data-theme='dark'] .user-chip,
:root[data-theme='dark'] .list-count {
color: var(--muted);
}
:root[data-theme='dark'] .user-chip {
background: rgba(17, 34, 56, 0.94);
border-color: var(--line);
box-shadow: 0 12px 24px rgba(1, 7, 18, 0.24);
}
:root[data-theme='dark'] .side-link:hover,
:root[data-theme='dark'] .mobile-tab:hover {
background: rgba(132, 182, 255, 0.11);
}
:root[data-theme='dark'] .side-link.active,
:root[data-theme='dark'] .mobile-tab.active,
:root[data-theme='dark'] .sort-menu-item.active,
:root[data-theme='dark'] .list-item.active {
background: linear-gradient(135deg, rgba(132, 182, 255, 0.2), rgba(56, 189, 248, 0.08));
border-color: rgba(132, 182, 255, 0.28);
color: var(--primary);
}
:root[data-theme='dark'] .input,
:root[data-theme='dark'] .textarea,
:root[data-theme='dark'] select.input,
:root[data-theme='dark'] .dialog input,
:root[data-theme='dark'] .dialog textarea,
:root[data-theme='dark'] .dialog select {
background: rgba(13, 24, 40, 0.94);
border-color: rgba(103, 136, 186, 0.36);
color: var(--text);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
:root[data-theme='dark'] .input::placeholder,
:root[data-theme='dark'] .textarea::placeholder,
:root[data-theme='dark'] input::placeholder,
:root[data-theme='dark'] textarea::placeholder {
color: #7488a8;
}
:root[data-theme='dark'] .input:focus,
:root[data-theme='dark'] .textarea:focus,
:root[data-theme='dark'] .search-input:focus,
:root[data-theme='dark'] .dialog input:focus,
:root[data-theme='dark'] .dialog textarea:focus,
:root[data-theme='dark'] .dialog select:focus {
border-color: rgba(132, 182, 255, 0.54);
background-color: rgba(16, 30, 49, 0.98);
box-shadow: 0 0 0 4px rgba(132, 182, 255, 0.12), 0 10px 22px rgba(5, 13, 28, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
:root[data-theme='dark'] .input-readonly {
background: #0f1b2d;
color: var(--muted-strong);
}
:root[data-theme='dark'] .input:disabled,
:root[data-theme='dark'] .btn:disabled {
background: #132033;
border-color: #22334c;
color: #70829d;
}
:root[data-theme='dark'] .btn-secondary {
background: linear-gradient(180deg, rgba(22, 41, 66, 0.98), rgba(16, 31, 52, 0.98));
border-color: rgba(132, 182, 255, 0.22);
color: #a9cdff;
box-shadow: 0 12px 22px rgba(1, 7, 18, 0.18);
}
:root[data-theme='dark'] .btn-secondary:hover {
background: linear-gradient(180deg, rgba(26, 49, 79, 0.98), rgba(19, 37, 61, 0.98));
border-color: rgba(132, 182, 255, 0.3);
}
:root[data-theme='dark'] .btn-danger {
background: linear-gradient(180deg, rgba(45, 23, 33, 0.98), rgba(35, 18, 28, 0.98));
border-color: rgba(255, 139, 168, 0.38);
color: #ff9bb0;
}
:root[data-theme='dark'] .btn-danger:hover {
background: linear-gradient(180deg, rgba(56, 27, 40, 0.98), rgba(41, 19, 31, 0.98));
border-color: rgba(255, 171, 192, 0.42);
}
:root[data-theme='dark'] .btn-primary {
background: linear-gradient(135deg, #79acff, #57c2ff 76%);
border-color: rgba(176, 214, 255, 0.22);
color: #061120;
box-shadow: 0 18px 32px rgba(10, 26, 52, 0.34);
}
:root[data-theme='dark'] .btn-primary:hover {
background: linear-gradient(135deg, #90bcff, #6accff 76%);
box-shadow: 0 22px 36px rgba(10, 26, 52, 0.38);
}
:root[data-theme='dark'] .toolbar.actions,
:root[data-theme='dark'] .list-head,
:root[data-theme='dark'] .mobile-panel-head,
:root[data-theme='dark'] .backup-recommendation-header,
:root[data-theme='dark'] .backup-operations-sidebar,
:root[data-theme='dark'] .backup-destination-sidebar,
:root[data-theme='dark'] .backup-detail-panel,
:root[data-theme='dark'] .detail-actions,
:root[data-theme='dark'] .topbar,
:root[data-theme='dark'] .app-side,
:root[data-theme='dark'] .kv-row,
:root[data-theme='dark'] .attachment-row,
:root[data-theme='dark'] .backup-browser-row {
border-color: var(--line);
}
:root[data-theme='dark'] .input,
:root[data-theme='dark'] .search-input,
:root[data-theme='dark'] .list-item,
:root[data-theme='dark'] .sidebar-block {
background: rgba(15, 28, 45, 0.94);
}
:root[data-theme='dark'] .sidebar,
:root[data-theme='dark'] .content,
:root[data-theme='dark'] .list-col,
:root[data-theme='dark'] .detail-col {
color: var(--text);
}
:root[data-theme='dark'] .mobile-sidebar-mask,
:root[data-theme='dark'] .dialog-mask {
background: var(--overlay-strong);
}
:root[data-theme='dark'] .toast {
background: linear-gradient(180deg, rgba(19, 34, 54, 0.98), rgba(14, 26, 42, 0.98));
border-color: #263a57;
color: var(--text);
}
:root[data-theme='dark'] .toast.success {
background: #0f2a1f;
border-color: #1f5b44;
color: #9be2bd;
}
:root[data-theme='dark'] .toast.error {
background: #2a1720;
border-color: #6c2b41;
color: #ffb1c0;
}
:root[data-theme='dark'] .toast.warning {
background: #2d2413;
border-color: #7b6230;
color: #f7d48b;
}
:root[data-theme='dark'] .jwt-warning-head,
:root[data-theme='dark'] .jwt-warning-label,
:root[data-theme='dark'] .jwt-warning-copy,
:root[data-theme='dark'] .jwt-warning-list {
color: #f4d48a;
}
:root[data-theme='dark'] .theme-switch-input:focus + .theme-switch-slider {
box-shadow: 0 0 0 2px rgba(132, 182, 255, 0.24);
}
:root[data-theme='dark'] .search-input,
:root[data-theme='dark'] .list-head .search-input,
:root[data-theme='dark'] .mobile-settings-card,
:root[data-theme='dark'] .mobile-settings-link,
:root[data-theme='dark'] .table tr,
:root[data-theme='dark'] .settings-subcard,
:root[data-theme='dark'] .backup-operations-sidebar,
:root[data-theme='dark'] .backup-destination-sidebar,
:root[data-theme='dark'] .backup-detail-panel,
:root[data-theme='dark'] .dialog-card,
:root[data-theme='dark'] .backup-browser-path,
:root[data-theme='dark'] .backup-browser-list,
:root[data-theme='dark'] .create-menu,
:root[data-theme='dark'] .create-menu-item,
:root[data-theme='dark'] .sort-menu,
:root[data-theme='dark'] .sort-menu-item,
:root[data-theme='dark'] .backup-recommendation-card,
:root[data-theme='dark'] .backup-recommendation-dav-item,
:root[data-theme='dark'] .backup-destination-item,
:root[data-theme='dark'] .totp-code-row,
:root[data-theme='dark'] .list-item {
background:
linear-gradient(180deg, rgba(18, 32, 52, 0.92), rgba(14, 26, 42, 0.92));
border-color: var(--line);
color: var(--text);
}
:root[data-theme='dark'] .list-item:hover,
:root[data-theme='dark'] .sort-menu-item:hover,
:root[data-theme='dark'] .create-menu-item:hover,
:root[data-theme='dark'] .mobile-settings-link:hover,
:root[data-theme='dark'] .backup-destination-item:hover {
background:
linear-gradient(180deg, rgba(24, 44, 70, 0.96), rgba(16, 31, 51, 0.96));
border-color: rgba(118, 150, 197, 0.32);
}
:root[data-theme='dark'] .list-item.active {
background: linear-gradient(135deg, rgba(132, 182, 255, 0.2), rgba(56, 189, 248, 0.1));
border-color: rgba(122, 176, 255, 0.34);
box-shadow: inset 0 0 0 1px rgba(200, 225, 255, 0.06), 0 12px 24px rgba(5, 13, 28, 0.18);
}
:root[data-theme='dark'] .list-item::before {
background:
linear-gradient(90deg, rgba(132, 182, 255, 0.08), transparent 24%, transparent 76%, rgba(56, 189, 248, 0.08)),
radial-gradient(circle at 18px 50%, rgba(255, 255, 255, 0.06), transparent 44%);
}
:root[data-theme='dark'] .backup-destination-item.active,
:root[data-theme='dark'] .backup-interval-preset.active,
:root[data-theme='dark'] .mobile-settings-link.active,
:root[data-theme='dark'] .tree-btn.active {
background: linear-gradient(135deg, rgba(132, 182, 255, 0.2), rgba(56, 189, 248, 0.1));
border-color: rgba(132, 182, 255, 0.34);
color: #f4f8ff;
}
:root[data-theme='dark'] .theme-switch-slider {
background: linear-gradient(180deg, #1d3659, #142845);
border-color: rgba(120, 152, 198, 0.34);
}
:root[data-theme='dark'] .theme-switch-slider::before {
background: linear-gradient(180deg, #f8fbff, #dce9ff);
box-shadow: 0 3px 10px rgba(2, 8, 20, 0.28);
}
:root[data-theme='dark'] .theme-switch .moon svg {
fill: #8db6ff;
}
:root[data-theme='dark'] .theme-switch .sun svg {
opacity: 0.82;
}
:root[data-theme='dark'] .totp-code-name,
:root[data-theme='dark'] .backup-destination-name,
:root[data-theme='dark'] .backup-browser-entry,
:root[data-theme='dark'] .mobile-settings-link,
:root[data-theme='dark'] .backup-browser-path strong,
:root[data-theme='dark'] .backup-option-label,
:root[data-theme='dark'] .sort-menu-item,
:root[data-theme='dark'] .create-menu-item,
:root[data-theme='dark'] .tree-btn,
:root[data-theme='dark'] .folder-add-btn,
:root[data-theme='dark'] .list-icon-fallback,
:root[data-theme='dark'] .totp-code-main strong,
:root[data-theme='dark'] .totp-timer-value {
color: var(--text);
}
:root[data-theme='dark'] .totp-code-username,
:root[data-theme='dark'] .backup-destination-meta,
:root[data-theme='dark'] .backup-browser-meta,
:root[data-theme='dark'] .table td::before,
:root[data-theme='dark'] .backup-recommendation-step,
:root[data-theme='dark'] .backup-recommendation-inline-note,
:root[data-theme='dark'] .backup-recommendation-linked-item,
:root[data-theme='dark'] .backup-inline-suffix,
:root[data-theme='dark'] .folder-delete-btn,
:root[data-theme='dark'] .folder-add-btn:hover,
:root[data-theme='dark'] .tree-label,
:root[data-theme='dark'] .list-sub {
color: var(--muted);
}
:root[data-theme='dark'] .import-export-panel p,
:root[data-theme='dark'] .dialog-message,
:root[data-theme='dark'] .local-error,
:root[data-theme='dark'] .status-ok {
color: var(--muted);
}
:root[data-theme='dark'] .backup-destination-type {
background: #1d3048;
color: #c9d8eb;
}
:root[data-theme='dark'] .backup-help-trigger {
border-color: #38618f;
background: #173150;
color: #9ec5ff;
}
:root[data-theme='dark'] .backup-help-trigger:hover,
:root[data-theme='dark'] .backup-help-trigger:focus-visible {
border-color: #5f92d7;
background: #20426a;
}
:root[data-theme='dark'] .backup-help-bubble {
background: var(--panel);
border-color: var(--line);
color: var(--text);
}
:root[data-theme='dark'] .backup-help-bubble::before {
background: var(--panel);
border-left-color: var(--line);
border-top-color: var(--line);
}
:root[data-theme='dark'] .table td {
border-bottom-color: #203047;
}
:root[data-theme='dark'] .local-error {
color: #ff9bb0;
}
:root[data-theme='dark'] .status-ok {
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 img {
background: #ffffff;
border-radius: 8px;
}
+309
View File
@@ -0,0 +1,309 @@
.muted {
margin: 0 0 16px 0;
text-align: center;
color: var(--muted);
line-height: 1.65;
}
.field {
display: block;
margin-bottom: 14px;
}
.field > span {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
}
.input {
width: 100%;
height: 48px;
border: 1px solid rgba(74, 103, 150, 0.42);
border-radius: 14px;
padding: 10px 14px;
font-size: 16px;
outline: none;
color: var(--text);
background: var(--panel);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
transition:
border-color var(--dur-fast) var(--ease-smooth),
box-shadow var(--dur-fast) var(--ease-out-soft),
background-color var(--dur-fast) var(--ease-smooth),
transform var(--dur-fast) var(--ease-out-soft);
}
select.input {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 42px;
background-image:
linear-gradient(45deg, transparent 50%, #365fa8 50%),
linear-gradient(135deg, #365fa8 50%, transparent 50%);
background-position:
calc(100% - 18px) calc(50% - 3px),
calc(100% - 12px) calc(50% - 3px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
input[type='file'].input {
height: auto;
min-height: 48px;
padding: 8px 10px;
font-size: 14px;
line-height: 1.4;
}
input[type='file'].input::file-selector-button {
height: 32px;
border: 1px solid #3f5b9e;
border-radius: 999px;
padding: 0 12px;
background: #eef4ff;
color: #1f4ea0;
font-weight: 700;
cursor: pointer;
margin-right: 10px;
}
input[type='file'].input::file-selector-button:hover {
background: #dfeaff;
border-color: #2f5fd8;
}
.textarea {
min-height: 110px;
height: auto;
resize: vertical;
}
.input:focus {
border-color: rgba(43, 102, 217, 0.6);
background-color: #fbfdff;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.11), 0 10px 20px rgba(37, 99, 235, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.95);
transform: translateY(-1px);
}
.input-readonly {
background: #eef2f7;
color: #475569;
}
.input:disabled {
background: #e2e8f0;
border-color: #cbd5e1;
color: #94a3b8;
cursor: not-allowed;
}
.password-wrap {
position: relative;
}
.password-wrap .input {
padding-right: 44px;
}
.password-toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border: none;
background: transparent;
color: #275ac2;
cursor: pointer;
display: grid;
place-items: center;
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft);
}
.eye-btn {
position: absolute;
right: 10px;
bottom: 9px;
width: 30px;
height: 30px;
border: none;
background: transparent;
cursor: pointer;
display: grid;
place-items: center;
color: #334155;
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft);
}
.password-toggle:hover,
.eye-btn:hover {
color: var(--primary);
transform: translateY(-1px) scale(1.04);
}
.btn {
height: 36px;
border: 1px solid transparent;
border-radius: 999px;
padding: 0 16px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
text-decoration: none;
transition:
transform var(--dur-fast) var(--ease-out-soft),
box-shadow var(--dur-fast) var(--ease-out-soft),
background-color var(--dur-fast) var(--ease-smooth),
border-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth),
opacity var(--dur-fast) var(--ease-smooth);
}
.topbar-actions .btn,
.user-chip,
.side-link,
.mobile-tab {
--mag-x: 0px;
--mag-y: 0px;
position: relative;
overflow: hidden;
}
.topbar-actions .btn::before,
.user-chip::before,
.side-link::before,
.mobile-tab::before {
content: '';
position: absolute;
left: var(--mx, 50%);
top: var(--my, 50%);
width: 110px;
height: 110px;
border-radius: 999px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.36), rgba(255, 255, 255, 0.08) 42%, transparent 72%);
transform: translate(-50%, -50%) scale(0.68);
opacity: 0;
pointer-events: none;
transition:
opacity var(--dur-fast) var(--ease-smooth),
transform var(--dur-medium) var(--ease-out-soft);
}
.topbar-actions .btn:hover::before,
.user-chip:hover::before,
.side-link:hover::before,
.mobile-tab:hover::before {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.btn:hover:not(:disabled) {
transform: translateY(-2px) scale(1.01);
}
.btn:active:not(:disabled) {
transform: translateY(0) scale(0.985);
}
.btn-icon {
flex-shrink: 0;
}
.btn.full {
width: 100%;
height: 50px;
font-size: 22px;
margin: 10px 0;
}
.btn-primary {
background: linear-gradient(135deg, #2563eb, #3b82f6 72%);
border-color: rgba(15, 63, 152, 0.32);
color: #fff;
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24);
}
.btn-primary:hover {
background: linear-gradient(135deg, #1d4ed8, #3377f0 72%);
border-color: rgba(15, 63, 152, 0.38);
box-shadow: 0 18px 34px rgba(37, 99, 235, 0.28);
}
.btn-secondary {
background: var(--panel);
border-color: rgba(37, 99, 235, 0.22);
color: var(--primary-strong);
box-shadow: 0 8px 18px rgba(13, 31, 68, 0.05);
}
.btn-secondary:hover {
background: #f4f8ff;
border-color: rgba(37, 99, 235, 0.34);
}
.btn-danger {
background: rgba(255, 255, 255, 0.8);
border-color: rgba(217, 45, 87, 0.28);
color: var(--danger);
}
.btn-danger:hover {
background: rgba(255, 241, 242, 0.96);
border-color: rgba(217, 45, 87, 0.38);
}
.btn:disabled {
background: #e2e8f0;
border-color: #cbd5e1;
color: #94a3b8;
cursor: not-allowed;
}
.or {
text-align: center;
margin: 10px 0;
color: #334155;
}
.field-help {
margin-top: 8px;
font-size: 13px;
line-height: 1.5;
color: #667085;
}
.auth-support-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: -2px 0 12px;
}
.auth-link-btn {
border: none;
background: transparent;
padding: 0;
color: #1d4ed8;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
}
.auth-link-btn:hover {
text-decoration: underline;
transform: translateX(2px);
}
.auth-link-btn:disabled {
color: #94a3b8;
cursor: not-allowed;
text-decoration: none;
}
File diff suppressed because it is too large Load Diff
+127
View File
@@ -0,0 +1,127 @@
@keyframes toast-life {
from {
transform: scaleX(1);
transform-origin: left center;
}
to {
transform: scaleX(0);
transform-origin: left center;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translate3d(0, 16px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes shell-enter {
from {
opacity: 0;
transform: translate3d(0, 18px, 0) scale(0.992);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes surface-enter {
from {
opacity: 0;
transform: translate3d(0, 20px, 0) scale(0.985);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes menu-in {
from {
opacity: 0;
transform: translate3d(0, 10px, 0) scale(0.96);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes dialog-in {
from {
opacity: 0;
transform: translate3d(0, 18px, 0) scale(0.96);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes toast-in {
from {
opacity: 0;
transform: translate3d(18px, 0, 0) scale(0.97);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes stagger-rise {
from {
opacity: 0;
transform: translate3d(0, 18px, 0) scale(0.985);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes dialog-out {
from {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
to {
opacity: 0;
transform: translate3d(0, 10px, 0) scale(0.972);
}
}
@keyframes route-stage-in {
from {
opacity: 0;
transform: translate3d(0, 14px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
+299
View File
@@ -0,0 +1,299 @@
.dialog-mask {
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
background: rgba(15, 23, 42, 0.5);
display: grid;
place-items: center;
z-index: 1200;
padding: 20px;
opacity: 0;
animation: fade-in var(--dur-medium) var(--ease-smooth) both;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.dialog-card {
width: min(460px, 100%);
background: #fff;
border-radius: 20px;
border: 1px solid var(--line);
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2);
padding: 20px;
text-align: center;
transform-origin: 50% 30%;
animation: dialog-in 240ms var(--ease-out-strong) both;
}
.dialog-mask.warning {
background:
radial-gradient(circle at top, rgba(255, 237, 213, 0.32), transparent 34%),
linear-gradient(180deg, rgba(127, 29, 29, 0.36), rgba(15, 23, 42, 0.72));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.dialog-card.warning {
width: min(520px, 100%);
border: 1px solid rgba(220, 38, 38, 0.22);
background:
linear-gradient(180deg, rgba(255, 246, 246, 0.98), rgba(255, 255, 255, 0.99));
box-shadow:
0 36px 90px rgba(69, 10, 10, 0.28),
0 0 0 1px rgba(255, 255, 255, 0.7) inset;
}
.dialog-warning-head {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.dialog-warning-badge {
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 16px;
background: linear-gradient(180deg, #fff1f2, #ffe4e6);
color: #dc2626;
box-shadow:
0 12px 30px rgba(220, 38, 38, 0.18),
0 0 0 1px rgba(220, 38, 38, 0.08) inset;
}
.dialog-warning-kicker {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #b91c1c;
}
.dialog-mask.closing {
animation: fade-out 220ms var(--ease-smooth) both;
}
.dialog-card.closing {
animation: dialog-out 220ms var(--ease-smooth) both;
}
.dialog-card .field {
text-align: left;
}
.dialog-title {
margin: 6px 0;
font-size: 30px;
}
.dialog-message {
color: #475467;
margin-bottom: 10px;
}
.dialog-card.warning .dialog-title {
color: #7f1d1d;
margin-bottom: 10px;
}
.dialog-message.warning {
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid rgba(220, 38, 38, 0.16);
background: linear-gradient(180deg, rgba(255, 241, 242, 0.94), rgba(255, 247, 237, 0.9));
color: #7a2832;
line-height: 1.65;
box-shadow: 0 10px 28px rgba(248, 113, 113, 0.08) inset;
}
.dialog-btn {
width: 100%;
height: 50px;
font-size: 20px;
margin-top: 8px;
}
.dialog-extra {
margin-top: 8px;
}
.dialog-divider {
height: 1px;
background: var(--line);
margin: 8px 0 10px;
}
.import-summary-dialog {
max-width: 520px;
text-align: left;
position: relative;
padding-top: 16px;
}
.import-summary-close {
position: absolute;
top: 10px;
right: 10px;
border: none;
background: transparent;
color: #64748b;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.import-summary-close:hover {
color: #0f172a;
}
.import-summary-table-wrap {
margin-top: 8px;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
}
.import-summary-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.import-summary-table th,
.import-summary-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
}
.import-summary-table th {
text-align: left;
color: #475467;
background: #f8fafc;
}
.import-summary-table td:last-child,
.import-summary-table th:last-child {
text-align: right;
width: 96px;
}
.import-summary-table tbody tr:last-child td {
border-bottom: none;
}
.import-summary-failed-list {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 10px;
background: #fef2f2;
color: #991b1b;
font-size: 13px;
}
.import-summary-failed-title {
font-weight: 700;
margin-bottom: 6px;
}
.import-summary-failed-list ul {
margin: 0;
padding-left: 18px;
}
.import-summary-failed-list li + li {
margin-top: 4px;
}
.settings-twofactor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.settings-subcard {
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: #fff;
}
.settings-subcard h3 {
margin-top: 0;
margin-bottom: 10px;
}
.toast-stack {
position: fixed;
top: 16px;
right: 16px;
z-index: 1400;
width: min(420px, calc(100vw - 20px));
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.toast-item {
position: relative;
border-radius: 10px;
border: 1px solid #bbdfc6;
background: #dff4e5;
color: #0f5132;
padding: 12px 14px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
overflow: hidden;
display: flex;
justify-content: space-between;
align-items: center;
animation: toast-in 240ms var(--ease-out-strong) both;
}
.toast-item.error {
border-color: #f2b8c1;
background: #fde7eb;
color: #9f1239;
}
.toast-item.warning {
border-color: #f2b8c1;
background: #fde7eb;
color: #9f1239;
}
.toast-text {
font-weight: 700;
padding-right: 10px;
}
.toast-close {
border: none;
background: transparent;
cursor: pointer;
font-size: 20px;
color: inherit;
transition: transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
}
.toast-close:hover {
transform: scale(1.08);
opacity: 0.84;
}
.toast-progress {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
background: rgba(15, 23, 42, 0.2);
animation: toast-life 4.5s linear forwards;
}
+32
View File
@@ -0,0 +1,32 @@
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
.btn:hover:not(:disabled),
.btn:active:not(:disabled),
.side-link:hover,
.tree-btn:hover,
.list-item:hover,
.list-item.active,
.search-input:focus,
.input:focus,
.password-toggle:hover,
.eye-btn:hover,
.auth-link-btn:hover,
.sort-menu-item:hover,
.create-menu-item:hover,
.toast-close:hover,
.mobile-sidebar-close:hover {
transform: none !important;
}
}
+790
View File
@@ -0,0 +1,790 @@
@media (max-width: 1180px) {
.app-page {
padding: 8px;
}
.app-shell {
height: calc(100vh - 16px);
border-radius: 12px;
}
.app-main {
grid-template-columns: 1fr;
}
.app-side {
border-right: none;
border-bottom: 1px solid #d9e0ea;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
align-self: start;
height: fit-content;
gap: 8px;
}
.app-side > .side-link {
min-height: 0;
}
.vault-grid {
grid-template-columns: 1fr;
height: auto;
}
.sidebar {
max-height: 280px;
}
.totp-grid,
.field-grid {
grid-template-columns: 1fr;
}
.totp-copy-btn {
justify-self: start;
}
.import-export-panels,
.backup-browser-row {
grid-template-columns: 1fr;
}
.settings-twofactor-grid {
grid-template-columns: 1fr;
}
.standalone-title {
font-size: 24px;
}
.standalone-footer {
font-size: 12px;
line-height: 1.4;
}
}
@media (max-width: 900px) {
.auth-page {
padding: 14px;
align-items: start;
}
.standalone-shell {
width: 100%;
max-width: 460px;
gap: 10px;
padding-top: 12px;
}
.standalone-brand-outside {
justify-content: flex-start;
}
.standalone-brand-logo {
width: 44px;
height: 44px;
}
.auth-card {
padding: 20px 16px;
border-radius: 18px;
}
.btn.full {
height: 48px;
font-size: 18px;
}
.auth-support-row {
align-items: center;
flex-direction: row;
}
.app-page {
padding: 0;
background: transparent;
}
.app-shell {
--mobile-topbar-height: 58px;
--mobile-tabbar-height: 70px;
height: 100dvh;
max-width: none;
border: none;
border-radius: 0;
box-shadow: none;
}
.topbar {
height: var(--mobile-topbar-height);
padding: 0 12px;
position: relative;
z-index: 20;
}
.brand {
min-width: 0;
gap: 10px;
font-size: 18px;
}
.brand-logo {
width: 34px;
height: 34px;
}
.mobile-page-title {
display: inline;
}
.topbar-actions .user-chip,
.topbar-actions > .btn:not(.mobile-sidebar-toggle):not(.mobile-lock-btn),
.topbar-actions > .theme-switch-wrap {
display: none;
}
.mobile-sidebar-toggle,
.mobile-lock-btn {
display: inline-flex;
width: 36px;
min-width: 36px;
height: 36px;
padding: 0;
justify-content: center;
font-size: 0;
gap: 0;
}
.mobile-sidebar-toggle .btn-icon,
.mobile-lock-btn .btn-icon {
margin: 0;
}
.mobile-theme-btn {
display: inline-flex;
align-items: center;
}
.mobile-theme-btn .theme-switch {
transform: scale(0.8);
transform-origin: center;
}
.app-main {
display: flex;
flex-direction: column;
min-height: 0;
}
.app-side {
display: none;
}
.content {
flex: 1;
min-height: 0;
-webkit-overflow-scrolling: touch;
}
.mobile-tabbar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center;
gap: 6px;
min-height: var(--mobile-tabbar-height);
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
background: rgba(248, 251, 255, 0.92);
}
.mobile-tab {
display: grid;
justify-items: center;
gap: 4px;
color: #64748b;
text-decoration: none;
font-size: 11px;
font-weight: 700;
padding: 6px 4px;
border-radius: 12px;
transition:
transform 220ms var(--ease-out-soft),
background-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth);
}
.mobile-tab:hover {
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 1px), 0);
}
.mobile-tab.active {
color: var(--primary-strong);
background: linear-gradient(135deg, rgba(37, 99, 235, 0.16), rgba(59, 130, 246, 0.08));
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.08);
}
.vault-grid {
gap: 10px;
padding: 0;
}
.sidebar {
display: none;
}
.mobile-sidebar-sheet {
display: block;
position: fixed;
left: 10px;
right: 10px;
top: calc(var(--mobile-topbar-height) + 10px);
bottom: auto;
max-height: calc(100dvh - 145px);
z-index: 55;
overflow: auto;
border: 1px solid #d8dee8;
border-radius: 18px;
background: #fff;
padding: 12px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate3d(0, 10px, 0) scale(0.98);
transition:
opacity 220ms var(--ease-smooth),
transform 240ms var(--ease-out-soft),
visibility 220ms var(--ease-smooth);
}
.mobile-sidebar-sheet.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translate3d(0, 0, 0) scale(1);
}
.mobile-sidebar-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.mobile-sidebar-title {
font-size: 16px;
font-weight: 800;
color: #0f172a;
}
.mobile-sidebar-close {
width: 34px;
height: 34px;
border: 1px solid #d7dde6;
border-radius: 999px;
background: #fff;
color: #0f172a;
display: inline-grid;
place-items: center;
cursor: pointer;
padding: 0;
transition:
transform var(--dur-fast) var(--ease-out-soft),
background-color var(--dur-fast) var(--ease-smooth),
border-color var(--dur-fast) var(--ease-smooth);
}
.mobile-sidebar-close:hover {
transform: scale(1.05);
}
.mobile-sidebar-sheet .sidebar-block {
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.mobile-sidebar-sheet .tree-btn {
margin-bottom: 2px;
}
.mobile-sidebar-sheet .folder-row {
align-items: stretch;
gap: 4px;
}
.mobile-sidebar-sheet .folder-row .tree-btn {
min-height: 42px;
}
.mobile-sidebar-sheet .sidebar-title,
.mobile-sidebar-sheet .sidebar-title-row {
padding-bottom: 6px;
margin-bottom: 0;
}
.mobile-sidebar-sheet .tree-btn {
padding-left: 8px;
padding-right: 8px;
border-radius: 10px;
}
.mobile-sidebar-sheet .tree-btn.active {
background: #eef4ff;
}
.mobile-sidebar-sheet .folder-delete-btn {
width: 28px;
height: 42px;
border-radius: 8px;
}
.list-col {
max-width: none;
}
.list-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 8px;
align-items: center;
}
.list-count {
grid-column: auto;
width: auto;
font-size: 12px;
white-space: nowrap;
}
.list-head .search-input-wrap {
width: 100%;
min-width: 0;
}
.list-head .search-input {
width: 100%;
min-width: 0;
height: 42px;
border-radius: 14px;
}
.list-icon-btn {
width: auto;
min-width: 0;
padding: 0 12px;
font-size: 13px;
gap: 6px;
white-space: nowrap;
}
.toolbar.actions {
justify-content: flex-end;
flex-wrap: unset;
gap: var(--actions-gap);
overflow: visible;
padding-bottom: 2px;
}
.actions {
gap: var(--actions-gap);
}
.toolbar.actions .btn.small {
width: auto;
min-width: 0;
height: 34px;
padding: 0 12px;
font-size: 13px;
gap: 6px;
border-radius: 999px;
white-space: nowrap;
}
.mobile-fab-wrap {
position: fixed;
right: 14px;
bottom: calc(14px + var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
z-index: 45;
}
.mobile-fab-trigger {
width: 36px;
height: 56px;
padding: 0;
border-radius: 999px;
font-size: 0;
gap: 0;
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28);
}
.mobile-fab-trigger .btn-icon {
margin: 0;
width: 20px;
height: 20px;
}
.mobile-fab-wrap .create-menu {
left: auto;
right: 0;
top: auto;
bottom: calc(100% + 10px);
}
.list-panel {
border-radius: 16px;
overflow: visible;
}
.list-item {
padding: 12px;
border-radius: 14px;
}
.row-check {
width: 18px;
height: 18px;
}
.vault-grid.mobile-panel-detail .sidebar,
.vault-grid.mobile-panel-detail .list-col,
.vault-grid.mobile-panel-edit .sidebar,
.vault-grid.mobile-panel-edit .list-col {
display: none;
}
.mobile-detail-sheet {
display: block;
position: fixed;
left: 0;
right: 0;
top: calc(var(--mobile-topbar-height) + env(safe-area-inset-top));
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
z-index: 35;
overflow: auto;
background: transparent;
padding: 0 0 18px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate3d(0, 18px, 0);
transition:
opacity 220ms var(--ease-smooth),
transform 260ms var(--ease-out-soft),
visibility 220ms var(--ease-smooth);
}
.mobile-detail-sheet.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translate3d(0, 0, 0);
}
.mobile-panel-head {
display: flex;
align-items: center;
margin: 0 10px 10px;
}
.mobile-panel-back {
min-height: 40px;
}
.mobile-detail-sheet > .detail-switch-stage,
.mobile-detail-sheet > .card,
.mobile-detail-sheet > .empty {
margin-left: 10px;
margin-right: 10px;
}
.detail-col .card,
.import-export-panel,
.settings-subcard {
border-radius: 16px;
}
.card {
padding: 14px 14px;
}
.section-head {
align-items: flex-start;
gap: 10px;
flex-direction: column;
}
.detail-actions {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.detail-actions .actions {
width: 100%;
}
.detail-actions .actions .btn,
.detail-delete-btn {
width: 100%;
}
.kv-row {
grid-template-columns: minmax(64px, 80px) minmax(0, 1fr) auto;
align-items: center;
}
.kv-line {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.kv-actions {
width: auto;
justify-content: flex-end;
flex-wrap: nowrap;
}
.kv-actions .btn.small {
width: 34px;
min-width: 34px;
height: 34px;
padding: 0;
font-size: 0;
gap: 0;
border-radius: 999px;
}
.kv-actions .btn.small .btn-icon {
margin: 0;
}
.import-export-panels,
.settings-twofactor-grid {
gap: 10px;
}
.import-export-panel .actions .btn,
.settings-subcard .actions .btn,
.section-head .actions .btn {
width: 100%;
}
.totp-grid {
gap: 10px;
}
.totp-qr {
min-height: 180px;
}
.totp-qr svg,
.totp-qr img {
width: 160px;
height: 160px;
}
.invite-toolbar {
align-items: stretch;
}
.mobile-settings-card {
min-height: calc(100dvh - 170px);
display: flex;
flex-direction: column;
gap: 12px;
}
.mobile-settings-subhead {
display: flex;
align-items: center;
}
.mobile-settings-back {
min-height: 38px;
}
.mobile-settings-links {
display: grid;
gap: 8px;
align-content: start;
}
.mobile-settings-link {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
padding: 0 12px;
border: 1px solid #dbe2ec;
border-radius: 14px;
background: #f8fafc;
color: #0f172a;
text-decoration: none;
font-weight: 700;
}
.mobile-settings-link.active {
background: #e8f0ff;
border-color: #b9cff6;
color: #175ddc;
}
.mobile-settings-logout {
width: 100%;
margin-top: auto;
}
.stack,
.import-export-page,
.totp-codes-page,
.detail-col {
min-height: auto;
}
.invite-create-group {
align-items: stretch;
width: 100%;
}
.input.small {
width: 100%;
}
.table,
.table tbody,
.table tr,
.table td {
display: block;
width: 100%;
}
.table thead {
display: none;
}
.table tr {
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
padding: 10px 12px;
margin-bottom: 10px;
}
.table td {
border-bottom: 1px solid #edf1f6;
padding: 10px 0;
}
.table td:last-child {
border-bottom: none;
padding-bottom: 0;
}
.table td::before {
display: block;
content: attr(data-label);
margin-bottom: 4px;
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.dialog-mask {
align-items: center;
justify-items: center;
padding: 16px;
}
.dialog-card {
width: 90%;
max-width: 460px;
max-height: calc(100dvh - 10px);
overflow: auto;
border-radius: 22px;
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
}
.dialog-card.warning {
max-width: 520px;
}
.dialog-warning-strip {
margin: -18px -16px 16px;
}
.dialog-title {
font-size: 24px;
}
.dialog-btn {
height: 46px;
font-size: 16px;
}
.toast-stack {
top: 10px;
left: 10px;
right: 10px;
width: auto;
}
}
@media (max-width: 900px) {
.backup-grid {
grid-template-columns: 1fr;
}
.backup-operations-sidebar,
.backup-destination-sidebar {
position: static;
}
}
@media (max-width: 640px) {
.backup-interval-row {
grid-template-columns: 1fr;
}
.backup-browser-row,
.field-grid {
grid-template-columns: 1fr;
}
.backup-destination-top {
align-items: flex-start;
flex-direction: column;
}
.backup-add-chooser {
flex-direction: column;
}
.backup-name-row {
grid-template-columns: 1fr;
}
.backup-option-field {
align-items: flex-start;
}
.backup-help-bubble {
left: 0;
transform: translate(0, -4px);
}
.backup-help-bubble::before {
left: 16px;
transform: rotate(45deg);
}
.backup-help-wrap:hover .backup-help-bubble,
.backup-help-wrap:focus-within .backup-help-bubble,
.backup-help-wrap.open .backup-help-bubble {
transform: translate(0, 0);
}
}
+321
View File
@@ -0,0 +1,321 @@
.app-page {
min-height: 100%;
padding: 20px;
position: relative;
background: transparent;
}
.app-shell {
height: calc(100vh - 40px);
max-width: 1600px;
margin: 0 auto;
position: relative;
background: var(--panel-soft);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
overflow: hidden;
animation: shell-enter 560ms var(--ease-out-strong) both;
}
.topbar {
height: 58px;
border-bottom: 1px solid var(--line-soft);
color: #0f172a;
background: rgba(244, 248, 255, 0.72);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
transition: background-color var(--dur-fast) var(--ease-smooth), border-color var(--dur-fast) var(--ease-smooth);
}
.brand {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 34px;
font-weight: 800;
color: var(--text);
}
.brand-wordmark {
display: block;
height: auto;
width: clamp(210px, 20vw, 290px);
max-width: 100%;
filter: drop-shadow(0 12px 24px rgba(43, 102, 217, 0.12));
}
.mobile-page-title {
display: none;
min-width: 0;
max-width: min(58vw, 240px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 19px;
line-height: 1.2;
font-weight: 800;
color: #0f172a;
}
.brand-logo {
width: 42px;
height: 42px;
object-fit: contain;
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.22));
transition: transform var(--dur-medium) var(--ease-out-soft), filter var(--dur-medium) var(--ease-out-soft);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 10px;
}
.mobile-tabbar {
display: none;
}
.mobile-sidebar-toggle {
display: none;
}
.mobile-lock-btn {
display: none;
}
.mobile-theme-btn {
display: none;
}
.theme-switch-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
}
.theme-switch {
position: relative;
display: inline-block;
width: 56px;
height: 32px;
}
.theme-switch-input {
opacity: 0;
width: 0;
height: 0;
}
.theme-switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, #dceaff, #c8dcff);
border: 1px solid #9dbbec;
transition:
background var(--dur-medium) var(--ease-out-soft),
border-color var(--dur-medium) var(--ease-smooth),
box-shadow var(--dur-fast) var(--ease-out-soft),
transform var(--dur-fast) var(--ease-out-soft);
border-radius: 999px;
}
.theme-switch-slider::before {
position: absolute;
content: '';
height: 26px;
width: 26px;
border-radius: 999px;
left: 2px;
bottom: 2px;
z-index: 2;
background: linear-gradient(180deg, #ffffff, #edf4ff);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.14);
transition:
transform var(--dur-medium) var(--ease-out-strong),
box-shadow var(--dur-fast) var(--ease-out-soft),
background var(--dur-fast) var(--ease-smooth);
}
.theme-switch .sun svg {
position: absolute;
top: 6px;
left: 32px;
z-index: 1;
width: 18px;
height: 18px;
opacity: 0.95;
transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
}
.theme-switch .moon svg {
fill: #5b86d6;
position: absolute;
top: 7px;
left: 7px;
z-index: 1;
width: 16px;
height: 16px;
opacity: 0.88;
transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
}
.theme-switch-input:checked + .theme-switch-slider {
background: linear-gradient(180deg, #173150, #122742);
border-color: #35527a;
}
.theme-switch-input:focus + .theme-switch-slider {
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.22);
}
.theme-switch-input:checked + .theme-switch-slider::before {
transform: translateX(24px);
}
.theme-switch:hover .theme-switch-slider {
transform: scale(1.02);
}
.theme-switch:hover .sun svg,
.theme-switch:hover .moon svg {
transform: scale(1.08);
}
.topbar-actions .btn {
height: 34px;
border-radius: 12px;
padding: 0 12px;
font-size: 13px;
font-weight: 600;
transform: translate3d(var(--mag-x), var(--mag-y), 0);
transition-duration: 220ms;
}
.topbar-actions .btn:hover:not(:disabled) {
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 2px), 0) scale(1.02);
}
.user-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
border-radius: 999px;
padding: 0 12px;
border: 1px solid rgba(148, 163, 184, 0.3);
background: rgba(249, 251, 255, 0.92);
color: var(--muted-strong);
font-size: 14px;
font-weight: 600;
box-shadow: 0 10px 18px rgba(13, 31, 68, 0.05);
transform: translate3d(var(--mag-x), var(--mag-y), 0);
transition:
transform 220ms var(--ease-out-soft),
box-shadow var(--dur-fast) var(--ease-out-soft),
border-color var(--dur-fast) var(--ease-smooth),
background-color var(--dur-fast) var(--ease-smooth);
}
.user-chip:hover {
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 1px), 0);
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.08);
}
.app-main {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 200px 1fr;
}
.app-side {
border-right: 1px solid var(--line-soft);
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.side-link {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 12px;
border-radius: 14px;
color: var(--muted-strong);
text-decoration: none;
border: 1px solid transparent;
font-weight: 600;
font-size: 14px;
transition:
background-color var(--dur-fast) var(--ease-smooth),
border-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth),
transform var(--dur-fast) var(--ease-out-soft),
box-shadow var(--dur-fast) var(--ease-out-soft);
}
.side-link:hover {
background: #ffffff;
border-color: rgba(128, 152, 192, 0.18);
color: var(--text);
transform: translate3d(calc(var(--mag-x) + 3px), var(--mag-y), 0);
box-shadow: 0 14px 24px rgba(15, 23, 42, 0.05);
}
.side-link.active {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(59, 130, 246, 0.08));
border-color: rgba(37, 99, 235, 0.28);
color: var(--primary-strong);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.64), 0 10px 18px rgba(37, 99, 235, 0.1);
}
.content {
min-height: 0;
padding: 14px;
overflow: hidden;
}
.route-stage {
height: 100%;
min-height: 0;
overflow: auto;
}
@media (min-width: 901px) {
.route-stage {
animation: route-stage-in 240ms var(--ease-out-soft) both;
}
}
.mobile-sidebar-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.36);
z-index: 54;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 220ms var(--ease-smooth),
visibility 220ms var(--ease-smooth);
}
.mobile-sidebar-mask.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.mobile-sidebar-head {
display: none;
}
+46
View File
@@ -0,0 +1,46 @@
:root {
--bg-accent: #e7edf8;
--panel: #f9fbff;
--panel-soft: #f2f6fd;
--panel-muted: #e8eff9;
--line: rgba(128, 152, 192, 0.32);
--line-soft: rgba(143, 167, 206, 0.18);
--text: #0b1730;
--muted: #60708b;
--muted-strong: #334765;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-strong: #0f3f98;
--danger: #d92d57;
--overlay-strong: rgba(15, 23, 42, 0.56);
--shadow-sm: 0 10px 22px rgba(13, 31, 68, 0.045);
--shadow-md: 0 22px 48px rgba(13, 31, 68, 0.08);
--shadow-lg: 0 28px 76px rgba(13, 31, 68, 0.11);
--ease-out-strong: cubic-bezier(0.22, 1, 0.36, 1);
--ease-out-soft: cubic-bezier(0.24, 0.8, 0.32, 1);
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
--dur-fast: 180ms;
--dur-medium: 240ms;
--dur-panel: 280ms;
--actions-gap: clamp(0px, calc((100vw - 520px) * 1), 10px);
}
:root[data-theme='dark'] {
--bg-accent: #06111d;
--panel: #0d192b;
--panel-soft: #112136;
--panel-muted: #0a1626;
--line: rgba(108, 141, 190, 0.28);
--line-soft: rgba(120, 152, 198, 0.16);
--text: #edf4ff;
--muted: #8fa6c6;
--muted-strong: #c3d5ef;
--primary: #84b6ff;
--primary-hover: #a6ccff;
--primary-strong: #f3f8ff;
--danger: #ff8ba8;
--overlay-strong: rgba(2, 8, 20, 0.84);
--shadow-sm: 0 14px 28px rgba(1, 7, 18, 0.24);
--shadow-md: 0 24px 52px rgba(1, 7, 18, 0.36);
--shadow-lg: 0 34px 88px rgba(1, 7, 18, 0.46);
}

Some files were not shown because too many files have changed in this diff Show More