mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-21 05:10:41 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 408874ac05 | |||
| dabd2c923e | |||
| 08414d7cf2 | |||
| 38b33df719 | |||
| 7ebd12fa07 | |||
| f7cbdaf730 | |||
| 6cae5cb218 | |||
| d96ad9bb1c | |||
| 92d1f07998 | |||
| a8432ab94b | |||
| 2230f75d8a | |||
| a982a5a57b | |||
| 4d7ee2164a | |||
| 34d4851981 | |||
| 4827a4958e | |||
| 70463d3fc7 | |||
| 681705ee13 | |||
| 5bf7c79ada | |||
| c516194d54 | |||
| 53231a4878 | |||
| c9e7417825 | |||
| 76623d7201 | |||
| 90a7731351 | |||
| f4adeb8ec9 | |||
| bb0b82f838 | |||
| be82c953d6 | |||
| edd2ba2e44 | |||
| 0f6da7d147 | |||
| 1184cb8d9a | |||
| 882fa2e8c8 | |||
| b6b7e46f79 | |||
| 144d3d9406 | |||
| 10707cf902 | |||
| 3bd4f6a9fe |
@@ -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."
|
||||||
@@ -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."
|
||||||
@@ -4,6 +4,11 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: "0 3 * * *"
|
- cron: "0 3 * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target_commit:
|
||||||
|
description: 'Commit hash (leave blank to use latest commit)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -11,9 +16,8 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
sync:
|
sync:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -22,13 +26,118 @@ jobs:
|
|||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
- name: Sync main from upstream
|
- name: Add upstream
|
||||||
run: |
|
run: |
|
||||||
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
||||||
git fetch upstream
|
git fetch upstream --tags
|
||||||
git checkout main
|
|
||||||
git merge upstream/main
|
|
||||||
|
|
||||||
- name: Push synced main
|
- name: Resolve target commit
|
||||||
|
id: resolve
|
||||||
run: |
|
run: |
|
||||||
git push origin main
|
TRIGGER="${{ github.event_name }}"
|
||||||
|
MANUAL_INPUT="${{ github.event.inputs.target_commit }}"
|
||||||
|
|
||||||
|
if [ "$TRIGGER" = "schedule" ]; then
|
||||||
|
# Auto mode: resolve latest upstream release tag
|
||||||
|
LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name)
|
||||||
|
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
|
||||||
|
echo "No release found in upstream."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null)
|
||||||
|
if [ -z "$TARGET_SHA" ]; then
|
||||||
|
echo "Tag '$LATEST_TAG' not found after fetch."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "mode=auto" >> $GITHUB_OUTPUT
|
||||||
|
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
||||||
|
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
|
||||||
|
|
||||||
|
elif [ -n "$MANUAL_INPUT" ]; then
|
||||||
|
# Manual mode: use provided commit hash or tag
|
||||||
|
TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null)
|
||||||
|
if [ -z "$TARGET_SHA" ]; then
|
||||||
|
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "mode=manual" >> $GITHUB_OUTPUT
|
||||||
|
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
||||||
|
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
|
||||||
|
|
||||||
|
else
|
||||||
|
# Manual mode, blank input: use latest commit on upstream/main
|
||||||
|
TARGET_SHA=$(git rev-parse upstream/main)
|
||||||
|
echo "mode=manual" >> $GITHUB_OUTPUT
|
||||||
|
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
||||||
|
echo "Manual mode — latest commit: $TARGET_SHA"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check if update is needed
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
|
||||||
|
MODE="${{ steps.resolve.outputs.mode }}"
|
||||||
|
|
||||||
|
if [ "$MODE" = "manual" ]; then
|
||||||
|
# Manual: skip only if HEAD is exactly this commit
|
||||||
|
CURRENT_SHA=$(git rev-parse HEAD)
|
||||||
|
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
|
||||||
|
echo "Already at $TARGET_SHA — skipping."
|
||||||
|
echo "needs_update=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Switching to $TARGET_SHA"
|
||||||
|
echo "needs_update=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Auto: skip if target is already in ancestry
|
||||||
|
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
|
||||||
|
echo "Already up to date with $TARGET_SHA — skipping."
|
||||||
|
echo "needs_update=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Update needed — target: $TARGET_SHA"
|
||||||
|
echo "needs_update=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Apply update
|
||||||
|
if: steps.check.outputs.needs_update == 'true'
|
||||||
|
run: |
|
||||||
|
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
|
||||||
|
MODE="${{ steps.resolve.outputs.mode }}"
|
||||||
|
git checkout main
|
||||||
|
if [ "$MODE" = "manual" ]; then
|
||||||
|
# Hard reset allows both upgrade and rollback
|
||||||
|
git reset --hard "$TARGET_SHA"
|
||||||
|
else
|
||||||
|
git merge "$TARGET_SHA" --no-edit
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Restore workflow file
|
||||||
|
if: steps.check.outputs.needs_update == 'true'
|
||||||
|
run: |
|
||||||
|
# Always keep our own workflow file, never let upstream overwrite it
|
||||||
|
git checkout HEAD@{1} -- .github/workflows/sync-upstream.yml 2>/dev/null || true
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
git commit -m "chore: restore sync-upstream workflow after sync"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Push
|
||||||
|
if: steps.check.outputs.needs_update == 'true'
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then
|
||||||
|
git push origin main --force
|
||||||
|
else
|
||||||
|
git push origin main
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
|
||||||
|
echo "### Synced successfully" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|||||||
@@ -40,3 +40,5 @@ npm-debug.log*
|
|||||||
|
|
||||||
tmp/
|
tmp/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
|
||||||
|
nodewarden.wiki/
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 472 KiB |
@@ -13,6 +13,10 @@
|
|||||||
|
|
||||||
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
|
|
||||||
|
[文档首页](./nodewarden.wiki/Home.md) | [快速开始](./nodewarden.wiki/快速开始.md)
|
||||||
|
|
||||||
|
[Telegram 频道](https://t.me/NodeWarden_News) | [Telegram 群组](https://t.me/NodeWarden_Official)
|
||||||
|
|
||||||
English: [`README_EN.md`](./README_EN.md)
|
English: [`README_EN.md`](./README_EN.md)
|
||||||
|
|
||||||
> **免责声明**
|
> **免责声明**
|
||||||
@@ -52,19 +56,26 @@ English: [`README_EN.md`](./README_EN.md)
|
|||||||
|
|
||||||
## 网页部署
|
## 网页部署
|
||||||
|
|
||||||
|
1. Fork `NodeWarden` 仓库到自己的 GitHub 账号
|
||||||
1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。
|
2. 进入 [Cloudflare Workers 创建页面](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||||
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可用 KV 来代替,将**部署命令**改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接
|
3. 选择 `Continue with GitHub`
|
||||||
|
4. 选择你刚刚 Fork 的仓库
|
||||||
| 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
5. 保持默认配置继续部署
|
||||||
|---|---|---|---|
|
6. 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||||
| R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
7. 等部署完成后,打开生成的 Workers 域名
|
||||||
| KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
8. 根据页面提示设置`JWT_SECRET` ,不建议临时乱填。这个值直接关系到令牌签发安全,正式环境至少使用 32 个字符以上的随机字符串。
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 同步方法(更新仓库):
|
> 默认R2与可选KV的区别:
|
||||||
>- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||||
>- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
> |---|---|---|---|
|
||||||
|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||||
|
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||||
|
|
||||||
|
|
||||||
|
## 更新方法:
|
||||||
|
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||||
|
- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+25
-25
@@ -5,32 +5,32 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
A third-party Bitwarden-compatible server running on Cloudflare Workers.
|
A third-party Bitwarden-compatible server running on Cloudflare Workers.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://workers.cloudflare.com/)
|
[](https://workers.cloudflare.com/)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
||||||
|
|
||||||
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
|
[Telegram Channel](https://t.me/NodeWarden_News) | [Telegram Group](https://t.me/NodeWarden_Official)
|
||||||
|
中文说明:[`README.md`](./README.md)
|
||||||
|
|
||||||
English: [`README.md`](./README.md)
|
> **Disclaimer**
|
||||||
|
>
|
||||||
> **Disclaimer**
|
> This project is for learning and discussion purposes only. Please back up your vault regularly.
|
||||||
> This project is for learning and communication purposes only. Please back up your vault regularly.
|
>
|
||||||
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Comparison with Official Bitwarden Server
|
## Feature Comparison with the Official Bitwarden Server
|
||||||
|
|
||||||
| Capability | Bitwarden | NodeWarden | Notes |
|
| Capability | Bitwarden | NodeWarden | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
||||||
| Full sync `/api/sync` | ✅ | ✅ | Optimized for official clients |
|
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
|
||||||
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
||||||
| Send | ✅ | ✅ | Supports both text and file Sends |
|
| Send | ✅ | ✅ | Supports both text and file Sends |
|
||||||
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
||||||
| **Cloud Backup Center** | ❌ | ✅ | **Supports scheduled backups with WebDAV / E3** |
|
| **Cloud Backup Center** | ❌ | ✅ | **Scheduled backup to WebDAV / E3** |
|
||||||
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
|
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
|
||||||
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
||||||
| Multi-user | ✅ | ✅ | Invite-based registration |
|
| Multi-user | ✅ | ✅ | Invite-based registration |
|
||||||
@@ -46,19 +46,20 @@ English: [`README.md`](./README.md)
|
|||||||
- ✅ Mobile app
|
- ✅ Mobile app
|
||||||
- ✅ Browser extension
|
- ✅ Browser extension
|
||||||
- ✅ Linux desktop client
|
- ✅ Linux desktop client
|
||||||
- ⚠️ macOS desktop client not fully verified
|
- ⚠️ macOS desktop client has not been fully verified yet
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Web Deploy
|
## Web Deploy
|
||||||
|
|
||||||
1. Fork this repository. If this project helps you, please consider giving it a Star.
|
1. Fork this repository. If this project helps you, consider giving it a Star.
|
||||||
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> `Next` -> deploy.
|
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
|
||||||
R2 is used by default. If R2 is unavailable for your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
|
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
|
||||||
|
4. Deploy and open the generated URL.
|
||||||
|
|
||||||
| Storage | Card required | Single attachment / Send file limit | Free tier |
|
| Storage | Card required | Single attachment / Send file limit | Free tier |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| R2 | Yes | 100 MB (soft limit, can be adjusted) | 10 GB |
|
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||||
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
@@ -71,7 +72,6 @@ English: [`README.md`](./README.md)
|
|||||||
```powershell
|
```powershell
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
cd NodeWarden
|
cd NodeWarden
|
||||||
|
|
||||||
npm install
|
npm install
|
||||||
npx wrangler login
|
npx wrangler login
|
||||||
|
|
||||||
@@ -92,13 +92,13 @@ npm run dev:kv
|
|||||||
|
|
||||||
- Remote backup supports **WebDAV** and **E3**
|
- Remote backup supports **WebDAV** and **E3**
|
||||||
- When `Include attachments` is enabled:
|
- When `Include attachments` is enabled:
|
||||||
- the ZIP still contains only `db.json` and `manifest.json`
|
- the ZIP still contains only `db.json` and `manifest.json`
|
||||||
- real attachment files are stored separately under `attachments/`
|
- actual attachment files are stored separately under `attachments/`
|
||||||
- later backups reuse existing attachments by stable blob name instead of uploading everything again
|
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
|
||||||
- During remote restore:
|
- During remote restore:
|
||||||
- required attachment files are loaded from `attachments/`
|
- required attachment files are loaded from `attachments/` on demand
|
||||||
- missing attachments are skipped safely
|
- missing attachments are skipped safely
|
||||||
- skipped attachments do not leave broken rows in the restored database
|
- skipped attachments do not leave broken rows in the restored database
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ Current supported import sources include:
|
|||||||
- Bitwarden CSV
|
- Bitwarden CSV
|
||||||
- Bitwarden vault + attachments ZIP
|
- Bitwarden vault + attachments ZIP
|
||||||
- NodeWarden JSON
|
- NodeWarden JSON
|
||||||
- Multiple browser / password-manager formats visible in the web import selector
|
- Multiple browser / password-manager formats available in the web import selector
|
||||||
|
|
||||||
Current supported export formats include:
|
Current supported export formats include:
|
||||||
|
|
||||||
@@ -130,9 +130,9 @@ LGPL-3.0 License
|
|||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - original design and clients
|
- [Bitwarden](https://bitwarden.com/) - Original design and clients
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS ciphers (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS folders (
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -106,6 +107,7 @@ CREATE TABLE IF NOT EXISTS sends (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date);
|
CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
@@ -151,12 +153,15 @@ CREATE TABLE IF NOT EXISTS devices (
|
|||||||
encrypted_user_key TEXT,
|
encrypted_user_key TEXT,
|
||||||
encrypted_public_key TEXT,
|
encrypted_public_key TEXT,
|
||||||
encrypted_private_key TEXT,
|
encrypted_private_key TEXT,
|
||||||
|
device_note TEXT,
|
||||||
|
last_seen_at TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (user_id, device_identifier),
|
PRIMARY KEY (user_id, device_identifier),
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.2",
|
"version": "1.4.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.2",
|
"version": "1.4.4",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.2",
|
"version": "1.4.4",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.4.2';
|
export const APP_VERSION = '1.4.4';
|
||||||
|
|||||||
@@ -130,6 +130,9 @@
|
|||||||
// Max total items (folders + ciphers) allowed in a single import.
|
// Max total items (folders + ciphers) allowed in a single import.
|
||||||
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
||||||
importItemLimit: 5000,
|
importItemLimit: 5000,
|
||||||
|
// Small fixed concurrency for blob/attachment batch cleanup work.
|
||||||
|
// 附件 / blob 批量清理时的保守并发数。
|
||||||
|
attachmentDeleteConcurrency: 4,
|
||||||
},
|
},
|
||||||
request: {
|
request: {
|
||||||
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DurableObject } from 'cloudflare:workers';
|
||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
|
|
||||||
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
||||||
@@ -6,11 +7,11 @@ const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
|||||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
||||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||||
const SIGNALR_PING_INTERVAL_MS = 15_000;
|
|
||||||
|
|
||||||
type HubProtocol = 'json' | 'messagepack';
|
type HubProtocol = 'json' | 'messagepack';
|
||||||
|
|
||||||
interface ConnectionState {
|
interface WsAttachment {
|
||||||
|
userId: string;
|
||||||
handshakeComplete: boolean;
|
handshakeComplete: boolean;
|
||||||
protocol: HubProtocol;
|
protocol: HubProtocol;
|
||||||
deviceIdentifier: string | null;
|
deviceIdentifier: string | null;
|
||||||
@@ -31,6 +32,12 @@ function encodeUtf8(value: string): Uint8Array {
|
|||||||
return new TextEncoder().encode(value);
|
return new TextEncoder().encode(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
|
||||||
|
if (typeof data === 'string') return data;
|
||||||
|
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
||||||
|
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||||
|
}
|
||||||
|
|
||||||
function encodeMsgPackInteger(value: number): Uint8Array {
|
function encodeMsgPackInteger(value: number): Uint8Array {
|
||||||
const normalized = Math.trunc(value);
|
const normalized = Math.trunc(value);
|
||||||
if (normalized >= 0 && normalized <= 0x7f) {
|
if (normalized >= 0 && normalized <= 0x7f) {
|
||||||
@@ -145,10 +152,6 @@ function buildSignalRJsonInvocation(
|
|||||||
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRJsonPing(): string {
|
|
||||||
return JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSignalRMessagePackInvocation(
|
function buildSignalRMessagePackInvocation(
|
||||||
updateType: number,
|
updateType: number,
|
||||||
messagePayload: Record<string, unknown>,
|
messagePayload: Record<string, unknown>,
|
||||||
@@ -172,24 +175,15 @@ function buildSignalRMessagePackInvocation(
|
|||||||
return frameSignalRBinary(encodedPayload);
|
return frameSignalRBinary(encodedPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRMessagePackPing(): Uint8Array {
|
export class NotificationsHub extends DurableObject<Env> {
|
||||||
return frameSignalRBinary(encodeMsgPack([6]));
|
constructor(ctx: DurableObjectState, env: Env) {
|
||||||
}
|
super(ctx, env);
|
||||||
|
this.ctx.setWebSocketAutoResponse(
|
||||||
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
|
new WebSocketRequestResponsePair(
|
||||||
if (typeof data === 'string') return data;
|
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR),
|
||||||
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR)
|
||||||
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
)
|
||||||
}
|
);
|
||||||
|
|
||||||
export class NotificationsHub {
|
|
||||||
private readonly connections = new Map<WebSocket, ConnectionState>();
|
|
||||||
private userId = '';
|
|
||||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
constructor(private readonly state: DurableObjectState, private readonly env: Env) {
|
|
||||||
void this.state;
|
|
||||||
void this.env;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetch(request: Request): Promise<Response> {
|
async fetch(request: Request): Promise<Response> {
|
||||||
@@ -205,14 +199,14 @@ export class NotificationsHub {
|
|||||||
payload?: Record<string, unknown> | null;
|
payload?: Record<string, unknown> | null;
|
||||||
} | null;
|
} | null;
|
||||||
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
||||||
this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim();
|
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
|
||||||
const contextId = String(body?.contextId || '').trim() || null;
|
const contextId = String(body?.contextId || '').trim() || null;
|
||||||
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
||||||
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
||||||
const payload = body?.payload && typeof body.payload === 'object'
|
const payload = body?.payload && typeof body.payload === 'object'
|
||||||
? body.payload
|
? body.payload
|
||||||
: {
|
: {
|
||||||
UserId: this.userId,
|
UserId: userId,
|
||||||
Date: revisionDate,
|
Date: revisionDate,
|
||||||
};
|
};
|
||||||
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
|
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
|
||||||
@@ -238,46 +232,27 @@ export class NotificationsHub {
|
|||||||
|
|
||||||
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
||||||
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
||||||
if (requestUserId) {
|
|
||||||
this.userId = requestUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.userId) {
|
if (!requestUserId) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pair = new WebSocketPair();
|
const pair = new WebSocketPair();
|
||||||
const client = pair[0];
|
const client = pair[0];
|
||||||
const server = pair[1];
|
const server = pair[1];
|
||||||
server.accept();
|
|
||||||
|
|
||||||
this.connections.set(server, {
|
const tags: string[] = [];
|
||||||
|
if (requestDeviceIdentifier) {
|
||||||
|
tags.push(`device:${requestDeviceIdentifier}`);
|
||||||
|
}
|
||||||
|
this.ctx.acceptWebSocket(server, tags);
|
||||||
|
|
||||||
|
server.serializeAttachment({
|
||||||
|
userId: requestUserId,
|
||||||
handshakeComplete: false,
|
handshakeComplete: false,
|
||||||
protocol: 'messagepack',
|
protocol: 'messagepack',
|
||||||
deviceIdentifier: requestDeviceIdentifier,
|
deviceIdentifier: requestDeviceIdentifier,
|
||||||
});
|
} satisfies WsAttachment);
|
||||||
this.ensurePingLoop();
|
|
||||||
|
|
||||||
server.addEventListener('message', (event) => {
|
|
||||||
void this.handleSocketMessage(server, event.data);
|
|
||||||
});
|
|
||||||
server.addEventListener('close', () => {
|
|
||||||
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
|
||||||
this.connections.delete(server);
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
if (shouldBroadcast) this.broadcastDeviceStatus();
|
|
||||||
});
|
|
||||||
server.addEventListener('error', () => {
|
|
||||||
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
|
||||||
this.connections.delete(server);
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
if (shouldBroadcast) this.broadcastDeviceStatus();
|
|
||||||
try {
|
|
||||||
server.close(1011, 'Socket error');
|
|
||||||
} catch {
|
|
||||||
// ignore close races
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 101,
|
status: 101,
|
||||||
@@ -285,21 +260,21 @@ export class NotificationsHub {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleSocketMessage(socket: WebSocket, rawData: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||||
const connection = this.connections.get(socket);
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
if (!connection) return;
|
if (!attachment) return;
|
||||||
|
|
||||||
if (!connection.handshakeComplete) {
|
if (!attachment.handshakeComplete) {
|
||||||
const text = decodeIncomingMessage(rawData);
|
const text = decodeIncomingMessage(message);
|
||||||
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
|
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
|
||||||
for (const frame of frames) {
|
for (const frame of frames) {
|
||||||
try {
|
try {
|
||||||
const handshake = JSON.parse(frame) as { protocol?: string };
|
const handshake = JSON.parse(frame) as { protocol?: string };
|
||||||
const protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
attachment.protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
||||||
connection.protocol = protocol;
|
attachment.handshakeComplete = true;
|
||||||
connection.handshakeComplete = true;
|
ws.serializeAttachment(attachment);
|
||||||
socket.send(SIGNALR_HANDSHAKE_ACK);
|
ws.send(SIGNALR_HANDSHAKE_ACK);
|
||||||
this.broadcastDeviceStatus();
|
this.broadcastDeviceStatus(attachment.userId);
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed pre-handshake payloads.
|
// Ignore malformed pre-handshake payloads.
|
||||||
@@ -307,53 +282,38 @@ export class NotificationsHub {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private ensurePingLoop(): void {
|
if (typeof message !== 'string') {
|
||||||
if (this.pingTimer !== null) return;
|
|
||||||
this.pingTimer = setInterval(() => {
|
|
||||||
this.broadcastPing();
|
|
||||||
}, SIGNALR_PING_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopPingLoopIfIdle(): void {
|
|
||||||
if (this.connections.size > 0 || this.pingTimer === null) return;
|
|
||||||
clearInterval(this.pingTimer);
|
|
||||||
this.pingTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private broadcastPing(): void {
|
|
||||||
if (this.connections.size === 0) {
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [socket, connection] of this.connections) {
|
|
||||||
if (!connection.handshakeComplete) continue;
|
|
||||||
try {
|
try {
|
||||||
if (connection.protocol === 'json') {
|
ws.send(message);
|
||||||
socket.send(buildSignalRJsonPing());
|
|
||||||
} else {
|
|
||||||
socket.send(buildSignalRMessagePackPing());
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
this.connections.delete(socket);
|
// ignore send errors on echo
|
||||||
try {
|
|
||||||
socket.close(1011, 'Ping send failed');
|
|
||||||
} catch {
|
|
||||||
// ignore close races
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.stopPingLoopIfIdle();
|
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
const shouldBroadcast = !!attachment?.handshakeComplete;
|
||||||
|
if (shouldBroadcast && attachment?.userId) {
|
||||||
|
this.broadcastDeviceStatus(attachment.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
const shouldBroadcast = !!attachment?.handshakeComplete;
|
||||||
|
if (shouldBroadcast && attachment?.userId) {
|
||||||
|
this.broadcastDeviceStatus(attachment.userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getOnlineDeviceIdentifiers(): string[] {
|
private getOnlineDeviceIdentifiers(): string[] {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
for (const connection of this.connections.values()) {
|
for (const ws of this.ctx.getWebSockets()) {
|
||||||
if (!connection.handshakeComplete || !connection.deviceIdentifier) continue;
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
out.add(connection.deviceIdentifier);
|
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
|
||||||
|
out.add(attachment.deviceIdentifier);
|
||||||
}
|
}
|
||||||
return Array.from(out);
|
return Array.from(out);
|
||||||
}
|
}
|
||||||
@@ -364,35 +324,36 @@ export class NotificationsHub {
|
|||||||
contextId: string | null,
|
contextId: string | null,
|
||||||
targetDeviceIdentifier: string | null
|
targetDeviceIdentifier: string | null
|
||||||
): void {
|
): void {
|
||||||
if (!this.userId || this.connections.size === 0) return;
|
const sockets = targetDeviceIdentifier
|
||||||
|
? this.ctx.getWebSockets(`device:${targetDeviceIdentifier}`)
|
||||||
|
: this.ctx.getWebSockets();
|
||||||
|
|
||||||
for (const [socket, connection] of this.connections) {
|
if (sockets.length === 0) return;
|
||||||
if (!connection.handshakeComplete) continue;
|
|
||||||
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue;
|
for (const ws of sockets) {
|
||||||
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
|
if (!attachment?.handshakeComplete) continue;
|
||||||
try {
|
try {
|
||||||
if (connection.protocol === 'json') {
|
if (attachment.protocol === 'json') {
|
||||||
socket.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
ws.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
||||||
} else {
|
} else {
|
||||||
socket.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
ws.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.connections.delete(socket);
|
|
||||||
try {
|
try {
|
||||||
socket.close(1011, 'Notification send failed');
|
ws.close(1011, 'Notification send failed');
|
||||||
} catch {
|
} catch {
|
||||||
// ignore close races
|
// ignore close races
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stopPingLoopIfIdle();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private broadcastDeviceStatus(): void {
|
private broadcastDeviceStatus(userId: string): void {
|
||||||
this.broadcastMessage(
|
this.broadcastMessage(
|
||||||
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
||||||
{
|
{
|
||||||
UserId: this.userId,
|
UserId: userId,
|
||||||
Date: new Date().toISOString(),
|
Date: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ async function verifyUserSecret(
|
|||||||
|
|
||||||
function toProfile(user: User, env: Env): ProfileResponse {
|
function toProfile(user: User, env: Env): ProfileResponse {
|
||||||
void env;
|
void env;
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -100,7 +101,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
|||||||
twoFactorEnabled: !!user.totpSecret,
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys,
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
+17
-10
@@ -10,7 +10,7 @@ import {
|
|||||||
verifyAttachmentUploadToken,
|
verifyAttachmentUploadToken,
|
||||||
verifyFileDownloadToken,
|
verifyFileDownloadToken,
|
||||||
} from '../utils/jwt';
|
} from '../utils/jwt';
|
||||||
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
|
import { cipherToResponse } from './ciphers';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +38,18 @@ function formatSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runWithConcurrency<T>(
|
||||||
|
items: T[],
|
||||||
|
concurrency: number,
|
||||||
|
worker: (item: T) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const limit = Math.max(1, concurrency);
|
||||||
|
for (let index = 0; index < items.length; index += limit) {
|
||||||
|
await Promise.all(items.slice(index, index + limit).map(worker));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function processAttachmentUpload(
|
async function processAttachmentUpload(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
@@ -158,9 +170,7 @@ export async function handleCreateAttachment(
|
|||||||
attachmentId: attachmentId,
|
attachmentId: attachmentId,
|
||||||
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
||||||
fileUploadType: 1,
|
fileUploadType: 1,
|
||||||
cipherResponse: cipherToResponse(updatedCipher!, attachments, {
|
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,9 +382,7 @@ export async function handleDeleteAttachment(
|
|||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
cipher: cipherToResponse(updatedCipher!, attachments, {
|
cipher: cipherToResponse(updatedCipher!, attachments),
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,10 +393,9 @@ export async function deleteAllAttachmentsForCipher(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => {
|
||||||
for (const attachment of attachments) {
|
|
||||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
await deleteBlobObject(env, path);
|
await deleteBlobObject(env, path);
|
||||||
await storage.deleteAttachment(attachment.id);
|
await storage.deleteAttachment(attachment.id);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-19
@@ -113,7 +113,19 @@ async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession):
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
if (message.includes('404') || message.includes('Please select a backup file')) {
|
const normalized = message.toLowerCase();
|
||||||
|
// Some WebDAV providers return non-standard codes such as 530 when the
|
||||||
|
// attachment index does not exist yet. Treat these "missing file" style
|
||||||
|
// responses as an empty index so first-time incremental backups can proceed.
|
||||||
|
if (
|
||||||
|
normalized.includes('404')
|
||||||
|
|| normalized.includes('403')
|
||||||
|
|| normalized.includes('530')
|
||||||
|
|| normalized.includes('not found')
|
||||||
|
|| normalized.includes('file not found')
|
||||||
|
|| normalized.includes('does not exist')
|
||||||
|
|| normalized.includes('please select a backup file')
|
||||||
|
) {
|
||||||
return new Map<string, number>();
|
return new Map<string, number>();
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -180,6 +192,7 @@ async function executeConfiguredBackup(
|
|||||||
});
|
});
|
||||||
const archive = await buildBackupArchive(env, now, {
|
const archive = await buildBackupArchive(env, now, {
|
||||||
includeAttachments: destination.includeAttachments,
|
includeAttachments: destination.includeAttachments,
|
||||||
|
timeZone: destination.schedule.timezone,
|
||||||
progress: progress
|
progress: progress
|
||||||
? async (event) => {
|
? async (event) => {
|
||||||
if (event.step === 'archive_ready') {
|
if (event.step === 'archive_ready') {
|
||||||
@@ -205,26 +218,28 @@ async function executeConfiguredBackup(
|
|||||||
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
|
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
|
||||||
});
|
});
|
||||||
const remoteSession = createRemoteBackupTransferSession(destination);
|
const remoteSession = createRemoteBackupTransferSession(destination);
|
||||||
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
if (destination.includeAttachments) {
|
||||||
let attachmentIndexChanged = false;
|
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||||
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
let attachmentIndexChanged = false;
|
||||||
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
||||||
continue;
|
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const remotePath = `attachments/${attachment.blobName}`;
|
||||||
|
const object = await getBlobObject(env, attachment.blobName);
|
||||||
|
if (!object) {
|
||||||
|
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
||||||
|
await remoteSession.putFile(remotePath, bytes, {
|
||||||
|
contentType: object.contentType,
|
||||||
|
});
|
||||||
|
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
||||||
|
attachmentIndexChanged = true;
|
||||||
}
|
}
|
||||||
const remotePath = `attachments/${attachment.blobName}`;
|
if (attachmentIndexChanged) {
|
||||||
const object = await getBlobObject(env, attachment.blobName);
|
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
||||||
if (!object) {
|
|
||||||
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
|
|
||||||
}
|
}
|
||||||
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
|
||||||
await remoteSession.putFile(remotePath, bytes, {
|
|
||||||
contentType: object.contentType,
|
|
||||||
});
|
|
||||||
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
|
||||||
attachmentIndexChanged = true;
|
|
||||||
}
|
|
||||||
if (attachmentIndexChanged) {
|
|
||||||
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
|
||||||
}
|
}
|
||||||
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||||
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||||
|
|||||||
+100
-107
@@ -1,4 +1,15 @@
|
|||||||
import { Env, Cipher, CipherResponse, Attachment } from '../types';
|
import {
|
||||||
|
Env,
|
||||||
|
Cipher,
|
||||||
|
CipherCard,
|
||||||
|
CipherIdentity,
|
||||||
|
CipherLogin,
|
||||||
|
CipherResponse,
|
||||||
|
CipherSecureNote,
|
||||||
|
CipherSshKey,
|
||||||
|
Attachment,
|
||||||
|
PasswordHistory,
|
||||||
|
} from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
@@ -32,6 +43,10 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
|
|||||||
return { present: false, value: undefined };
|
return { present: false, value: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCipherProp<T = unknown>(source: any, aliases: string[]): { present: boolean; value: T | undefined } {
|
||||||
|
return getAliasedProp(source, aliases) as { present: boolean; value: T | undefined };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCipherTimestamp(value: unknown): string | null {
|
function normalizeCipherTimestamp(value: unknown): string | null {
|
||||||
if (value == null || value === '') return null;
|
if (value == null || value === '') return null;
|
||||||
const parsed = new Date(String(value));
|
const parsed = new Date(String(value));
|
||||||
@@ -44,6 +59,19 @@ function readCipherArchivedAt(source: any, fallback: string | null = null): stri
|
|||||||
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
|
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCipherRevisionDate(source: any): string | null {
|
||||||
|
const revision = getAliasedProp(source, ['lastKnownRevisionDate', 'LastKnownRevisionDate']);
|
||||||
|
return revision.present ? normalizeCipherTimestamp(revision.value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: string | null): boolean {
|
||||||
|
if (!clientRevisionDate) return false;
|
||||||
|
const existingTs = Date.parse(existingUpdatedAt);
|
||||||
|
const clientTs = Date.parse(clientRevisionDate);
|
||||||
|
if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false;
|
||||||
|
return existingTs - clientTs > 1000;
|
||||||
|
}
|
||||||
|
|
||||||
function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
||||||
cipher.archivedDate = cipher.archivedAt ?? null;
|
cipher.archivedDate = cipher.archivedAt ?? null;
|
||||||
cipher.deletedDate = cipher.deletedAt ?? null;
|
cipher.deletedDate = cipher.deletedAt ?? null;
|
||||||
@@ -61,80 +89,18 @@ function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
|||||||
return syncCipherComputedAliases(cipher);
|
return syncCipherComputedAliases(cipher);
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeCipherString(value: unknown): boolean {
|
|
||||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldOmitPasskeysForResponse(request: Request | null | undefined): boolean {
|
|
||||||
const userAgent = String(request?.headers.get('user-agent') || '').toLowerCase();
|
|
||||||
if (!userAgent) return false;
|
|
||||||
|
|
||||||
// Temporary compatibility fallback:
|
|
||||||
// mobile clients expect official EncString payloads for most FIDO2 fields.
|
|
||||||
// Keep passkeys available everywhere, but suppress only legacy malformed data
|
|
||||||
// for mobile clients so newly-saved credentials can flow through unchanged.
|
|
||||||
return (
|
|
||||||
userAgent.includes('android') ||
|
|
||||||
userAgent.includes('iphone') ||
|
|
||||||
userAgent.includes('ipad') ||
|
|
||||||
userAgent.includes('ios')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeCipherLoginForStorage(login: any): any {
|
export function normalizeCipherLoginForStorage(login: any): any {
|
||||||
if (!login || typeof login !== 'object') return login ?? null;
|
if (!login || typeof login !== 'object') return login ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...login,
|
...login,
|
||||||
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeCipherLoginForCompatibility(
|
export function normalizeCipherLoginForCompatibility(login: any): any {
|
||||||
login: any,
|
|
||||||
options?: { omitFido2Credentials?: boolean }
|
|
||||||
): any {
|
|
||||||
const normalized = normalizeCipherLoginForStorage(login);
|
const normalized = normalizeCipherLoginForStorage(login);
|
||||||
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||||
if (!options?.omitFido2Credentials) return normalized;
|
return normalized;
|
||||||
|
|
||||||
const credentials = Array.isArray(normalized.fido2Credentials) ? normalized.fido2Credentials : null;
|
|
||||||
if (!credentials?.length) return normalized;
|
|
||||||
|
|
||||||
const hasMalformedCredential = credentials.some((credential: any) => {
|
|
||||||
if (!credential || typeof credential !== 'object') return true;
|
|
||||||
const requiredEncryptedFields = [
|
|
||||||
credential.credentialId,
|
|
||||||
credential.keyType,
|
|
||||||
credential.keyAlgorithm,
|
|
||||||
credential.keyCurve,
|
|
||||||
credential.keyValue,
|
|
||||||
credential.rpId,
|
|
||||||
credential.counter,
|
|
||||||
credential.discoverable,
|
|
||||||
];
|
|
||||||
const optionalEncryptedFields = [
|
|
||||||
credential.userHandle,
|
|
||||||
credential.userName,
|
|
||||||
credential.rpName,
|
|
||||||
credential.userDisplayName,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (requiredEncryptedFields.some((value) => !looksLikeCipherString(value))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (optionalEncryptedFields.some((value) => value != null && !looksLikeCipherString(value))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
return hasMalformedCredential
|
|
||||||
? {
|
|
||||||
...normalized,
|
|
||||||
fido2Credentials: null,
|
|
||||||
}
|
|
||||||
: normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
||||||
@@ -180,12 +146,11 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
|
|||||||
// survive a round-trip without code changes.
|
// survive a round-trip without code changes.
|
||||||
export function cipherToResponse(
|
export function cipherToResponse(
|
||||||
cipher: Cipher,
|
cipher: Cipher,
|
||||||
attachments: Attachment[] = [],
|
attachments: Attachment[] = []
|
||||||
options?: { omitFido2Credentials?: boolean }
|
|
||||||
): CipherResponse {
|
): CipherResponse {
|
||||||
// Strip internal-only fields that must not appear in the API response
|
// Strip internal-only fields that must not appear in the API response
|
||||||
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
||||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options);
|
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
|
||||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -194,8 +159,8 @@ export function cipherToResponse(
|
|||||||
// Server-computed / enforced fields (always override)
|
// Server-computed / enforced fields (always override)
|
||||||
folderId: normalizeOptionalId(cipher.folderId),
|
folderId: normalizeOptionalId(cipher.folderId),
|
||||||
type: Number(cipher.type) || 1,
|
type: Number(cipher.type) || 1,
|
||||||
organizationId: null,
|
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
||||||
organizationUseTotp: false,
|
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
||||||
creationDate: createdAt,
|
creationDate: createdAt,
|
||||||
revisionDate: updatedAt,
|
revisionDate: updatedAt,
|
||||||
deletedDate: deletedAt,
|
deletedDate: deletedAt,
|
||||||
@@ -206,12 +171,12 @@ export function cipherToResponse(
|
|||||||
delete: true,
|
delete: true,
|
||||||
restore: true,
|
restore: true,
|
||||||
},
|
},
|
||||||
object: 'cipher',
|
object: 'cipherDetails',
|
||||||
collectionIds: [],
|
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
|
||||||
attachments: formatAttachments(attachments),
|
attachments: formatAttachments(attachments),
|
||||||
login: normalizedLogin,
|
login: normalizedLogin,
|
||||||
sshKey: normalizedSshKey,
|
sshKey: normalizedSshKey,
|
||||||
encryptedFor: null,
|
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +186,6 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||||
const pagination = parsePagination(url);
|
const pagination = parsePagination(url);
|
||||||
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
|
||||||
|
|
||||||
let filteredCiphers: Cipher[];
|
let filteredCiphers: Cipher[];
|
||||||
let continuationToken: string | null = null;
|
let continuationToken: string | null = null;
|
||||||
@@ -242,13 +206,15 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
: ciphers.filter(c => !c.deletedAt);
|
: ciphers.filter(c => !c.deletedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
|
||||||
|
filteredCiphers.map((cipher) => cipher.id)
|
||||||
|
);
|
||||||
|
|
||||||
// Get attachments for all ciphers
|
// Build responses only for the current page to keep pagination cheap.
|
||||||
const cipherResponses = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
@@ -269,9 +235,7 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
|||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments, {
|
cipherToResponse(cipher, attachments)
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +259,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
// Handle nested cipher object (from some clients)
|
// Handle nested cipher object (from some clients)
|
||||||
// Android client sends PascalCase "Cipher" for organization ciphers
|
// Android client sends PascalCase "Cipher" for organization ciphers
|
||||||
const cipherData = body.Cipher || body.cipher || body;
|
const cipherData = body.Cipher || body.cipher || body;
|
||||||
|
const createFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
|
||||||
|
const createKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
|
||||||
|
const createLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
|
||||||
|
const createCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
|
||||||
|
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||||
|
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||||
|
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||||
|
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
|
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
|
||||||
@@ -312,6 +284,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
archivedAt: readCipherArchivedAt(cipherData, null),
|
archivedAt: readCipherArchivedAt(cipherData, null),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
|
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
|
||||||
|
cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null);
|
||||||
|
cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
|
||||||
|
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
|
||||||
|
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
|
||||||
|
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
|
||||||
|
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
|
||||||
|
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
|
||||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||||
normalizeCipherForStorage(cipher);
|
normalizeCipherForStorage(cipher);
|
||||||
@@ -327,9 +307,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, []),
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
}),
|
|
||||||
200
|
200
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -353,6 +331,21 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
// Handle nested cipher object
|
// Handle nested cipher object
|
||||||
// Android client sends PascalCase "Cipher" for organization ciphers
|
// Android client sends PascalCase "Cipher" for organization ciphers
|
||||||
const cipherData = body.Cipher || body.cipher || body;
|
const cipherData = body.Cipher || body.cipher || body;
|
||||||
|
const incomingFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
|
||||||
|
const incomingKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
|
||||||
|
const incomingLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
|
||||||
|
const incomingCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
|
||||||
|
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||||
|
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||||
|
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||||
|
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||||
|
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||||
|
|
||||||
|
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
|
||||||
|
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextType = Number(cipherData.type) || existingCipher.type;
|
||||||
|
|
||||||
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
||||||
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
||||||
@@ -362,7 +355,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
// Server-controlled fields (never from client)
|
// Server-controlled fields (never from client)
|
||||||
id: existingCipher.id,
|
id: existingCipher.id,
|
||||||
userId: existingCipher.userId,
|
userId: existingCipher.userId,
|
||||||
type: Number(cipherData.type) || existingCipher.type,
|
type: nextType,
|
||||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||||
createdAt: existingCipher.createdAt,
|
createdAt: existingCipher.createdAt,
|
||||||
@@ -370,6 +363,20 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||||
deletedAt: existingCipher.deletedAt,
|
deletedAt: existingCipher.deletedAt,
|
||||||
};
|
};
|
||||||
|
if (incomingFolderId.present) {
|
||||||
|
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
|
||||||
|
}
|
||||||
|
if (incomingKey.present) {
|
||||||
|
cipher.key = incomingKey.value ?? null;
|
||||||
|
}
|
||||||
|
cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null;
|
||||||
|
cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
|
||||||
|
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
|
||||||
|
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
|
||||||
|
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
|
||||||
|
if (incomingPasswordHistory.present) {
|
||||||
|
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// Custom fields deletion compatibility:
|
// Custom fields deletion compatibility:
|
||||||
// - Accept both camelCase "fields" and PascalCase "Fields".
|
// - Accept both camelCase "fields" and PascalCase "Fields".
|
||||||
@@ -392,11 +399,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, attachments)
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,9 +424,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
|||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, [])
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,9 +488,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
|||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, [])
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,9 +527,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
|||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [], {
|
cipherToResponse(cipher, [])
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,13 +568,10 @@ async function buildCipherListResponse(
|
|||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const ciphers = await storage.getCiphersByIds(ids, userId);
|
const ciphers = await storage.getCiphersByIds(ids, userId);
|
||||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
|
||||||
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: ciphers.map((cipher) =>
|
data: ciphers.map((cipher) =>
|
||||||
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], {
|
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])
|
||||||
omitFido2Credentials,
|
|
||||||
})
|
|
||||||
),
|
),
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: null,
|
||||||
@@ -607,9 +604,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments, {
|
cipherToResponse(cipher, attachments)
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,9 +626,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
|
|||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments, {
|
cipherToResponse(cipher, attachments)
|
||||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-4
@@ -23,13 +23,18 @@ function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPub
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDeviceResponse(device: Device): DeviceResponse {
|
function buildDeviceResponse(device: Device): DeviceResponse {
|
||||||
|
const displayName = String(device.deviceNote || '').trim() || device.name;
|
||||||
const response = {
|
const response = {
|
||||||
Id: device.deviceIdentifier,
|
Id: device.deviceIdentifier,
|
||||||
id: device.deviceIdentifier,
|
id: device.deviceIdentifier,
|
||||||
UserId: device.userId,
|
UserId: device.userId,
|
||||||
userId: device.userId,
|
userId: device.userId,
|
||||||
Name: device.name,
|
Name: displayName,
|
||||||
name: device.name,
|
name: displayName,
|
||||||
|
SystemName: device.name,
|
||||||
|
systemName: device.name,
|
||||||
|
DeviceNote: device.deviceNote,
|
||||||
|
deviceNote: device.deviceNote,
|
||||||
Identifier: device.deviceIdentifier,
|
Identifier: device.deviceIdentifier,
|
||||||
identifier: device.deviceIdentifier,
|
identifier: device.deviceIdentifier,
|
||||||
Type: device.type,
|
Type: device.type,
|
||||||
@@ -38,6 +43,10 @@ function buildDeviceResponse(device: Device): DeviceResponse {
|
|||||||
creationDate: device.createdAt,
|
creationDate: device.createdAt,
|
||||||
RevisionDate: device.updatedAt,
|
RevisionDate: device.updatedAt,
|
||||||
revisionDate: device.updatedAt,
|
revisionDate: device.updatedAt,
|
||||||
|
LastSeenAt: device.lastSeenAt,
|
||||||
|
lastSeenAt: device.lastSeenAt,
|
||||||
|
HasStoredDevice: true,
|
||||||
|
hasStoredDevice: true,
|
||||||
IsTrusted: isTrustedDevice(device),
|
IsTrusted: isTrustedDevice(device),
|
||||||
isTrusted: isTrustedDevice(device),
|
isTrusted: isTrustedDevice(device),
|
||||||
EncryptedUserKey: device.encryptedUserKey,
|
EncryptedUserKey: device.encryptedUserKey,
|
||||||
@@ -55,8 +64,12 @@ function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireRespon
|
|||||||
const response = {
|
const response = {
|
||||||
Id: device.deviceIdentifier,
|
Id: device.deviceIdentifier,
|
||||||
id: device.deviceIdentifier,
|
id: device.deviceIdentifier,
|
||||||
Name: device.name,
|
Name: String(device.deviceNote || '').trim() || device.name,
|
||||||
name: device.name,
|
name: String(device.deviceNote || '').trim() || device.name,
|
||||||
|
SystemName: device.name,
|
||||||
|
systemName: device.name,
|
||||||
|
DeviceNote: device.deviceNote,
|
||||||
|
deviceNote: device.deviceNote,
|
||||||
Identifier: device.deviceIdentifier,
|
Identifier: device.deviceIdentifier,
|
||||||
identifier: device.deviceIdentifier,
|
identifier: device.deviceIdentifier,
|
||||||
Type: device.type,
|
Type: device.type,
|
||||||
@@ -101,6 +114,10 @@ async function readJsonBody(request: Request): Promise<any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDeviceName(value: unknown): string {
|
||||||
|
return String(value || '').trim().slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/devices/knowndevice
|
// GET /api/devices/knowndevice
|
||||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||||
// - X-Request-Email: base64url(email) without padding
|
// - X-Request-Email: base64url(email) without padding
|
||||||
@@ -203,12 +220,15 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
|||||||
encryptedPublicKey: null,
|
encryptedPublicKey: null,
|
||||||
encryptedPrivateKey: null,
|
encryptedPrivateKey: null,
|
||||||
devicePendingAuthRequest: null,
|
devicePendingAuthRequest: null,
|
||||||
|
deviceNote: null,
|
||||||
|
lastSeenAt: null,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
};
|
};
|
||||||
data.push({
|
data.push({
|
||||||
...buildDeviceResponse(placeholderDevice),
|
...buildDeviceResponse(placeholderDevice),
|
||||||
isTrusted: true,
|
isTrusted: true,
|
||||||
|
hasStoredDevice: false,
|
||||||
online: onlineSet.has(row.deviceIdentifier),
|
online: onlineSet.has(row.deviceIdentifier),
|
||||||
trusted: true,
|
trusted: true,
|
||||||
trustedTokenCount: row.tokenCount,
|
trustedTokenCount: row.tokenCount,
|
||||||
@@ -269,6 +289,29 @@ export async function handleDeleteDevice(
|
|||||||
return jsonResponse({ success: deleted });
|
return jsonResponse({ success: deleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PUT /api/devices/:deviceIdentifier/name
|
||||||
|
export async function handleUpdateDeviceName(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const normalized = String(deviceIdentifier || '').trim();
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const name = parseDeviceName(body?.name);
|
||||||
|
if (!name) return errorResponse('Device name is required', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const updated = await storage.updateDeviceName(userId, normalized, name);
|
||||||
|
if (!updated) return errorResponse('Device not found', 404);
|
||||||
|
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) return errorResponse('Device not found', 404);
|
||||||
|
return jsonResponse(buildDeviceResponse(device));
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE /api/devices
|
// DELETE /api/devices
|
||||||
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
void request;
|
void request;
|
||||||
|
|||||||
+92
-16
@@ -18,6 +18,7 @@ import {
|
|||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||||
|
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
||||||
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
|
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
|
||||||
// Keep request parsing backward-compatible with historical provider values (8 / 100).
|
// Keep request parsing backward-compatible with historical provider values (8 / 100).
|
||||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
||||||
@@ -31,6 +32,54 @@ function resolveTotpSecret(userSecret: string | null): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseWebSession(request: Request): boolean {
|
||||||
|
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookieValue(request: Request, name: string): string | null {
|
||||||
|
const rawCookie = String(request.headers.get('Cookie') || '').trim();
|
||||||
|
if (!rawCookie) return null;
|
||||||
|
for (const part of rawCookie.split(';')) {
|
||||||
|
const [key, ...rest] = part.trim().split('=');
|
||||||
|
if (key !== name) continue;
|
||||||
|
const value = rest.join('=').trim();
|
||||||
|
return value ? decodeURIComponent(value) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||||
|
const isHttps = new URL(request.url).protocol === 'https:';
|
||||||
|
const parts = [
|
||||||
|
`${WEB_REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}`,
|
||||||
|
'Path=/identity/connect',
|
||||||
|
'HttpOnly',
|
||||||
|
'SameSite=Strict',
|
||||||
|
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
||||||
|
];
|
||||||
|
if (isHttps) parts.push('Secure');
|
||||||
|
return parts.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClearedRefreshCookie(request: Request): string {
|
||||||
|
return buildRefreshCookie(request, '', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withWebRefreshCookie(request: Request, response: Response, refreshToken: string | null): Response {
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.append(
|
||||||
|
'Set-Cookie',
|
||||||
|
refreshToken
|
||||||
|
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000))
|
||||||
|
: buildClearedRefreshCookie(request)
|
||||||
|
);
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildPreloginResponse(
|
function buildPreloginResponse(
|
||||||
email: string,
|
email: string,
|
||||||
kdfType: number,
|
kdfType: number,
|
||||||
@@ -278,17 +327,19 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
refresh_token: refreshToken,
|
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
AccountKeys: buildAccountKeys(user),
|
AccountKeys: accountKeys,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys: accountKeys,
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
@@ -301,11 +352,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(response);
|
const baseResponse = jsonResponse(response);
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||||
|
: baseResponse;
|
||||||
|
|
||||||
} else if (grantType === 'send_access') {
|
} else if (grantType === 'send_access') {
|
||||||
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||||
@@ -371,14 +425,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh token
|
// Refresh token
|
||||||
const refreshToken = body.refresh_token;
|
const refreshToken = String(body.refresh_token || '').trim() || (
|
||||||
|
shouldUseWebSession(request)
|
||||||
|
? parseCookieValue(request, WEB_REFRESH_COOKIE)
|
||||||
|
: null
|
||||||
|
);
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await auth.refreshAccessToken(refreshToken);
|
const result = await auth.refreshAccessToken(refreshToken);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, invalidResponse, null)
|
||||||
|
: invalidResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep a short overlap window for old refresh token to absorb
|
// Keep a short overlap window for old refresh token to absorb
|
||||||
@@ -389,17 +450,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { accessToken, user, device } = result;
|
const { accessToken, user, device } = result;
|
||||||
|
if (device?.identifier) {
|
||||||
|
await storage.touchDeviceLastSeen(user.id, device.identifier);
|
||||||
|
}
|
||||||
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
refresh_token: newRefreshToken,
|
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
AccountKeys: buildAccountKeys(user),
|
AccountKeys: accountKeys,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys: accountKeys,
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
@@ -412,11 +478,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(response);
|
const baseResponse = jsonResponse(response);
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, baseResponse, newRefreshToken)
|
||||||
|
: baseResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
|
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
|
||||||
@@ -470,10 +539,17 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
|
|||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = String(body.token || '').trim();
|
const token = String(body.token || '').trim() || (
|
||||||
|
shouldUseWebSession(request)
|
||||||
|
? (parseCookieValue(request, WEB_REFRESH_COOKIE) || '')
|
||||||
|
: ''
|
||||||
|
);
|
||||||
if (token) {
|
if (token) {
|
||||||
await storage.deleteRefreshToken(token);
|
await storage.deleteRefreshToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
const baseResponse = new Response(null, { status: 200 });
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, baseResponse, null)
|
||||||
|
: baseResponse;
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-44
@@ -24,7 +24,6 @@ interface CiphersImportRequest {
|
|||||||
password?: string | null;
|
password?: string | null;
|
||||||
totp?: string | null;
|
totp?: string | null;
|
||||||
autofillOnPageLoad?: boolean | null;
|
autofillOnPageLoad?: boolean | null;
|
||||||
fido2Credentials?: any[] | null;
|
|
||||||
uri?: string | null;
|
uri?: string | null;
|
||||||
passwordRevisionDate?: string | null;
|
passwordRevisionDate?: string | null;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
@@ -83,6 +82,16 @@ function bindNull(v: any): any {
|
|||||||
return v === undefined ? null : v;
|
return v === undefined ? null : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T | undefined {
|
||||||
|
if (!source || typeof source !== 'object') return undefined;
|
||||||
|
for (const key of aliases) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
return source[key] as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
||||||
for (let i = 0; i < statements.length; i += chunkSize) {
|
for (let i = 0; i < statements.length; i += chunkSize) {
|
||||||
const chunk = statements.slice(i, i + chunkSize);
|
const chunk = statements.slice(i, i + chunkSize);
|
||||||
@@ -159,9 +168,16 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
||||||
for (let i = 0; i < ciphers.length; i++) {
|
for (let i = 0; i < ciphers.length; i++) {
|
||||||
const c = ciphers[i];
|
const c = ciphers[i];
|
||||||
const folderId = cipherFolderMap.get(i) || null;
|
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
|
||||||
const sourceIdRaw = String(c?.id ?? '').trim();
|
const sourceIdRaw = String(c?.id ?? '').trim();
|
||||||
const sourceId = sourceIdRaw || null;
|
const sourceId = sourceIdRaw || null;
|
||||||
|
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
|
||||||
|
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
|
||||||
|
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
|
||||||
|
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
|
||||||
|
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
|
||||||
|
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
|
||||||
|
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
|
||||||
|
|
||||||
const cipher: Cipher = {
|
const cipher: Cipher = {
|
||||||
...c,
|
...c,
|
||||||
@@ -172,64 +188,64 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
name: c.name ?? 'Untitled',
|
name: c.name ?? 'Untitled',
|
||||||
notes: c.notes ?? null,
|
notes: c.notes ?? null,
|
||||||
favorite: c.favorite ?? false,
|
favorite: c.favorite ?? false,
|
||||||
login: c.login ? {
|
login: login ? {
|
||||||
...c.login,
|
...login,
|
||||||
username: c.login.username ?? null,
|
username: login.username ?? null,
|
||||||
password: c.login.password ?? null,
|
password: login.password ?? null,
|
||||||
uris: c.login.uris?.map(u => ({
|
uris: login.uris?.map((u: any) => ({
|
||||||
...u,
|
...u,
|
||||||
uri: u.uri ?? null,
|
uri: u.uri ?? null,
|
||||||
uriChecksum: null,
|
uriChecksum: null,
|
||||||
match: u.match ?? null,
|
match: u.match ?? null,
|
||||||
})) || null,
|
})) || null,
|
||||||
totp: c.login.totp ?? null,
|
totp: login.totp ?? null,
|
||||||
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
autofillOnPageLoad: login.autofillOnPageLoad ?? null,
|
||||||
fido2Credentials: c.login.fido2Credentials ?? null,
|
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||||
uri: c.login.uri ?? null,
|
uri: login.uri ?? null,
|
||||||
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
passwordRevisionDate: login.passwordRevisionDate ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
card: c.card ? {
|
card: card ? {
|
||||||
...c.card,
|
...card,
|
||||||
cardholderName: c.card.cardholderName ?? null,
|
cardholderName: card.cardholderName ?? null,
|
||||||
brand: c.card.brand ?? null,
|
brand: card.brand ?? null,
|
||||||
number: c.card.number ?? null,
|
number: card.number ?? null,
|
||||||
expMonth: c.card.expMonth ?? null,
|
expMonth: card.expMonth ?? null,
|
||||||
expYear: c.card.expYear ?? null,
|
expYear: card.expYear ?? null,
|
||||||
code: c.card.code ?? null,
|
code: card.code ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
identity: c.identity ? {
|
identity: identity ? {
|
||||||
...c.identity,
|
...identity,
|
||||||
title: c.identity.title ?? null,
|
title: identity.title ?? null,
|
||||||
firstName: c.identity.firstName ?? null,
|
firstName: identity.firstName ?? null,
|
||||||
middleName: c.identity.middleName ?? null,
|
middleName: identity.middleName ?? null,
|
||||||
lastName: c.identity.lastName ?? null,
|
lastName: identity.lastName ?? null,
|
||||||
address1: c.identity.address1 ?? null,
|
address1: identity.address1 ?? null,
|
||||||
address2: c.identity.address2 ?? null,
|
address2: identity.address2 ?? null,
|
||||||
address3: c.identity.address3 ?? null,
|
address3: identity.address3 ?? null,
|
||||||
city: c.identity.city ?? null,
|
city: identity.city ?? null,
|
||||||
state: c.identity.state ?? null,
|
state: identity.state ?? null,
|
||||||
postalCode: c.identity.postalCode ?? null,
|
postalCode: identity.postalCode ?? null,
|
||||||
country: c.identity.country ?? null,
|
country: identity.country ?? null,
|
||||||
company: c.identity.company ?? null,
|
company: identity.company ?? null,
|
||||||
email: c.identity.email ?? null,
|
email: identity.email ?? null,
|
||||||
phone: c.identity.phone ?? null,
|
phone: identity.phone ?? null,
|
||||||
ssn: c.identity.ssn ?? null,
|
ssn: identity.ssn ?? null,
|
||||||
username: c.identity.username ?? null,
|
username: identity.username ?? null,
|
||||||
passportNumber: c.identity.passportNumber ?? null,
|
passportNumber: identity.passportNumber ?? null,
|
||||||
licenseNumber: c.identity.licenseNumber ?? null,
|
licenseNumber: identity.licenseNumber ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
secureNote: c.secureNote ?? null,
|
secureNote: secureNote ?? null,
|
||||||
fields: c.fields?.map(f => ({
|
fields: fields?.map((f: any) => ({
|
||||||
...f,
|
...f,
|
||||||
name: f.name ?? null,
|
name: f.name ?? null,
|
||||||
value: f.value ?? null,
|
value: f.value ?? null,
|
||||||
type: f.type,
|
type: f.type,
|
||||||
linkedId: f.linkedId ?? null,
|
linkedId: f.linkedId ?? null,
|
||||||
})) || null,
|
})) || null,
|
||||||
passwordHistory: c.passwordHistory ?? null,
|
passwordHistory: passwordHistory ?? null,
|
||||||
reprompt: c.reprompt ?? 0,
|
reprompt: c.reprompt ?? 0,
|
||||||
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
||||||
key: (c as any).key ?? null,
|
key: key ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
|||||||
@@ -97,8 +97,9 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
|
|||||||
sends = await storage.getAllSends(userId);
|
sends = await storage.getAllSends(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendResponses = sends.map(sendToResponse);
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: sends.map(sendToResponse),
|
data: sendResponses,
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken,
|
continuationToken,
|
||||||
});
|
});
|
||||||
|
|||||||
+48
-117
@@ -10,87 +10,23 @@ import {
|
|||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
} from '../utils/user-decryption';
|
} from '../utils/user-decryption';
|
||||||
|
|
||||||
interface SyncCacheEntry {
|
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request {
|
||||||
userId: string;
|
const url = new URL(request.url);
|
||||||
revisionDate: string;
|
const cacheUrl = new URL(
|
||||||
body: string;
|
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`,
|
||||||
expiresAt: number;
|
url.origin
|
||||||
bytes: number;
|
);
|
||||||
|
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
|
||||||
let syncResponseCacheTotalBytes = 0;
|
const hit = await caches.default.match(cacheRequest);
|
||||||
const textEncoder = new TextEncoder();
|
|
||||||
|
|
||||||
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
|
|
||||||
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSyncCache(key: string): string | null {
|
|
||||||
const hit = syncResponseCache.get(key);
|
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
if (hit.expiresAt <= Date.now()) {
|
return new Response(hit.body, hit);
|
||||||
deleteSyncCacheEntry(key, hit);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return hit.body;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void {
|
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
|
||||||
const existing = entry ?? syncResponseCache.get(key);
|
await caches.default.put(cacheRequest, response.clone());
|
||||||
if (!existing) return;
|
|
||||||
syncResponseCache.delete(key);
|
|
||||||
syncResponseCacheTotalBytes = Math.max(0, syncResponseCacheTotalBytes - existing.bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneExpiredSyncCache(nowMs: number = Date.now()): void {
|
|
||||||
for (const [key, entry] of syncResponseCache.entries()) {
|
|
||||||
if (entry.expiresAt <= nowMs) {
|
|
||||||
deleteSyncCacheEntry(key, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneStaleUserSyncCache(userId: string, revisionDate: string): void {
|
|
||||||
for (const [key, entry] of syncResponseCache.entries()) {
|
|
||||||
if (entry.userId === userId && entry.revisionDate !== revisionDate) {
|
|
||||||
deleteSyncCacheEntry(key, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeSyncCache(userId: string, revisionDate: string, key: string, body: string): void {
|
|
||||||
const nowMs = Date.now();
|
|
||||||
pruneExpiredSyncCache(nowMs);
|
|
||||||
pruneStaleUserSyncCache(userId, revisionDate);
|
|
||||||
|
|
||||||
const bodyBytes = textEncoder.encode(body).byteLength;
|
|
||||||
if (bodyBytes > LIMITS.cache.syncResponseMaxBodyBytes) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = syncResponseCache.get(key);
|
|
||||||
if (existing) {
|
|
||||||
deleteSyncCacheEntry(key, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (
|
|
||||||
syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries ||
|
|
||||||
syncResponseCacheTotalBytes + bodyBytes > LIMITS.cache.syncResponseMaxTotalBytes
|
|
||||||
) {
|
|
||||||
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
|
|
||||||
if (!oldestKey) break;
|
|
||||||
deleteSyncCacheEntry(oldestKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
syncResponseCache.set(key, {
|
|
||||||
userId,
|
|
||||||
revisionDate,
|
|
||||||
body,
|
|
||||||
expiresAt: nowMs + LIMITS.cache.syncResponseTtlMs,
|
|
||||||
bytes: bodyBytes,
|
|
||||||
});
|
|
||||||
syncResponseCacheTotalBytes += bodyBytes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/sync
|
// GET /api/sync
|
||||||
@@ -99,34 +35,28 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
const userAgent = String(request.headers.get('user-agent') || '').toLowerCase();
|
|
||||||
const omitFido2Credentials =
|
|
||||||
userAgent.includes('android') ||
|
|
||||||
userAgent.includes('iphone') ||
|
|
||||||
userAgent.includes('ipad') ||
|
|
||||||
userAgent.includes('ios');
|
|
||||||
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return errorResponse('User not found', 404);
|
return errorResponse('User not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const revisionDate = await storage.getRevisionDate(userId);
|
const revisionDate = await storage.getRevisionDate(userId);
|
||||||
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
|
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains);
|
||||||
const cachedBody = readSyncCache(cacheKey);
|
const cachedResponse = await readSyncCache(cacheRequest);
|
||||||
if (cachedBody) {
|
if (cachedResponse) {
|
||||||
return new Response(cachedBody, {
|
return cachedResponse;
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ciphers = await storage.getAllCiphers(userId);
|
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
|
||||||
const folders = await storage.getAllFolders(userId);
|
storage.getAllCiphers(userId),
|
||||||
const sends = await storage.getAllSends(userId);
|
storage.getAllFolders(userId),
|
||||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
storage.getAllSends(userId),
|
||||||
|
storage.getAttachmentsByUserId(userId),
|
||||||
|
]);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
// Build profile response
|
|
||||||
const profile: ProfileResponse = {
|
const profile: ProfileResponse = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -140,7 +70,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
twoFactorEnabled: !!user.totpSecret,
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys,
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
@@ -152,23 +82,24 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
object: 'profile',
|
object: 'profile',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build cipher responses with attachments
|
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
for (const cipher of ciphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []));
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build folder responses
|
const folderResponses: FolderResponse[] = [];
|
||||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
for (const folder of folders) {
|
||||||
id: folder.id,
|
folderResponses.push({
|
||||||
name: folder.name,
|
id: folder.id,
|
||||||
revisionDate: folder.updatedAt,
|
name: folder.name,
|
||||||
object: 'folder',
|
revisionDate: folder.updatedAt,
|
||||||
}));
|
object: 'folder',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendResponses = sends.map(sendToResponse);
|
||||||
const syncResponse: SyncResponse = {
|
const syncResponse: SyncResponse = {
|
||||||
profile: profile,
|
profile,
|
||||||
folders: folderResponses,
|
folders: folderResponses,
|
||||||
collections: [],
|
collections: [],
|
||||||
ciphers: cipherResponses,
|
ciphers: cipherResponses,
|
||||||
@@ -180,25 +111,25 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
object: 'domains',
|
object: 'domains',
|
||||||
},
|
},
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: sends.map(sendToResponse),
|
sends: sendResponses,
|
||||||
UserDecryption: {
|
UserDecryption: {
|
||||||
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
|
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||||
TrustedDeviceOption: null,
|
TrustedDeviceOption: null,
|
||||||
KeyConnectorOption: null,
|
KeyConnectorOption: null,
|
||||||
Object: 'userDecryption',
|
Object: 'userDecryption',
|
||||||
},
|
},
|
||||||
// PascalCase for desktop/browser clients
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
|
||||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
|
||||||
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||||
object: 'sync',
|
object: 'sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = JSON.stringify(syncResponse);
|
const response = new Response(JSON.stringify(syncResponse), {
|
||||||
writeSyncCache(userId, revisionDate, cacheKey, body);
|
|
||||||
|
|
||||||
return new Response(body, {
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': `private, max-age=${Math.max(1, Math.floor(LIMITS.cache.syncResponseTtlMs / 1000))}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
await writeSyncCache(cacheRequest, response);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-5
@@ -9,6 +9,15 @@ let dbInitialized = false;
|
|||||||
let dbInitError: string | null = null;
|
let dbInitError: string | null = null;
|
||||||
let dbInitPromise: Promise<void> | null = null;
|
let dbInitPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function normalizeRequestUrl(request: Request): Request {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const normalizedPathname = url.pathname.length <= 1 ? url.pathname : url.pathname.replace(/\/+$/, '');
|
||||||
|
if (normalizedPathname === url.pathname) return request;
|
||||||
|
|
||||||
|
url.pathname = normalizedPathname;
|
||||||
|
return new Request(url.toString(), request);
|
||||||
|
}
|
||||||
|
|
||||||
function isWorkerHandledPath(path: string): boolean {
|
function isWorkerHandledPath(path: string): boolean {
|
||||||
return (
|
return (
|
||||||
path.startsWith('/api/') ||
|
path.startsWith('/api/') ||
|
||||||
@@ -56,9 +65,10 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
|||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
void ctx;
|
void ctx;
|
||||||
const assetResponse = await maybeServeAsset(request, env);
|
const normalizedRequest = normalizeRequestUrl(request);
|
||||||
|
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||||
if (assetResponse) {
|
if (assetResponse) {
|
||||||
return applyCors(request, assetResponse);
|
return applyCors(normalizedRequest, assetResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDatabaseInitialized(env);
|
await ensureDatabaseInitialized(env);
|
||||||
@@ -76,11 +86,11 @@ export default {
|
|||||||
},
|
},
|
||||||
500
|
500
|
||||||
);
|
);
|
||||||
return applyCors(request, resp);
|
return applyCors(normalizedRequest, resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await handleRequest(request, env);
|
const resp = await handleRequest(normalizedRequest, env);
|
||||||
return applyCors(request, resp);
|
return applyCors(normalizedRequest, resp);
|
||||||
},
|
},
|
||||||
|
|
||||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
handleRevokeTrustedDevice,
|
handleRevokeTrustedDevice,
|
||||||
handleDeleteAllDevices,
|
handleDeleteAllDevices,
|
||||||
handleDeleteDevice,
|
handleDeleteDevice,
|
||||||
|
handleUpdateDeviceName,
|
||||||
handleUpdateDeviceToken,
|
handleUpdateDeviceToken,
|
||||||
handleUpdateDeviceWebPushAuth,
|
handleUpdateDeviceWebPushAuth,
|
||||||
handleClearDeviceToken,
|
handleClearDeviceToken,
|
||||||
@@ -53,6 +54,12 @@ export async function handleAuthenticatedDeviceRoute(
|
|||||||
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
|
||||||
|
if (updateDeviceNameMatch && method === 'PUT') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
|
||||||
|
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
||||||
if (identifierMatch && method === 'GET') {
|
if (identifierMatch && method === 'GET') {
|
||||||
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ function buildConfigResponse(origin: string) {
|
|||||||
_icon_service_url: buildIconServiceTemplate(origin),
|
_icon_service_url: buildIconServiceTemplate(origin),
|
||||||
_icon_service_csp: buildIconServiceCsp(origin),
|
_icon_service_csp: buildIconServiceCsp(origin),
|
||||||
featureStates: {
|
featureStates: {
|
||||||
|
'cipher-key-encryption': true,
|
||||||
'duo-redirect': true,
|
'duo-redirect': true,
|
||||||
'email-verification': true,
|
'email-verification': true,
|
||||||
'pm-19051-send-email-verification': false,
|
'pm-19051-send-email-verification': false,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface BackupFileIntegrityCheckResult {
|
|||||||
export interface BuildBackupArchiveOptions {
|
export interface BuildBackupArchiveOptions {
|
||||||
includeAttachments?: boolean;
|
includeAttachments?: boolean;
|
||||||
progress?: BackupArchiveBuildProgressReporter;
|
progress?: BackupArchiveBuildProgressReporter;
|
||||||
|
timeZone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupArchiveBuildProgressEvent {
|
export interface BackupArchiveBuildProgressEvent {
|
||||||
@@ -93,17 +94,30 @@ async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|||||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBackupFileName(date: Date = new Date(), checksumPrefix: string | null = null): string {
|
function getDateParts(date: Date, timeZone: string): string {
|
||||||
const parts = [
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
timeZone,
|
||||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
year: 'numeric',
|
||||||
date.getUTCDate().toString().padStart(2, '0'),
|
month: '2-digit',
|
||||||
date.getUTCHours().toString().padStart(2, '0'),
|
day: '2-digit',
|
||||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
hour: '2-digit',
|
||||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
minute: '2-digit',
|
||||||
];
|
second: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
});
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
|
||||||
|
return `${pick('year')}${pick('month')}${pick('day')}_${pick('hour')}${pick('minute')}${pick('second')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupFileNameInTimeZone(
|
||||||
|
date: Date = new Date(),
|
||||||
|
checksumPrefix: string | null = null,
|
||||||
|
timeZone: string = 'UTC'
|
||||||
|
): string {
|
||||||
|
const parts = getDateParts(date, timeZone);
|
||||||
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
||||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}${suffix}.zip`;
|
return `nodewarden_backup_${parts}${suffix}.zip`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||||
@@ -398,7 +412,8 @@ export async function buildBackupArchive(
|
|||||||
});
|
});
|
||||||
const bytes = zipSync(createZipEntries(files));
|
const bytes = zipSync(createZipEntries(files));
|
||||||
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||||
const fileName = buildBackupFileName(date, fileHashPrefix);
|
const backupTimeZone = options.timeZone || 'UTC';
|
||||||
|
const fileName = buildBackupFileNameInTimeZone(date, fileHashPrefix, backupTimeZone);
|
||||||
await options.progress?.({
|
await options.progress?.({
|
||||||
step: 'archive_ready',
|
step: 'archive_ready',
|
||||||
fileName,
|
fileName,
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ function mapDeviceRow(row: any): Device {
|
|||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
deviceIdentifier: row.device_identifier,
|
deviceIdentifier: row.device_identifier,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
deviceNote: row.device_note ?? null,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
sessionStamp: row.session_stamp || '',
|
sessionStamp: row.session_stamp || '',
|
||||||
encryptedUserKey: row.encrypted_user_key ?? null,
|
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||||
encryptedPublicKey: row.encrypted_public_key ?? null,
|
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||||
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||||
|
lastSeenAt: row.last_seen_at ?? null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
@@ -33,31 +35,62 @@ export async function upsertDevice(
|
|||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
|
const existingDevice = await getDeviceById(userId, deviceIdentifier);
|
||||||
|
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
|
||||||
|
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
|
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
||||||
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||||
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||||
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||||
|
'last_seen_at=excluded.last_seen_at, ' +
|
||||||
'updated_at=excluded.updated_at'
|
'updated_at=excluded.updated_at'
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
userId,
|
userId,
|
||||||
deviceIdentifier,
|
deviceIdentifier,
|
||||||
name,
|
effectiveName,
|
||||||
type,
|
type,
|
||||||
effectiveSessionStamp,
|
effectiveSessionStamp,
|
||||||
keys?.encryptedUserKey ?? null,
|
keys?.encryptedUserKey ?? null,
|
||||||
keys?.encryptedPublicKey ?? null,
|
keys?.encryptedPublicKey ?? null,
|
||||||
keys?.encryptedPrivateKey ?? null,
|
keys?.encryptedPrivateKey ?? null,
|
||||||
|
existingDevice?.deviceNote ?? null,
|
||||||
|
now,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateDeviceName(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
name: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('UPDATE devices SET device_note = ? WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(String(name || '').trim(), userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function touchDeviceLastSeen(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare('UPDATE devices SET last_seen_at = ? WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(now, userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateDeviceKeys(
|
export async function updateDeviceKeys(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -133,8 +166,8 @@ export async function isKnownDeviceByEmail(
|
|||||||
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
||||||
const res = await db
|
const res = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||||
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
|
||||||
)
|
)
|
||||||
.bind(userId)
|
.bind(userId)
|
||||||
.all<any>();
|
.all<any>();
|
||||||
@@ -144,7 +177,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
|
|||||||
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||||
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||||
)
|
)
|
||||||
.bind(userId, deviceIdentifier)
|
.bind(userId, deviceIdentifier)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
@@ -47,6 +48,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id)',
|
||||||
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
||||||
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||||
|
|
||||||
@@ -71,7 +73,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
|
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
|
||||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
@@ -82,6 +84,9 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||||
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN device_note TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||||
|
|||||||
+11
-1
@@ -92,7 +92,9 @@ import {
|
|||||||
isKnownDevice as getKnownStoredDevice,
|
isKnownDevice as getKnownStoredDevice,
|
||||||
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
||||||
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
||||||
|
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
||||||
upsertDevice as saveStoredDevice,
|
upsertDevice as saveStoredDevice,
|
||||||
|
updateDeviceName as updateStoredDeviceName,
|
||||||
updateDeviceKeys as updateStoredDeviceKeys,
|
updateDeviceKeys as updateStoredDeviceKeys,
|
||||||
} from './storage-device-repo';
|
} from './storage-device-repo';
|
||||||
import {
|
import {
|
||||||
@@ -106,7 +108,7 @@ import {
|
|||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||||
const STORAGE_SCHEMA_VERSION = '2026-03-23.1';
|
const STORAGE_SCHEMA_VERSION = '2026-04-18.1';
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
@@ -550,6 +552,14 @@ export class StorageService {
|
|||||||
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
|
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise<boolean> {
|
||||||
|
return updateStoredDeviceName(this.db, userId, deviceIdentifier, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
|
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
||||||
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -189,12 +189,14 @@ export interface Device {
|
|||||||
userId: string;
|
userId: string;
|
||||||
deviceIdentifier: string;
|
deviceIdentifier: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
deviceNote: string | null;
|
||||||
type: number;
|
type: number;
|
||||||
sessionStamp: string;
|
sessionStamp: string;
|
||||||
encryptedUserKey: string | null;
|
encryptedUserKey: string | null;
|
||||||
encryptedPublicKey: string | null;
|
encryptedPublicKey: string | null;
|
||||||
encryptedPrivateKey: string | null;
|
encryptedPrivateKey: string | null;
|
||||||
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
||||||
|
lastSeenAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -208,10 +210,14 @@ export interface DeviceResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
systemName?: string | null;
|
||||||
|
deviceNote?: string | null;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
type: number;
|
type: number;
|
||||||
creationDate: string;
|
creationDate: string;
|
||||||
revisionDate: string;
|
revisionDate: string;
|
||||||
|
lastSeenAt?: string | null;
|
||||||
|
hasStoredDevice?: boolean;
|
||||||
isTrusted: boolean;
|
isTrusted: boolean;
|
||||||
encryptedUserKey: string | null;
|
encryptedUserKey: string | null;
|
||||||
encryptedPublicKey: string | null;
|
encryptedPublicKey: string | null;
|
||||||
@@ -347,7 +353,8 @@ export interface TokenResponse {
|
|||||||
access_token: string;
|
access_token: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
refresh_token: string;
|
refresh_token?: string;
|
||||||
|
web_session?: boolean;
|
||||||
TwoFactorToken?: string;
|
TwoFactorToken?: string;
|
||||||
Key: string;
|
Key: string;
|
||||||
PrivateKey: string | null;
|
PrivateKey: string | null;
|
||||||
@@ -367,6 +374,10 @@ export interface TokenResponse {
|
|||||||
accountKeys?: any | null;
|
accountKeys?: any | null;
|
||||||
UserDecryptionOptions: UserDecryptionOptions;
|
UserDecryptionOptions: UserDecryptionOptions;
|
||||||
userDecryptionOptions?: UserDecryptionOptions;
|
userDecryptionOptions?: UserDecryptionOptions;
|
||||||
|
VaultKeys?: {
|
||||||
|
symEncKey: string;
|
||||||
|
symMacKey: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileResponse {
|
export interface ProfileResponse {
|
||||||
|
|||||||
@@ -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
@@ -15,12 +15,42 @@ const DEFAULT_CORS_HEADERS = [
|
|||||||
'X-Request-Email',
|
'X-Request-Email',
|
||||||
'X-Device-Identifier',
|
'X-Device-Identifier',
|
||||||
'X-Device-Name',
|
'X-Device-Name',
|
||||||
|
'X-NodeWarden-Web-Session',
|
||||||
];
|
];
|
||||||
|
|
||||||
function getAllowedOrigin(request: Request): string | null {
|
function isExtensionOrigin(origin: string): boolean {
|
||||||
|
return (
|
||||||
|
origin.startsWith('chrome-extension://')
|
||||||
|
|| origin.startsWith('moz-extension://')
|
||||||
|
|| origin.startsWith('safari-web-extension://')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWildcardCorsPath(path: string): boolean {
|
||||||
|
return (
|
||||||
|
path.startsWith('/icons/')
|
||||||
|
|| path === '/config'
|
||||||
|
|| path === '/api/config'
|
||||||
|
|| path === '/api/version'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
|
||||||
|
const url = new URL(request.url);
|
||||||
const origin = request.headers.get('Origin');
|
const origin = request.headers.get('Origin');
|
||||||
if (!origin) return '*';
|
if (isWildcardCorsPath(url.pathname)) {
|
||||||
return origin;
|
return { allowOrigin: '*', allowCredentials: false };
|
||||||
|
}
|
||||||
|
if (!origin) {
|
||||||
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
|
}
|
||||||
|
if (origin === url.origin) {
|
||||||
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
|
}
|
||||||
|
if (isExtensionOrigin(origin)) {
|
||||||
|
return { allowOrigin: origin, allowCredentials: false };
|
||||||
|
}
|
||||||
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||||
@@ -35,13 +65,14 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
|||||||
'Access-Control-Allow-Headers': allowHeaders.join(', '),
|
'Access-Control-Allow-Headers': allowHeaders.join(', '),
|
||||||
'Access-Control-Expose-Headers': '*',
|
'Access-Control-Expose-Headers': '*',
|
||||||
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||||
'Access-Control-Allow-Private-Network': 'true',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowedOrigin = getAllowedOrigin(request);
|
const corsPolicy = getCorsPolicy(request);
|
||||||
if (allowedOrigin) {
|
if (corsPolicy.allowOrigin) {
|
||||||
headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
||||||
headers['Access-Control-Allow-Credentials'] = 'true';
|
if (corsPolicy.allowCredentials) {
|
||||||
|
headers['Access-Control-Allow-Credentials'] = 'true';
|
||||||
|
}
|
||||||
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
|
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+125
-20
@@ -10,8 +10,12 @@ import JwtWarningPage from '@/components/JwtWarningPage';
|
|||||||
import {
|
import {
|
||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
getAuthorizedDevices,
|
getAuthorizedDevices,
|
||||||
|
clearProfileSnapshot,
|
||||||
getCurrentDeviceIdentifier,
|
getCurrentDeviceIdentifier,
|
||||||
getPasswordHint,
|
getPasswordHint,
|
||||||
|
loadProfileSnapshot,
|
||||||
|
saveProfileSnapshot,
|
||||||
|
revokeCurrentSession,
|
||||||
getTotpStatus,
|
getTotpStatus,
|
||||||
saveSession,
|
saveSession,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
@@ -39,6 +43,7 @@ import {
|
|||||||
performRecoverTwoFactorLogin,
|
performRecoverTwoFactorLogin,
|
||||||
performRegistration,
|
performRegistration,
|
||||||
performTotpLogin,
|
performTotpLogin,
|
||||||
|
hydrateLockedSession,
|
||||||
performUnlock,
|
performUnlock,
|
||||||
type JwtUnsafeReason,
|
type JwtUnsafeReason,
|
||||||
type PendingTotp,
|
type PendingTotp,
|
||||||
@@ -53,6 +58,17 @@ import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
|||||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||||
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||||
|
|
||||||
|
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const detail = value as Record<string, unknown>;
|
||||||
|
const operation = detail.operation;
|
||||||
|
return (
|
||||||
|
(operation === 'backup-restore' || operation === 'backup-export' || operation === 'backup-remote-run')
|
||||||
|
&& typeof detail.step === 'string'
|
||||||
|
&& typeof detail.fileName === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const IMPORT_ROUTE = '/backup/import-export';
|
const IMPORT_ROUTE = '/backup/import-export';
|
||||||
const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||||
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
||||||
@@ -124,11 +140,12 @@ function resolveSystemTheme(): 'light' | 'dark' {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
||||||
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
||||||
|
const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]);
|
||||||
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
|
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
||||||
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
|
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(initialProfileSnapshot);
|
||||||
const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
|
const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
|
||||||
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
|
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
|
||||||
|
|
||||||
@@ -155,12 +172,15 @@ export default function App() {
|
|||||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||||
const [totpCode, setTotpCode] = useState('');
|
const [totpCode, setTotpCode] = useState('');
|
||||||
const [rememberDevice, setRememberDevice] = useState(true);
|
const [rememberDevice, setRememberDevice] = useState(true);
|
||||||
|
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
||||||
|
|
||||||
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
||||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||||
|
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
|
||||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
||||||
|
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key);
|
||||||
|
|
||||||
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
||||||
const [mobileLayout, setMobileLayout] = useState(false);
|
const [mobileLayout, setMobileLayout] = useState(false);
|
||||||
@@ -262,6 +282,16 @@ export default function App() {
|
|||||||
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
||||||
}, [themePreference]);
|
}, [themePreference]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveProfileSnapshot(profile);
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === 'locked' && profile?.key && session) {
|
||||||
|
setUnlockPreparing(false);
|
||||||
|
}
|
||||||
|
}, [phase, profile, session]);
|
||||||
|
|
||||||
useEffect(() => installMagneticUiFeedback(), []);
|
useEffect(() => installMagneticUiFeedback(), []);
|
||||||
|
|
||||||
function handleToggleTheme() {
|
function handleToggleTheme() {
|
||||||
@@ -323,6 +353,7 @@ export default function App() {
|
|||||||
setSession(boot.session);
|
setSession(boot.session);
|
||||||
setProfile(boot.profile);
|
setProfile(boot.profile);
|
||||||
setPhase(boot.phase);
|
setPhase(boot.phase);
|
||||||
|
setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -330,9 +361,34 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [initialBootstrap]);
|
}, [initialBootstrap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'locked' || !session) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
const result = await hydrateLockedSession(session, profile);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!result.session) {
|
||||||
|
setSession(null);
|
||||||
|
setProfile(null);
|
||||||
|
setUnlockPreparing(false);
|
||||||
|
setPhase('login');
|
||||||
|
if (location !== '/login') navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSession(result.session);
|
||||||
|
if (result.profile) {
|
||||||
|
setProfile(result.profile);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [phase, session?.email, location, navigate]);
|
||||||
|
|
||||||
async function finalizeLogin(login: CompletedLogin) {
|
async function finalizeLogin(login: CompletedLogin) {
|
||||||
setSession(login.session);
|
setSession(login.session);
|
||||||
setProfile(login.profile);
|
setProfile(login.profile);
|
||||||
|
setUnlockPreparing(false);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setPhase('app');
|
setPhase('app');
|
||||||
@@ -379,16 +435,20 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleTotpVerify() {
|
async function handleTotpVerify() {
|
||||||
|
if (totpSubmitting) return;
|
||||||
if (!pendingTotp) return;
|
if (!pendingTotp) return;
|
||||||
if (!totpCode.trim()) {
|
if (!totpCode.trim()) {
|
||||||
pushToast('error', t('txt_please_input_totp_code'));
|
pushToast('error', t('txt_please_input_totp_code'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setTotpSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||||
await finalizeLogin(login);
|
await finalizeLogin(login);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||||
|
} finally {
|
||||||
|
setTotpSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,6 +577,7 @@ export default function App() {
|
|||||||
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
setUnlockPassword('');
|
setUnlockPassword('');
|
||||||
|
setUnlockPreparing(false);
|
||||||
setPhase('app');
|
setPhase('app');
|
||||||
if (location === '/' || location === '/lock') navigate('/vault');
|
if (location === '/' || location === '/lock') navigate('/vault');
|
||||||
pushToast('success', t('txt_unlocked'));
|
pushToast('success', t('txt_unlocked'));
|
||||||
@@ -533,14 +594,18 @@ export default function App() {
|
|||||||
delete nextSession.symEncKey;
|
delete nextSession.symEncKey;
|
||||||
delete nextSession.symMacKey;
|
delete nextSession.symMacKey;
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
|
setUnlockPreparing(false);
|
||||||
setPhase('locked');
|
setPhase('locked');
|
||||||
navigate('/lock');
|
navigate('/lock');
|
||||||
}
|
}
|
||||||
|
|
||||||
function logoutNow() {
|
function logoutNow() {
|
||||||
|
void revokeCurrentSession(sessionRef.current);
|
||||||
setConfirm(null);
|
setConfirm(null);
|
||||||
setSession(null);
|
setSession(null);
|
||||||
|
clearProfileSnapshot();
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
|
setUnlockPreparing(false);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setPhase('login');
|
setPhase('login');
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@@ -572,11 +637,13 @@ export default function App() {
|
|||||||
onConfirmTotp={() => {}}
|
onConfirmTotp={() => {}}
|
||||||
onCancelTotp={() => {}}
|
onCancelTotp={() => {}}
|
||||||
onUseRecoveryCode={() => {}}
|
onUseRecoveryCode={() => {}}
|
||||||
|
totpSubmitting={false}
|
||||||
disableTotpOpen={false}
|
disableTotpOpen={false}
|
||||||
disableTotpPassword=""
|
disableTotpPassword=""
|
||||||
onDisableTotpPasswordChange={() => {}}
|
onDisableTotpPasswordChange={() => {}}
|
||||||
onConfirmDisableTotp={() => {}}
|
onConfirmDisableTotp={() => {}}
|
||||||
onCancelDisableTotp={() => {}}
|
onCancelDisableTotp={() => {}}
|
||||||
|
disableTotpSubmitting={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -691,9 +758,6 @@ export default function App() {
|
|||||||
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
|
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
|
||||||
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
|
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
|
||||||
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
|
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
|
||||||
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
|
||||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
|
||||||
: null,
|
|
||||||
uris: await Promise.all(
|
uris: await Promise.all(
|
||||||
(cipher.login.uris || []).map(async (u) => ({
|
(cipher.login.uris || []).map(async (u) => ({
|
||||||
...u,
|
...u,
|
||||||
@@ -702,6 +766,14 @@ export default function App() {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(cipher.passwordHistory)) {
|
||||||
|
nextCipher.passwordHistory = await Promise.all(
|
||||||
|
cipher.passwordHistory.map(async (entry) => ({
|
||||||
|
...entry,
|
||||||
|
decPassword: await decryptField(entry?.password || '', itemEnc, itemMac),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
if (cipher.card) {
|
if (cipher.card) {
|
||||||
nextCipher.card = {
|
nextCipher.card = {
|
||||||
...cipher.card,
|
...cipher.card,
|
||||||
@@ -874,9 +946,11 @@ export default function App() {
|
|||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
const accessToken = session.accessToken;
|
||||||
|
if (!accessToken) return;
|
||||||
try {
|
try {
|
||||||
const hubUrl = new URL('/notifications/hub', window.location.origin);
|
const hubUrl = new URL('/notifications/hub', window.location.origin);
|
||||||
hubUrl.searchParams.set('access_token', session.accessToken);
|
hubUrl.searchParams.set('access_token', accessToken);
|
||||||
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
socket = new WebSocket(hubUrl.toString());
|
socket = new WebSocket(hubUrl.toString());
|
||||||
} catch {
|
} catch {
|
||||||
@@ -884,6 +958,15 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pingTimer: number | null = null;
|
||||||
|
|
||||||
|
const clearPingTimer = () => {
|
||||||
|
if (pingTimer !== null) {
|
||||||
|
window.clearInterval(pingTimer);
|
||||||
|
pingTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
socket.addEventListener('open', () => {
|
socket.addEventListener('open', () => {
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
void refreshAuthorizedDevicesRef.current();
|
void refreshAuthorizedDevicesRef.current();
|
||||||
@@ -891,7 +974,16 @@ export default function App() {
|
|||||||
socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`);
|
socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`);
|
||||||
} catch {
|
} catch {
|
||||||
socket?.close();
|
socket?.close();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
clearPingTimer();
|
||||||
|
pingTimer = window.setInterval(() => {
|
||||||
|
try {
|
||||||
|
socket?.send(`{"type":6}${SIGNALR_RECORD_SEPARATOR}`);
|
||||||
|
} catch {
|
||||||
|
// send failure will trigger close event
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener('message', (event) => {
|
socket.addEventListener('message', (event) => {
|
||||||
@@ -912,17 +1004,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
|
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
|
||||||
const payload = frame.arguments?.[0]?.Payload;
|
const payload = frame.arguments?.[0]?.Payload;
|
||||||
if (
|
if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
|
||||||
payload
|
|
||||||
&& typeof payload === 'object'
|
|
||||||
&& (
|
|
||||||
payload.operation === 'backup-restore'
|
|
||||||
|| payload.operation === 'backup-export'
|
|
||||||
|| payload.operation === 'backup-remote-run'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
dispatchBackupProgress(payload as BackupProgressDetail);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
||||||
@@ -934,6 +1016,7 @@ export default function App() {
|
|||||||
|
|
||||||
socket.addEventListener('close', () => {
|
socket.addEventListener('close', () => {
|
||||||
socket = null;
|
socket = null;
|
||||||
|
clearPingTimer();
|
||||||
void refreshAuthorizedDevicesRef.current();
|
void refreshAuthorizedDevicesRef.current();
|
||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
});
|
});
|
||||||
@@ -952,9 +1035,11 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
clearReconnectTimer();
|
clearReconnectTimer();
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
if (socket) {
|
||||||
|
const s = socket;
|
||||||
|
socket = null;
|
||||||
try {
|
try {
|
||||||
socket.close();
|
s.close();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore close races
|
// ignore close races
|
||||||
}
|
}
|
||||||
@@ -1095,6 +1180,7 @@ export default function App() {
|
|||||||
onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems,
|
onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems,
|
||||||
onVerifyMasterPassword: vaultSendActions.verifyMasterPassword,
|
onVerifyMasterPassword: vaultSendActions.verifyMasterPassword,
|
||||||
onCreateFolder: vaultSendActions.createFolder,
|
onCreateFolder: vaultSendActions.createFolder,
|
||||||
|
onRenameFolder: vaultSendActions.renameFolder,
|
||||||
onDeleteFolder: vaultSendActions.deleteFolder,
|
onDeleteFolder: vaultSendActions.deleteFolder,
|
||||||
onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders,
|
onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders,
|
||||||
onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment,
|
onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment,
|
||||||
@@ -1118,6 +1204,7 @@ export default function App() {
|
|||||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||||
|
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
||||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||||
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
||||||
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
|
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
|
||||||
@@ -1178,7 +1265,8 @@ export default function App() {
|
|||||||
<AuthViews
|
<AuthViews
|
||||||
mode={phase}
|
mode={phase}
|
||||||
pendingAction={pendingAuthAction}
|
pendingAction={pendingAuthAction}
|
||||||
unlockReady={!!profile}
|
unlockReady={!!profile?.key && !!session}
|
||||||
|
unlockPreparing={unlockPreparing}
|
||||||
loginValues={loginValues}
|
loginValues={loginValues}
|
||||||
registerValues={registerValues}
|
registerValues={registerValues}
|
||||||
unlockPassword={unlockPassword}
|
unlockPassword={unlockPassword}
|
||||||
@@ -1217,21 +1305,25 @@ export default function App() {
|
|||||||
onRememberDeviceChange={setRememberDevice}
|
onRememberDeviceChange={setRememberDevice}
|
||||||
onConfirmTotp={() => void handleTotpVerify()}
|
onConfirmTotp={() => void handleTotpVerify()}
|
||||||
onCancelTotp={() => {
|
onCancelTotp={() => {
|
||||||
|
if (totpSubmitting) return;
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setRememberDevice(true);
|
setRememberDevice(true);
|
||||||
}}
|
}}
|
||||||
onUseRecoveryCode={() => {
|
onUseRecoveryCode={() => {
|
||||||
|
if (totpSubmitting) return;
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setRememberDevice(true);
|
setRememberDevice(true);
|
||||||
navigate('/recover-2fa');
|
navigate('/recover-2fa');
|
||||||
}}
|
}}
|
||||||
|
totpSubmitting={totpSubmitting}
|
||||||
disableTotpOpen={false}
|
disableTotpOpen={false}
|
||||||
disableTotpPassword=""
|
disableTotpPassword=""
|
||||||
onDisableTotpPasswordChange={() => {}}
|
onDisableTotpPasswordChange={() => {}}
|
||||||
onConfirmDisableTotp={() => {}}
|
onConfirmDisableTotp={() => {}}
|
||||||
onCancelDisableTotp={() => {}}
|
onCancelDisableTotp={() => {}}
|
||||||
|
disableTotpSubmitting={false}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -1270,14 +1362,27 @@ export default function App() {
|
|||||||
onConfirmTotp={() => {}}
|
onConfirmTotp={() => {}}
|
||||||
onCancelTotp={() => {}}
|
onCancelTotp={() => {}}
|
||||||
onUseRecoveryCode={() => {}}
|
onUseRecoveryCode={() => {}}
|
||||||
|
totpSubmitting={false}
|
||||||
disableTotpOpen={disableTotpOpen}
|
disableTotpOpen={disableTotpOpen}
|
||||||
disableTotpPassword={disableTotpPassword}
|
disableTotpPassword={disableTotpPassword}
|
||||||
onDisableTotpPasswordChange={setDisableTotpPassword}
|
onDisableTotpPasswordChange={setDisableTotpPassword}
|
||||||
onConfirmDisableTotp={() => void accountSecurityActions.disableTotp()}
|
onConfirmDisableTotp={() => {
|
||||||
|
if (disableTotpSubmitting) return;
|
||||||
|
void (async () => {
|
||||||
|
setDisableTotpSubmitting(true);
|
||||||
|
try {
|
||||||
|
await accountSecurityActions.disableTotp();
|
||||||
|
} finally {
|
||||||
|
setDisableTotpSubmitting(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
onCancelDisableTotp={() => {
|
onCancelDisableTotp={() => {
|
||||||
|
if (disableTotpSubmitting) return;
|
||||||
setDisableTotpOpen(false);
|
setDisableTotpOpen(false);
|
||||||
setDisableTotpPassword('');
|
setDisableTotpPassword('');
|
||||||
}}
|
}}
|
||||||
|
disableTotpSubmitting={disableTotpSubmitting}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
return status || '-';
|
return status || '-';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeToggleableStatus = (status: string): 'active' | 'banned' | null => {
|
||||||
|
const normalized = String(status || '').toLowerCase();
|
||||||
|
if (normalized === 'active' || normalized === 'banned') return normalized;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
@@ -55,8 +61,10 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{props.users.map((user) => (
|
{props.users.map((user) => {
|
||||||
<tr key={user.id}>
|
const toggleableStatus = normalizeToggleableStatus(user.status);
|
||||||
|
return (
|
||||||
|
<tr key={user.id}>
|
||||||
<td data-label={t('txt_email')}>{user.email}</td>
|
<td data-label={t('txt_email')}>{user.email}</td>
|
||||||
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
||||||
<td data-label={t('txt_role')}>{roleText(user.role)}</td>
|
<td data-label={t('txt_role')}>{roleText(user.role)}</td>
|
||||||
@@ -66,8 +74,11 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={user.id === props.currentUserId}
|
disabled={user.id === props.currentUserId || !toggleableStatus}
|
||||||
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
|
onClick={() => {
|
||||||
|
if (!toggleableStatus) return;
|
||||||
|
void props.onToggleUserStatus(user.id, toggleableStatus);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
|
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
|
||||||
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
|
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
|
||||||
@@ -80,8 +91,9 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ interface AppGlobalOverlaysProps {
|
|||||||
onConfirmTotp: () => void;
|
onConfirmTotp: () => void;
|
||||||
onCancelTotp: () => void;
|
onCancelTotp: () => void;
|
||||||
onUseRecoveryCode: () => void;
|
onUseRecoveryCode: () => void;
|
||||||
|
totpSubmitting: boolean;
|
||||||
disableTotpOpen: boolean;
|
disableTotpOpen: boolean;
|
||||||
disableTotpPassword: string;
|
disableTotpPassword: string;
|
||||||
onDisableTotpPasswordChange: (value: string) => void;
|
onDisableTotpPasswordChange: (value: string) => void;
|
||||||
onConfirmDisableTotp: () => void;
|
onConfirmDisableTotp: () => void;
|
||||||
onCancelDisableTotp: () => void;
|
onCancelDisableTotp: () => void;
|
||||||
|
disableTotpSubmitting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||||
@@ -57,12 +59,14 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
confirmText={t('txt_verify')}
|
confirmText={t('txt_verify')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={props.totpSubmitting}
|
||||||
|
cancelDisabled={props.totpSubmitting}
|
||||||
onConfirm={props.onConfirmTotp}
|
onConfirm={props.onConfirmTotp}
|
||||||
onCancel={props.onCancelTotp}
|
onCancel={props.onCancelTotp}
|
||||||
afterActions={(
|
afterActions={(
|
||||||
<div className="dialog-extra">
|
<div className="dialog-extra">
|
||||||
<div className="dialog-divider" />
|
<div className="dialog-divider" />
|
||||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onUseRecoveryCode}>
|
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
|
||||||
{t('txt_use_recovery_code')}
|
{t('txt_use_recovery_code')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,6 +90,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
danger
|
danger
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={props.disableTotpSubmitting}
|
||||||
|
cancelDisabled={props.disableTotpSubmitting}
|
||||||
onConfirm={props.onConfirmDisableTotp}
|
onConfirm={props.onConfirmDisableTotp}
|
||||||
onCancel={props.onCancelDisableTotp}
|
onCancel={props.onCancelDisableTotp}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface AppMainRoutesProps {
|
|||||||
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
|
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
|
||||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||||
onCreateFolder: (name: string) => Promise<void>;
|
onCreateFolder: (name: string) => Promise<void>;
|
||||||
|
onRenameFolder: (folderId: string, name: string) => Promise<void>;
|
||||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||||
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
||||||
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||||
@@ -94,6 +95,7 @@ export interface AppMainRoutesProps {
|
|||||||
onOpenDisableTotp: () => void;
|
onOpenDisableTotp: () => void;
|
||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||||
|
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
onRevokeAllDeviceTrust: () => void;
|
onRevokeAllDeviceTrust: () => void;
|
||||||
@@ -192,6 +194,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
onCreateFolder={props.onCreateFolder}
|
onCreateFolder={props.onCreateFolder}
|
||||||
|
onRenameFolder={props.onRenameFolder}
|
||||||
onDeleteFolder={props.onDeleteFolder}
|
onDeleteFolder={props.onDeleteFolder}
|
||||||
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||||
onDownloadAttachment={props.onDownloadVaultAttachment}
|
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||||
@@ -279,6 +282,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
devices={props.authorizedDevices}
|
devices={props.authorizedDevices}
|
||||||
loading={props.authorizedDevicesLoading}
|
loading={props.authorizedDevicesLoading}
|
||||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||||
|
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||||
onRemoveDevice={props.onRemoveDevice}
|
onRemoveDevice={props.onRemoveDevice}
|
||||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface AuthViewsProps {
|
|||||||
mode: 'login' | 'register' | 'locked';
|
mode: 'login' | 'register' | 'locked';
|
||||||
pendingAction: 'login' | 'register' | 'unlock' | null;
|
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||||
unlockReady: boolean;
|
unlockReady: boolean;
|
||||||
|
unlockPreparing: boolean;
|
||||||
loginValues: LoginValues;
|
loginValues: LoginValues;
|
||||||
registerValues: RegisterValues;
|
registerValues: RegisterValues;
|
||||||
unlockPassword: string;
|
unlockPassword: string;
|
||||||
@@ -97,14 +98,17 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="auth-link-btn"
|
className="auth-link-btn"
|
||||||
onClick={props.onShowLockedPasswordHint}
|
onClick={props.onShowLockedPasswordHint}
|
||||||
disabled={unlockBusy}
|
disabled={unlockBusy || props.unlockPreparing}
|
||||||
>
|
>
|
||||||
{t('txt_show_password_hint')}
|
{t('txt_show_password_hint')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}>
|
{props.unlockPreparing ? (
|
||||||
|
<p className="muted standalone-muted">{t('txt_loading')}</p>
|
||||||
|
) : null}
|
||||||
|
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || props.unlockPreparing || !props.unlockReady}>
|
||||||
<Unlock size={16} className="btn-icon" />
|
<Unlock size={16} className="btn-icon" />
|
||||||
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
|
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
|
||||||
</button>
|
</button>
|
||||||
<div className="or">{t('txt_or')}</div>
|
<div className="or">{t('txt_or')}</div>
|
||||||
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
||||||
|
|||||||
@@ -528,6 +528,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
allowChecksumMismatch: boolean = false,
|
allowChecksumMismatch: boolean = false,
|
||||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
) {
|
) {
|
||||||
|
if (importing) return;
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
const message = t('txt_backup_file_required');
|
const message = t('txt_backup_file_required');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
@@ -625,7 +626,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setSettings(result.settings);
|
setSettings(result.settings);
|
||||||
setSelectedDestinationId(selectedDestination.id);
|
setSelectedDestinationId(selectedDestination.id);
|
||||||
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
||||||
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.fileName }));
|
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
@@ -654,6 +655,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteRemote(path: string) {
|
async function handleDeleteRemote(path: string) {
|
||||||
|
if (deletingRemotePath) return;
|
||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
setDeletingRemotePath(path);
|
setDeletingRemotePath(path);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
@@ -723,6 +725,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
allowChecksumMismatch: boolean = false,
|
allowChecksumMismatch: boolean = false,
|
||||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
) {
|
) {
|
||||||
|
if (restoringRemotePath) return;
|
||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
setConfirmRemoteReplaceOpen(false);
|
setConfirmRemoteReplaceOpen(false);
|
||||||
setConfirmIntegrityWarningOpen(false);
|
setConfirmIntegrityWarningOpen(false);
|
||||||
@@ -896,9 +899,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')}
|
message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')}
|
||||||
confirmText={t('txt_backup_import')}
|
confirmText={t('txt_backup_import')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={importing}
|
||||||
|
cancelDisabled={importing}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => void runLocalRestore(false)}
|
onConfirm={() => void runLocalRestore(false)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
if (importing) return;
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
resetSelectedFile();
|
resetSelectedFile();
|
||||||
resetPendingIntegrityWarning();
|
resetPendingIntegrityWarning();
|
||||||
@@ -959,6 +965,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
variant="warning"
|
variant="warning"
|
||||||
confirmText={t('txt_backup_restore_checksum_warning_confirm')}
|
confirmText={t('txt_backup_restore_checksum_warning_confirm')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={importing || !!restoringRemotePath}
|
||||||
|
cancelDisabled={importing || !!restoringRemotePath}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (!pendingRestoreIntegrity) return;
|
if (!pendingRestoreIntegrity) return;
|
||||||
@@ -984,6 +992,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
|
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
|
||||||
confirmText={t('txt_delete')}
|
confirmText={t('txt_delete')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={!!deletingRemotePath}
|
||||||
|
cancelDisabled={!!deletingRemotePath}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
|
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
@@ -1001,6 +1011,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
})}
|
})}
|
||||||
confirmText={t('txt_delete')}
|
confirmText={t('txt_delete')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={savingSettings}
|
||||||
|
cancelDisabled={savingSettings}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => void handleDeleteDestination()}
|
onConfirm={() => void handleDeleteDestination()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePasswordImportConfirm() {
|
async function handlePasswordImportConfirm() {
|
||||||
|
if (isPasswordSubmitting) return;
|
||||||
if (!pendingPasswordImport) return;
|
if (!pendingPasswordImport) return;
|
||||||
setIsPasswordSubmitting(true);
|
setIsPasswordSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -486,6 +487,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleZipPasswordImportConfirm() {
|
async function handleZipPasswordImportConfirm() {
|
||||||
|
if (isZipPasswordSubmitting) return;
|
||||||
if (!pendingZipFile) return;
|
if (!pendingZipFile) return;
|
||||||
setIsZipPasswordSubmitting(true);
|
setIsZipPasswordSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -558,6 +560,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleExportConfirmPassword() {
|
async function handleExportConfirmPassword() {
|
||||||
|
if (isExporting) return;
|
||||||
const masterPassword = String(exportAuthPassword || '').trim();
|
const masterPassword = String(exportAuthPassword || '').trim();
|
||||||
if (!masterPassword) {
|
if (!masterPassword) {
|
||||||
onNotify('error', t('txt_master_password_is_required'));
|
onNotify('error', t('txt_master_password_is_required'));
|
||||||
@@ -736,6 +739,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
|
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={isExporting}
|
||||||
|
cancelDisabled={isExporting}
|
||||||
onConfirm={() => void handleExportConfirmPassword()}
|
onConfirm={() => void handleExportConfirmPassword()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (isExporting) return;
|
if (isExporting) return;
|
||||||
@@ -761,6 +766,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={isPasswordSubmitting}
|
||||||
|
cancelDisabled={isPasswordSubmitting}
|
||||||
onConfirm={() => void handlePasswordImportConfirm()}
|
onConfirm={() => void handlePasswordImportConfirm()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (isPasswordSubmitting) return;
|
if (isPasswordSubmitting) return;
|
||||||
@@ -787,6 +794,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={isZipPasswordSubmitting}
|
||||||
|
cancelDisabled={isZipPasswordSubmitting}
|
||||||
onConfirm={() => void handleZipPasswordImportConfirm()}
|
onConfirm={() => void handleZipPasswordImportConfirm()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (isZipPasswordSubmitting) return;
|
if (isZipPasswordSubmitting) return;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { Download, Eye, Lock } from 'lucide-preact';
|
import { Download, Eye, Lock } from 'lucide-preact';
|
||||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||||
|
import { toBufferSource } from '@/lib/crypto';
|
||||||
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -61,13 +62,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
if (props.keyPart) {
|
if (props.keyPart) {
|
||||||
try {
|
try {
|
||||||
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
|
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
|
||||||
blob = new Blob([decryptedBytes as unknown as BlobPart], { type: 'application/octet-stream' });
|
blob = new Blob([toBufferSource(decryptedBytes)], { type: 'application/octet-stream' });
|
||||||
} catch {
|
} catch {
|
||||||
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
|
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
|
||||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||||
}
|
}
|
||||||
downloadBytesAsFile(
|
downloadBytesAsFile(
|
||||||
new Uint8Array(await blob.arrayBuffer()),
|
new Uint8Array(await blob.arrayBuffer()),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
import { useState } from 'preact/hooks';
|
||||||
|
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import type { AuthorizedDevice } from '@/lib/types';
|
import type { AuthorizedDevice } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -6,6 +8,7 @@ interface SecurityDevicesPageProps {
|
|||||||
devices: AuthorizedDevice[];
|
devices: AuthorizedDevice[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
onRevokeAll: () => void;
|
onRevokeAll: () => void;
|
||||||
@@ -41,9 +44,26 @@ function mapDeviceTypeName(type: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||||
|
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
|
||||||
|
const [deviceNote, setDeviceNote] = useState('');
|
||||||
|
const [savingNote, setSavingNote] = useState(false);
|
||||||
|
|
||||||
|
async function handleSaveDeviceNote(): Promise<void> {
|
||||||
|
if (!editingDevice || savingNote) return;
|
||||||
|
setSavingNote(true);
|
||||||
|
try {
|
||||||
|
await props.onRenameDevice(editingDevice, deviceNote);
|
||||||
|
setEditingDevice(null);
|
||||||
|
setDeviceNote('');
|
||||||
|
} finally {
|
||||||
|
setSavingNote(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<>
|
||||||
<section className="card">
|
<div className="stack">
|
||||||
|
<section className="card">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
|
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
|
||||||
@@ -66,9 +86,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
|
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -87,6 +107,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<tr key={device.identifier}>
|
<tr key={device.identifier}>
|
||||||
<td data-label={t('txt_device')}>
|
<td data-label={t('txt_device')}>
|
||||||
<div>{device.name || t('txt_unknown_device')}</div>
|
<div>{device.name || t('txt_unknown_device')}</div>
|
||||||
|
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
|
||||||
|
<div className="muted-inline">{device.systemName}</div>
|
||||||
|
)}
|
||||||
<div className="muted-inline">{device.identifier}</div>
|
<div className="muted-inline">{device.identifier}</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
|
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
|
||||||
@@ -96,7 +119,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
|
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
|
||||||
<td data-label={t('txt_last_seen')}>{formatDateTime(device.revisionDate)}</td>
|
<td data-label={t('txt_last_seen')}>{formatDateTime(device.lastSeenAt || device.revisionDate)}</td>
|
||||||
<td data-label={t('txt_trusted_until')}>
|
<td data-label={t('txt_trusted_until')}>
|
||||||
{device.trusted ? (
|
{device.trusted ? (
|
||||||
<div className="trusted-cell">
|
<div className="trusted-cell">
|
||||||
@@ -116,11 +139,28 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
onClick={() => props.onRevokeTrust(device)}
|
onClick={() => props.onRevokeTrust(device)}
|
||||||
>
|
>
|
||||||
<ShieldOff size={14} className="btn-icon" />
|
<ShieldOff size={14} className="btn-icon" />
|
||||||
{t('txt_revoke_trust')}
|
{t('txt_untrust')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={device.hasStoredDevice === false}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingDevice(device);
|
||||||
|
setDeviceNote(device.deviceNote || device.name || '');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={14} className="btn-icon" />
|
||||||
|
{t('txt_device_note')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger small"
|
||||||
|
disabled={device.hasStoredDevice === false}
|
||||||
|
onClick={() => props.onRemoveDevice(device)}
|
||||||
|
>
|
||||||
<Trash2 size={14} className="btn-icon" />
|
<Trash2 size={14} className="btn-icon" />
|
||||||
{t('txt_remove_device_2')}
|
{t('txt_delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -135,7 +175,35 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!editingDevice}
|
||||||
|
title={t('txt_device_note')}
|
||||||
|
message={t('txt_replace_device_name_with_note')}
|
||||||
|
confirmText={t('txt_save')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
confirmDisabled={savingNote}
|
||||||
|
cancelDisabled={savingNote}
|
||||||
|
onConfirm={() => void handleSaveDeviceNote()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (savingNote) return;
|
||||||
|
setEditingDevice(null);
|
||||||
|
setDeviceNote('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_device_note')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
maxLength={128}
|
||||||
|
value={deviceNote}
|
||||||
|
onInput={(e) => setDeviceNote((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
|||||||
import qrcode from 'qrcode-generator';
|
import qrcode from 'qrcode-generator';
|
||||||
import type { Profile } from '@/lib/types';
|
import type { Profile } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
profile: Profile;
|
profile: Profile;
|
||||||
@@ -64,7 +65,8 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const qr = qrcode(0, 'M');
|
const qr = qrcode(0, 'M');
|
||||||
qr.addData(buildOtpUri(props.profile.email, secret));
|
qr.addData(buildOtpUri(props.profile.email, secret));
|
||||||
qr.make();
|
qr.make();
|
||||||
const svg = qr.createSvgTag({ scalable: true, margin: 0 });
|
// Keep a visible quiet zone so authenticator apps can scan reliably in both themes.
|
||||||
|
const svg = qr.createSvgTag({ scalable: true, margin: 4 });
|
||||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||||
}, [props.profile.email, secret]);
|
}, [props.profile.email, secret]);
|
||||||
|
|
||||||
@@ -85,6 +87,13 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return t('txt_dash');
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return value;
|
||||||
|
return parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { JSX } from 'preact';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
||||||
import {
|
import {
|
||||||
@@ -96,6 +97,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
|||||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: props.cipher.id,
|
id: props.cipher.id,
|
||||||
});
|
});
|
||||||
|
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -113,7 +115,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
|||||||
className="btn btn-secondary small totp-drag-btn"
|
className="btn btn-secondary small totp-drag-btn"
|
||||||
title={t('txt_drag_to_reorder')}
|
title={t('txt_drag_to_reorder')}
|
||||||
aria-label={t('txt_drag_to_reorder')}
|
aria-label={t('txt_drag_to_reorder')}
|
||||||
{...attributes}
|
{...dragButtonAttributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
>
|
>
|
||||||
<GripVertical size={14} className="btn-icon" />
|
<GripVertical size={14} className="btn-icon" />
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface VaultPageProps {
|
|||||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
onCreateFolder: (name: string) => Promise<void>;
|
onCreateFolder: (name: string) => Promise<void>;
|
||||||
|
onRenameFolder: (folderId: string, name: string) => Promise<void>;
|
||||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||||
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
||||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||||
@@ -91,6 +92,8 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [moveFolderId, setMoveFolderId] = useState('__none__');
|
const [moveFolderId, setMoveFolderId] = useState('__none__');
|
||||||
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
|
const [pendingRenameFolder, setPendingRenameFolder] = useState<Folder | null>(null);
|
||||||
|
const [renameFolderName, setRenameFolderName] = useState('');
|
||||||
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
||||||
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
|
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
|
||||||
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
||||||
@@ -101,6 +104,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [repromptOpen, setRepromptOpen] = useState(false);
|
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||||
const [repromptPassword, setRepromptPassword] = useState('');
|
const [repromptPassword, setRepromptPassword] = useState('');
|
||||||
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
||||||
|
const [pendingDeletePasskeyIndex, setPendingDeletePasskeyIndex] = useState<number | null>(null);
|
||||||
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
||||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
@@ -349,7 +353,6 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
() => filteredCiphers.slice(virtualRange.start, virtualRange.end),
|
() => filteredCiphers.slice(virtualRange.start, virtualRange.end),
|
||||||
[filteredCiphers, virtualRange.start, virtualRange.end]
|
[filteredCiphers, virtualRange.start, virtualRange.end]
|
||||||
);
|
);
|
||||||
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
|
|
||||||
const selectedAttachments = useMemo(
|
const selectedAttachments = useMemo(
|
||||||
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
|
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
|
||||||
[selectedCipher]
|
[selectedCipher]
|
||||||
@@ -443,6 +446,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setLocalError('');
|
setLocalError('');
|
||||||
setAttachmentQueue([]);
|
setAttachmentQueue([]);
|
||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
|
setPendingDeletePasskeyIndex(null);
|
||||||
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,6 +454,18 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmDeleteLoginPasskey(): void {
|
||||||
|
if (pendingDeletePasskeyIndex == null) return;
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loginFido2Credentials: prev.loginFido2Credentials.filter((_, index) => index !== pendingDeletePasskeyIndex),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPendingDeletePasskeyIndex(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function seedSshDefaults(force = false): Promise<void> {
|
async function seedSshDefaults(force = false): Promise<void> {
|
||||||
const ticket = ++sshSeedTicketRef.current;
|
const ticket = ++sshSeedTicketRef.current;
|
||||||
try {
|
try {
|
||||||
@@ -699,6 +715,23 @@ function folderName(id: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmRenameFolder(): Promise<void> {
|
||||||
|
if (!pendingRenameFolder) return;
|
||||||
|
const nextName = renameFolderName.trim();
|
||||||
|
if (!nextName) {
|
||||||
|
props.onNotify('error', t('txt_folder_name_is_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await props.onRenameFolder(pendingRenameFolder.id, nextName);
|
||||||
|
setPendingRenameFolder(null);
|
||||||
|
setRenameFolderName('');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmBulkRestore(): Promise<void> {
|
async function confirmBulkRestore(): Promise<void> {
|
||||||
const ids = Object.entries(selectedMap)
|
const ids = Object.entries(selectedMap)
|
||||||
.filter(([, selected]) => selected)
|
.filter(([, selected]) => selected)
|
||||||
@@ -806,6 +839,10 @@ function folderName(id: string | null | undefined): string {
|
|||||||
onChangeFilter={setSidebarFilter}
|
onChangeFilter={setSidebarFilter}
|
||||||
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
|
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
|
||||||
onOpenCreateFolder={() => setCreateFolderOpen(true)}
|
onOpenCreateFolder={() => setCreateFolderOpen(true)}
|
||||||
|
onOpenRenameFolder={(folder) => {
|
||||||
|
setPendingRenameFolder(folder);
|
||||||
|
setRenameFolderName(folder.decName || folder.name || '');
|
||||||
|
}}
|
||||||
onOpenDeleteFolder={setPendingDeleteFolder}
|
onOpenDeleteFolder={setPendingDeleteFolder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -924,6 +961,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
onUpdateDraftLoginUri={updateDraftLoginUri}
|
onUpdateDraftLoginUri={updateDraftLoginUri}
|
||||||
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
||||||
onReorderDraftLoginUri={reorderDraftLoginUri}
|
onReorderDraftLoginUri={reorderDraftLoginUri}
|
||||||
|
onRequestDeleteLoginPasskey={setPendingDeletePasskeyIndex}
|
||||||
onQueueAttachmentFiles={queueAttachmentFiles}
|
onQueueAttachmentFiles={queueAttachmentFiles}
|
||||||
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
||||||
onRemoveQueuedAttachment={removeQueuedAttachment}
|
onRemoveQueuedAttachment={removeQueuedAttachment}
|
||||||
@@ -949,7 +987,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
repromptApprovedCipherId={repromptApprovedCipherId}
|
repromptApprovedCipherId={repromptApprovedCipherId}
|
||||||
showPassword={showPassword}
|
showPassword={showPassword}
|
||||||
totpLive={totpLive}
|
totpLive={totpLive}
|
||||||
passkeyCreatedAt={passkeyCreatedAt}
|
passkeyCreatedAt={firstPasskeyCreationTime(selectedCipher)}
|
||||||
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
||||||
folderName={folderName}
|
folderName={folderName}
|
||||||
onOpenReprompt={() => setRepromptOpen(true)}
|
onOpenReprompt={() => setRepromptOpen(true)}
|
||||||
@@ -971,6 +1009,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VaultDialogs
|
<VaultDialogs
|
||||||
|
busy={busy}
|
||||||
fieldModalOpen={fieldModalOpen}
|
fieldModalOpen={fieldModalOpen}
|
||||||
fieldType={fieldType}
|
fieldType={fieldType}
|
||||||
fieldLabel={fieldLabel}
|
fieldLabel={fieldLabel}
|
||||||
@@ -986,10 +1025,13 @@ function folderName(id: string | null | undefined): string {
|
|||||||
folders={props.folders}
|
folders={props.folders}
|
||||||
createFolderOpen={createFolderOpen}
|
createFolderOpen={createFolderOpen}
|
||||||
newFolderName={newFolderName}
|
newFolderName={newFolderName}
|
||||||
|
renameFolderOpen={!!pendingRenameFolder}
|
||||||
|
renameFolderName={renameFolderName}
|
||||||
pendingDeleteFolder={pendingDeleteFolder}
|
pendingDeleteFolder={pendingDeleteFolder}
|
||||||
deleteAllFoldersOpen={deleteAllFoldersOpen}
|
deleteAllFoldersOpen={deleteAllFoldersOpen}
|
||||||
repromptOpen={repromptOpen}
|
repromptOpen={repromptOpen}
|
||||||
repromptPassword={repromptPassword}
|
repromptPassword={repromptPassword}
|
||||||
|
deletePasskeyOpen={pendingDeletePasskeyIndex != null}
|
||||||
onConfirmAddField={() => {
|
onConfirmAddField={() => {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!fieldLabel.trim()) {
|
if (!fieldLabel.trim()) {
|
||||||
@@ -1036,6 +1078,12 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setNewFolderName('');
|
setNewFolderName('');
|
||||||
}}
|
}}
|
||||||
onNewFolderNameChange={setNewFolderName}
|
onNewFolderNameChange={setNewFolderName}
|
||||||
|
onConfirmRenameFolder={() => void confirmRenameFolder()}
|
||||||
|
onCancelRenameFolder={() => {
|
||||||
|
setPendingRenameFolder(null);
|
||||||
|
setRenameFolderName('');
|
||||||
|
}}
|
||||||
|
onRenameFolderNameChange={setRenameFolderName}
|
||||||
onConfirmDeleteFolder={() => void confirmDeleteFolder()}
|
onConfirmDeleteFolder={() => void confirmDeleteFolder()}
|
||||||
onCancelDeleteFolder={() => setPendingDeleteFolder(null)}
|
onCancelDeleteFolder={() => setPendingDeleteFolder(null)}
|
||||||
onConfirmDeleteAllFolders={() => void confirmDeleteAllFolders()}
|
onConfirmDeleteAllFolders={() => void confirmDeleteAllFolders()}
|
||||||
@@ -1046,12 +1094,10 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setRepromptPassword('');
|
setRepromptPassword('');
|
||||||
}}
|
}}
|
||||||
onRepromptPasswordChange={setRepromptPassword}
|
onRepromptPasswordChange={setRepromptPassword}
|
||||||
|
onConfirmDeletePasskey={confirmDeleteLoginPasskey}
|
||||||
|
onCancelDeletePasskey={() => setPendingDeletePasskeyIndex(null)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
...COMMON_TIME_ZONES,
|
...COMMON_TIME_ZONES,
|
||||||
...props.availableTimeZones,
|
...props.availableTimeZones,
|
||||||
]));
|
]));
|
||||||
|
const selectedIntervalHours = props.selectedDestination?.schedule.intervalHours ?? 24;
|
||||||
|
|
||||||
if (props.selectedRecommendedProvider) {
|
if (props.selectedRecommendedProvider) {
|
||||||
return (
|
return (
|
||||||
@@ -216,7 +217,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
value={String(props.selectedDestination.schedule.intervalHours || 24)}
|
value={String(selectedIntervalHours)}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
||||||
@@ -234,7 +235,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
||||||
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
||||||
const active = preset === props.selectedDestination.schedule.intervalHours;
|
const active = preset === selectedIntervalHours;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={preset}
|
key={preset}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { createPortal } from 'preact/compat';
|
||||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact';
|
import { useMemo, useState } from 'preact/hooks';
|
||||||
|
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||||
|
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
@@ -35,10 +37,60 @@ interface VaultDetailViewProps {
|
|||||||
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PasswordHistoryDialog(props: {
|
||||||
|
open: boolean;
|
||||||
|
entries: Array<{ password: string; lastUsedDate: string | null }>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
useDialogLifecycle(props.open, props.onClose);
|
||||||
|
|
||||||
|
if (!props.open || typeof document === 'undefined') return null;
|
||||||
|
return createPortal(
|
||||||
|
<div className="dialog-mask open" onClick={(event) => event.target === event.currentTarget && props.onClose()}>
|
||||||
|
<section className="dialog-card password-history-dialog open" role="dialog" aria-modal="true" aria-label={t('txt_password_history')}>
|
||||||
|
<div className="password-history-head">
|
||||||
|
<h3 className="dialog-title">{t('txt_password_history')}</h3>
|
||||||
|
<button type="button" className="password-history-close" aria-label={t('txt_close')} onClick={props.onClose}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="password-history-list">
|
||||||
|
{props.entries.map((entry, index) => (
|
||||||
|
<div key={`password-history-${index}-${entry.lastUsedDate || 'none'}`} className="password-history-item">
|
||||||
|
<div className="password-history-copy">
|
||||||
|
<button type="button" className="btn btn-secondary small password-history-copy-btn" onClick={() => copyToClipboard(entry.password)}>
|
||||||
|
<Clipboard size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="password-history-value">{entry.password}</div>
|
||||||
|
<div className="password-history-time">{formatHistoryTime(entry.lastUsedDate)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary dialog-btn" onClick={props.onClose}>
|
||||||
|
{t('txt_close')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function VaultDetailView(props: VaultDetailViewProps) {
|
export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||||
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
||||||
|
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
|
||||||
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
||||||
|
const passwordHistoryEntries = useMemo(
|
||||||
|
() =>
|
||||||
|
(props.selectedCipher.passwordHistory || [])
|
||||||
|
.map((entry) => ({
|
||||||
|
password: String(entry?.decPassword || entry?.password || ''),
|
||||||
|
lastUsedDate: entry?.lastUsedDate ?? null,
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.password.trim()),
|
||||||
|
[props.selectedCipher.passwordHistory]
|
||||||
|
);
|
||||||
const formatDownloadLabel = (attachmentId: string) => {
|
const formatDownloadLabel = (attachmentId: string) => {
|
||||||
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||||
@@ -355,6 +407,14 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
<h4>{t('txt_item_history')}</h4>
|
<h4>{t('txt_item_history')}</h4>
|
||||||
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
|
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
|
||||||
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
|
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
|
||||||
|
{!!props.selectedCipher.login?.passwordRevisionDate && (
|
||||||
|
<div className="detail-sub">{t('txt_password_updated_value', { value: formatHistoryTime(props.selectedCipher.login.passwordRevisionDate) })}</div>
|
||||||
|
)}
|
||||||
|
{passwordHistoryEntries.length > 0 && (
|
||||||
|
<button type="button" className="password-history-link" onClick={() => setPasswordHistoryOpen(true)}>
|
||||||
|
{t('txt_password_history')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -379,6 +439,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<PasswordHistoryDialog
|
||||||
|
open={passwordHistoryOpen}
|
||||||
|
entries={passwordHistoryEntries}
|
||||||
|
onClose={() => setPasswordHistoryOpen(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vaul
|
|||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface VaultDialogsProps {
|
interface VaultDialogsProps {
|
||||||
|
busy: boolean;
|
||||||
fieldModalOpen: boolean;
|
fieldModalOpen: boolean;
|
||||||
fieldType: CustomFieldType;
|
fieldType: CustomFieldType;
|
||||||
fieldLabel: string;
|
fieldLabel: string;
|
||||||
@@ -19,10 +20,13 @@ interface VaultDialogsProps {
|
|||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
createFolderOpen: boolean;
|
createFolderOpen: boolean;
|
||||||
newFolderName: string;
|
newFolderName: string;
|
||||||
|
renameFolderOpen: boolean;
|
||||||
|
renameFolderName: string;
|
||||||
pendingDeleteFolder: Folder | null;
|
pendingDeleteFolder: Folder | null;
|
||||||
deleteAllFoldersOpen: boolean;
|
deleteAllFoldersOpen: boolean;
|
||||||
repromptOpen: boolean;
|
repromptOpen: boolean;
|
||||||
repromptPassword: string;
|
repromptPassword: string;
|
||||||
|
deletePasskeyOpen: boolean;
|
||||||
onConfirmAddField: () => void;
|
onConfirmAddField: () => void;
|
||||||
onCancelFieldModal: () => void;
|
onCancelFieldModal: () => void;
|
||||||
onFieldTypeChange: (value: CustomFieldType) => void;
|
onFieldTypeChange: (value: CustomFieldType) => void;
|
||||||
@@ -42,6 +46,9 @@ interface VaultDialogsProps {
|
|||||||
onConfirmCreateFolder: () => void;
|
onConfirmCreateFolder: () => void;
|
||||||
onCancelCreateFolder: () => void;
|
onCancelCreateFolder: () => void;
|
||||||
onNewFolderNameChange: (value: string) => void;
|
onNewFolderNameChange: (value: string) => void;
|
||||||
|
onConfirmRenameFolder: () => void;
|
||||||
|
onCancelRenameFolder: () => void;
|
||||||
|
onRenameFolderNameChange: (value: string) => void;
|
||||||
onConfirmDeleteFolder: () => void;
|
onConfirmDeleteFolder: () => void;
|
||||||
onCancelDeleteFolder: () => void;
|
onCancelDeleteFolder: () => void;
|
||||||
onConfirmDeleteAllFolders: () => void;
|
onConfirmDeleteAllFolders: () => void;
|
||||||
@@ -49,6 +56,8 @@ interface VaultDialogsProps {
|
|||||||
onConfirmReprompt: () => void;
|
onConfirmReprompt: () => void;
|
||||||
onCancelReprompt: () => void;
|
onCancelReprompt: () => void;
|
||||||
onRepromptPasswordChange: (value: string) => void;
|
onRepromptPasswordChange: (value: string) => void;
|
||||||
|
onConfirmDeletePasskey: () => void;
|
||||||
|
onCancelDeletePasskey: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VaultDialogs(props: VaultDialogsProps) {
|
export default function VaultDialogs(props: VaultDialogsProps) {
|
||||||
@@ -100,6 +109,8 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
message={t('txt_archive_item_message')}
|
message={t('txt_archive_item_message')}
|
||||||
confirmText={t('txt_archive')}
|
confirmText={t('txt_archive')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
onConfirm={props.onConfirmArchive}
|
onConfirm={props.onConfirmArchive}
|
||||||
onCancel={props.onCancelArchive}
|
onCancel={props.onCancelArchive}
|
||||||
/>
|
/>
|
||||||
@@ -110,11 +121,22 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
|
message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
|
||||||
confirmText={t('txt_archive')}
|
confirmText={t('txt_archive')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
onConfirm={props.onConfirmBulkArchive}
|
onConfirm={props.onConfirmBulkArchive}
|
||||||
onCancel={props.onCancelBulkArchive}
|
onCancel={props.onCancelBulkArchive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} />
|
<ConfirmDialog
|
||||||
|
open={props.pendingDeleteOpen}
|
||||||
|
title={t('txt_delete_item')}
|
||||||
|
message={t('txt_are_you_sure_you_want_to_delete_this_item')}
|
||||||
|
danger
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmDelete}
|
||||||
|
onCancel={props.onCancelDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={props.bulkDeleteOpen}
|
open={props.bulkDeleteOpen}
|
||||||
@@ -125,11 +147,23 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
|
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
|
||||||
}
|
}
|
||||||
danger
|
danger
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
onConfirm={props.onConfirmBulkDelete}
|
onConfirm={props.onConfirmBulkDelete}
|
||||||
onCancel={props.onCancelBulkDelete}
|
onCancel={props.onCancelBulkDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog open={props.moveOpen} title={t('txt_move_selected_items')} message={t('txt_choose_destination_folder')} confirmText={t('txt_move')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmMove} onCancel={props.onCancelMove}>
|
<ConfirmDialog
|
||||||
|
open={props.moveOpen}
|
||||||
|
title={t('txt_move_selected_items')}
|
||||||
|
message={t('txt_choose_destination_folder')}
|
||||||
|
confirmText={t('txt_move')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmMove}
|
||||||
|
onCancel={props.onCancelMove}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_folder')}</span>
|
<span>{t('txt_folder')}</span>
|
||||||
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}>
|
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}>
|
||||||
@@ -143,13 +177,40 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog open={props.createFolderOpen} title={t('txt_create_folder')} message={t('txt_enter_a_folder_name')} confirmText={t('txt_create')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmCreateFolder} onCancel={props.onCancelCreateFolder}>
|
<ConfirmDialog
|
||||||
|
open={props.createFolderOpen}
|
||||||
|
title={t('txt_create_folder')}
|
||||||
|
message={t('txt_enter_a_folder_name')}
|
||||||
|
confirmText={t('txt_create')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmCreateFolder}
|
||||||
|
onCancel={props.onCancelCreateFolder}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_folder_name')}</span>
|
<span>{t('txt_folder_name')}</span>
|
||||||
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.renameFolderOpen}
|
||||||
|
title={t('txt_edit')}
|
||||||
|
message={t('txt_enter_a_folder_name')}
|
||||||
|
confirmText={t('txt_save')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmRenameFolder}
|
||||||
|
onCancel={props.onCancelRenameFolder}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_folder_name')}</span>
|
||||||
|
<input className="input" value={props.renameFolderName} onInput={(e) => props.onRenameFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!props.pendingDeleteFolder}
|
open={!!props.pendingDeleteFolder}
|
||||||
title={t('txt_delete_folder')}
|
title={t('txt_delete_folder')}
|
||||||
@@ -157,18 +218,53 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
confirmText={t('txt_delete')}
|
confirmText={t('txt_delete')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
danger
|
danger
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
onConfirm={props.onConfirmDeleteFolder}
|
onConfirm={props.onConfirmDeleteFolder}
|
||||||
onCancel={props.onCancelDeleteFolder}
|
onCancel={props.onCancelDeleteFolder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog open={props.deleteAllFoldersOpen} title={t('txt_delete_all_folders')} message={t('txt_delete_all_folders_message')} confirmText={t('txt_delete')} cancelText={t('txt_cancel')} danger onConfirm={props.onConfirmDeleteAllFolders} onCancel={props.onCancelDeleteAllFolders} />
|
<ConfirmDialog
|
||||||
|
open={props.deleteAllFoldersOpen}
|
||||||
|
title={t('txt_delete_all_folders')}
|
||||||
|
message={t('txt_delete_all_folders_message')}
|
||||||
|
confirmText={t('txt_delete')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmDeleteAllFolders}
|
||||||
|
onCancel={props.onCancelDeleteAllFolders}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog open={props.repromptOpen} title={t('txt_unlock_item')} message={t('txt_enter_master_password_to_view_this_item')} confirmText={t('txt_unlock')} cancelText={t('txt_cancel')} showIcon={false} onConfirm={props.onConfirmReprompt} onCancel={props.onCancelReprompt}>
|
<ConfirmDialog
|
||||||
|
open={props.repromptOpen}
|
||||||
|
title={t('txt_unlock_item')}
|
||||||
|
message={t('txt_enter_master_password_to_view_this_item')}
|
||||||
|
confirmText={t('txt_unlock')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmReprompt}
|
||||||
|
onCancel={props.onCancelReprompt}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_master_password')}</span>
|
<span>{t('txt_master_password')}</span>
|
||||||
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.deletePasskeyOpen}
|
||||||
|
title={t('txt_delete_passkey')}
|
||||||
|
message={t('txt_are_you_sure_you_want_to_delete_this_passkey')}
|
||||||
|
confirmText={t('txt_delete')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={props.onConfirmDeletePasskey}
|
||||||
|
onCancel={props.onCancelDeletePasskey}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RefObject } from 'preact';
|
import type { JSX, RefObject } from 'preact';
|
||||||
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +20,15 @@ import {
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers';
|
import {
|
||||||
|
CREATE_TYPE_OPTIONS,
|
||||||
|
cipherTypeLabel,
|
||||||
|
createEmptyLoginUri,
|
||||||
|
formatAttachmentSize,
|
||||||
|
formatHistoryTime,
|
||||||
|
toBooleanFieldValue,
|
||||||
|
WEBSITE_MATCH_OPTIONS,
|
||||||
|
} from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
interface VaultEditorProps {
|
interface VaultEditorProps {
|
||||||
draft: VaultDraft;
|
draft: VaultDraft;
|
||||||
@@ -44,6 +52,7 @@ interface VaultEditorProps {
|
|||||||
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
||||||
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
|
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
|
||||||
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
|
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
|
||||||
|
onRequestDeleteLoginPasskey: (index: number) => void;
|
||||||
onQueueAttachmentFiles: (list: FileList | null) => void;
|
onQueueAttachmentFiles: (list: FileList | null) => void;
|
||||||
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
||||||
onRemoveQueuedAttachment: (index: number) => void;
|
onRemoveQueuedAttachment: (index: number) => void;
|
||||||
@@ -71,6 +80,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
|||||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: props.id,
|
id: props.id,
|
||||||
});
|
});
|
||||||
|
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -89,7 +99,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
|||||||
className="btn btn-secondary small website-drag-btn"
|
className="btn btn-secondary small website-drag-btn"
|
||||||
title={t('txt_drag_to_reorder')}
|
title={t('txt_drag_to_reorder')}
|
||||||
aria-label={t('txt_drag_to_reorder')}
|
aria-label={t('txt_drag_to_reorder')}
|
||||||
{...attributes}
|
{...dragButtonAttributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
>
|
>
|
||||||
<GripVertical size={14} className="btn-icon" />
|
<GripVertical size={14} className="btn-icon" />
|
||||||
@@ -287,6 +297,42 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
{props.draft.loginFido2Credentials.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="section-head" style={{ marginTop: '18px' }}>
|
||||||
|
<h4>{t('txt_passkeys')}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="attachment-list">
|
||||||
|
{props.draft.loginFido2Credentials.map((credential, index) => {
|
||||||
|
const createdAt = String(credential?.creationDate || '').trim();
|
||||||
|
const label = createdAt
|
||||||
|
? t('txt_passkey_created_at_value', { value: formatHistoryTime(createdAt) })
|
||||||
|
: t('txt_passkey');
|
||||||
|
return (
|
||||||
|
<div key={`login-passkey-${index}`} className="attachment-row">
|
||||||
|
<div className="attachment-main">
|
||||||
|
<div className="attachment-text">
|
||||||
|
<strong>{t('txt_passkey')}</strong>
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={() => props.onRequestDeleteLoginPasskey(index)}
|
||||||
|
>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
Pencil,
|
||||||
ShieldUser,
|
ShieldUser,
|
||||||
Star,
|
Star,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
@@ -28,6 +29,7 @@ interface VaultSidebarProps {
|
|||||||
onChangeFilter: (filter: SidebarFilter) => void;
|
onChangeFilter: (filter: SidebarFilter) => void;
|
||||||
onOpenDeleteAllFolders: () => void;
|
onOpenDeleteAllFolders: () => void;
|
||||||
onOpenCreateFolder: () => void;
|
onOpenCreateFolder: () => void;
|
||||||
|
onOpenRenameFolder: (folder: Folder) => void;
|
||||||
onOpenDeleteFolder: (folder: Folder) => void;
|
onOpenDeleteFolder: (folder: Folder) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +115,20 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
|||||||
{folder.decName || folder.name || folder.id}
|
{folder.decName || folder.name || folder.id}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="folder-delete-btn folder-edit-btn"
|
||||||
|
title={t('txt_edit')}
|
||||||
|
aria-label={t('txt_edit')}
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onOpenRenameFolder(folder);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="folder-delete-btn"
|
className="folder-delete-btn"
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function websiteIconUrl(host: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||||
return { uri: '', match: null };
|
return { uri: '', match: null, originalUri: '', extra: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function websiteMatchLabel(value: number | null | undefined): string {
|
export function websiteMatchLabel(value: number | null | undefined): string {
|
||||||
@@ -313,6 +313,10 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
|||||||
draft.loginUris = (cipher.login.uris || []).map((x) => ({
|
draft.loginUris = (cipher.login.uris || []).map((x) => ({
|
||||||
uri: x.decUri || x.uri || '',
|
uri: x.decUri || x.uri || '',
|
||||||
match: x.match ?? null,
|
match: x.match ?? null,
|
||||||
|
originalUri: x.decUri || x.uri || '',
|
||||||
|
extra: Object.fromEntries(
|
||||||
|
Object.entries(x as Record<string, unknown>).filter(([key]) => !['uri', 'match', 'decUri'].includes(key))
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
||||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
revokeAuthorizedDeviceTrust,
|
revokeAuthorizedDeviceTrust,
|
||||||
revokeAllAuthorizedDeviceTrust,
|
revokeAllAuthorizedDeviceTrust,
|
||||||
setTotp,
|
setTotp,
|
||||||
|
updateAuthorizedDeviceName,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -151,6 +152,21 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
await refetchAuthorizedDevices();
|
await refetchAuthorizedDevices();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async renameAuthorizedDevice(device: AuthorizedDevice, name: string) {
|
||||||
|
const normalized = String(name || '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
onNotify('error', t('txt_device_note_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateAuthorizedDeviceName(authedFetch, device.identifier, normalized);
|
||||||
|
await refetchAuthorizedDevices();
|
||||||
|
onNotify('success', t('txt_device_note_updated'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_update_device_note_failed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
openRevokeDeviceTrust(device: AuthorizedDevice) {
|
openRevokeDeviceTrust(device: AuthorizedDevice) {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_revoke_device_authorization'),
|
title: t('txt_revoke_device_authorization'),
|
||||||
@@ -159,9 +175,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
try {
|
||||||
await refetchAuthorizedDevices();
|
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
||||||
onNotify('success', t('txt_device_authorization_revoked'));
|
await refetchAuthorizedDevices();
|
||||||
|
onNotify('success', t('txt_device_authorization_revoked'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_device_trust_failed'));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -175,14 +195,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
try {
|
||||||
if (device.identifier === getCurrentDeviceIdentifier()) {
|
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
||||||
|
if (device.identifier === getCurrentDeviceIdentifier()) {
|
||||||
|
onNotify('success', t('txt_device_removed'));
|
||||||
|
onLogoutNow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refetchAuthorizedDevices();
|
||||||
onNotify('success', t('txt_device_removed'));
|
onNotify('success', t('txt_device_removed'));
|
||||||
onLogoutNow();
|
} catch (error) {
|
||||||
return;
|
onNotify('error', error instanceof Error ? error.message : t('txt_remove_device_failed'));
|
||||||
}
|
}
|
||||||
await refetchAuthorizedDevices();
|
|
||||||
onNotify('success', t('txt_device_removed'));
|
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -196,9 +220,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
try {
|
||||||
await refetchAuthorizedDevices();
|
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
||||||
onNotify('success', t('txt_all_device_authorizations_revoked'));
|
await refetchAuthorizedDevices();
|
||||||
|
onNotify('success', t('txt_all_device_authorizations_revoked'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_all_device_trust_failed'));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -212,9 +240,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await deleteAllAuthorizedDevices(authedFetch);
|
try {
|
||||||
onNotify('success', t('txt_all_devices_removed'));
|
await deleteAllAuthorizedDevices(authedFetch);
|
||||||
onLogoutNow();
|
onNotify('success', t('txt_all_devices_removed'));
|
||||||
|
onLogoutNow();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_remove_all_devices_failed'));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
type CiphersImportPayload,
|
type CiphersImportPayload,
|
||||||
type ImportedCipherMapEntry,
|
type ImportedCipherMapEntry,
|
||||||
updateCipher,
|
updateCipher,
|
||||||
|
updateFolder,
|
||||||
unarchiveCipher,
|
unarchiveCipher,
|
||||||
uploadCipherAttachment,
|
uploadCipherAttachment,
|
||||||
} from '@/lib/api/vault';
|
} from '@/lib/api/vault';
|
||||||
@@ -340,6 +341,28 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async renameFolder(folderId: string, name: string) {
|
||||||
|
const id = String(folderId || '').trim();
|
||||||
|
const nextName = String(name || '').trim();
|
||||||
|
if (!id) {
|
||||||
|
onNotify('error', t('txt_folder_not_found'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!nextName) {
|
||||||
|
onNotify('error', t('txt_folder_name_is_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
|
await updateFolder(authedFetch, session, id, nextName);
|
||||||
|
await refetchFolders();
|
||||||
|
onNotify('success', t('txt_folder_updated'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async bulkRestoreVaultItems(ids: string[]) {
|
async bulkRestoreVaultItems(ids: string[]) {
|
||||||
try {
|
try {
|
||||||
await bulkRestoreCiphers(authedFetch, ids);
|
await bulkRestoreCiphers(authedFetch, ids);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { base64ToBytes, decryptBw } from './crypto';
|
import { base64ToBytes, decryptBw, toBufferSource } from './crypto';
|
||||||
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
|
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
|
||||||
import type { Profile, SessionState } from './types';
|
import type { Profile, SessionState } from './types';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ const AES_GCM_ALGORITHM = 'AES-GCM';
|
|||||||
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
||||||
return crypto.subtle.importKey(
|
return crypto.subtle.importKey(
|
||||||
'pkcs8',
|
'pkcs8',
|
||||||
pkcs8,
|
toBufferSource(pkcs8),
|
||||||
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
||||||
false,
|
false,
|
||||||
['decrypt']
|
['decrypt']
|
||||||
@@ -17,7 +17,7 @@ async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
return crypto.subtle.importKey('raw', keyBytes, { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
return crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptPortableBackupSettings(
|
export async function decryptPortableBackupSettings(
|
||||||
@@ -50,15 +50,15 @@ export async function decryptPortableBackupSettings(
|
|||||||
await crypto.subtle.decrypt(
|
await crypto.subtle.decrypt(
|
||||||
{ name: PORTABLE_ALGORITHM },
|
{ name: PORTABLE_ALGORITHM },
|
||||||
privateKey,
|
privateKey,
|
||||||
base64ToBytes(wrap.wrappedKey)
|
toBufferSource(base64ToBytes(wrap.wrappedKey))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const aesKey = await importPortableAesKey(portableDek);
|
const aesKey = await importPortableAesKey(portableDek);
|
||||||
const plaintext = new Uint8Array(
|
const plaintext = new Uint8Array(
|
||||||
await crypto.subtle.decrypt(
|
await crypto.subtle.decrypt(
|
||||||
{ name: AES_GCM_ALGORITHM, iv: base64ToBytes(portable.iv) },
|
{ name: AES_GCM_ALGORITHM, iv: toBufferSource(base64ToBytes(portable.iv)) },
|
||||||
aesKey,
|
aesKey,
|
||||||
base64ToBytes(portable.ciphertext)
|
toBufferSource(base64ToBytes(portable.ciphertext))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
|
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
|
||||||
|
|||||||
+131
-19
@@ -10,8 +10,10 @@ import type {
|
|||||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||||
|
|
||||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||||
|
const PROFILE_SNAPSHOT_KEY = 'nodewarden.web.profile-snapshot.v1';
|
||||||
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
||||||
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
||||||
|
const WEB_SESSION_HEADER = 'X-NodeWarden-Web-Session';
|
||||||
|
|
||||||
export interface PreloginResult {
|
export interface PreloginResult {
|
||||||
hash: string;
|
hash: string;
|
||||||
@@ -26,6 +28,24 @@ export interface PreloginKdfConfig {
|
|||||||
kdfParallelism: number | null;
|
kdfParallelism: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PersistedSessionState {
|
||||||
|
email: string;
|
||||||
|
authMode: 'token' | 'web-cookie';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshFailure {
|
||||||
|
ok: false;
|
||||||
|
transient: boolean;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshSuccess {
|
||||||
|
ok: true;
|
||||||
|
token: TokenSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshResult = RefreshFailure | RefreshSuccess;
|
||||||
|
|
||||||
function randomHex(length: number): string {
|
function randomHex(length: number): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||||
@@ -66,12 +86,19 @@ export function loadSession(): SessionState | null {
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(SESSION_KEY);
|
const raw = localStorage.getItem(SESSION_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw) as SessionState;
|
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
|
||||||
if (!parsed.accessToken || !parsed.refreshToken) return null;
|
if (parsed.authMode === 'web-cookie' && parsed.email) {
|
||||||
|
return {
|
||||||
|
email: parsed.email,
|
||||||
|
authMode: 'web-cookie',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
||||||
return {
|
return {
|
||||||
accessToken: parsed.accessToken,
|
accessToken: parsed.accessToken,
|
||||||
refreshToken: parsed.refreshToken,
|
refreshToken: parsed.refreshToken,
|
||||||
email: parsed.email,
|
email: parsed.email,
|
||||||
|
authMode: 'token',
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -83,14 +110,35 @@ export function saveSession(session: SessionState | null): void {
|
|||||||
localStorage.removeItem(SESSION_KEY);
|
localStorage.removeItem(SESSION_KEY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const persisted: SessionState = {
|
const persisted: PersistedSessionState = {
|
||||||
accessToken: session.accessToken,
|
|
||||||
refreshToken: session.refreshToken,
|
|
||||||
email: session.email,
|
email: session.email,
|
||||||
|
authMode: session.authMode === 'token' ? 'token' : 'web-cookie',
|
||||||
};
|
};
|
||||||
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadProfileSnapshot(email?: string | null): Profile | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as Profile;
|
||||||
|
if (!parsed?.email || !parsed?.key) return null;
|
||||||
|
if (email && parsed.email !== email) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveProfileSnapshot(profile: Profile | null): void {
|
||||||
|
if (!profile) return;
|
||||||
|
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearProfileSnapshot(): void {
|
||||||
|
localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
export function getCurrentDeviceIdentifier(): string {
|
export function getCurrentDeviceIdentifier(): string {
|
||||||
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||||
}
|
}
|
||||||
@@ -170,7 +218,10 @@ export async function loginWithPassword(
|
|||||||
}
|
}
|
||||||
const resp = await fetch('/identity/connect/token', {
|
const resp = await fetch('/identity/connect/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
[WEB_SESSION_HEADER]: '1',
|
||||||
|
},
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
});
|
});
|
||||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||||
@@ -183,18 +234,60 @@ export async function loginWithPassword(
|
|||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshAccessToken(refreshToken: string): Promise<TokenSuccess | null> {
|
function isTransientRefreshStatus(status: number): boolean {
|
||||||
|
return status === 0 || status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
body.set('grant_type', 'refresh_token');
|
body.set('grant_type', 'refresh_token');
|
||||||
body.set('refresh_token', refreshToken);
|
if (session.authMode !== 'web-cookie' && session.refreshToken) {
|
||||||
const resp = await fetch('/identity/connect/token', {
|
body.set('refresh_token', session.refreshToken);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/identity/connect/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||||
|
},
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const json = await parseJson<TokenError>(resp);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
transient: isTransientRefreshStatus(resp.status),
|
||||||
|
error: json?.error_description || json?.error || 'Session refresh failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const json = await parseJson<TokenSuccess>(resp);
|
||||||
|
if (!json?.access_token) {
|
||||||
|
return { ok: false, transient: false, error: 'Session refresh failed' };
|
||||||
|
}
|
||||||
|
return { ok: true, token: json };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
transient: true,
|
||||||
|
error: error instanceof Error ? error.message : 'Network error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
||||||
|
body.set('token', session.refreshToken);
|
||||||
|
}
|
||||||
|
await fetch('/identity/connect/revocation', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||||
|
},
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
});
|
}).catch(() => undefined);
|
||||||
if (!resp.ok) return null;
|
|
||||||
const json = await parseJson<TokenSuccess>(resp);
|
|
||||||
return json || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerAccount(args: {
|
export async function registerAccount(args: {
|
||||||
@@ -279,18 +372,22 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||||
|
|
||||||
let resp = await fetch(input, { ...init, headers });
|
let resp = await fetch(input, { ...init, headers });
|
||||||
if (resp.status !== 401 || !session.refreshToken) return resp;
|
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||||
|
|
||||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
const refreshed = await refreshAccessToken(session);
|
||||||
if (!refreshed?.access_token) {
|
if (!refreshed.ok) {
|
||||||
|
if (refreshed.transient) {
|
||||||
|
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
||||||
|
}
|
||||||
setSession(null);
|
setSession(null);
|
||||||
throw new Error('Session expired');
|
throw new Error('Session expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSession: SessionState = {
|
const nextSession: SessionState = {
|
||||||
...session,
|
...session,
|
||||||
accessToken: refreshed.access_token,
|
accessToken: refreshed.token.access_token,
|
||||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||||
|
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||||
};
|
};
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
saveSession(nextSession);
|
saveSession(nextSession);
|
||||||
@@ -478,6 +575,21 @@ export async function deleteAuthorizedDevice(
|
|||||||
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
|
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateAuthorizedDeviceName(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
name: string
|
||||||
|
): Promise<void> {
|
||||||
|
const normalized = String(name || '').trim();
|
||||||
|
if (!normalized) throw new Error(t('txt_device_note_required'));
|
||||||
|
const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}/name`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: normalized }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
|
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
|
||||||
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
||||||
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type AuthedFetch,
|
type AuthedFetch,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { readResponseBytesWithProgress } from '../download';
|
import { readResponseBytesWithProgress } from '../download';
|
||||||
|
import { toBufferSource } from '../crypto';
|
||||||
import { unzipSync, zipSync } from 'fflate';
|
import { unzipSync, zipSync } from 'fflate';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -148,32 +149,21 @@ interface BackupExportManifest {
|
|||||||
|
|
||||||
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||||
|
|
||||||
function parseBackupTimestampFromFileName(fileName: string): Date | null {
|
function extractBackupTimestampFromFileName(fileName: string): string | null {
|
||||||
const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i);
|
const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const datePart = match[1];
|
return `${match[1]}_${match[2]}`;
|
||||||
const timePart = match[2];
|
|
||||||
const iso = `${datePart.slice(0, 4)}-${datePart.slice(4, 6)}-${datePart.slice(6, 8)}T${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}.000Z`;
|
|
||||||
const parsed = new Date(iso);
|
|
||||||
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBackupFileName(date: Date, checksumPrefix: string): string {
|
function buildBackupFileName(timestamp: string, checksumPrefix: string): string {
|
||||||
const parts = [
|
return `nodewarden_backup_${timestamp}_${checksumPrefix}.zip`;
|
||||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
|
||||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
|
||||||
date.getUTCDate().toString().padStart(2, '0'),
|
|
||||||
date.getUTCHours().toString().padStart(2, '0'),
|
|
||||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
|
||||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
|
||||||
];
|
|
||||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}_${checksumPrefix}.zip`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> {
|
async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> {
|
||||||
const integrity = await verifyBackupFileIntegrity(bytes, fileName);
|
const integrity = await verifyBackupFileIntegrity(bytes, fileName);
|
||||||
const effectiveDate = parseBackupTimestampFromFileName(fileName) || new Date();
|
const timestamp = extractBackupTimestampFromFileName(fileName);
|
||||||
return buildBackupFileName(effectiveDate, integrity.actualPrefix);
|
if (!timestamp) return fileName;
|
||||||
|
return buildBackupFileName(timestamp, integrity.actualPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportAdminBackup(
|
export async function exportAdminBackup(
|
||||||
@@ -378,7 +368,7 @@ export function extractBackupFileChecksumPrefix(fileName: string): string | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
const digest = await crypto.subtle.digest('SHA-256', toBufferSource(bytes));
|
||||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
|
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
|
||||||
import type { Send, SendDraft, SessionState } from '../types';
|
import type { Send, SendDraft, SessionState } from '../types';
|
||||||
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
|
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
|
||||||
|
import { loadVaultSyncSnapshot } from './vault-sync';
|
||||||
|
|
||||||
function toIsoDateFromDays(value: string, required: boolean): string | null {
|
function toIsoDateFromDays(value: string, required: boolean): string | null {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
@@ -61,10 +62,8 @@ function parseMaxAccessCountRaw(value: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
||||||
const resp = await authedFetch('/api/sends');
|
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||||
if (!resp.ok) throw new Error('Failed to load sends');
|
return body.sends || [];
|
||||||
const body = await parseJson<{ object: 'list'; data: Send[] }>(resp);
|
|
||||||
return body?.data || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSend(
|
export async function createSend(
|
||||||
@@ -152,10 +151,13 @@ export async function createSend(
|
|||||||
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
|
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
|
||||||
const uploadUrl = uploadInfo?.url;
|
const uploadUrl = uploadInfo?.url;
|
||||||
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
||||||
|
if (!session.accessToken) throw new Error('Unauthorized');
|
||||||
|
const payload = new ArrayBuffer(encryptedFileBytes.byteLength);
|
||||||
|
new Uint8Array(payload).set(encryptedFileBytes);
|
||||||
const uploadResp = await uploadDirectEncryptedPayload({
|
const uploadResp = await uploadDirectEncryptedPayload({
|
||||||
accessToken: session.accessToken,
|
accessToken: session.accessToken,
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
payload: encryptedFileBytes,
|
payload,
|
||||||
fileUploadType: uploadInfo?.fileUploadType,
|
fileUploadType: uploadInfo?.fileUploadType,
|
||||||
unsupportedMessage: 'Unsupported send upload type',
|
unsupportedMessage: 'Unsupported send upload type',
|
||||||
onProgress,
|
onProgress,
|
||||||
|
|||||||
@@ -63,14 +63,14 @@ interface UploadWithProgressOptions {
|
|||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
headers?: HeadersInit;
|
headers?: HeadersInit;
|
||||||
body?: Document | XMLHttpRequestBodyInit | null;
|
body?: XMLHttpRequestBodyInit | null;
|
||||||
onProgress?: (percent: number | null) => void;
|
onProgress?: (percent: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DirectEncryptedUploadOptions {
|
interface DirectEncryptedUploadOptions {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
payload: ArrayBuffer | Uint8Array;
|
payload: XMLHttpRequestBodyInit;
|
||||||
fileUploadType: number | null | undefined;
|
fileUploadType: number | null | undefined;
|
||||||
unsupportedMessage: string;
|
unsupportedMessage: string;
|
||||||
onProgress?: (percent: number | null) => void;
|
onProgress?: (percent: number | null) => void;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Cipher, Folder, Send } from '../types';
|
||||||
|
import { parseJson, type AuthedFetch } from './shared';
|
||||||
|
|
||||||
|
interface VaultSyncResponse {
|
||||||
|
ciphers?: Cipher[];
|
||||||
|
folders?: Folder[];
|
||||||
|
sends?: Send[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSyncRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>();
|
||||||
|
|
||||||
|
export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> {
|
||||||
|
const existing = pendingSyncRequests.get(authedFetch);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
const resp = await authedFetch('/api/sync');
|
||||||
|
if (!resp.ok) throw new Error('Failed to load vault');
|
||||||
|
const body = await parseJson<VaultSyncResponse>(resp);
|
||||||
|
return body || {};
|
||||||
|
})();
|
||||||
|
|
||||||
|
pendingSyncRequests.set(authedFetch, request);
|
||||||
|
try {
|
||||||
|
return await request;
|
||||||
|
} finally {
|
||||||
|
if (pendingSyncRequests.get(authedFetch) === request) {
|
||||||
|
pendingSyncRequests.delete(authedFetch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
-11
@@ -1,8 +1,8 @@
|
|||||||
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto';
|
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto';
|
||||||
import type {
|
import type {
|
||||||
Cipher,
|
Cipher,
|
||||||
|
CipherPasswordHistoryEntry,
|
||||||
Folder,
|
Folder,
|
||||||
ListResponse,
|
|
||||||
SessionState,
|
SessionState,
|
||||||
VaultDraft,
|
VaultDraft,
|
||||||
VaultDraftField,
|
VaultDraftField,
|
||||||
@@ -16,12 +16,11 @@ import {
|
|||||||
type AuthedFetch,
|
type AuthedFetch,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { readResponseBytesWithProgress } from '../download';
|
import { readResponseBytesWithProgress } from '../download';
|
||||||
|
import { loadVaultSyncSnapshot } from './vault-sync';
|
||||||
|
|
||||||
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
||||||
const resp = await authedFetch('/api/folders');
|
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||||
if (!resp.ok) throw new Error('Failed to load folders');
|
return body.folders || [];
|
||||||
const body = await parseJson<ListResponse<Folder>>(resp);
|
|
||||||
return body?.data || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFolder(
|
export async function createFolder(
|
||||||
@@ -93,10 +92,8 @@ export async function updateFolder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
|
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
|
||||||
const resp = await authedFetch('/api/ciphers?deleted=true');
|
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||||
if (!resp.ok) throw new Error('Failed to load ciphers');
|
return body.ciphers || [];
|
||||||
const body = await parseJson<ListResponse<Cipher>>(resp);
|
|
||||||
return body?.data || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CiphersImportPayload {
|
export interface CiphersImportPayload {
|
||||||
@@ -240,6 +237,7 @@ export async function uploadCipherAttachment(
|
|||||||
const attachmentId = String(meta.attachmentId || '').trim();
|
const attachmentId = String(meta.attachmentId || '').trim();
|
||||||
const uploadUrl = String(meta.url || '').trim();
|
const uploadUrl = String(meta.url || '').trim();
|
||||||
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
|
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
|
||||||
|
if (!session.accessToken) throw new Error('Unauthorized');
|
||||||
|
|
||||||
const payload = new ArrayBuffer(encryptedBytes.byteLength);
|
const payload = new ArrayBuffer(encryptedBytes.byteLength);
|
||||||
new Uint8Array(payload).set(encryptedBytes);
|
new Uint8Array(payload).set(encryptedBytes);
|
||||||
@@ -349,6 +347,61 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array)
|
|||||||
return encryptBw(new TextEncoder().encode(s), enc, mac);
|
return encryptBw(new TextEncoder().encode(s), enc, mac);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function encryptPasswordHistory(
|
||||||
|
entries: CipherPasswordHistoryEntry[] | null | undefined,
|
||||||
|
enc: Uint8Array,
|
||||||
|
mac: Uint8Array
|
||||||
|
): Promise<CipherPasswordHistoryEntry[] | null> {
|
||||||
|
if (!Array.isArray(entries) || entries.length === 0) return null;
|
||||||
|
|
||||||
|
const out: CipherPasswordHistoryEntry[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const rawPassword = String(entry?.password || '');
|
||||||
|
const plainPassword = entry?.decPassword ?? rawPassword;
|
||||||
|
const encryptedPassword = looksLikeCipherString(rawPassword)
|
||||||
|
? rawPassword
|
||||||
|
: await encryptTextValue(plainPassword, enc, mac);
|
||||||
|
if (!encryptedPassword) continue;
|
||||||
|
out.push({
|
||||||
|
password: encryptedPassword,
|
||||||
|
lastUsedDate: toIsoDateOrNow(entry?.lastUsedDate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.length ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildUpdatedPasswordHistory(
|
||||||
|
cipher: Cipher | null,
|
||||||
|
draft: VaultDraft,
|
||||||
|
enc: Uint8Array,
|
||||||
|
mac: Uint8Array
|
||||||
|
): Promise<CipherPasswordHistoryEntry[] | null> {
|
||||||
|
const existingHistory = Array.isArray(cipher?.passwordHistory) ? cipher.passwordHistory : [];
|
||||||
|
const currentPassword = String(cipher?.login?.decPassword || '');
|
||||||
|
const nextPassword = String(draft.loginPassword || '');
|
||||||
|
const passwordChanged = currentPassword !== nextPassword;
|
||||||
|
const history = await encryptPasswordHistory(existingHistory, enc, mac);
|
||||||
|
|
||||||
|
if (!passwordChanged || !currentPassword.trim()) {
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedCurrentPassword = await encryptTextValue(currentPassword, enc, mac);
|
||||||
|
if (!encryptedCurrentPassword) {
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEntries: CipherPasswordHistoryEntry[] = [
|
||||||
|
{
|
||||||
|
password: encryptedCurrentPassword,
|
||||||
|
lastUsedDate: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
...(history || []),
|
||||||
|
];
|
||||||
|
return nextEntries.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
async function encryptCustomFields(
|
async function encryptCustomFields(
|
||||||
fields: VaultDraftField[],
|
fields: VaultDraftField[],
|
||||||
enc: Uint8Array,
|
enc: Uint8Array,
|
||||||
@@ -371,12 +424,20 @@ async function encryptUris(
|
|||||||
uris: VaultDraft['loginUris'],
|
uris: VaultDraft['loginUris'],
|
||||||
enc: Uint8Array,
|
enc: Uint8Array,
|
||||||
mac: Uint8Array
|
mac: Uint8Array
|
||||||
): Promise<Array<{ uri: string | null; match: number | null }>> {
|
): Promise<Array<Record<string, unknown>>> {
|
||||||
const out: Array<{ uri: string | null; match: number | null }> = [];
|
const out: Array<Record<string, unknown>> = [];
|
||||||
for (const entry of uris || []) {
|
for (const entry of uris || []) {
|
||||||
const trimmed = String(entry?.uri || '').trim();
|
const trimmed = String(entry?.uri || '').trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
|
const preservedExtra =
|
||||||
|
entry?.extra && typeof entry.extra === 'object'
|
||||||
|
? { ...entry.extra }
|
||||||
|
: {};
|
||||||
|
if (String(entry?.originalUri || '').trim() !== trimmed) {
|
||||||
|
delete preservedExtra.uriChecksum;
|
||||||
|
}
|
||||||
out.push({
|
out.push({
|
||||||
|
...preservedExtra,
|
||||||
uri: await encryptTextValue(trimmed, enc, mac),
|
uri: await encryptTextValue(trimmed, enc, mac),
|
||||||
match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
|
match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
|
||||||
});
|
});
|
||||||
@@ -468,6 +529,7 @@ async function buildCipherPayload(
|
|||||||
const userMac = base64ToBytes(session.symMacKey);
|
const userMac = base64ToBytes(session.symMacKey);
|
||||||
const keys = await getCipherKeys(cipher, userEnc, userMac);
|
const keys = await getCipherKeys(cipher, userEnc, userMac);
|
||||||
const type = Number(draft.type || cipher?.type || 1);
|
const type = Number(draft.type || cipher?.type || 1);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
type,
|
type,
|
||||||
@@ -482,6 +544,7 @@ async function buildCipherPayload(
|
|||||||
secureNote: null,
|
secureNote: null,
|
||||||
sshKey: null,
|
sshKey: null,
|
||||||
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
|
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
|
||||||
|
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cipher?.id) {
|
if (cipher?.id) {
|
||||||
@@ -490,17 +553,25 @@ async function buildCipherPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 1) {
|
if (type === 1) {
|
||||||
|
const passwordChanged = String(cipher?.login?.decPassword || '') !== String(draft.loginPassword || '');
|
||||||
const existingFido2 =
|
const existingFido2 =
|
||||||
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
|
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
|
||||||
? (cipher.login as any).fido2Credentials
|
? (cipher.login as any).fido2Credentials
|
||||||
: draft.loginFido2Credentials;
|
: draft.loginFido2Credentials;
|
||||||
|
const existingLogin =
|
||||||
|
cipher?.login && typeof cipher.login === 'object'
|
||||||
|
? { ...(cipher.login as Record<string, unknown>) }
|
||||||
|
: {};
|
||||||
payload.login = {
|
payload.login = {
|
||||||
|
...existingLogin,
|
||||||
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||||
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
||||||
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
||||||
|
passwordRevisionDate: passwordChanged ? now : existingLogin.passwordRevisionDate ?? null,
|
||||||
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
|
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
|
||||||
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
||||||
};
|
};
|
||||||
|
payload.passwordHistory = await buildUpdatedPasswordHistory(cipher, draft, keys.enc, keys.mac);
|
||||||
} else if (type === 3) {
|
} else if (type === 3) {
|
||||||
payload.card = {
|
payload.card = {
|
||||||
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
|
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
|
||||||
|
|||||||
+48
-22
@@ -2,6 +2,7 @@ import {
|
|||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
deriveLoginHashLocally,
|
deriveLoginHashLocally,
|
||||||
getProfile,
|
getProfile,
|
||||||
|
loadProfileSnapshot,
|
||||||
loadSession,
|
loadSession,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
@@ -26,6 +27,7 @@ export interface BootstrapAppResult {
|
|||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
phase: AppPhase;
|
phase: AppPhase;
|
||||||
|
needsBackgroundHydration?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InitialAppBootstrapState {
|
export interface InitialAppBootstrapState {
|
||||||
@@ -51,8 +53,9 @@ export interface RecoverTwoFactorResult {
|
|||||||
newRecoveryCode: string | null;
|
newRecoveryCode: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeJwtExp(accessToken: string): number | null {
|
function decodeJwtExp(accessToken: string | undefined): number | null {
|
||||||
try {
|
try {
|
||||||
|
if (!accessToken) return null;
|
||||||
const parts = accessToken.split('.');
|
const parts = accessToken.split('.');
|
||||||
if (parts.length < 2) return null;
|
if (parts.length < 2) return null;
|
||||||
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||||
@@ -66,23 +69,24 @@ function decodeJwtExp(accessToken: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> {
|
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> {
|
||||||
if (!session.refreshToken) return session;
|
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null;
|
||||||
const exp = decodeJwtExp(session.accessToken);
|
const exp = decodeJwtExp(session.accessToken);
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
if (exp !== null && exp - nowSeconds > 60) {
|
if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
const refreshed = await refreshAccessToken(session);
|
||||||
if (!refreshed?.access_token) {
|
if (!refreshed.ok) {
|
||||||
return exp !== null && exp > nowSeconds ? session : null;
|
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
accessToken: refreshed.access_token,
|
accessToken: refreshed.token.access_token,
|
||||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||||
|
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,31 +201,51 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cachedProfile = loadProfileSnapshot(loaded.email);
|
||||||
|
if (cachedProfile) {
|
||||||
|
return {
|
||||||
|
defaultKdfIterations,
|
||||||
|
jwtWarning: null,
|
||||||
|
session: loaded,
|
||||||
|
profile: cachedProfile,
|
||||||
|
phase: 'locked',
|
||||||
|
needsBackgroundHydration: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultKdfIterations,
|
||||||
|
jwtWarning: null,
|
||||||
|
session: loaded,
|
||||||
|
profile: null,
|
||||||
|
phase: 'locked',
|
||||||
|
needsBackgroundHydration: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hydrateLockedSession(
|
||||||
|
session: SessionState,
|
||||||
|
fallbackProfile: Profile | null = null
|
||||||
|
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||||
|
const refreshedSession = await maybeRefreshSession(session);
|
||||||
|
if (!refreshedSession?.accessToken) {
|
||||||
|
return { session: null, profile: null };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const session = await maybeRefreshSession(loaded);
|
|
||||||
if (!session) {
|
|
||||||
throw new Error('Session expired');
|
|
||||||
}
|
|
||||||
const profile = await getProfile(
|
const profile = await getProfile(
|
||||||
createAuthedFetch(
|
createAuthedFetch(
|
||||||
() => session,
|
() => refreshedSession,
|
||||||
() => {}
|
() => {}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
session: refreshedSession,
|
||||||
jwtWarning: null,
|
|
||||||
session,
|
|
||||||
profile,
|
profile,
|
||||||
phase: 'locked',
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
session: refreshedSession,
|
||||||
jwtWarning: null,
|
profile: fallbackProfile,
|
||||||
session: null,
|
|
||||||
profile: null,
|
|
||||||
phase: initial.phase === 'register' ? 'register' : 'login',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +260,7 @@ export async function completeLogin(
|
|||||||
accessToken: token.access_token,
|
accessToken: token.access_token,
|
||||||
refreshToken: token.refresh_token,
|
refreshToken: token.refresh_token,
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
|
authMode: token.web_session ? 'web-cookie' : 'token',
|
||||||
};
|
};
|
||||||
const tempFetch = createAuthedFetch(
|
const tempFetch = createAuthedFetch(
|
||||||
() => baseSession,
|
() => baseSession,
|
||||||
@@ -359,3 +384,4 @@ export async function performUnlock(
|
|||||||
}
|
}
|
||||||
return { ...refreshedSession, ...keys };
|
return { ...refreshedSession, ...keys };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,11 +161,6 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
|||||||
draft.loginUsername = asText(login.username);
|
draft.loginUsername = asText(login.username);
|
||||||
draft.loginPassword = asText(login.password);
|
draft.loginPassword = asText(login.password);
|
||||||
draft.loginTotp = asText(login.totp);
|
draft.loginTotp = asText(login.totp);
|
||||||
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
|
|
||||||
? login.fido2Credentials
|
|
||||||
.filter((credential): credential is Record<string, unknown> => !!credential && typeof credential === 'object')
|
|
||||||
.map((credential) => ({ ...credential }))
|
|
||||||
: [];
|
|
||||||
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
||||||
const uris = urisRaw
|
const uris = urisRaw
|
||||||
.map((u) => {
|
.map((u) => {
|
||||||
@@ -175,10 +170,17 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
|||||||
return {
|
return {
|
||||||
uri,
|
uri,
|
||||||
match: typeof matchRaw === 'number' && Number.isFinite(matchRaw) ? matchRaw : null,
|
match: typeof matchRaw === 'number' && Number.isFinite(matchRaw) ? matchRaw : null,
|
||||||
|
originalUri: uri,
|
||||||
|
extra: Object.fromEntries(
|
||||||
|
Object.entries(row).filter(([key]) => !['uri', 'match'].includes(key))
|
||||||
|
),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((u) => !!u.uri);
|
.filter((u) => !!u.uri);
|
||||||
draft.loginUris = uris.length ? uris : [{ uri: '', match: null }];
|
draft.loginUris = uris.length ? uris : [{ uri: '', match: null, originalUri: '', extra: {} }];
|
||||||
|
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
|
||||||
|
? login.fido2Credentials.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||||
|
: [];
|
||||||
} else if (type === 3) {
|
} else if (type === 3) {
|
||||||
const card = (cipher.card || {}) as Record<string, unknown>;
|
const card = (cipher.card || {}) as Record<string, unknown>;
|
||||||
draft.cardholderName = asText(card.cardholderName);
|
draft.cardholderName = asText(card.cardholderName);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
||||||
return new Uint8Array(bytes).buffer;
|
return new Uint8Array(bytes).buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -293,7 +293,9 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
|
|||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
||||||
? await Promise.all(cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac)))
|
? await Promise.all(
|
||||||
|
cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac))
|
||||||
|
)
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+30
-5
@@ -293,6 +293,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?",
|
txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?",
|
||||||
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: "Are you sure you want to permanently delete {count} selected items?",
|
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: "Are you sure you want to permanently delete {count} selected items?",
|
||||||
txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?",
|
txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?",
|
||||||
|
txt_are_you_sure_you_want_to_delete_this_passkey: "Are you sure you want to delete this passkey?",
|
||||||
txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?",
|
txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?",
|
||||||
txt_authenticator_key: "Authenticator Key",
|
txt_authenticator_key: "Authenticator Key",
|
||||||
txt_authorized_devices: "Authorized Devices",
|
txt_authorized_devices: "Authorized Devices",
|
||||||
@@ -352,6 +353,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_delete_all_invite_codes_active_inactive: "Delete all invite codes (active/inactive)?",
|
txt_delete_all_invite_codes_active_inactive: "Delete all invite codes (active/inactive)?",
|
||||||
txt_delete_all_invites: "Delete all invites",
|
txt_delete_all_invites: "Delete all invites",
|
||||||
txt_delete_item: "Delete Item",
|
txt_delete_item: "Delete Item",
|
||||||
|
txt_delete_passkey: "Delete Passkey",
|
||||||
txt_delete_item_failed: "Delete item failed",
|
txt_delete_item_failed: "Delete item failed",
|
||||||
txt_delete_permanently: "Delete Permanently",
|
txt_delete_permanently: "Delete Permanently",
|
||||||
txt_archive: "Archive",
|
txt_archive: "Archive",
|
||||||
@@ -385,6 +387,9 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_device: "Device",
|
txt_device: "Device",
|
||||||
txt_device_authorization_revoked: "Device trust revoked",
|
txt_device_authorization_revoked: "Device trust revoked",
|
||||||
txt_device_management: "Device Management",
|
txt_device_management: "Device Management",
|
||||||
|
txt_device_note: "Device Note",
|
||||||
|
txt_device_note_required: "Device name is required",
|
||||||
|
txt_device_note_updated: "Device name updated",
|
||||||
txt_device_removed: "Device removed",
|
txt_device_removed: "Device removed",
|
||||||
txt_load_devices_failed: "Failed to load devices",
|
txt_load_devices_failed: "Failed to load devices",
|
||||||
txt_disable_this_send: "Disable this send",
|
txt_disable_this_send: "Disable this send",
|
||||||
@@ -457,6 +462,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_item_created: "Item created",
|
txt_item_created: "Item created",
|
||||||
txt_item_deleted: "Item deleted",
|
txt_item_deleted: "Item deleted",
|
||||||
txt_item_history: "Item History",
|
txt_item_history: "Item History",
|
||||||
|
txt_password_history: "Password History",
|
||||||
|
txt_password_updated_value: "Password updated: {value}",
|
||||||
txt_item_name_is_required: "Item name is required.",
|
txt_item_name_is_required: "Item name is required.",
|
||||||
txt_item_updated: "Item updated",
|
txt_item_updated: "Item updated",
|
||||||
txt_last_edited_value: "Last edited: {value}",
|
txt_last_edited_value: "Last edited: {value}",
|
||||||
@@ -548,6 +555,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_not_trusted: "Not trusted",
|
txt_not_trusted: "Not trusted",
|
||||||
txt_note: "Note",
|
txt_note: "Note",
|
||||||
txt_notes: "Notes",
|
txt_notes: "Notes",
|
||||||
|
txt_replace_device_name_with_note: "Set a custom name for this device without changing its detected system type.",
|
||||||
txt_number: "Number",
|
txt_number: "Number",
|
||||||
txt_open: "Open",
|
txt_open: "Open",
|
||||||
txt_opera_browser: "Opera Browser",
|
txt_opera_browser: "Opera Browser",
|
||||||
@@ -571,6 +579,9 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_password_hint_not_set: "No password hint is available for this email.",
|
txt_password_hint_not_set: "No password hint is available for this email.",
|
||||||
txt_password_hint_load_failed: "Failed to load password hint",
|
txt_password_hint_load_failed: "Failed to load password hint",
|
||||||
txt_password_hint_too_long: "Password hint must be 120 characters or fewer",
|
txt_password_hint_too_long: "Password hint must be 120 characters or fewer",
|
||||||
|
txt_passkey: "Passkey",
|
||||||
|
txt_passkeys: "Passkeys",
|
||||||
|
txt_passkey_created_at_value: "Created on {value}",
|
||||||
txt_phone: "Phone",
|
txt_phone: "Phone",
|
||||||
txt_please_input_email_and_password: "Please input email and password",
|
txt_please_input_email_and_password: "Please input email and password",
|
||||||
txt_please_input_master_password: "Please input master password",
|
txt_please_input_master_password: "Please input master password",
|
||||||
@@ -613,6 +624,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_revoke_device_trust_failed: "Failed to revoke device trust",
|
txt_revoke_device_trust_failed: "Failed to revoke device trust",
|
||||||
txt_revoke_all_device_trust_failed: "Failed to revoke all device trust",
|
txt_revoke_all_device_trust_failed: "Failed to revoke all device trust",
|
||||||
txt_revoke_trust: "Revoke Trust",
|
txt_revoke_trust: "Revoke Trust",
|
||||||
|
txt_untrust: "Untrust",
|
||||||
|
txt_update_device_note_failed: "Update device note failed",
|
||||||
txt_role: "Role",
|
txt_role: "Role",
|
||||||
txt_save: "Save",
|
txt_save: "Save",
|
||||||
txt_save_profile: "Save Profile",
|
txt_save_profile: "Save Profile",
|
||||||
@@ -671,8 +684,6 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_total_items_count: "{count} items",
|
txt_total_items_count: "{count} items",
|
||||||
txt_totp_secret: "TOTP Secret",
|
txt_totp_secret: "TOTP Secret",
|
||||||
txt_totp_verify_failed: "TOTP verify failed",
|
txt_totp_verify_failed: "TOTP verify failed",
|
||||||
txt_passkey: "Passkey",
|
|
||||||
txt_passkey_created_at_value: "Created at {value}",
|
|
||||||
txt_attachments: "Attachments",
|
txt_attachments: "Attachments",
|
||||||
txt_upload_attachments: "Upload attachments",
|
txt_upload_attachments: "Upload attachments",
|
||||||
txt_new_attachments: "New attachments",
|
txt_new_attachments: "New attachments",
|
||||||
@@ -1064,7 +1075,10 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_additional_options: '附加选项',
|
txt_additional_options: '附加选项',
|
||||||
txt_custom_fields: '自定义字段',
|
txt_custom_fields: '自定义字段',
|
||||||
txt_notes: '备注',
|
txt_notes: '备注',
|
||||||
|
txt_replace_device_name_with_note: '为这台设备设置自定义名称,不会改变系统识别到的设备类型。',
|
||||||
txt_item_history: '项目历史',
|
txt_item_history: '项目历史',
|
||||||
|
txt_password_history: '密码历史记录',
|
||||||
|
txt_password_updated_value: '密码新于: {value}',
|
||||||
txt_last_edited_value: '最后编辑:{value}',
|
txt_last_edited_value: '最后编辑:{value}',
|
||||||
txt_created_value: '创建于:{value}',
|
txt_created_value: '创建于:{value}',
|
||||||
txt_username: '用户名',
|
txt_username: '用户名',
|
||||||
@@ -1110,12 +1124,17 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_view_recovery_code: '查看恢复代码',
|
txt_view_recovery_code: '查看恢复代码',
|
||||||
txt_copy_code: '复制代码',
|
txt_copy_code: '复制代码',
|
||||||
txt_device_management: '设备管理',
|
txt_device_management: '设备管理',
|
||||||
|
txt_device_note: '备注',
|
||||||
|
txt_device_note_required: '设备名称不能为空',
|
||||||
|
txt_device_note_updated: '设备名称已更新',
|
||||||
txt_authorized_devices: '已授权设备',
|
txt_authorized_devices: '已授权设备',
|
||||||
txt_device: '设备',
|
txt_device: '设备',
|
||||||
txt_last_seen: '最后在线',
|
txt_last_seen: '最后在线',
|
||||||
txt_trusted_until: '信任至',
|
txt_trusted_until: '信任至',
|
||||||
txt_revoke_trust: '撤销信任',
|
txt_revoke_trust: '撤销信任',
|
||||||
|
txt_untrust: '不信任',
|
||||||
txt_remove_device_2: '移除设备',
|
txt_remove_device_2: '移除设备',
|
||||||
|
txt_update_device_note_failed: '更新设备备注失败',
|
||||||
txt_not_trusted: '未信任',
|
txt_not_trusted: '未信任',
|
||||||
txt_unknown_device: '未知设备',
|
txt_unknown_device: '未知设备',
|
||||||
txt_users: '用户',
|
txt_users: '用户',
|
||||||
@@ -1163,6 +1182,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_no_name: '(无名称)',
|
txt_no_name: '(无名称)',
|
||||||
txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?',
|
txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?',
|
||||||
txt_delete_item: '删除项目',
|
txt_delete_item: '删除项目',
|
||||||
|
txt_delete_passkey: '删除通行密钥',
|
||||||
txt_delete_selected_items: '删除所选项目',
|
txt_delete_selected_items: '删除所选项目',
|
||||||
txt_move_selected_items: '移动所选项目',
|
txt_move_selected_items: '移动所选项目',
|
||||||
txt_create_folder: '创建文件夹',
|
txt_create_folder: '创建文件夹',
|
||||||
@@ -1226,6 +1246,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?',
|
txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?',
|
||||||
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: '确认永久删除所选的 {count} 个项目?',
|
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: '确认永久删除所选的 {count} 个项目?',
|
||||||
txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?',
|
txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?',
|
||||||
|
txt_are_you_sure_you_want_to_delete_this_passkey: '确认删除这个通行密钥?',
|
||||||
txt_authenticator_key: '验证器密钥',
|
txt_authenticator_key: '验证器密钥',
|
||||||
txt_brand: '品牌',
|
txt_brand: '品牌',
|
||||||
txt_bulk_delete_failed: '批量删除失败',
|
txt_bulk_delete_failed: '批量删除失败',
|
||||||
@@ -1326,6 +1347,9 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。',
|
txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。',
|
||||||
txt_password_hint_load_failed: '加载密码提示失败',
|
txt_password_hint_load_failed: '加载密码提示失败',
|
||||||
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
|
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
|
||||||
|
txt_passkey: '通行密钥',
|
||||||
|
txt_passkeys: '通行密钥',
|
||||||
|
txt_passkey_created_at_value: '创建于 {value}',
|
||||||
txt_phone: '电话',
|
txt_phone: '电话',
|
||||||
txt_please_input_email_and_password: '请输入邮箱和密码',
|
txt_please_input_email_and_password: '请输入邮箱和密码',
|
||||||
txt_please_input_master_password: '请输入主密码',
|
txt_please_input_master_password: '请输入主密码',
|
||||||
@@ -1431,8 +1455,6 @@ zhCNOverrides.txt_lock = '锁定';
|
|||||||
zhCNOverrides.txt_menu = '菜单';
|
zhCNOverrides.txt_menu = '菜单';
|
||||||
zhCNOverrides.txt_settings = '设置';
|
zhCNOverrides.txt_settings = '设置';
|
||||||
zhCNOverrides.txt_back = '返回';
|
zhCNOverrides.txt_back = '返回';
|
||||||
zhCNOverrides.txt_passkey = 'Passkey';
|
|
||||||
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
|
|
||||||
zhCNOverrides.txt_attachments = '附件';
|
zhCNOverrides.txt_attachments = '附件';
|
||||||
zhCNOverrides.txt_upload_attachments = '上传附件';
|
zhCNOverrides.txt_upload_attachments = '上传附件';
|
||||||
zhCNOverrides.txt_new_attachments = '待上传附件';
|
zhCNOverrides.txt_new_attachments = '待上传附件';
|
||||||
@@ -1493,7 +1515,9 @@ messages.en.txt_delete_all_folders = 'Delete All Folders';
|
|||||||
messages.en.txt_delete_all_folders_message = 'Delete all folders? Items inside will move to No Folder.';
|
messages.en.txt_delete_all_folders_message = 'Delete all folders? Items inside will move to No Folder.';
|
||||||
messages.en.txt_folder_not_found = 'Folder not found';
|
messages.en.txt_folder_not_found = 'Folder not found';
|
||||||
messages.en.txt_folder_deleted = 'Folder deleted';
|
messages.en.txt_folder_deleted = 'Folder deleted';
|
||||||
|
messages.en.txt_folder_updated = 'Folder updated';
|
||||||
messages.en.txt_folders_deleted = 'Folders deleted';
|
messages.en.txt_folders_deleted = 'Folders deleted';
|
||||||
|
messages.en.txt_update_folder_failed = 'Update folder failed';
|
||||||
messages.en.txt_delete_folder_failed = 'Delete folder failed';
|
messages.en.txt_delete_folder_failed = 'Delete folder failed';
|
||||||
messages.en.txt_delete_all_folders_failed = 'Delete all folders failed';
|
messages.en.txt_delete_all_folders_failed = 'Delete all folders failed';
|
||||||
messages.en.txt_other = 'Other';
|
messages.en.txt_other = 'Other';
|
||||||
@@ -1575,7 +1599,9 @@ zhCNOverrides.txt_delete_all_folders = '删除全部文件夹';
|
|||||||
zhCNOverrides.txt_delete_all_folders_message = '确认删除全部文件夹吗?其中的项目将移至无文件夹。';
|
zhCNOverrides.txt_delete_all_folders_message = '确认删除全部文件夹吗?其中的项目将移至无文件夹。';
|
||||||
zhCNOverrides.txt_folder_not_found = '文件夹不存在';
|
zhCNOverrides.txt_folder_not_found = '文件夹不存在';
|
||||||
zhCNOverrides.txt_folder_deleted = '文件夹已删除';
|
zhCNOverrides.txt_folder_deleted = '文件夹已删除';
|
||||||
|
zhCNOverrides.txt_folder_updated = '文件夹已重命名';
|
||||||
zhCNOverrides.txt_folders_deleted = '文件夹已删除';
|
zhCNOverrides.txt_folders_deleted = '文件夹已删除';
|
||||||
|
zhCNOverrides.txt_update_folder_failed = '重命名文件夹失败';
|
||||||
zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败';
|
zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败';
|
||||||
zhCNOverrides.txt_delete_all_folders_failed = '删除全部文件夹失败';
|
zhCNOverrides.txt_delete_all_folders_failed = '删除全部文件夹失败';
|
||||||
zhCNOverrides.txt_other = '其他';
|
zhCNOverrides.txt_other = '其他';
|
||||||
@@ -1629,4 +1655,3 @@ export function setLocale(next: Locale): void {
|
|||||||
// ignore storage errors
|
// ignore storage errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export function makeLoginCipher(): Record<string, unknown> {
|
|||||||
favorite: false,
|
favorite: false,
|
||||||
reprompt: 0,
|
reprompt: 0,
|
||||||
key: null,
|
key: null,
|
||||||
login: { username: null, password: null, totp: null, fido2Credentials: null, uris: null },
|
login: { username: null, password: null, totp: null, uris: null },
|
||||||
card: null,
|
card: null,
|
||||||
identity: null,
|
identity: null,
|
||||||
secureNote: null,
|
secureNote: null,
|
||||||
|
|||||||
+30
-4
@@ -1,9 +1,10 @@
|
|||||||
export type AppPhase = 'register' | 'login' | 'locked' | 'app';
|
export type AppPhase = 'register' | 'login' | 'locked' | 'app';
|
||||||
|
|
||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
accessToken: string;
|
accessToken?: string;
|
||||||
refreshToken: string;
|
refreshToken?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
authMode?: 'token' | 'web-cookie';
|
||||||
symEncKey?: string;
|
symEncKey?: string;
|
||||||
symMacKey?: string;
|
symMacKey?: string;
|
||||||
}
|
}
|
||||||
@@ -28,13 +29,18 @@ export interface Folder {
|
|||||||
|
|
||||||
export interface CipherLoginUri {
|
export interface CipherLoginUri {
|
||||||
uri?: string | null;
|
uri?: string | null;
|
||||||
|
uriChecksum?: string | null;
|
||||||
match?: number | null;
|
match?: number | null;
|
||||||
|
response?: unknown | null;
|
||||||
decUri?: string;
|
decUri?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VaultDraftLoginUri {
|
export interface VaultDraftLoginUri {
|
||||||
uri: string;
|
uri: string;
|
||||||
match: number | null;
|
match: number | null;
|
||||||
|
originalUri?: string;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CipherAttachment {
|
export interface CipherAttachment {
|
||||||
@@ -59,9 +65,14 @@ export interface CipherLogin {
|
|||||||
totp?: string | null;
|
totp?: string | null;
|
||||||
uris?: CipherLoginUri[] | null;
|
uris?: CipherLoginUri[] | null;
|
||||||
fido2Credentials?: CipherLoginPasskey[] | null;
|
fido2Credentials?: CipherLoginPasskey[] | null;
|
||||||
|
autofillOnPageLoad?: boolean | null;
|
||||||
|
uri?: string | null;
|
||||||
|
passwordRevisionDate?: string | null;
|
||||||
|
response?: unknown | null;
|
||||||
decUsername?: string;
|
decUsername?: string;
|
||||||
decPassword?: string;
|
decPassword?: string;
|
||||||
decTotp?: string;
|
decTotp?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CipherCard {
|
export interface CipherCard {
|
||||||
@@ -137,6 +148,12 @@ export interface CipherField {
|
|||||||
decValue?: string;
|
decValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CipherPasswordHistoryEntry {
|
||||||
|
password?: string | null;
|
||||||
|
lastUsedDate?: string | null;
|
||||||
|
decPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Cipher {
|
export interface Cipher {
|
||||||
id: string;
|
id: string;
|
||||||
type: number;
|
type: number;
|
||||||
@@ -156,7 +173,7 @@ export interface Cipher {
|
|||||||
identity?: CipherIdentity | null;
|
identity?: CipherIdentity | null;
|
||||||
sshKey?: CipherSshKey | null;
|
sshKey?: CipherSshKey | null;
|
||||||
secureNote?: { type?: number | null } | null;
|
secureNote?: { type?: number | null } | null;
|
||||||
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null;
|
passwordHistory?: CipherPasswordHistoryEntry[] | null;
|
||||||
fields?: CipherField[] | null;
|
fields?: CipherField[] | null;
|
||||||
decName?: string;
|
decName?: string;
|
||||||
decNotes?: string;
|
decNotes?: string;
|
||||||
@@ -272,7 +289,8 @@ export interface WebBootstrapResponse {
|
|||||||
|
|
||||||
export interface TokenSuccess {
|
export interface TokenSuccess {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token?: string;
|
||||||
|
web_session?: boolean;
|
||||||
expires_in?: number;
|
expires_in?: number;
|
||||||
token_type?: string;
|
token_type?: string;
|
||||||
TwoFactorToken?: string;
|
TwoFactorToken?: string;
|
||||||
@@ -290,6 +308,10 @@ export interface TokenSuccess {
|
|||||||
unofficialServer?: boolean;
|
unofficialServer?: boolean;
|
||||||
UserDecryptionOptions?: unknown;
|
UserDecryptionOptions?: unknown;
|
||||||
userDecryptionOptions?: unknown;
|
userDecryptionOptions?: unknown;
|
||||||
|
VaultKeys?: {
|
||||||
|
symEncKey?: string;
|
||||||
|
symMacKey?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenError {
|
export interface TokenError {
|
||||||
@@ -322,10 +344,14 @@ export interface AdminInvite {
|
|||||||
export interface AuthorizedDevice {
|
export interface AuthorizedDevice {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
systemName?: string | null;
|
||||||
|
deviceNote?: string | null;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
type: number;
|
type: number;
|
||||||
creationDate: string | null;
|
creationDate: string | null;
|
||||||
revisionDate: string | null;
|
revisionDate: string | null;
|
||||||
|
lastSeenAt?: string | null;
|
||||||
|
hasStoredDevice?: boolean;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
trusted: boolean;
|
trusted: boolean;
|
||||||
trustedTokenCount: number;
|
trustedTokenCount: number;
|
||||||
|
|||||||
+103
-1
@@ -1164,6 +1164,11 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
transform: scale(1.06);
|
transform: scale(1.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.folder-edit-btn:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
.list-col {
|
.list-col {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1556,6 +1561,22 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.password-history-link {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-history-link:hover {
|
||||||
|
color: var(--primary-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.kv-line {
|
.kv-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1586,6 +1607,81 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.password-history-dialog {
|
||||||
|
width: min(560px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-history-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-history-head .dialog-title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-history-close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted-strong);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-history-close:hover {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-history-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 10px 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-history-item {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
padding: 16px 54px 14px 16px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-history-value {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-history-time {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-history-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-history-copy-btn {
|
||||||
|
min-width: 36px;
|
||||||
|
padding: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
.kv-label {
|
.kv-label {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -2669,6 +2765,8 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
.totp-qr img {
|
.totp-qr img {
|
||||||
width: 180px;
|
width: 180px;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-head {
|
.section-head {
|
||||||
@@ -4768,7 +4866,6 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
:root[data-theme='dark'] .backup-browser-list,
|
:root[data-theme='dark'] .backup-browser-list,
|
||||||
:root[data-theme='dark'] .backup-schedule-current,
|
:root[data-theme='dark'] .backup-schedule-current,
|
||||||
:root[data-theme='dark'] .backup-status-card,
|
:root[data-theme='dark'] .backup-status-card,
|
||||||
:root[data-theme='dark'] .totp-qr,
|
|
||||||
:root[data-theme='dark'] .create-menu,
|
:root[data-theme='dark'] .create-menu,
|
||||||
:root[data-theme='dark'] .create-menu-item,
|
:root[data-theme='dark'] .create-menu-item,
|
||||||
:root[data-theme='dark'] .sort-menu,
|
:root[data-theme='dark'] .sort-menu,
|
||||||
@@ -4926,6 +5023,11 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
color: #9be2bd;
|
color: #9be2bd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .totp-qr {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .totp-qr svg,
|
:root[data-theme='dark'] .totp-qr svg,
|
||||||
:root[data-theme='dark'] .totp-qr img {
|
:root[data-theme='dark'] .totp-qr img {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
|||||||
@@ -6,9 +6,8 @@
|
|||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "preact",
|
"jsxImportSource": "preact",
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"],
|
"@/*": ["./src/*"],
|
||||||
"@shared/*": ["../shared/*"]
|
"@shared/*": ["../shared/*"]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user