Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4246e179f1 | |||
| fe8d9e0b7d | |||
| 1147c1e013 | |||
| 31ffd98166 | |||
| 7d7562d191 | |||
| d6e5a1c40b | |||
| 77794e43ce | |||
| b990f17a3e | |||
| 31b8ec6f7d | |||
| ef47597be5 | |||
| 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:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_commit:
|
||||
description: 'Commit hash (leave blank to use latest commit)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -11,9 +16,8 @@ permissions:
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -22,13 +26,118 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Sync main from upstream
|
||||
- name: Add upstream
|
||||
run: |
|
||||
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
||||
git fetch upstream
|
||||
git checkout main
|
||||
git merge upstream/main
|
||||
git fetch upstream --tags
|
||||
|
||||
- name: Push synced main
|
||||
- name: Resolve target commit
|
||||
id: resolve
|
||||
run: |
|
||||
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/
|
||||
|
||||
nodewarden.wiki/
|
||||
|
||||
|
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)
|
||||
|
||||
[文档首页](./nodewarden.wiki/Home.md) | [快速开始](./nodewarden.wiki/快速开始.md)
|
||||
|
||||
[Telegram 频道](https://t.me/NodeWarden_News) | [Telegram 群组](https://t.me/NodeWarden_Official)
|
||||
|
||||
English: [`README_EN.md`](./README_EN.md)
|
||||
|
||||
> **免责声明**
|
||||
@@ -52,19 +56,26 @@ English: [`README_EN.md`](./README_EN.md)
|
||||
|
||||
## 网页部署
|
||||
|
||||
|
||||
1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。
|
||||
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可用 KV 来代替,将**部署命令**改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接
|
||||
|
||||
| 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||
|---|---|---|---|
|
||||
| R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||
| KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
1. Fork `NodeWarden` 仓库到自己的 GitHub 账号
|
||||
2. 进入 [Cloudflare Workers 创建页面](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||
3. 选择 `Continue with GitHub`
|
||||
4. 选择你刚刚 Fork 的仓库
|
||||
5. 保持默认配置继续部署
|
||||
6. 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
||||
7. 等部署完成后,打开生成的 Workers 域名
|
||||
8. 根据页面提示设置`JWT_SECRET` ,不建议临时乱填。这个值直接关系到令牌签发安全,正式环境至少使用 32 个字符以上的随机字符串。
|
||||
|
||||
> [!TIP]
|
||||
> 同步方法(更新仓库):
|
||||
>- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
>- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
||||
> 默认R2与可选KV的区别:
|
||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||
> |---|---|---|---|
|
||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||
|
||||
|
||||
## 更新方法:
|
||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||
- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
<p align="center">
|
||||
A third-party Bitwarden-compatible server running on Cloudflare Workers.
|
||||
</p>
|
||||
|
||||
[](https://workers.cloudflare.com/)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
||||
|
||||
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||
|
||||
English: [`README.md`](./README.md)
|
||||
[Telegram Channel](https://t.me/NodeWarden_News) | [Telegram Group](https://t.me/NodeWarden_Official)
|
||||
中文说明:[`README.md`](./README.md)
|
||||
|
||||
> **Disclaimer**
|
||||
> This project is for learning and communication purposes only. Please back up your vault regularly.
|
||||
>
|
||||
> This project is for learning and discussion purposes only. Please back up your vault regularly.
|
||||
>
|
||||
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
||||
|
||||
---
|
||||
|
||||
## Feature Comparison with Official Bitwarden Server
|
||||
## Feature Comparison with the Official Bitwarden Server
|
||||
|
||||
| Capability | Bitwarden | NodeWarden | Notes |
|
||||
|---|---|---|---|
|
||||
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
||||
| Full sync `/api/sync` | ✅ | ✅ | Optimized for official clients |
|
||||
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
|
||||
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
||||
| Send | ✅ | ✅ | Supports both text and file Sends |
|
||||
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
||||
| **Cloud Backup Center** | ❌ | ✅ | **Supports scheduled backups with WebDAV / E3** |
|
||||
| **Cloud Backup Center** | ❌ | ✅ | **Scheduled backup to WebDAV / E3** |
|
||||
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
|
||||
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
||||
| Multi-user | ✅ | ✅ | Invite-based registration |
|
||||
@@ -46,19 +46,20 @@ English: [`README.md`](./README.md)
|
||||
- ✅ Mobile app
|
||||
- ✅ Browser extension
|
||||
- ✅ Linux desktop client
|
||||
- ⚠️ macOS desktop client not fully verified
|
||||
- ⚠️ macOS desktop client has not been fully verified yet
|
||||
|
||||
---
|
||||
|
||||
## Web Deploy
|
||||
|
||||
1. Fork this repository. If this project helps you, please consider giving it a Star.
|
||||
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> `Next` -> deploy.
|
||||
R2 is used by default. If R2 is unavailable for your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
|
||||
1. Fork this repository. If this project helps you, consider giving it a Star.
|
||||
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
|
||||
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
|
||||
4. Deploy and open the generated URL.
|
||||
|
||||
| Storage | Card required | Single attachment / Send file limit | Free tier |
|
||||
|---|---|---|---|
|
||||
| R2 | Yes | 100 MB (soft limit, can be adjusted) | 10 GB |
|
||||
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||
|
||||
> [!TIP]
|
||||
@@ -71,7 +72,6 @@ English: [`README.md`](./README.md)
|
||||
```powershell
|
||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||
cd NodeWarden
|
||||
|
||||
npm install
|
||||
npx wrangler login
|
||||
|
||||
@@ -93,10 +93,10 @@ npm run dev:kv
|
||||
- Remote backup supports **WebDAV** and **E3**
|
||||
- When `Include attachments` is enabled:
|
||||
- the ZIP still contains only `db.json` and `manifest.json`
|
||||
- real attachment files are stored separately under `attachments/`
|
||||
- later backups reuse existing attachments by stable blob name instead of uploading everything again
|
||||
- actual attachment files are stored separately under `attachments/`
|
||||
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
|
||||
- During remote restore:
|
||||
- required attachment files are loaded from `attachments/`
|
||||
- required attachment files are loaded from `attachments/` on demand
|
||||
- missing attachments are skipped safely
|
||||
- skipped attachments do not leave broken rows in the restored database
|
||||
|
||||
@@ -110,7 +110,7 @@ Current supported import sources include:
|
||||
- Bitwarden CSV
|
||||
- Bitwarden vault + attachments ZIP
|
||||
- NodeWarden JSON
|
||||
- Multiple browser / password-manager formats visible in the web import selector
|
||||
- Multiple browser / password-manager formats available in the web import selector
|
||||
|
||||
Current supported export formats include:
|
||||
|
||||
@@ -130,9 +130,9 @@ LGPL-3.0 License
|
||||
|
||||
## Credits
|
||||
|
||||
- [Bitwarden](https://bitwarden.com/) - original design and clients
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
|
||||
- [Bitwarden](https://bitwarden.com/) - Original design and clients
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
verify_devices INTEGER NOT NULL DEFAULT 1,
|
||||
totp_secret TEXT,
|
||||
totp_recovery_code TEXT,
|
||||
api_key TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -59,6 +60,7 @@ CREATE TABLE IF NOT EXISTS ciphers (
|
||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -106,6 +108,7 @@ CREATE TABLE IF NOT EXISTS sends (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
@@ -151,12 +154,15 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
encrypted_user_key TEXT,
|
||||
encrypted_public_key TEXT,
|
||||
encrypted_private_key TEXT,
|
||||
device_note TEXT,
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, device_identifier),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nodewarden",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.4",
|
||||
"license": "LGPL-3.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.4",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.4.2';
|
||||
export const APP_VERSION = '1.4.4';
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
||||
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
||||
defaultKdfIterations: 600000,
|
||||
// clientSecret length
|
||||
// clientSecret 长度
|
||||
clientSecretLength: 30,
|
||||
},
|
||||
rateLimit: {
|
||||
// Max failed login attempts before temporary lock.
|
||||
@@ -130,6 +133,9 @@
|
||||
// Max total items (folders + ciphers) allowed in a single import.
|
||||
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
||||
importItemLimit: 5000,
|
||||
// Small fixed concurrency for blob/attachment batch cleanup work.
|
||||
// 附件 / blob 批量清理时的保守并发数。
|
||||
attachmentDeleteConcurrency: 4,
|
||||
},
|
||||
request: {
|
||||
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
import type { Env } from '../types';
|
||||
|
||||
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_DEVICE_STATUS = 12;
|
||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||
const SIGNALR_PING_INTERVAL_MS = 15_000;
|
||||
|
||||
type HubProtocol = 'json' | 'messagepack';
|
||||
|
||||
interface ConnectionState {
|
||||
interface WsAttachment {
|
||||
userId: string;
|
||||
handshakeComplete: boolean;
|
||||
protocol: HubProtocol;
|
||||
deviceIdentifier: string | null;
|
||||
@@ -31,6 +32,12 @@ function encodeUtf8(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
|
||||
if (typeof data === 'string') return data;
|
||||
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
||||
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||
}
|
||||
|
||||
function encodeMsgPackInteger(value: number): Uint8Array {
|
||||
const normalized = Math.trunc(value);
|
||||
if (normalized >= 0 && normalized <= 0x7f) {
|
||||
@@ -145,10 +152,6 @@ function buildSignalRJsonInvocation(
|
||||
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||
}
|
||||
|
||||
function buildSignalRJsonPing(): string {
|
||||
return JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||
}
|
||||
|
||||
function buildSignalRMessagePackInvocation(
|
||||
updateType: number,
|
||||
messagePayload: Record<string, unknown>,
|
||||
@@ -172,24 +175,15 @@ function buildSignalRMessagePackInvocation(
|
||||
return frameSignalRBinary(encodedPayload);
|
||||
}
|
||||
|
||||
function buildSignalRMessagePackPing(): Uint8Array {
|
||||
return frameSignalRBinary(encodeMsgPack([6]));
|
||||
}
|
||||
|
||||
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
|
||||
if (typeof data === 'string') return data;
|
||||
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
||||
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||
}
|
||||
|
||||
export class NotificationsHub {
|
||||
private readonly connections = new Map<WebSocket, ConnectionState>();
|
||||
private userId = '';
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(private readonly state: DurableObjectState, private readonly env: Env) {
|
||||
void this.state;
|
||||
void this.env;
|
||||
export class NotificationsHub extends DurableObject<Env> {
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env);
|
||||
this.ctx.setWebSocketAutoResponse(
|
||||
new WebSocketRequestResponsePair(
|
||||
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR),
|
||||
JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
@@ -205,14 +199,14 @@ export class NotificationsHub {
|
||||
payload?: Record<string, unknown> | null;
|
||||
} | null;
|
||||
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
||||
this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim();
|
||||
const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim();
|
||||
const contextId = String(body?.contextId || '').trim() || null;
|
||||
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
||||
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
||||
const payload = body?.payload && typeof body.payload === 'object'
|
||||
? body.payload
|
||||
: {
|
||||
UserId: this.userId,
|
||||
UserId: userId,
|
||||
Date: revisionDate,
|
||||
};
|
||||
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
|
||||
@@ -238,46 +232,27 @@ export class NotificationsHub {
|
||||
|
||||
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
||||
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
||||
if (requestUserId) {
|
||||
this.userId = requestUserId;
|
||||
}
|
||||
|
||||
if (!this.userId) {
|
||||
if (!requestUserId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const pair = new WebSocketPair();
|
||||
const client = pair[0];
|
||||
const server = pair[1];
|
||||
server.accept();
|
||||
|
||||
this.connections.set(server, {
|
||||
const tags: string[] = [];
|
||||
if (requestDeviceIdentifier) {
|
||||
tags.push(`device:${requestDeviceIdentifier}`);
|
||||
}
|
||||
this.ctx.acceptWebSocket(server, tags);
|
||||
|
||||
server.serializeAttachment({
|
||||
userId: requestUserId,
|
||||
handshakeComplete: false,
|
||||
protocol: 'messagepack',
|
||||
deviceIdentifier: requestDeviceIdentifier,
|
||||
});
|
||||
this.ensurePingLoop();
|
||||
|
||||
server.addEventListener('message', (event) => {
|
||||
void this.handleSocketMessage(server, event.data);
|
||||
});
|
||||
server.addEventListener('close', () => {
|
||||
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
||||
this.connections.delete(server);
|
||||
this.stopPingLoopIfIdle();
|
||||
if (shouldBroadcast) this.broadcastDeviceStatus();
|
||||
});
|
||||
server.addEventListener('error', () => {
|
||||
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
||||
this.connections.delete(server);
|
||||
this.stopPingLoopIfIdle();
|
||||
if (shouldBroadcast) this.broadcastDeviceStatus();
|
||||
try {
|
||||
server.close(1011, 'Socket error');
|
||||
} catch {
|
||||
// ignore close races
|
||||
}
|
||||
});
|
||||
} satisfies WsAttachment);
|
||||
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
@@ -285,21 +260,21 @@ export class NotificationsHub {
|
||||
});
|
||||
}
|
||||
|
||||
private async handleSocketMessage(socket: WebSocket, rawData: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||
const connection = this.connections.get(socket);
|
||||
if (!connection) return;
|
||||
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
if (!attachment) return;
|
||||
|
||||
if (!connection.handshakeComplete) {
|
||||
const text = decodeIncomingMessage(rawData);
|
||||
if (!attachment.handshakeComplete) {
|
||||
const text = decodeIncomingMessage(message);
|
||||
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
|
||||
for (const frame of frames) {
|
||||
try {
|
||||
const handshake = JSON.parse(frame) as { protocol?: string };
|
||||
const protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
||||
connection.protocol = protocol;
|
||||
connection.handshakeComplete = true;
|
||||
socket.send(SIGNALR_HANDSHAKE_ACK);
|
||||
this.broadcastDeviceStatus();
|
||||
attachment.protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
||||
attachment.handshakeComplete = true;
|
||||
ws.serializeAttachment(attachment);
|
||||
ws.send(SIGNALR_HANDSHAKE_ACK);
|
||||
this.broadcastDeviceStatus(attachment.userId);
|
||||
return;
|
||||
} catch {
|
||||
// Ignore malformed pre-handshake payloads.
|
||||
@@ -307,53 +282,38 @@ export class NotificationsHub {
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private ensurePingLoop(): void {
|
||||
if (this.pingTimer !== null) return;
|
||||
this.pingTimer = setInterval(() => {
|
||||
this.broadcastPing();
|
||||
}, SIGNALR_PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopPingLoopIfIdle(): void {
|
||||
if (this.connections.size > 0 || this.pingTimer === null) return;
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
|
||||
private broadcastPing(): void {
|
||||
if (this.connections.size === 0) {
|
||||
this.stopPingLoopIfIdle();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [socket, connection] of this.connections) {
|
||||
if (!connection.handshakeComplete) continue;
|
||||
if (typeof message !== 'string') {
|
||||
try {
|
||||
if (connection.protocol === 'json') {
|
||||
socket.send(buildSignalRJsonPing());
|
||||
} else {
|
||||
socket.send(buildSignalRMessagePackPing());
|
||||
}
|
||||
ws.send(message);
|
||||
} catch {
|
||||
this.connections.delete(socket);
|
||||
try {
|
||||
socket.close(1011, 'Ping send failed');
|
||||
} catch {
|
||||
// ignore close races
|
||||
// ignore send errors on echo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.stopPingLoopIfIdle();
|
||||
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
const shouldBroadcast = !!attachment?.handshakeComplete;
|
||||
if (shouldBroadcast && attachment?.userId) {
|
||||
this.broadcastDeviceStatus(attachment.userId);
|
||||
}
|
||||
}
|
||||
|
||||
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
const shouldBroadcast = !!attachment?.handshakeComplete;
|
||||
if (shouldBroadcast && attachment?.userId) {
|
||||
this.broadcastDeviceStatus(attachment.userId);
|
||||
}
|
||||
}
|
||||
|
||||
private getOnlineDeviceIdentifiers(): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const connection of this.connections.values()) {
|
||||
if (!connection.handshakeComplete || !connection.deviceIdentifier) continue;
|
||||
out.add(connection.deviceIdentifier);
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
|
||||
out.add(attachment.deviceIdentifier);
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||
@@ -364,35 +324,36 @@ export class NotificationsHub {
|
||||
contextId: string | null,
|
||||
targetDeviceIdentifier: string | null
|
||||
): void {
|
||||
if (!this.userId || this.connections.size === 0) return;
|
||||
const sockets = targetDeviceIdentifier
|
||||
? this.ctx.getWebSockets(`device:${targetDeviceIdentifier}`)
|
||||
: this.ctx.getWebSockets();
|
||||
|
||||
for (const [socket, connection] of this.connections) {
|
||||
if (!connection.handshakeComplete) continue;
|
||||
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue;
|
||||
if (sockets.length === 0) return;
|
||||
|
||||
for (const ws of sockets) {
|
||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||
if (!attachment?.handshakeComplete) continue;
|
||||
try {
|
||||
if (connection.protocol === 'json') {
|
||||
socket.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
||||
if (attachment.protocol === 'json') {
|
||||
ws.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
||||
} else {
|
||||
socket.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
||||
ws.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
||||
}
|
||||
} catch {
|
||||
this.connections.delete(socket);
|
||||
try {
|
||||
socket.close(1011, 'Notification send failed');
|
||||
ws.close(1011, 'Notification send failed');
|
||||
} catch {
|
||||
// ignore close races
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.stopPingLoopIfIdle();
|
||||
}
|
||||
|
||||
private broadcastDeviceStatus(): void {
|
||||
private broadcastDeviceStatus(userId: string): void {
|
||||
this.broadcastMessage(
|
||||
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
||||
{
|
||||
UserId: this.userId,
|
||||
UserId: userId,
|
||||
Date: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -87,6 +87,7 @@ async function verifyUserSecret(
|
||||
|
||||
function toProfile(user: User, env: Env): ProfileResponse {
|
||||
void env;
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -100,7 +101,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: buildAccountKeys(user),
|
||||
accountKeys,
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations: [],
|
||||
providers: [],
|
||||
@@ -208,6 +209,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
verifyDevices: true,
|
||||
totpSecret: null,
|
||||
totpRecoveryCode: null,
|
||||
apiKey: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -750,3 +752,68 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
// POST /api/accounts/api-key
|
||||
export async function handleGetApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
return apiKey(request, env, userId, false);
|
||||
}
|
||||
|
||||
// POST /api/accounts/rotate-api-key
|
||||
export async function handleRotateApiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
return apiKey(request, env, userId, true);
|
||||
}
|
||||
|
||||
async function apiKey(request: Request, env: Env, userId: string, rotate: boolean): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, string | undefined>;
|
||||
try {
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const formData = await request.formData();
|
||||
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||
} else {
|
||||
body = await request.json();
|
||||
}
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
|
||||
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
|
||||
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
||||
if (!valid) return errorResponse('Invalid password', 400);
|
||||
|
||||
if (rotate || user.apiKey === null) {
|
||||
// Upstream apikeys are 30-character random alphanumeric strings
|
||||
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
||||
if (rotate) {
|
||||
user.securityStamp = generateUUID();
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
apiKey: user.apiKey,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'apiKey',
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a random alphanumeric string of the given length using crypto.getRandomValues.
|
||||
function randomStringAlphanum(length: number): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const array = new Uint8Array(length);
|
||||
crypto.getRandomValues(array);
|
||||
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[array[i] % chars.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
verifyAttachmentUploadToken,
|
||||
verifyFileDownloadToken,
|
||||
} from '../utils/jwt';
|
||||
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import {
|
||||
@@ -38,6 +38,18 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T) => Promise<void>
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
const limit = Math.max(1, concurrency);
|
||||
for (let index = 0; index < items.length; index += limit) {
|
||||
await Promise.all(items.slice(index, index + limit).map(worker));
|
||||
}
|
||||
}
|
||||
|
||||
async function processAttachmentUpload(
|
||||
request: Request,
|
||||
env: Env,
|
||||
@@ -158,9 +170,7 @@ export async function handleCreateAttachment(
|
||||
attachmentId: attachmentId,
|
||||
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
||||
fileUploadType: 1,
|
||||
cipherResponse: cipherToResponse(updatedCipher!, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
}),
|
||||
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -372,9 +382,7 @@ export async function handleDeleteAttachment(
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
return jsonResponse({
|
||||
cipher: cipherToResponse(updatedCipher!, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
}),
|
||||
cipher: cipherToResponse(updatedCipher!, attachments),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -385,10 +393,9 @@ export async function deleteAllAttachmentsForCipher(
|
||||
): Promise<void> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
for (const attachment of attachments) {
|
||||
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => {
|
||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||
await deleteBlobObject(env, path);
|
||||
await storage.deleteAttachment(attachment.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,7 +113,19 @@ async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession):
|
||||
);
|
||||
} catch (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>();
|
||||
}
|
||||
throw error;
|
||||
@@ -180,6 +192,7 @@ async function executeConfiguredBackup(
|
||||
});
|
||||
const archive = await buildBackupArchive(env, now, {
|
||||
includeAttachments: destination.includeAttachments,
|
||||
timeZone: destination.schedule.timezone,
|
||||
progress: progress
|
||||
? async (event) => {
|
||||
if (event.step === 'archive_ready') {
|
||||
@@ -205,6 +218,7 @@ async function executeConfiguredBackup(
|
||||
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
|
||||
});
|
||||
const remoteSession = createRemoteBackupTransferSession(destination);
|
||||
if (destination.includeAttachments) {
|
||||
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||
let attachmentIndexChanged = false;
|
||||
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
||||
@@ -226,6 +240,7 @@ async function executeConfiguredBackup(
|
||||
if (attachmentIndexChanged) {
|
||||
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
||||
}
|
||||
}
|
||||
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||
await progress?.({
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Env, Cipher, CipherResponse, Attachment } from '../types';
|
||||
import {
|
||||
Env,
|
||||
Cipher,
|
||||
CipherCard,
|
||||
CipherIdentity,
|
||||
CipherLogin,
|
||||
CipherResponse,
|
||||
CipherSecureNote,
|
||||
CipherSshKey,
|
||||
Attachment,
|
||||
PasswordHistory,
|
||||
} from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
@@ -32,6 +43,10 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
|
||||
return { present: false, value: undefined };
|
||||
}
|
||||
|
||||
function readCipherProp<T = unknown>(source: any, aliases: string[]): { present: boolean; value: T | undefined } {
|
||||
return getAliasedProp(source, aliases) as { present: boolean; value: T | undefined };
|
||||
}
|
||||
|
||||
function normalizeCipherTimestamp(value: unknown): string | null {
|
||||
if (value == null || value === '') return null;
|
||||
const parsed = new Date(String(value));
|
||||
@@ -44,6 +59,19 @@ function readCipherArchivedAt(source: any, fallback: string | null = null): stri
|
||||
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
|
||||
}
|
||||
|
||||
function readCipherRevisionDate(source: any): string | null {
|
||||
const revision = getAliasedProp(source, ['lastKnownRevisionDate', 'LastKnownRevisionDate']);
|
||||
return revision.present ? normalizeCipherTimestamp(revision.value) : null;
|
||||
}
|
||||
|
||||
function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: string | null): boolean {
|
||||
if (!clientRevisionDate) return false;
|
||||
const existingTs = Date.parse(existingUpdatedAt);
|
||||
const clientTs = Date.parse(clientRevisionDate);
|
||||
if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false;
|
||||
return existingTs - clientTs > 1000;
|
||||
}
|
||||
|
||||
function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
||||
cipher.archivedDate = cipher.archivedAt ?? null;
|
||||
cipher.deletedDate = cipher.deletedAt ?? null;
|
||||
@@ -61,80 +89,18 @@ function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||
return syncCipherComputedAliases(cipher);
|
||||
}
|
||||
|
||||
function looksLikeCipherString(value: unknown): boolean {
|
||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
export function shouldOmitPasskeysForResponse(request: Request | null | undefined): boolean {
|
||||
const userAgent = String(request?.headers.get('user-agent') || '').toLowerCase();
|
||||
if (!userAgent) return false;
|
||||
|
||||
// Temporary compatibility fallback:
|
||||
// mobile clients expect official EncString payloads for most FIDO2 fields.
|
||||
// Keep passkeys available everywhere, but suppress only legacy malformed data
|
||||
// for mobile clients so newly-saved credentials can flow through unchanged.
|
||||
return (
|
||||
userAgent.includes('android') ||
|
||||
userAgent.includes('iphone') ||
|
||||
userAgent.includes('ipad') ||
|
||||
userAgent.includes('ios')
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeCipherLoginForStorage(login: any): any {
|
||||
if (!login || typeof login !== 'object') return login ?? null;
|
||||
|
||||
return {
|
||||
...login,
|
||||
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCipherLoginForCompatibility(
|
||||
login: any,
|
||||
options?: { omitFido2Credentials?: boolean }
|
||||
): any {
|
||||
export function normalizeCipherLoginForCompatibility(login: any): any {
|
||||
const normalized = normalizeCipherLoginForStorage(login);
|
||||
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||
if (!options?.omitFido2Credentials) return normalized;
|
||||
|
||||
const credentials = Array.isArray(normalized.fido2Credentials) ? normalized.fido2Credentials : null;
|
||||
if (!credentials?.length) return normalized;
|
||||
|
||||
const hasMalformedCredential = credentials.some((credential: any) => {
|
||||
if (!credential || typeof credential !== 'object') return true;
|
||||
const requiredEncryptedFields = [
|
||||
credential.credentialId,
|
||||
credential.keyType,
|
||||
credential.keyAlgorithm,
|
||||
credential.keyCurve,
|
||||
credential.keyValue,
|
||||
credential.rpId,
|
||||
credential.counter,
|
||||
credential.discoverable,
|
||||
];
|
||||
const optionalEncryptedFields = [
|
||||
credential.userHandle,
|
||||
credential.userName,
|
||||
credential.rpName,
|
||||
credential.userDisplayName,
|
||||
];
|
||||
|
||||
if (requiredEncryptedFields.some((value) => !looksLikeCipherString(value))) {
|
||||
return true;
|
||||
}
|
||||
if (optionalEncryptedFields.some((value) => value != null && !looksLikeCipherString(value))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return hasMalformedCredential
|
||||
? {
|
||||
...normalized,
|
||||
fido2Credentials: null,
|
||||
}
|
||||
: normalized;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
||||
@@ -180,12 +146,11 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||
// survive a round-trip without code changes.
|
||||
export function cipherToResponse(
|
||||
cipher: Cipher,
|
||||
attachments: Attachment[] = [],
|
||||
options?: { omitFido2Credentials?: boolean }
|
||||
attachments: Attachment[] = []
|
||||
): CipherResponse {
|
||||
// Strip internal-only fields that must not appear in the API response
|
||||
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options);
|
||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
|
||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||
|
||||
return {
|
||||
@@ -194,8 +159,8 @@ export function cipherToResponse(
|
||||
// Server-computed / enforced fields (always override)
|
||||
folderId: normalizeOptionalId(cipher.folderId),
|
||||
type: Number(cipher.type) || 1,
|
||||
organizationId: null,
|
||||
organizationUseTotp: false,
|
||||
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
||||
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
||||
creationDate: createdAt,
|
||||
revisionDate: updatedAt,
|
||||
deletedDate: deletedAt,
|
||||
@@ -206,12 +171,12 @@ export function cipherToResponse(
|
||||
delete: true,
|
||||
restore: true,
|
||||
},
|
||||
object: 'cipher',
|
||||
collectionIds: [],
|
||||
object: 'cipherDetails',
|
||||
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
|
||||
attachments: formatAttachments(attachments),
|
||||
login: normalizedLogin,
|
||||
sshKey: normalizedSshKey,
|
||||
encryptedFor: null,
|
||||
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,7 +186,6 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
const url = new URL(request.url);
|
||||
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||
const pagination = parsePagination(url);
|
||||
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
||||
|
||||
let filteredCiphers: Cipher[];
|
||||
let continuationToken: string | null = null;
|
||||
@@ -242,13 +206,15 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
: ciphers.filter(c => !c.deletedAt);
|
||||
}
|
||||
|
||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
|
||||
filteredCiphers.map((cipher) => cipher.id)
|
||||
);
|
||||
|
||||
// Get attachments for all ciphers
|
||||
const cipherResponses = [];
|
||||
// Build responses only for the current page to keep pagination cheap.
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of filteredCiphers) {
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
@@ -269,9 +235,7 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, attachments)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,6 +259,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
// Handle nested cipher object (from some clients)
|
||||
// Android client sends PascalCase "Cipher" for organization ciphers
|
||||
const cipherData = body.Cipher || body.cipher || body;
|
||||
const createFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
|
||||
const createKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
|
||||
const createLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
|
||||
const createCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
|
||||
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
|
||||
@@ -312,6 +284,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
archivedAt: readCipherArchivedAt(cipherData, null),
|
||||
deletedAt: null,
|
||||
};
|
||||
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
|
||||
cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null);
|
||||
cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
|
||||
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
|
||||
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
|
||||
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
|
||||
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
|
||||
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
|
||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||
normalizeCipherForStorage(cipher);
|
||||
@@ -327,9 +307,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
}),
|
||||
cipherToResponse(cipher, []),
|
||||
200
|
||||
);
|
||||
}
|
||||
@@ -353,6 +331,21 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
// Handle nested cipher object
|
||||
// Android client sends PascalCase "Cipher" for organization ciphers
|
||||
const cipherData = body.Cipher || body.cipher || body;
|
||||
const incomingFolderId = readCipherProp<string | null>(cipherData, ['folderId', 'FolderId']);
|
||||
const incomingKey = readCipherProp<string | null>(cipherData, ['key', 'Key']);
|
||||
const incomingLogin = readCipherProp<CipherLogin | null>(cipherData, ['login', 'Login']);
|
||||
const incomingCard = readCipherProp<CipherCard | null>(cipherData, ['card', 'Card']);
|
||||
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||
|
||||
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
|
||||
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
||||
}
|
||||
|
||||
const nextType = Number(cipherData.type) || existingCipher.type;
|
||||
|
||||
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
||||
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
||||
@@ -362,7 +355,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
// Server-controlled fields (never from client)
|
||||
id: existingCipher.id,
|
||||
userId: existingCipher.userId,
|
||||
type: Number(cipherData.type) || existingCipher.type,
|
||||
type: nextType,
|
||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||
createdAt: existingCipher.createdAt,
|
||||
@@ -370,6 +363,20 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||
deletedAt: existingCipher.deletedAt,
|
||||
};
|
||||
if (incomingFolderId.present) {
|
||||
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
|
||||
}
|
||||
if (incomingKey.present) {
|
||||
cipher.key = incomingKey.value ?? null;
|
||||
}
|
||||
cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null;
|
||||
cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
|
||||
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
|
||||
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
|
||||
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
|
||||
if (incomingPasswordHistory.present) {
|
||||
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
||||
}
|
||||
|
||||
// Custom fields deletion compatibility:
|
||||
// - Accept both camelCase "fields" and PascalCase "Fields".
|
||||
@@ -392,11 +399,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, attachments)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -418,9 +424,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -484,9 +488,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -525,9 +527,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, [])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -568,13 +568,10 @@ async function buildCipherListResponse(
|
||||
): Promise<Response> {
|
||||
const ciphers = await storage.getCiphersByIds(ids, userId);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
|
||||
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
||||
|
||||
return jsonResponse({
|
||||
data: ciphers.map((cipher) =>
|
||||
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], {
|
||||
omitFido2Credentials,
|
||||
})
|
||||
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])
|
||||
),
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
@@ -607,9 +604,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, attachments)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -631,9 +626,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
cipherToResponse(cipher, attachments)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,13 +23,18 @@ function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPub
|
||||
}
|
||||
|
||||
function buildDeviceResponse(device: Device): DeviceResponse {
|
||||
const displayName = String(device.deviceNote || '').trim() || device.name;
|
||||
const response = {
|
||||
Id: device.deviceIdentifier,
|
||||
id: device.deviceIdentifier,
|
||||
UserId: device.userId,
|
||||
userId: device.userId,
|
||||
Name: device.name,
|
||||
name: device.name,
|
||||
Name: displayName,
|
||||
name: displayName,
|
||||
SystemName: device.name,
|
||||
systemName: device.name,
|
||||
DeviceNote: device.deviceNote,
|
||||
deviceNote: device.deviceNote,
|
||||
Identifier: device.deviceIdentifier,
|
||||
identifier: device.deviceIdentifier,
|
||||
Type: device.type,
|
||||
@@ -38,6 +43,10 @@ function buildDeviceResponse(device: Device): DeviceResponse {
|
||||
creationDate: device.createdAt,
|
||||
RevisionDate: device.updatedAt,
|
||||
revisionDate: device.updatedAt,
|
||||
LastSeenAt: device.lastSeenAt,
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
HasStoredDevice: true,
|
||||
hasStoredDevice: true,
|
||||
IsTrusted: isTrustedDevice(device),
|
||||
isTrusted: isTrustedDevice(device),
|
||||
EncryptedUserKey: device.encryptedUserKey,
|
||||
@@ -55,8 +64,12 @@ function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireRespon
|
||||
const response = {
|
||||
Id: device.deviceIdentifier,
|
||||
id: device.deviceIdentifier,
|
||||
Name: device.name,
|
||||
name: device.name,
|
||||
Name: String(device.deviceNote || '').trim() || device.name,
|
||||
name: String(device.deviceNote || '').trim() || device.name,
|
||||
SystemName: device.name,
|
||||
systemName: device.name,
|
||||
DeviceNote: device.deviceNote,
|
||||
deviceNote: device.deviceNote,
|
||||
Identifier: device.deviceIdentifier,
|
||||
identifier: device.deviceIdentifier,
|
||||
Type: device.type,
|
||||
@@ -101,6 +114,10 @@ async function readJsonBody(request: Request): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
function parseDeviceName(value: unknown): string {
|
||||
return String(value || '').trim().slice(0, 128);
|
||||
}
|
||||
|
||||
// GET /api/devices/knowndevice
|
||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||
// - X-Request-Email: base64url(email) without padding
|
||||
@@ -203,12 +220,15 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
||||
encryptedPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
devicePendingAuthRequest: null,
|
||||
deviceNote: null,
|
||||
lastSeenAt: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
data.push({
|
||||
...buildDeviceResponse(placeholderDevice),
|
||||
isTrusted: true,
|
||||
hasStoredDevice: false,
|
||||
online: onlineSet.has(row.deviceIdentifier),
|
||||
trusted: true,
|
||||
trustedTokenCount: row.tokenCount,
|
||||
@@ -269,6 +289,29 @@ export async function handleDeleteDevice(
|
||||
return jsonResponse({ success: deleted });
|
||||
}
|
||||
|
||||
// PUT /api/devices/:deviceIdentifier/name
|
||||
export async function handleUpdateDeviceName(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<Response> {
|
||||
const normalized = String(deviceIdentifier || '').trim();
|
||||
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
const name = parseDeviceName(body?.name);
|
||||
if (!name) return errorResponse('Device name is required', 400);
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const updated = await storage.updateDeviceName(userId, normalized, name);
|
||||
if (!updated) return errorResponse('Device not found', 404);
|
||||
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
if (!device) return errorResponse('Device not found', 404);
|
||||
return jsonResponse(buildDeviceResponse(device));
|
||||
}
|
||||
|
||||
// DELETE /api/devices
|
||||
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
void request;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
||||
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
|
||||
// Keep request parsing backward-compatible with historical provider values (8 / 100).
|
||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
||||
@@ -31,6 +32,66 @@ function resolveTotpSecret(userSecret: string | null): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldUseWebSession(request: Request): boolean {
|
||||
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
|
||||
}
|
||||
|
||||
function parseCookieValue(request: Request, name: string): string | null {
|
||||
const rawCookie = String(request.headers.get('Cookie') || '').trim();
|
||||
if (!rawCookie) return null;
|
||||
for (const part of rawCookie.split(';')) {
|
||||
const [key, ...rest] = part.trim().split('=');
|
||||
if (key !== name) continue;
|
||||
const value = rest.join('=').trim();
|
||||
return value ? decodeURIComponent(value) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function constantTimeEquals(a: string, b: string): boolean {
|
||||
const encA = new TextEncoder().encode(a);
|
||||
const encB = new TextEncoder().encode(b);
|
||||
if (encA.length !== encB.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < encA.length; i++) {
|
||||
diff |= encA[i] ^ encB[i];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||
const isHttps = new URL(request.url).protocol === 'https:';
|
||||
const parts = [
|
||||
`${WEB_REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}`,
|
||||
'Path=/identity/connect',
|
||||
'HttpOnly',
|
||||
'SameSite=Strict',
|
||||
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
||||
];
|
||||
if (isHttps) parts.push('Secure');
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function buildClearedRefreshCookie(request: Request): string {
|
||||
return buildRefreshCookie(request, '', 0);
|
||||
}
|
||||
|
||||
function withWebRefreshCookie(request: Request, response: Response, refreshToken: string | null): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.append(
|
||||
'Set-Cookie',
|
||||
refreshToken
|
||||
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000))
|
||||
: buildClearedRefreshCookie(request)
|
||||
);
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function buildPreloginResponse(
|
||||
email: string,
|
||||
kdfType: number,
|
||||
@@ -278,17 +339,19 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: buildAccountKeys(user),
|
||||
accountKeys: buildAccountKeys(user),
|
||||
AccountKeys: accountKeys,
|
||||
accountKeys: accountKeys,
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
@@ -301,11 +364,106 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
return jsonResponse(response);
|
||||
const baseResponse = jsonResponse(response);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||
: baseResponse;
|
||||
|
||||
} else if (grantType === 'client_credentials') {
|
||||
// Login with client credentials
|
||||
const clientId = body.client_id;
|
||||
const clientSecret = body.client_secret;
|
||||
const scope = body.scope;
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||
|
||||
const loginIdentifier = `${clientIdentifier}:${clientId}`;
|
||||
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
||||
if (!parmValid) {
|
||||
return identityErrorResponse('Parameter error', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||
if (!loginCheck.allowed) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
const uid = clientId.slice(5);
|
||||
const user = await storage.getUserById(uid);
|
||||
if (!user) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
// Persist device only after successful client credential verification.
|
||||
const deviceSession =
|
||||
deviceInfo.deviceIdentifier
|
||||
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
|
||||
: null;
|
||||
if (deviceSession) {
|
||||
await storage.upsertDevice(
|
||||
user.id,
|
||||
deviceSession.identifier,
|
||||
deviceInfo.deviceName,
|
||||
deviceInfo.deviceType,
|
||||
deviceSession.sessionStamp
|
||||
);
|
||||
}
|
||||
|
||||
// Successful login - clear failed attempts
|
||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: accountKeys,
|
||||
accountKeys: accountKeys,
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
KdfParallelism: user.kdfParallelism,
|
||||
ForcePasswordReset: false,
|
||||
ResetMasterPassword: false,
|
||||
MasterPasswordPolicy: {
|
||||
Object: 'masterPasswordPolicy',
|
||||
},
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
const baseResponse = jsonResponse(response);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||
: baseResponse;
|
||||
|
||||
} else if (grantType === 'send_access') {
|
||||
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||
@@ -371,14 +529,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
|
||||
// Refresh token
|
||||
const refreshToken = body.refresh_token;
|
||||
const refreshToken = String(body.refresh_token || '').trim() || (
|
||||
shouldUseWebSession(request)
|
||||
? parseCookieValue(request, WEB_REFRESH_COOKIE)
|
||||
: null
|
||||
);
|
||||
if (!refreshToken) {
|
||||
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
const result = await auth.refreshAccessToken(refreshToken);
|
||||
if (!result) {
|
||||
return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
||||
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, invalidResponse, null)
|
||||
: invalidResponse;
|
||||
}
|
||||
|
||||
// Keep a short overlap window for old refresh token to absorb
|
||||
@@ -389,17 +554,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
);
|
||||
|
||||
const { accessToken, user, device } = result;
|
||||
if (device?.identifier) {
|
||||
await storage.touchDeviceLastSeen(user.id, device.identifier);
|
||||
}
|
||||
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: newRefreshToken,
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: buildAccountKeys(user),
|
||||
accountKeys: buildAccountKeys(user),
|
||||
AccountKeys: accountKeys,
|
||||
accountKeys: accountKeys,
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
@@ -412,11 +582,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
return jsonResponse(response);
|
||||
const baseResponse = jsonResponse(response);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, newRefreshToken)
|
||||
: baseResponse;
|
||||
}
|
||||
|
||||
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
|
||||
@@ -470,10 +643,30 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
const token = String(body.token || '').trim();
|
||||
const token = String(body.token || '').trim() || (
|
||||
shouldUseWebSession(request)
|
||||
? (parseCookieValue(request, WEB_REFRESH_COOKIE) || '')
|
||||
: ''
|
||||
);
|
||||
if (token) {
|
||||
await storage.deleteRefreshToken(token);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
const baseResponse = new Response(null, { status: 200 });
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, baseResponse, null)
|
||||
: baseResponse;
|
||||
}
|
||||
|
||||
export function checkClientCredentialsParam(clientId: string, clientSecret: string, scope: string): boolean {
|
||||
if (scope !== 'api') {
|
||||
return false;
|
||||
}
|
||||
if (!clientId.startsWith('user.')) {
|
||||
return false;
|
||||
}
|
||||
if (!clientSecret) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ interface CiphersImportRequest {
|
||||
password?: string | null;
|
||||
totp?: string | null;
|
||||
autofillOnPageLoad?: boolean | null;
|
||||
fido2Credentials?: any[] | null;
|
||||
uri?: string | null;
|
||||
passwordRevisionDate?: string | null;
|
||||
[key: string]: any;
|
||||
@@ -83,6 +82,16 @@ function bindNull(v: any): any {
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T | undefined {
|
||||
if (!source || typeof source !== 'object') return undefined;
|
||||
for (const key of aliases) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
return source[key] as T;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
||||
for (let i = 0; i < statements.length; i += chunkSize) {
|
||||
const chunk = statements.slice(i, i + chunkSize);
|
||||
@@ -159,9 +168,16 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
||||
for (let i = 0; i < ciphers.length; i++) {
|
||||
const c = ciphers[i];
|
||||
const folderId = cipherFolderMap.get(i) || null;
|
||||
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
|
||||
const sourceIdRaw = String(c?.id ?? '').trim();
|
||||
const sourceId = sourceIdRaw || null;
|
||||
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
|
||||
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
|
||||
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
|
||||
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
|
||||
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
|
||||
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
|
||||
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
|
||||
|
||||
const cipher: Cipher = {
|
||||
...c,
|
||||
@@ -172,64 +188,64 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
name: c.name ?? 'Untitled',
|
||||
notes: c.notes ?? null,
|
||||
favorite: c.favorite ?? false,
|
||||
login: c.login ? {
|
||||
...c.login,
|
||||
username: c.login.username ?? null,
|
||||
password: c.login.password ?? null,
|
||||
uris: c.login.uris?.map(u => ({
|
||||
login: login ? {
|
||||
...login,
|
||||
username: login.username ?? null,
|
||||
password: login.password ?? null,
|
||||
uris: login.uris?.map((u: any) => ({
|
||||
...u,
|
||||
uri: u.uri ?? null,
|
||||
uriChecksum: null,
|
||||
match: u.match ?? null,
|
||||
})) || null,
|
||||
totp: c.login.totp ?? null,
|
||||
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
||||
fido2Credentials: c.login.fido2Credentials ?? null,
|
||||
uri: c.login.uri ?? null,
|
||||
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
||||
totp: login.totp ?? null,
|
||||
autofillOnPageLoad: login.autofillOnPageLoad ?? null,
|
||||
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||
uri: login.uri ?? null,
|
||||
passwordRevisionDate: login.passwordRevisionDate ?? null,
|
||||
} : null,
|
||||
card: c.card ? {
|
||||
...c.card,
|
||||
cardholderName: c.card.cardholderName ?? null,
|
||||
brand: c.card.brand ?? null,
|
||||
number: c.card.number ?? null,
|
||||
expMonth: c.card.expMonth ?? null,
|
||||
expYear: c.card.expYear ?? null,
|
||||
code: c.card.code ?? null,
|
||||
card: card ? {
|
||||
...card,
|
||||
cardholderName: card.cardholderName ?? null,
|
||||
brand: card.brand ?? null,
|
||||
number: card.number ?? null,
|
||||
expMonth: card.expMonth ?? null,
|
||||
expYear: card.expYear ?? null,
|
||||
code: card.code ?? null,
|
||||
} : null,
|
||||
identity: c.identity ? {
|
||||
...c.identity,
|
||||
title: c.identity.title ?? null,
|
||||
firstName: c.identity.firstName ?? null,
|
||||
middleName: c.identity.middleName ?? null,
|
||||
lastName: c.identity.lastName ?? null,
|
||||
address1: c.identity.address1 ?? null,
|
||||
address2: c.identity.address2 ?? null,
|
||||
address3: c.identity.address3 ?? null,
|
||||
city: c.identity.city ?? null,
|
||||
state: c.identity.state ?? null,
|
||||
postalCode: c.identity.postalCode ?? null,
|
||||
country: c.identity.country ?? null,
|
||||
company: c.identity.company ?? null,
|
||||
email: c.identity.email ?? null,
|
||||
phone: c.identity.phone ?? null,
|
||||
ssn: c.identity.ssn ?? null,
|
||||
username: c.identity.username ?? null,
|
||||
passportNumber: c.identity.passportNumber ?? null,
|
||||
licenseNumber: c.identity.licenseNumber ?? null,
|
||||
identity: identity ? {
|
||||
...identity,
|
||||
title: identity.title ?? null,
|
||||
firstName: identity.firstName ?? null,
|
||||
middleName: identity.middleName ?? null,
|
||||
lastName: identity.lastName ?? null,
|
||||
address1: identity.address1 ?? null,
|
||||
address2: identity.address2 ?? null,
|
||||
address3: identity.address3 ?? null,
|
||||
city: identity.city ?? null,
|
||||
state: identity.state ?? null,
|
||||
postalCode: identity.postalCode ?? null,
|
||||
country: identity.country ?? null,
|
||||
company: identity.company ?? null,
|
||||
email: identity.email ?? null,
|
||||
phone: identity.phone ?? null,
|
||||
ssn: identity.ssn ?? null,
|
||||
username: identity.username ?? null,
|
||||
passportNumber: identity.passportNumber ?? null,
|
||||
licenseNumber: identity.licenseNumber ?? null,
|
||||
} : null,
|
||||
secureNote: c.secureNote ?? null,
|
||||
fields: c.fields?.map(f => ({
|
||||
secureNote: secureNote ?? null,
|
||||
fields: fields?.map((f: any) => ({
|
||||
...f,
|
||||
name: f.name ?? null,
|
||||
value: f.value ?? null,
|
||||
type: f.type,
|
||||
linkedId: f.linkedId ?? null,
|
||||
})) || null,
|
||||
passwordHistory: c.passwordHistory ?? null,
|
||||
passwordHistory: passwordHistory ?? null,
|
||||
reprompt: c.reprompt ?? 0,
|
||||
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
||||
key: (c as any).key ?? null,
|
||||
key: key ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
|
||||
@@ -97,8 +97,9 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
|
||||
sends = await storage.getAllSends(userId);
|
||||
}
|
||||
|
||||
const sendResponses = sends.map(sendToResponse);
|
||||
return jsonResponse({
|
||||
data: sends.map(sendToResponse),
|
||||
data: sendResponses,
|
||||
object: 'list',
|
||||
continuationToken,
|
||||
});
|
||||
|
||||
@@ -10,87 +10,23 @@ import {
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
|
||||
interface SyncCacheEntry {
|
||||
userId: string;
|
||||
revisionDate: string;
|
||||
body: string;
|
||||
expiresAt: number;
|
||||
bytes: number;
|
||||
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request {
|
||||
const url = new URL(request.url);
|
||||
const cacheUrl = new URL(
|
||||
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`,
|
||||
url.origin
|
||||
);
|
||||
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||
}
|
||||
|
||||
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
||||
let syncResponseCacheTotalBytes = 0;
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
|
||||
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
|
||||
}
|
||||
|
||||
function readSyncCache(key: string): string | null {
|
||||
const hit = syncResponseCache.get(key);
|
||||
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
|
||||
const hit = await caches.default.match(cacheRequest);
|
||||
if (!hit) return null;
|
||||
if (hit.expiresAt <= Date.now()) {
|
||||
deleteSyncCacheEntry(key, hit);
|
||||
return null;
|
||||
}
|
||||
return hit.body;
|
||||
return new Response(hit.body, hit);
|
||||
}
|
||||
|
||||
function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void {
|
||||
const existing = entry ?? syncResponseCache.get(key);
|
||||
if (!existing) return;
|
||||
syncResponseCache.delete(key);
|
||||
syncResponseCacheTotalBytes = Math.max(0, syncResponseCacheTotalBytes - existing.bytes);
|
||||
}
|
||||
|
||||
function pruneExpiredSyncCache(nowMs: number = Date.now()): void {
|
||||
for (const [key, entry] of syncResponseCache.entries()) {
|
||||
if (entry.expiresAt <= nowMs) {
|
||||
deleteSyncCacheEntry(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneStaleUserSyncCache(userId: string, revisionDate: string): void {
|
||||
for (const [key, entry] of syncResponseCache.entries()) {
|
||||
if (entry.userId === userId && entry.revisionDate !== revisionDate) {
|
||||
deleteSyncCacheEntry(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeSyncCache(userId: string, revisionDate: string, key: string, body: string): void {
|
||||
const nowMs = Date.now();
|
||||
pruneExpiredSyncCache(nowMs);
|
||||
pruneStaleUserSyncCache(userId, revisionDate);
|
||||
|
||||
const bodyBytes = textEncoder.encode(body).byteLength;
|
||||
if (bodyBytes > LIMITS.cache.syncResponseMaxBodyBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = syncResponseCache.get(key);
|
||||
if (existing) {
|
||||
deleteSyncCacheEntry(key, existing);
|
||||
}
|
||||
|
||||
while (
|
||||
syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries ||
|
||||
syncResponseCacheTotalBytes + bodyBytes > LIMITS.cache.syncResponseMaxTotalBytes
|
||||
) {
|
||||
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
deleteSyncCacheEntry(oldestKey);
|
||||
}
|
||||
|
||||
syncResponseCache.set(key, {
|
||||
userId,
|
||||
revisionDate,
|
||||
body,
|
||||
expiresAt: nowMs + LIMITS.cache.syncResponseTtlMs,
|
||||
bytes: bodyBytes,
|
||||
});
|
||||
syncResponseCacheTotalBytes += bodyBytes;
|
||||
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
|
||||
await caches.default.put(cacheRequest, response.clone());
|
||||
}
|
||||
|
||||
// GET /api/sync
|
||||
@@ -99,12 +35,6 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
const url = new URL(request.url);
|
||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||
const userAgent = String(request.headers.get('user-agent') || '').toLowerCase();
|
||||
const omitFido2Credentials =
|
||||
userAgent.includes('android') ||
|
||||
userAgent.includes('iphone') ||
|
||||
userAgent.includes('ipad') ||
|
||||
userAgent.includes('ios');
|
||||
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) {
|
||||
@@ -112,21 +42,21 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
}
|
||||
|
||||
const revisionDate = await storage.getRevisionDate(userId);
|
||||
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
|
||||
const cachedBody = readSyncCache(cacheKey);
|
||||
if (cachedBody) {
|
||||
return new Response(cachedBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains);
|
||||
const cachedResponse = await readSyncCache(cacheRequest);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const ciphers = await storage.getAllCiphers(userId);
|
||||
const folders = await storage.getAllFolders(userId);
|
||||
const sends = await storage.getAllSends(userId);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
||||
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
|
||||
storage.getAllCiphers(userId),
|
||||
storage.getAllFolders(userId),
|
||||
storage.getAllSends(userId),
|
||||
storage.getAttachmentsByUserId(userId),
|
||||
]);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
// Build profile response
|
||||
const profile: ProfileResponse = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -140,7 +70,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: buildAccountKeys(user),
|
||||
accountKeys,
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations: [],
|
||||
providers: [],
|
||||
@@ -152,23 +82,24 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
object: 'profile',
|
||||
};
|
||||
|
||||
// Build cipher responses with attachments
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
||||
cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []));
|
||||
}
|
||||
|
||||
// Build folder responses
|
||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
||||
const folderResponses: FolderResponse[] = [];
|
||||
for (const folder of folders) {
|
||||
folderResponses.push({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
object: 'folder',
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
const sendResponses = sends.map(sendToResponse);
|
||||
const syncResponse: SyncResponse = {
|
||||
profile: profile,
|
||||
profile,
|
||||
folders: folderResponses,
|
||||
collections: [],
|
||||
ciphers: cipherResponses,
|
||||
@@ -180,25 +111,25 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
object: 'domains',
|
||||
},
|
||||
policies: [],
|
||||
sends: sends.map(sendToResponse),
|
||||
sends: sendResponses,
|
||||
UserDecryption: {
|
||||
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
|
||||
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||
TrustedDeviceOption: null,
|
||||
KeyConnectorOption: null,
|
||||
Object: 'userDecryption',
|
||||
},
|
||||
// PascalCase for desktop/browser clients
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||
object: 'sync',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(syncResponse);
|
||||
writeSyncCache(userId, revisionDate, cacheKey, body);
|
||||
|
||||
return new Response(body, {
|
||||
const response = new Response(JSON.stringify(syncResponse), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': `private, max-age=${Math.max(1, Math.floor(LIMITS.cache.syncResponseTtlMs / 1000))}`,
|
||||
},
|
||||
});
|
||||
await writeSyncCache(cacheRequest, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,15 @@ let dbInitialized = false;
|
||||
let dbInitError: string | null = null;
|
||||
let dbInitPromise: Promise<void> | null = null;
|
||||
|
||||
function normalizeRequestUrl(request: Request): Request {
|
||||
const url = new URL(request.url);
|
||||
const normalizedPathname = url.pathname.length <= 1 ? url.pathname : url.pathname.replace(/\/+$/, '');
|
||||
if (normalizedPathname === url.pathname) return request;
|
||||
|
||||
url.pathname = normalizedPathname;
|
||||
return new Request(url.toString(), request);
|
||||
}
|
||||
|
||||
function isWorkerHandledPath(path: string): boolean {
|
||||
return (
|
||||
path.startsWith('/api/') ||
|
||||
@@ -56,9 +65,10 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
void ctx;
|
||||
const assetResponse = await maybeServeAsset(request, env);
|
||||
const normalizedRequest = normalizeRequestUrl(request);
|
||||
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||
if (assetResponse) {
|
||||
return applyCors(request, assetResponse);
|
||||
return applyCors(normalizedRequest, assetResponse);
|
||||
}
|
||||
|
||||
await ensureDatabaseInitialized(env);
|
||||
@@ -76,11 +86,11 @@ export default {
|
||||
},
|
||||
500
|
||||
);
|
||||
return applyCors(request, resp);
|
||||
return applyCors(normalizedRequest, resp);
|
||||
}
|
||||
|
||||
const resp = await handleRequest(request, env);
|
||||
return applyCors(request, resp);
|
||||
const resp = await handleRequest(normalizedRequest, env);
|
||||
return applyCors(normalizedRequest, resp);
|
||||
},
|
||||
|
||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
handleGetTotpStatus,
|
||||
handleSetTotpStatus,
|
||||
handleGetTotpRecoveryCode,
|
||||
handleGetApiKey,
|
||||
handleRotateApiKey,
|
||||
} from './handlers/accounts';
|
||||
import {
|
||||
handleGetCiphers,
|
||||
@@ -119,6 +121,14 @@ export async function handleAuthenticatedRoute(
|
||||
return handleSetVerifyDevices(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/api-key' || path === '/api/accounts/api_key') && method === 'POST') {
|
||||
return handleGetApiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/accounts/rotate-api-key' || path === '/api/accounts/rotate_api_key') && method === 'POST') {
|
||||
return handleRotateApiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/sync' && method === 'GET') {
|
||||
return handleSync(request, env, userId);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
handleRevokeTrustedDevice,
|
||||
handleDeleteAllDevices,
|
||||
handleDeleteDevice,
|
||||
handleUpdateDeviceName,
|
||||
handleUpdateDeviceToken,
|
||||
handleUpdateDeviceWebPushAuth,
|
||||
handleClearDeviceToken,
|
||||
@@ -53,6 +54,12 @@ export async function handleAuthenticatedDeviceRoute(
|
||||
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i);
|
||||
if (updateDeviceNameMatch && method === 'PUT') {
|
||||
const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]);
|
||||
return handleUpdateDeviceName(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
||||
if (identifierMatch && method === 'GET') {
|
||||
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
||||
|
||||
@@ -52,12 +52,12 @@ function isSameOriginWriteRequest(request: Request): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getNwIconSvg(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
||||
function getDefaultWebsiteIconSvg(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="Globe icon"><circle cx="48" cy="48" r="34" fill="none" stroke="#8ea9c7" stroke-width="6"/><path d="M14 48h68M48 14c10 10 16 21.5 16 34s-6 24-16 34c-10-10-16-21.5-16-34s6-24 16-34zm-24 10c8 5 17 8 24 8s16-3 24-8m-48 48c8-5 17-8 24-8s16 3 24 8" fill="none" stroke="#8ea9c7" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
||||
}
|
||||
|
||||
function handleNwFavicon(): Response {
|
||||
return new Response(getNwIconSvg(), {
|
||||
return new Response(getDefaultWebsiteIconSvg(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||
@@ -66,6 +66,15 @@ function handleNwFavicon(): Response {
|
||||
});
|
||||
}
|
||||
|
||||
function handleMissingWebsiteIcon(): Response {
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildIconServiceBase(origin: string): string {
|
||||
return `${origin}/icons`;
|
||||
}
|
||||
@@ -104,6 +113,7 @@ function buildConfigResponse(origin: string) {
|
||||
_icon_service_url: buildIconServiceTemplate(origin),
|
||||
_icon_service_csp: buildIconServiceCsp(origin),
|
||||
featureStates: {
|
||||
'cipher-key-encryption': true,
|
||||
'duo-redirect': true,
|
||||
'email-verification': true,
|
||||
'pm-19051-send-email-verification': false,
|
||||
@@ -126,9 +136,9 @@ function normalizeIconHost(rawHost: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebsiteIcon(host: string): Promise<Response> {
|
||||
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||
const normalizedHost = normalizeIconHost(host);
|
||||
if (!normalizedHost) return handleNwFavicon();
|
||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
|
||||
const encodedHost = encodeURIComponent(normalizedHost);
|
||||
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
||||
@@ -171,9 +181,9 @@ async function handleWebsiteIcon(host: string): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
return handleNwFavicon();
|
||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
} catch {
|
||||
return handleNwFavicon();
|
||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +230,8 @@ export async function handlePublicRoute(
|
||||
|
||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||
if (iconMatch && method === 'GET') {
|
||||
return handleWebsiteIcon(iconMatch[1]);
|
||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
||||
}
|
||||
|
||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface BackupFileIntegrityCheckResult {
|
||||
export interface BuildBackupArchiveOptions {
|
||||
includeAttachments?: boolean;
|
||||
progress?: BackupArchiveBuildProgressReporter;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
function buildBackupFileName(date: Date = new Date(), checksumPrefix: string | null = null): string {
|
||||
const parts = [
|
||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||
date.getUTCDate().toString().padStart(2, '0'),
|
||||
date.getUTCHours().toString().padStart(2, '0'),
|
||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
||||
];
|
||||
function getDateParts(date: Date, timeZone: string): string {
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
const parts = formatter.formatToParts(date);
|
||||
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
|
||||
return `${pick('year')}${pick('month')}${pick('day')}_${pick('hour')}${pick('minute')}${pick('second')}`;
|
||||
}
|
||||
|
||||
function buildBackupFileNameInTimeZone(
|
||||
date: Date = new Date(),
|
||||
checksumPrefix: string | null = null,
|
||||
timeZone: string = 'UTC'
|
||||
): string {
|
||||
const parts = getDateParts(date, timeZone);
|
||||
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
||||
return `nodewarden_backup_${parts[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 {
|
||||
@@ -333,7 +347,7 @@ export async function buildBackupArchive(
|
||||
const encoder = new TextEncoder();
|
||||
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||
@@ -398,7 +412,8 @@ export async function buildBackupArchive(
|
||||
});
|
||||
const bytes = zipSync(createZipEntries(files));
|
||||
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?.({
|
||||
step: 'archive_ready',
|
||||
fileName,
|
||||
|
||||
@@ -594,7 +594,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('users'),
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'api_key', 'created_at', 'updated_at'],
|
||||
payload.users || []
|
||||
)
|
||||
);
|
||||
|
||||
@@ -8,11 +8,13 @@ function mapDeviceRow(row: any): Device {
|
||||
userId: row.user_id,
|
||||
deviceIdentifier: row.device_identifier,
|
||||
name: row.name,
|
||||
deviceNote: row.device_note ?? null,
|
||||
type: row.type,
|
||||
sessionStamp: row.session_stamp || '',
|
||||
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||
lastSeenAt: row.last_seen_at ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
@@ -33,31 +35,62 @@ export async function upsertDevice(
|
||||
}
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
|
||||
const existingDevice = await getDeviceById(userId, deviceIdentifier);
|
||||
const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || '';
|
||||
const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
||||
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||
'last_seen_at=excluded.last_seen_at, ' +
|
||||
'updated_at=excluded.updated_at'
|
||||
)
|
||||
.bind(
|
||||
userId,
|
||||
deviceIdentifier,
|
||||
name,
|
||||
effectiveName,
|
||||
type,
|
||||
effectiveSessionStamp,
|
||||
keys?.encryptedUserKey ?? null,
|
||||
keys?.encryptedPublicKey ?? null,
|
||||
keys?.encryptedPrivateKey ?? null,
|
||||
existingDevice?.deviceNote ?? null,
|
||||
now,
|
||||
now,
|
||||
now
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function updateDeviceName(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
name: string
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.prepare('UPDATE devices SET device_note = ? WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(String(name || '').trim(), userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function touchDeviceLastSeen(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
deviceIdentifier: string
|
||||
): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare('UPDATE devices SET last_seen_at = ? WHERE user_id = ? AND device_identifier = ?')
|
||||
.bind(now, userId, deviceIdentifier)
|
||||
.run();
|
||||
return Number(result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function updateDeviceKeys(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
@@ -133,8 +166,8 @@ export async function isKnownDeviceByEmail(
|
||||
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
||||
const res = await db
|
||||
.prepare(
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
@@ -144,7 +177,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis
|
||||
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
||||
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||
)
|
||||
.bind(userId, deviceIdentifier)
|
||||
|
||||
@@ -6,13 +6,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||
@@ -27,6 +28,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
@@ -47,6 +49,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id)',
|
||||
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
||||
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||
|
||||
@@ -71,7 +74,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
|
||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
@@ -82,6 +85,9 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN device_note TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||
|
||||
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
|
||||
const USER_SELECT_COLUMNS =
|
||||
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
||||
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
||||
'totp_secret, totp_recovery_code, created_at, updated_at';
|
||||
'totp_secret, totp_recovery_code, api_key, created_at, updated_at';
|
||||
|
||||
function mapUserRow(row: any): User {
|
||||
return {
|
||||
@@ -26,6 +26,7 @@ function mapUserRow(row: any): User {
|
||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
apiKey: row.api_key ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
@@ -64,11 +65,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
@@ -90,6 +91,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
).run();
|
||||
@@ -102,8 +104,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
@@ -126,6 +128,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
).run();
|
||||
|
||||
@@ -92,7 +92,9 @@ import {
|
||||
isKnownDevice as getKnownStoredDevice,
|
||||
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
||||
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
||||
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
||||
upsertDevice as saveStoredDevice,
|
||||
updateDeviceName as updateStoredDeviceName,
|
||||
updateDeviceKeys as updateStoredDeviceKeys,
|
||||
} from './storage-device-repo';
|
||||
import {
|
||||
@@ -106,7 +108,7 @@ import {
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-03-23.1';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-04-22';
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
@@ -550,6 +552,14 @@ export class StorageService {
|
||||
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
|
||||
}
|
||||
|
||||
async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise<boolean> {
|
||||
return updateStoredDeviceName(this.db, userId, deviceIdentifier, name);
|
||||
}
|
||||
|
||||
async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
||||
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface User {
|
||||
verifyDevices?: boolean;
|
||||
totpSecret: string | null;
|
||||
totpRecoveryCode: string | null;
|
||||
apiKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -189,12 +190,14 @@ export interface Device {
|
||||
userId: string;
|
||||
deviceIdentifier: string;
|
||||
name: string;
|
||||
deviceNote: string | null;
|
||||
type: number;
|
||||
sessionStamp: string;
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
encryptedPrivateKey: string | null;
|
||||
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -208,10 +211,14 @@ export interface DeviceResponse {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
name: string;
|
||||
systemName?: string | null;
|
||||
deviceNote?: string | null;
|
||||
identifier: string;
|
||||
type: number;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
lastSeenAt?: string | null;
|
||||
hasStoredDevice?: boolean;
|
||||
isTrusted: boolean;
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
@@ -347,7 +354,8 @@ export interface TokenResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
refresh_token: string;
|
||||
refresh_token?: string;
|
||||
web_session?: boolean;
|
||||
TwoFactorToken?: string;
|
||||
Key: string;
|
||||
PrivateKey: string | null;
|
||||
@@ -367,6 +375,10 @@ export interface TokenResponse {
|
||||
accountKeys?: any | null;
|
||||
UserDecryptionOptions: UserDecryptionOptions;
|
||||
userDecryptionOptions?: UserDecryptionOptions;
|
||||
VaultKeys?: {
|
||||
symEncKey: string;
|
||||
symMacKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProfileResponse {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,42 @@ const DEFAULT_CORS_HEADERS = [
|
||||
'X-Request-Email',
|
||||
'X-Device-Identifier',
|
||||
'X-Device-Name',
|
||||
'X-NodeWarden-Web-Session',
|
||||
];
|
||||
|
||||
function getAllowedOrigin(request: Request): string | null {
|
||||
function isExtensionOrigin(origin: string): boolean {
|
||||
return (
|
||||
origin.startsWith('chrome-extension://')
|
||||
|| origin.startsWith('moz-extension://')
|
||||
|| origin.startsWith('safari-web-extension://')
|
||||
);
|
||||
}
|
||||
|
||||
function isWildcardCorsPath(path: string): boolean {
|
||||
return (
|
||||
path.startsWith('/icons/')
|
||||
|| path === '/config'
|
||||
|| path === '/api/config'
|
||||
|| path === '/api/version'
|
||||
);
|
||||
}
|
||||
|
||||
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
|
||||
const url = new URL(request.url);
|
||||
const origin = request.headers.get('Origin');
|
||||
if (!origin) return '*';
|
||||
return origin;
|
||||
if (isWildcardCorsPath(url.pathname)) {
|
||||
return { allowOrigin: '*', allowCredentials: false };
|
||||
}
|
||||
if (!origin) {
|
||||
return { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
if (origin === url.origin) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isExtensionOrigin(origin)) {
|
||||
return { allowOrigin: origin, allowCredentials: false };
|
||||
}
|
||||
return { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
|
||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
@@ -35,13 +65,14 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
'Access-Control-Allow-Headers': allowHeaders.join(', '),
|
||||
'Access-Control-Expose-Headers': '*',
|
||||
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||
'Access-Control-Allow-Private-Network': 'true',
|
||||
};
|
||||
|
||||
const allowedOrigin = getAllowedOrigin(request);
|
||||
if (allowedOrigin) {
|
||||
headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
||||
const corsPolicy = getCorsPolicy(request);
|
||||
if (corsPolicy.allowOrigin) {
|
||||
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
||||
if (corsPolicy.allowCredentials) {
|
||||
headers['Access-Control-Allow-Credentials'] = 'true';
|
||||
}
|
||||
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="862" height="101" viewBox="0 0 8620 1017" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M238.439 995.188H0V209.944C0 111.788 76.3004 53.1675 156.688 53.1675C220.726 53.1675 276.589 74.9799 309.289 126.784L633.566 640.737V74.9799H872.005V860.224C872.005 958.379 795.704 1015.64 715.317 1015.64C652.641 1015.64 595.416 993.824 562.716 942.02L238.439 428.067V995.188Z" fill="#006DF4"/>
|
||||
<path d="M1389.81 1015.64C1177.26 1015.64 1015.12 852.044 1015.12 653.007C1015.12 455.332 1177.26 291.74 1389.81 291.74C1602.36 291.74 1764.5 455.332 1764.5 653.007C1764.5 852.044 1602.36 1015.64 1389.81 1015.64ZM1389.81 785.244C1467.47 785.244 1519.25 725.26 1519.25 654.37C1519.25 582.117 1467.47 522.133 1389.81 522.133C1312.15 522.133 1260.37 582.117 1260.37 654.37C1260.37 725.26 1312.15 785.244 1389.81 785.244Z" fill="#006DF4"/>
|
||||
<path d="M2221.42 1015.64C2008.87 1015.64 1846.73 853.407 1846.73 655.733C1846.73 437.61 1991.16 293.103 2207.79 293.103C2258.21 293.103 2308.62 308.099 2350.86 331.275V0H2596.11V655.733C2596.11 864.314 2439.42 1015.64 2221.42 1015.64ZM2221.42 785.244C2299.08 785.244 2350.86 726.623 2350.86 654.37C2350.86 583.48 2299.08 523.496 2221.42 523.496C2143.76 523.496 2091.98 583.48 2091.98 654.37C2091.98 726.623 2143.76 785.244 2221.42 785.244Z" fill="#006DF4"/>
|
||||
<path d="M3086.45 1014.27C2868.45 1014.27 2704.95 869.767 2704.95 646.19C2704.95 449.879 2852.1 286.287 3067.38 286.287C3290.83 286.287 3414.82 452.606 3414.82 635.284V696.631H2940.66C2957.01 764.795 3008.79 805.693 3083.73 805.693C3149.13 805.693 3200.9 770.248 3225.43 717.08L3413.45 811.146C3354.87 937.93 3239.05 1014.27 3086.45 1014.27ZM2951.56 569.847H3170.93C3160.03 531.676 3121.88 496.231 3064.65 496.231C3006.06 496.231 2966.55 530.312 2951.56 569.847Z" fill="#006DF4"/>
|
||||
<path d="M3604.95 845.228L3441.45 74.9799H3693.51L3812.05 704.811L3915.6 246.752C3945.58 111.788 4009.62 54.5308 4107.72 54.5308C4205.82 54.5308 4269.85 111.788 4299.83 246.752L4403.38 704.811L4521.92 74.9799H4773.98L4610.48 845.228C4587.32 955.653 4513.74 1017 4414.28 1017C4324.35 1017 4243.97 957.016 4220.8 856.134L4107.72 358.54L3994.63 856.134C3971.46 957.016 3891.08 1017 3801.15 1017C3701.69 1017 3628.11 955.653 3604.95 845.228Z" fill="#006DF4"/>
|
||||
<path d="M5121.11 1015.64C4922.19 1015.64 4787.3 852.044 4787.3 653.007C4787.3 455.332 4949.44 291.74 5161.99 291.74C5379.99 291.74 5536.68 444.426 5536.68 653.007V995.188H5305.05V944.747C5261.45 989.735 5200.14 1015.64 5121.11 1015.64ZM5161.99 785.244C5239.65 785.244 5291.43 725.26 5291.43 654.37C5291.43 582.117 5239.65 522.133 5161.99 522.133C5084.33 522.133 5032.55 582.117 5032.55 654.37C5032.55 725.26 5084.33 785.244 5161.99 785.244Z" fill="#006DF4"/>
|
||||
<path d="M5918.02 995.188H5672.77V617.562C5672.77 436.247 5776.32 291.74 5998.41 291.74C6044.73 291.74 6095.15 299.92 6129.21 314.916V550.761C6096.51 533.039 6055.63 523.496 6021.57 523.496C5957.53 523.496 5918.02 560.304 5918.02 625.741V995.188Z" fill="#006DF4"/>
|
||||
<path d="M6565.74 1015.64C6353.19 1015.64 6191.05 853.407 6191.05 655.733C6191.05 437.61 6335.48 293.103 6552.12 293.103C6602.53 293.103 6652.94 308.099 6695.18 331.275V0H6940.43V655.733C6940.43 864.314 6783.74 1015.64 6565.74 1015.64ZM6565.74 785.244C6643.41 785.244 6695.18 726.623 6695.18 654.37C6695.18 583.48 6643.41 523.496 6565.74 523.496C6488.08 523.496 6436.31 583.48 6436.31 654.37C6436.31 726.623 6488.08 785.244 6565.74 785.244Z" fill="#006DF4"/>
|
||||
<path d="M7430.78 1014.27C7212.77 1014.27 7049.27 869.767 7049.27 646.19C7049.27 449.879 7196.42 286.287 7411.7 286.287C7635.15 286.287 7759.14 452.606 7759.14 635.284V696.631H7284.99C7301.34 764.795 7353.11 805.693 7428.05 805.693C7493.45 805.693 7545.23 770.248 7569.75 717.08L7757.78 811.146C7699.19 937.93 7583.38 1014.27 7430.78 1014.27ZM7295.89 569.847H7515.25C7504.35 531.676 7466.2 496.231 7408.98 496.231C7350.39 496.231 7310.88 530.312 7295.89 569.847Z" fill="#006DF4"/>
|
||||
<path d="M8250.76 531.676C8160.84 531.676 8126.77 603.929 8126.77 689.815V995.188H7881.52V659.823C7881.52 459.422 7998.7 293.103 8250.76 293.103C8502.82 293.103 8620 459.422 8620 659.823V995.188H8374.75V689.815C8374.75 603.929 8340.69 531.676 8250.76 531.676Z" fill="#006DF4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -10,8 +10,12 @@ import JwtWarningPage from '@/components/JwtWarningPage';
|
||||
import {
|
||||
createAuthedFetch,
|
||||
getAuthorizedDevices,
|
||||
clearProfileSnapshot,
|
||||
getCurrentDeviceIdentifier,
|
||||
getPasswordHint,
|
||||
loadProfileSnapshot,
|
||||
saveProfileSnapshot,
|
||||
revokeCurrentSession,
|
||||
getTotpStatus,
|
||||
saveSession,
|
||||
} from '@/lib/api/auth';
|
||||
@@ -39,6 +43,7 @@ import {
|
||||
performRecoverTwoFactorLogin,
|
||||
performRegistration,
|
||||
performTotpLogin,
|
||||
hydrateLockedSession,
|
||||
performUnlock,
|
||||
type JwtUnsafeReason,
|
||||
type PendingTotp,
|
||||
@@ -53,6 +58,17 @@ import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||
|
||||
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const detail = value as Record<string, unknown>;
|
||||
const operation = detail.operation;
|
||||
return (
|
||||
(operation === 'backup-restore' || operation === 'backup-export' || operation === 'backup-remote-run')
|
||||
&& typeof detail.step === 'string'
|
||||
&& typeof detail.fileName === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
const IMPORT_ROUTE = '/backup/import-export';
|
||||
const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
||||
@@ -124,11 +140,12 @@ function resolveSystemTheme(): 'light' | 'dark' {
|
||||
export default function App() {
|
||||
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
||||
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
||||
const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]);
|
||||
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
|
||||
const [location, navigate] = useLocation();
|
||||
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
||||
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [profile, setProfile] = useState<Profile | null>(initialProfileSnapshot);
|
||||
const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
|
||||
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
|
||||
|
||||
@@ -155,12 +172,15 @@ export default function App() {
|
||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [rememberDevice, setRememberDevice] = useState(true);
|
||||
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
||||
|
||||
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
|
||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
||||
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key);
|
||||
|
||||
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
||||
const [mobileLayout, setMobileLayout] = useState(false);
|
||||
@@ -262,6 +282,16 @@ export default function App() {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
||||
}, [themePreference]);
|
||||
|
||||
useEffect(() => {
|
||||
saveProfileSnapshot(profile);
|
||||
}, [profile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'locked' && profile?.key && session) {
|
||||
setUnlockPreparing(false);
|
||||
}
|
||||
}, [phase, profile, session]);
|
||||
|
||||
useEffect(() => installMagneticUiFeedback(), []);
|
||||
|
||||
function handleToggleTheme() {
|
||||
@@ -323,6 +353,7 @@ export default function App() {
|
||||
setSession(boot.session);
|
||||
setProfile(boot.profile);
|
||||
setPhase(boot.phase);
|
||||
setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
@@ -330,9 +361,34 @@ export default function App() {
|
||||
};
|
||||
}, [initialBootstrap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'locked' || !session) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const result = await hydrateLockedSession(session, profile);
|
||||
if (cancelled) return;
|
||||
if (!result.session) {
|
||||
setSession(null);
|
||||
setProfile(null);
|
||||
setUnlockPreparing(false);
|
||||
setPhase('login');
|
||||
if (location !== '/login') navigate('/login');
|
||||
return;
|
||||
}
|
||||
setSession(result.session);
|
||||
if (result.profile) {
|
||||
setProfile(result.profile);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [phase, session?.email, location, navigate]);
|
||||
|
||||
async function finalizeLogin(login: CompletedLogin) {
|
||||
setSession(login.session);
|
||||
setProfile(login.profile);
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
setTotpCode('');
|
||||
setPhase('app');
|
||||
@@ -379,16 +435,20 @@ export default function App() {
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (totpSubmitting) return;
|
||||
if (!pendingTotp) return;
|
||||
if (!totpCode.trim()) {
|
||||
pushToast('error', t('txt_please_input_totp_code'));
|
||||
return;
|
||||
}
|
||||
setTotpSubmitting(true);
|
||||
try {
|
||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||
await finalizeLogin(login);
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||
} finally {
|
||||
setTotpSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +577,7 @@ export default function App() {
|
||||
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
||||
setSession(nextSession);
|
||||
setUnlockPassword('');
|
||||
setUnlockPreparing(false);
|
||||
setPhase('app');
|
||||
if (location === '/' || location === '/lock') navigate('/vault');
|
||||
pushToast('success', t('txt_unlocked'));
|
||||
@@ -533,14 +594,18 @@ export default function App() {
|
||||
delete nextSession.symEncKey;
|
||||
delete nextSession.symMacKey;
|
||||
setSession(nextSession);
|
||||
setUnlockPreparing(false);
|
||||
setPhase('locked');
|
||||
navigate('/lock');
|
||||
}
|
||||
|
||||
function logoutNow() {
|
||||
void revokeCurrentSession(sessionRef.current);
|
||||
setConfirm(null);
|
||||
setSession(null);
|
||||
clearProfileSnapshot();
|
||||
setProfile(null);
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
setPhase('login');
|
||||
navigate('/login');
|
||||
@@ -572,11 +637,13 @@ export default function App() {
|
||||
onConfirmTotp={() => {}}
|
||||
onCancelTotp={() => {}}
|
||||
onUseRecoveryCode={() => {}}
|
||||
totpSubmitting={false}
|
||||
disableTotpOpen={false}
|
||||
disableTotpPassword=""
|
||||
onDisableTotpPasswordChange={() => {}}
|
||||
onConfirmDisableTotp={() => {}}
|
||||
onCancelDisableTotp={() => {}}
|
||||
disableTotpSubmitting={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -691,9 +758,6 @@ export default function App() {
|
||||
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
|
||||
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
|
||||
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
|
||||
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||
: null,
|
||||
uris: await Promise.all(
|
||||
(cipher.login.uris || []).map(async (u) => ({
|
||||
...u,
|
||||
@@ -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) {
|
||||
nextCipher.card = {
|
||||
...cipher.card,
|
||||
@@ -874,9 +946,11 @@ export default function App() {
|
||||
|
||||
const connect = () => {
|
||||
if (disposed) return;
|
||||
const accessToken = session.accessToken;
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const hubUrl = new URL('/notifications/hub', window.location.origin);
|
||||
hubUrl.searchParams.set('access_token', session.accessToken);
|
||||
hubUrl.searchParams.set('access_token', accessToken);
|
||||
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(hubUrl.toString());
|
||||
} catch {
|
||||
@@ -884,6 +958,15 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
let pingTimer: number | null = null;
|
||||
|
||||
const clearPingTimer = () => {
|
||||
if (pingTimer !== null) {
|
||||
window.clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
reconnectAttempts = 0;
|
||||
void refreshAuthorizedDevicesRef.current();
|
||||
@@ -891,7 +974,16 @@ export default function App() {
|
||||
socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`);
|
||||
} catch {
|
||||
socket?.close();
|
||||
return;
|
||||
}
|
||||
clearPingTimer();
|
||||
pingTimer = window.setInterval(() => {
|
||||
try {
|
||||
socket?.send(`{"type":6}${SIGNALR_RECORD_SEPARATOR}`);
|
||||
} catch {
|
||||
// send failure will trigger close event
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
@@ -912,17 +1004,7 @@ export default function App() {
|
||||
}
|
||||
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
|
||||
const payload = frame.arguments?.[0]?.Payload;
|
||||
if (
|
||||
payload
|
||||
&& typeof payload === 'object'
|
||||
&& (
|
||||
payload.operation === 'backup-restore'
|
||||
|| payload.operation === 'backup-export'
|
||||
|| payload.operation === 'backup-remote-run'
|
||||
)
|
||||
) {
|
||||
dispatchBackupProgress(payload as BackupProgressDetail);
|
||||
}
|
||||
if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
|
||||
continue;
|
||||
}
|
||||
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
||||
@@ -934,6 +1016,7 @@ export default function App() {
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
socket = null;
|
||||
clearPingTimer();
|
||||
void refreshAuthorizedDevicesRef.current();
|
||||
scheduleReconnect();
|
||||
});
|
||||
@@ -952,9 +1035,11 @@ export default function App() {
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearReconnectTimer();
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
if (socket) {
|
||||
const s = socket;
|
||||
socket = null;
|
||||
try {
|
||||
socket.close();
|
||||
s.close();
|
||||
} catch {
|
||||
// ignore close races
|
||||
}
|
||||
@@ -1095,6 +1180,7 @@ export default function App() {
|
||||
onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems,
|
||||
onVerifyMasterPassword: vaultSendActions.verifyMasterPassword,
|
||||
onCreateFolder: vaultSendActions.createFolder,
|
||||
onRenameFolder: vaultSendActions.renameFolder,
|
||||
onDeleteFolder: vaultSendActions.deleteFolder,
|
||||
onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders,
|
||||
onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment,
|
||||
@@ -1117,7 +1203,10 @@ export default function App() {
|
||||
},
|
||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||
onGetApiKey: accountSecurityActions.getApiKey,
|
||||
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
||||
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
|
||||
@@ -1178,7 +1267,8 @@ export default function App() {
|
||||
<AuthViews
|
||||
mode={phase}
|
||||
pendingAction={pendingAuthAction}
|
||||
unlockReady={!!profile}
|
||||
unlockReady={!!profile?.key && !!session}
|
||||
unlockPreparing={unlockPreparing}
|
||||
loginValues={loginValues}
|
||||
registerValues={registerValues}
|
||||
unlockPassword={unlockPassword}
|
||||
@@ -1217,21 +1307,25 @@ export default function App() {
|
||||
onRememberDeviceChange={setRememberDevice}
|
||||
onConfirmTotp={() => void handleTotpVerify()}
|
||||
onCancelTotp={() => {
|
||||
if (totpSubmitting) return;
|
||||
setPendingTotp(null);
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
}}
|
||||
onUseRecoveryCode={() => {
|
||||
if (totpSubmitting) return;
|
||||
setPendingTotp(null);
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
navigate('/recover-2fa');
|
||||
}}
|
||||
totpSubmitting={totpSubmitting}
|
||||
disableTotpOpen={false}
|
||||
disableTotpPassword=""
|
||||
onDisableTotpPasswordChange={() => {}}
|
||||
onConfirmDisableTotp={() => {}}
|
||||
onCancelDisableTotp={() => {}}
|
||||
disableTotpSubmitting={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -1270,14 +1364,27 @@ export default function App() {
|
||||
onConfirmTotp={() => {}}
|
||||
onCancelTotp={() => {}}
|
||||
onUseRecoveryCode={() => {}}
|
||||
totpSubmitting={false}
|
||||
disableTotpOpen={disableTotpOpen}
|
||||
disableTotpPassword={disableTotpPassword}
|
||||
onDisableTotpPasswordChange={setDisableTotpPassword}
|
||||
onConfirmDisableTotp={() => void accountSecurityActions.disableTotp()}
|
||||
onConfirmDisableTotp={() => {
|
||||
if (disableTotpSubmitting) return;
|
||||
void (async () => {
|
||||
setDisableTotpSubmitting(true);
|
||||
try {
|
||||
await accountSecurityActions.disableTotp();
|
||||
} finally {
|
||||
setDisableTotpSubmitting(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
onCancelDisableTotp={() => {
|
||||
if (disableTotpSubmitting) return;
|
||||
setDisableTotpOpen(false);
|
||||
setDisableTotpPassword('');
|
||||
}}
|
||||
disableTotpSubmitting={disableTotpSubmitting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,12 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
return status || '-';
|
||||
};
|
||||
|
||||
const normalizeToggleableStatus = (status: string): 'active' | 'banned' | null => {
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
if (normalized === 'active' || normalized === 'banned') return normalized;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
@@ -55,7 +61,9 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.users.map((user) => (
|
||||
{props.users.map((user) => {
|
||||
const toggleableStatus = normalizeToggleableStatus(user.status);
|
||||
return (
|
||||
<tr key={user.id}>
|
||||
<td data-label={t('txt_email')}>{user.email}</td>
|
||||
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
||||
@@ -66,8 +74,11 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={user.id === props.currentUserId}
|
||||
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
|
||||
disabled={user.id === props.currentUserId || !toggleableStatus}
|
||||
onClick={() => {
|
||||
if (!toggleableStatus) return;
|
||||
void props.onToggleUserStatus(user.id, toggleableStatus);
|
||||
}}
|
||||
>
|
||||
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
|
||||
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
|
||||
@@ -81,7 +92,8 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
|
||||
<span className="brand-name">NodeWarden</span>
|
||||
<img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="brand-wordmark" />
|
||||
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
|
||||
@@ -27,11 +27,13 @@ interface AppGlobalOverlaysProps {
|
||||
onConfirmTotp: () => void;
|
||||
onCancelTotp: () => void;
|
||||
onUseRecoveryCode: () => void;
|
||||
totpSubmitting: boolean;
|
||||
disableTotpOpen: boolean;
|
||||
disableTotpPassword: string;
|
||||
onDisableTotpPasswordChange: (value: string) => void;
|
||||
onConfirmDisableTotp: () => void;
|
||||
onCancelDisableTotp: () => void;
|
||||
disableTotpSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
@@ -57,12 +59,14 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
confirmText={t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
confirmDisabled={props.totpSubmitting}
|
||||
cancelDisabled={props.totpSubmitting}
|
||||
onConfirm={props.onConfirmTotp}
|
||||
onCancel={props.onCancelTotp}
|
||||
afterActions={(
|
||||
<div className="dialog-extra">
|
||||
<div className="dialog-divider" />
|
||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onUseRecoveryCode}>
|
||||
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
|
||||
{t('txt_use_recovery_code')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -86,6 +90,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
showIcon={false}
|
||||
confirmDisabled={props.disableTotpSubmitting}
|
||||
cancelDisabled={props.disableTotpSubmitting}
|
||||
onConfirm={props.onConfirmDisableTotp}
|
||||
onCancel={props.onCancelDisableTotp}
|
||||
>
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface AppMainRoutesProps {
|
||||
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
|
||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||
onCreateFolder: (name: string) => Promise<void>;
|
||||
onRenameFolder: (folderId: string, name: string) => Promise<void>;
|
||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
||||
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||
@@ -93,7 +94,10 @@ export interface AppMainRoutesProps {
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||
onRevokeAllDeviceTrust: () => void;
|
||||
@@ -192,6 +196,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onNotify={props.onNotify}
|
||||
onCreateFolder={props.onCreateFolder}
|
||||
onRenameFolder={props.onRenameFolder}
|
||||
onDeleteFolder={props.onDeleteFolder}
|
||||
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||
@@ -222,6 +227,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onEnableTotp={props.onEnableTotp}
|
||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onGetApiKey={props.onGetApiKey}
|
||||
onRotateApiKey={props.onRotateApiKey}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -279,6 +286,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
devices={props.authorizedDevices}
|
||||
loading={props.authorizedDevicesLoading}
|
||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||
onRemoveDevice={props.onRemoveDevice}
|
||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface AuthViewsProps {
|
||||
mode: 'login' | 'register' | 'locked';
|
||||
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||
unlockReady: boolean;
|
||||
unlockPreparing: boolean;
|
||||
loginValues: LoginValues;
|
||||
registerValues: RegisterValues;
|
||||
unlockPassword: string;
|
||||
@@ -97,14 +98,17 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
type="button"
|
||||
className="auth-link-btn"
|
||||
onClick={props.onShowLockedPasswordHint}
|
||||
disabled={unlockBusy}
|
||||
disabled={unlockBusy || props.unlockPreparing}
|
||||
>
|
||||
{t('txt_show_password_hint')}
|
||||
</button>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}>
|
||||
{props.unlockPreparing ? (
|
||||
<p className="muted standalone-muted">{t('txt_loading')}</p>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || props.unlockPreparing || !props.unlockReady}>
|
||||
<Unlock size={16} className="btn-icon" />
|
||||
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
|
||||
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
|
||||
</button>
|
||||
<div className="or">{t('txt_or')}</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
||||
|
||||
@@ -528,6 +528,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
allowChecksumMismatch: boolean = false,
|
||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||
) {
|
||||
if (importing) return;
|
||||
if (!selectedFile) {
|
||||
const message = t('txt_backup_file_required');
|
||||
setLocalError(message);
|
||||
@@ -625,7 +626,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
setSettings(result.settings);
|
||||
setSelectedDestinationId(selectedDestination.id);
|
||||
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) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
||||
setLocalError(message);
|
||||
@@ -654,6 +655,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
}
|
||||
|
||||
async function handleDeleteRemote(path: string) {
|
||||
if (deletingRemotePath) return;
|
||||
if (!savedSelectedDestination) return;
|
||||
setDeletingRemotePath(path);
|
||||
setLocalError('');
|
||||
@@ -723,6 +725,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
allowChecksumMismatch: boolean = false,
|
||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||
) {
|
||||
if (restoringRemotePath) return;
|
||||
if (!savedSelectedDestination) return;
|
||||
setConfirmRemoteReplaceOpen(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')}
|
||||
confirmText={t('txt_backup_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={importing}
|
||||
cancelDisabled={importing}
|
||||
danger
|
||||
onConfirm={() => void runLocalRestore(false)}
|
||||
onCancel={() => {
|
||||
if (importing) return;
|
||||
setConfirmLocalRestoreOpen(false);
|
||||
resetSelectedFile();
|
||||
resetPendingIntegrityWarning();
|
||||
@@ -959,6 +965,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
variant="warning"
|
||||
confirmText={t('txt_backup_restore_checksum_warning_confirm')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={importing || !!restoringRemotePath}
|
||||
cancelDisabled={importing || !!restoringRemotePath}
|
||||
danger
|
||||
onConfirm={() => {
|
||||
if (!pendingRestoreIntegrity) return;
|
||||
@@ -984,6 +992,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={!!deletingRemotePath}
|
||||
cancelDisabled={!!deletingRemotePath}
|
||||
danger
|
||||
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
|
||||
onCancel={() => {
|
||||
@@ -1001,6 +1011,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
})}
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={savingSettings}
|
||||
cancelDisabled={savingSettings}
|
||||
danger
|
||||
onConfirm={() => void handleDeleteDestination()}
|
||||
onCancel={() => {
|
||||
|
||||
@@ -468,6 +468,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
}
|
||||
|
||||
async function handlePasswordImportConfirm() {
|
||||
if (isPasswordSubmitting) return;
|
||||
if (!pendingPasswordImport) return;
|
||||
setIsPasswordSubmitting(true);
|
||||
try {
|
||||
@@ -486,6 +487,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
}
|
||||
|
||||
async function handleZipPasswordImportConfirm() {
|
||||
if (isZipPasswordSubmitting) return;
|
||||
if (!pendingZipFile) return;
|
||||
setIsZipPasswordSubmitting(true);
|
||||
try {
|
||||
@@ -558,6 +560,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
}
|
||||
|
||||
async function handleExportConfirmPassword() {
|
||||
if (isExporting) return;
|
||||
const masterPassword = String(exportAuthPassword || '').trim();
|
||||
if (!masterPassword) {
|
||||
onNotify('error', t('txt_master_password_is_required'));
|
||||
@@ -736,6 +739,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
confirmDisabled={isExporting}
|
||||
cancelDisabled={isExporting}
|
||||
onConfirm={() => void handleExportConfirmPassword()}
|
||||
onCancel={() => {
|
||||
if (isExporting) return;
|
||||
@@ -761,6 +766,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
confirmDisabled={isPasswordSubmitting}
|
||||
cancelDisabled={isPasswordSubmitting}
|
||||
onConfirm={() => void handlePasswordImportConfirm()}
|
||||
onCancel={() => {
|
||||
if (isPasswordSubmitting) return;
|
||||
@@ -787,6 +794,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
confirmDisabled={isZipPasswordSubmitting}
|
||||
cancelDisabled={isZipPasswordSubmitting}
|
||||
onConfirm={() => void handleZipPasswordImportConfirm()}
|
||||
onCancel={() => {
|
||||
if (isZipPasswordSubmitting) return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Download, Eye, Lock } from 'lucide-preact';
|
||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||
import { toBufferSource } from '@/lib/crypto';
|
||||
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -61,13 +62,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
if (props.keyPart) {
|
||||
try {
|
||||
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
|
||||
blob = new Blob([decryptedBytes as unknown as BlobPart], { type: 'application/octet-stream' });
|
||||
blob = new Blob([toBufferSource(decryptedBytes)], { type: 'application/octet-stream' });
|
||||
} catch {
|
||||
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
|
||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
||||
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||
}
|
||||
} else {
|
||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
||||
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||
}
|
||||
downloadBytesAsFile(
|
||||
new Uint8Array(await blob.arrayBuffer()),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import type { AuthorizedDevice } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
@@ -6,6 +8,7 @@ interface SecurityDevicesPageProps {
|
||||
devices: AuthorizedDevice[];
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||
onRevokeAll: () => void;
|
||||
@@ -41,7 +44,24 @@ function mapDeviceTypeName(type: number): string {
|
||||
}
|
||||
|
||||
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
|
||||
const [deviceNote, setDeviceNote] = useState('');
|
||||
const [savingNote, setSavingNote] = useState(false);
|
||||
|
||||
async function handleSaveDeviceNote(): Promise<void> {
|
||||
if (!editingDevice || savingNote) return;
|
||||
setSavingNote(true);
|
||||
try {
|
||||
await props.onRenameDevice(editingDevice, deviceNote);
|
||||
setEditingDevice(null);
|
||||
setDeviceNote('');
|
||||
} finally {
|
||||
setSavingNote(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<div className="section-head">
|
||||
@@ -87,6 +107,9 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
<tr key={device.identifier}>
|
||||
<td data-label={t('txt_device')}>
|
||||
<div>{device.name || t('txt_unknown_device')}</div>
|
||||
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
|
||||
<div className="muted-inline">{device.systemName}</div>
|
||||
)}
|
||||
<div className="muted-inline">{device.identifier}</div>
|
||||
</td>
|
||||
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
|
||||
@@ -96,7 +119,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
</span>
|
||||
</td>
|
||||
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
|
||||
<td data-label={t('txt_last_seen')}>{formatDateTime(device.revisionDate)}</td>
|
||||
<td data-label={t('txt_last_seen')}>{formatDateTime(device.lastSeenAt || device.revisionDate)}</td>
|
||||
<td data-label={t('txt_trusted_until')}>
|
||||
{device.trusted ? (
|
||||
<div className="trusted-cell">
|
||||
@@ -116,11 +139,28 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
onClick={() => props.onRevokeTrust(device)}
|
||||
>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
{t('txt_revoke_trust')}
|
||||
{t('txt_untrust')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={device.hasStoredDevice === false}
|
||||
onClick={() => {
|
||||
setEditingDevice(device);
|
||||
setDeviceNote(device.deviceNote || device.name || '');
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} className="btn-icon" />
|
||||
{t('txt_device_note')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger small"
|
||||
disabled={device.hasStoredDevice === false}
|
||||
onClick={() => props.onRemoveDevice(device)}
|
||||
>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{t('txt_remove_device_2')}
|
||||
{t('txt_delete')}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -137,5 +177,33 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!editingDevice}
|
||||
title={t('txt_device_note')}
|
||||
message={t('txt_replace_device_name_with_note')}
|
||||
confirmText={t('txt_save')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
confirmDisabled={savingNote}
|
||||
cancelDisabled={savingNote}
|
||||
onConfirm={() => void handleSaveDeviceNote()}
|
||||
onCancel={() => {
|
||||
if (savingNote) return;
|
||||
setEditingDevice(null);
|
||||
setDeviceNote('');
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_device_note')}</span>
|
||||
<input
|
||||
className="input"
|
||||
maxLength={128}
|
||||
value={deviceNote}
|
||||
onInput={(e) => setDeviceNote((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import qrcode from 'qrcode-generator';
|
||||
import type { Profile } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
|
||||
interface SettingsPageProps {
|
||||
profile: Profile;
|
||||
@@ -13,6 +14,8 @@ interface SettingsPageProps {
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
onNotify?: (type: 'success' | 'error', text: string) => void;
|
||||
}
|
||||
|
||||
@@ -47,6 +50,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
||||
const [recoveryCode, setRecoveryCode] = useState('');
|
||||
const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.totpEnabled) {
|
||||
@@ -64,7 +71,8 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(buildOtpUri(props.profile.email, secret));
|
||||
qr.make();
|
||||
const svg = qr.createSvgTag({ scalable: true, margin: 0 });
|
||||
// Keep a visible quiet zone so authenticator apps can scan reliably in both themes.
|
||||
const svg = qr.createSvgTag({ scalable: true, margin: 4 });
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}, [props.profile.email, secret]);
|
||||
|
||||
@@ -85,6 +93,34 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
||||
}
|
||||
|
||||
async function loadApiKey(): Promise<void> {
|
||||
try {
|
||||
const key = await props.onGetApiKey(apiKeyMasterPassword);
|
||||
setApiKey(key);
|
||||
setApiKeyDialogOpen(true);
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty'));
|
||||
}
|
||||
}
|
||||
|
||||
async function doRotateApiKey(): Promise<void> {
|
||||
try {
|
||||
const key = await props.onRotateApiKey(apiKeyMasterPassword);
|
||||
setApiKey(key);
|
||||
setApiKeyDialogOpen(true);
|
||||
props.onNotify?.('success', t('txt_api_key_rotated'));
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty'));
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return t('txt_dash');
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
@@ -226,8 +262,105 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-subcard">
|
||||
<h3>{t('txt_api_key')}</h3>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={apiKeyMasterPassword}
|
||||
onInput={(e) => setApiKeyMasterPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void loadApiKey()}>
|
||||
<KeyRound size={14} className="btn-icon" />
|
||||
{t('txt_view_api_key')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setRotateApiKeyConfirmOpen(true)}
|
||||
>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_rotate_api_key')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<ConfirmDialog
|
||||
open={apiKeyDialogOpen}
|
||||
title={t('txt_api_key')}
|
||||
message={t('txt_api_key_dialog_intro')}
|
||||
hideCancel
|
||||
confirmText={t('txt_close')}
|
||||
onConfirm={() => setApiKeyDialogOpen(false)}
|
||||
onCancel={() => setApiKeyDialogOpen(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid color-mix(in srgb, var(--danger) 24%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--danger) 7%, var(--surface))',
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
marginTop: 12,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 800, color: 'var(--danger)', marginBottom: 8 }}>{t('txt_warning')}</div>
|
||||
<div style={{ color: 'var(--text)', lineHeight: 1.55 }}>{t('txt_api_key_warning_body')}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--primary) 7%, var(--surface))',
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontWeight: 800, color: 'var(--primary)', marginBottom: 10 }}>
|
||||
<KeyRound size={15} />
|
||||
<span>{t('txt_oauth_client_credentials')}</span>
|
||||
</div>
|
||||
{([
|
||||
[t('txt_client_id'), `user.${props.profile.id}`],
|
||||
[t('txt_client_secret'), apiKey],
|
||||
[t('txt_scope'), 'api'],
|
||||
[t('txt_grant_type'), 'client_credentials'],
|
||||
] as [string, string][]).map(([label, value]) => (
|
||||
<label key={label} className="field">
|
||||
<span>{label}</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', gap: 8 }}>
|
||||
<input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
onClick={() => void copyTextToClipboard(value, { successMessage: t('txt_copied') })}
|
||||
>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
{t('txt_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={rotateApiKeyConfirmOpen}
|
||||
title={t('txt_rotate_api_key')}
|
||||
message={t('txt_rotate_api_key_confirm')}
|
||||
danger
|
||||
onConfirm={() => {
|
||||
setRotateApiKeyConfirmOpen(false);
|
||||
void doRotateApiKey();
|
||||
}}
|
||||
onCancel={() => setRotateApiKeyConfirmOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
||||
<div className="standalone-brand standalone-brand-outside">
|
||||
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
|
||||
<div>
|
||||
<div className="standalone-brand-title">NodeWarden</div>
|
||||
<img src="/nodewarden-wordmark.svg" alt="NodeWarden" className="standalone-brand-wordmark" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JSX } from 'preact';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
||||
import {
|
||||
@@ -64,6 +65,10 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||
const uri = firstCipherUri(cipher);
|
||||
const host = hostFromUri(uri);
|
||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||
useEffect(() => {
|
||||
setErrored(host ? failedIconHosts.has(host) : false);
|
||||
}, [host]);
|
||||
|
||||
if (host && !errored) {
|
||||
return (
|
||||
<img
|
||||
@@ -96,6 +101,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.cipher.id,
|
||||
});
|
||||
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -113,7 +119,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
||||
className="btn btn-secondary small totp-drag-btn"
|
||||
title={t('txt_drag_to_reorder')}
|
||||
aria-label={t('txt_drag_to_reorder')}
|
||||
{...attributes}
|
||||
{...dragButtonAttributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} className="btn-icon" />
|
||||
|
||||
@@ -50,6 +50,7 @@ interface VaultPageProps {
|
||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
onCreateFolder: (name: string) => Promise<void>;
|
||||
onRenameFolder: (folderId: string, name: string) => Promise<void>;
|
||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||
@@ -91,6 +92,8 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [moveFolderId, setMoveFolderId] = useState('__none__');
|
||||
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [pendingRenameFolder, setPendingRenameFolder] = useState<Folder | null>(null);
|
||||
const [renameFolderName, setRenameFolderName] = useState('');
|
||||
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
||||
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
|
||||
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
||||
@@ -101,6 +104,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||
const [repromptPassword, setRepromptPassword] = useState('');
|
||||
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
||||
const [pendingDeletePasskeyIndex, setPendingDeletePasskeyIndex] = useState<number | null>(null);
|
||||
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
@@ -349,7 +353,6 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
() => filteredCiphers.slice(virtualRange.start, virtualRange.end),
|
||||
[filteredCiphers, virtualRange.start, virtualRange.end]
|
||||
);
|
||||
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
|
||||
const selectedAttachments = useMemo(
|
||||
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
|
||||
[selectedCipher]
|
||||
@@ -443,6 +446,7 @@ function folderName(id: string | null | undefined): string {
|
||||
setLocalError('');
|
||||
setAttachmentQueue([]);
|
||||
setRemovedAttachmentIds({});
|
||||
setPendingDeletePasskeyIndex(null);
|
||||
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
||||
}
|
||||
|
||||
@@ -450,6 +454,18 @@ function folderName(id: string | null | undefined): string {
|
||||
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
}
|
||||
|
||||
function confirmDeleteLoginPasskey(): void {
|
||||
if (pendingDeletePasskeyIndex == null) return;
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
loginFido2Credentials: prev.loginFido2Credentials.filter((_, index) => index !== pendingDeletePasskeyIndex),
|
||||
};
|
||||
});
|
||||
setPendingDeletePasskeyIndex(null);
|
||||
}
|
||||
|
||||
async function seedSshDefaults(force = false): Promise<void> {
|
||||
const ticket = ++sshSeedTicketRef.current;
|
||||
try {
|
||||
@@ -699,6 +715,23 @@ function folderName(id: string | null | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRenameFolder(): Promise<void> {
|
||||
if (!pendingRenameFolder) return;
|
||||
const nextName = renameFolderName.trim();
|
||||
if (!nextName) {
|
||||
props.onNotify('error', t('txt_folder_name_is_required'));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onRenameFolder(pendingRenameFolder.id, nextName);
|
||||
setPendingRenameFolder(null);
|
||||
setRenameFolderName('');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBulkRestore(): Promise<void> {
|
||||
const ids = Object.entries(selectedMap)
|
||||
.filter(([, selected]) => selected)
|
||||
@@ -806,6 +839,10 @@ function folderName(id: string | null | undefined): string {
|
||||
onChangeFilter={setSidebarFilter}
|
||||
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
|
||||
onOpenCreateFolder={() => setCreateFolderOpen(true)}
|
||||
onOpenRenameFolder={(folder) => {
|
||||
setPendingRenameFolder(folder);
|
||||
setRenameFolderName(folder.decName || folder.name || '');
|
||||
}}
|
||||
onOpenDeleteFolder={setPendingDeleteFolder}
|
||||
/>
|
||||
|
||||
@@ -924,6 +961,7 @@ function folderName(id: string | null | undefined): string {
|
||||
onUpdateDraftLoginUri={updateDraftLoginUri}
|
||||
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
||||
onReorderDraftLoginUri={reorderDraftLoginUri}
|
||||
onRequestDeleteLoginPasskey={setPendingDeletePasskeyIndex}
|
||||
onQueueAttachmentFiles={queueAttachmentFiles}
|
||||
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
||||
onRemoveQueuedAttachment={removeQueuedAttachment}
|
||||
@@ -949,7 +987,7 @@ function folderName(id: string | null | undefined): string {
|
||||
repromptApprovedCipherId={repromptApprovedCipherId}
|
||||
showPassword={showPassword}
|
||||
totpLive={totpLive}
|
||||
passkeyCreatedAt={passkeyCreatedAt}
|
||||
passkeyCreatedAt={firstPasskeyCreationTime(selectedCipher)}
|
||||
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
||||
folderName={folderName}
|
||||
onOpenReprompt={() => setRepromptOpen(true)}
|
||||
@@ -971,6 +1009,7 @@ function folderName(id: string | null | undefined): string {
|
||||
</div>
|
||||
|
||||
<VaultDialogs
|
||||
busy={busy}
|
||||
fieldModalOpen={fieldModalOpen}
|
||||
fieldType={fieldType}
|
||||
fieldLabel={fieldLabel}
|
||||
@@ -986,10 +1025,13 @@ function folderName(id: string | null | undefined): string {
|
||||
folders={props.folders}
|
||||
createFolderOpen={createFolderOpen}
|
||||
newFolderName={newFolderName}
|
||||
renameFolderOpen={!!pendingRenameFolder}
|
||||
renameFolderName={renameFolderName}
|
||||
pendingDeleteFolder={pendingDeleteFolder}
|
||||
deleteAllFoldersOpen={deleteAllFoldersOpen}
|
||||
repromptOpen={repromptOpen}
|
||||
repromptPassword={repromptPassword}
|
||||
deletePasskeyOpen={pendingDeletePasskeyIndex != null}
|
||||
onConfirmAddField={() => {
|
||||
if (!draft) return;
|
||||
if (!fieldLabel.trim()) {
|
||||
@@ -1036,6 +1078,12 @@ function folderName(id: string | null | undefined): string {
|
||||
setNewFolderName('');
|
||||
}}
|
||||
onNewFolderNameChange={setNewFolderName}
|
||||
onConfirmRenameFolder={() => void confirmRenameFolder()}
|
||||
onCancelRenameFolder={() => {
|
||||
setPendingRenameFolder(null);
|
||||
setRenameFolderName('');
|
||||
}}
|
||||
onRenameFolderNameChange={setRenameFolderName}
|
||||
onConfirmDeleteFolder={() => void confirmDeleteFolder()}
|
||||
onCancelDeleteFolder={() => setPendingDeleteFolder(null)}
|
||||
onConfirmDeleteAllFolders={() => void confirmDeleteAllFolders()}
|
||||
@@ -1046,12 +1094,10 @@ function folderName(id: string | null | undefined): string {
|
||||
setRepromptPassword('');
|
||||
}}
|
||||
onRepromptPasswordChange={setRepromptPassword}
|
||||
onConfirmDeletePasskey={confirmDeleteLoginPasskey}
|
||||
onCancelDeletePasskey={() => setPendingDeletePasskeyIndex(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
...COMMON_TIME_ZONES,
|
||||
...props.availableTimeZones,
|
||||
]));
|
||||
const selectedIntervalHours = props.selectedDestination?.schedule.intervalHours ?? 24;
|
||||
|
||||
if (props.selectedRecommendedProvider) {
|
||||
return (
|
||||
@@ -216,7 +217,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={String(props.selectedDestination.schedule.intervalHours || 24)}
|
||||
value={String(selectedIntervalHours)}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => {
|
||||
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
||||
@@ -234,7 +235,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
</div>
|
||||
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
||||
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
||||
const active = preset === props.selectedDestination.schedule.intervalHours;
|
||||
const active = preset === selectedIntervalHours;
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useMemo, useState } from 'preact/hooks';
|
||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
@@ -35,10 +37,60 @@ interface VaultDetailViewProps {
|
||||
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function PasswordHistoryDialog(props: {
|
||||
open: boolean;
|
||||
entries: Array<{ password: string; lastUsedDate: string | null }>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useDialogLifecycle(props.open, props.onClose);
|
||||
|
||||
if (!props.open || typeof document === 'undefined') return null;
|
||||
return createPortal(
|
||||
<div className="dialog-mask open" onClick={(event) => event.target === event.currentTarget && props.onClose()}>
|
||||
<section className="dialog-card password-history-dialog open" role="dialog" aria-modal="true" aria-label={t('txt_password_history')}>
|
||||
<div className="password-history-head">
|
||||
<h3 className="dialog-title">{t('txt_password_history')}</h3>
|
||||
<button type="button" className="password-history-close" aria-label={t('txt_close')} onClick={props.onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="password-history-list">
|
||||
{props.entries.map((entry, index) => (
|
||||
<div key={`password-history-${index}-${entry.lastUsedDate || 'none'}`} className="password-history-item">
|
||||
<div className="password-history-copy">
|
||||
<button type="button" className="btn btn-secondary small password-history-copy-btn" onClick={() => copyToClipboard(entry.password)}>
|
||||
<Clipboard size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="password-history-value">{entry.password}</div>
|
||||
<div className="password-history-time">{formatHistoryTime(entry.lastUsedDate)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary dialog-btn" onClick={props.onClose}>
|
||||
{t('txt_close')}
|
||||
</button>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
||||
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
|
||||
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
||||
const passwordHistoryEntries = useMemo(
|
||||
() =>
|
||||
(props.selectedCipher.passwordHistory || [])
|
||||
.map((entry) => ({
|
||||
password: String(entry?.decPassword || entry?.password || ''),
|
||||
lastUsedDate: entry?.lastUsedDate ?? null,
|
||||
}))
|
||||
.filter((entry) => entry.password.trim()),
|
||||
[props.selectedCipher.passwordHistory]
|
||||
);
|
||||
const formatDownloadLabel = (attachmentId: string) => {
|
||||
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||
@@ -355,6 +407,14 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
<h4>{t('txt_item_history')}</h4>
|
||||
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
|
||||
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
|
||||
{!!props.selectedCipher.login?.passwordRevisionDate && (
|
||||
<div className="detail-sub">{t('txt_password_updated_value', { value: formatHistoryTime(props.selectedCipher.login.passwordRevisionDate) })}</div>
|
||||
)}
|
||||
{passwordHistoryEntries.length > 0 && (
|
||||
<button type="button" className="password-history-link" onClick={() => setPasswordHistoryOpen(true)}>
|
||||
{t('txt_password_history')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -379,6 +439,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<PasswordHistoryDialog
|
||||
open={passwordHistoryOpen}
|
||||
entries={passwordHistoryEntries}
|
||||
onClose={() => setPasswordHistoryOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vaul
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface VaultDialogsProps {
|
||||
busy: boolean;
|
||||
fieldModalOpen: boolean;
|
||||
fieldType: CustomFieldType;
|
||||
fieldLabel: string;
|
||||
@@ -19,10 +20,13 @@ interface VaultDialogsProps {
|
||||
folders: Folder[];
|
||||
createFolderOpen: boolean;
|
||||
newFolderName: string;
|
||||
renameFolderOpen: boolean;
|
||||
renameFolderName: string;
|
||||
pendingDeleteFolder: Folder | null;
|
||||
deleteAllFoldersOpen: boolean;
|
||||
repromptOpen: boolean;
|
||||
repromptPassword: string;
|
||||
deletePasskeyOpen: boolean;
|
||||
onConfirmAddField: () => void;
|
||||
onCancelFieldModal: () => void;
|
||||
onFieldTypeChange: (value: CustomFieldType) => void;
|
||||
@@ -42,6 +46,9 @@ interface VaultDialogsProps {
|
||||
onConfirmCreateFolder: () => void;
|
||||
onCancelCreateFolder: () => void;
|
||||
onNewFolderNameChange: (value: string) => void;
|
||||
onConfirmRenameFolder: () => void;
|
||||
onCancelRenameFolder: () => void;
|
||||
onRenameFolderNameChange: (value: string) => void;
|
||||
onConfirmDeleteFolder: () => void;
|
||||
onCancelDeleteFolder: () => void;
|
||||
onConfirmDeleteAllFolders: () => void;
|
||||
@@ -49,6 +56,8 @@ interface VaultDialogsProps {
|
||||
onConfirmReprompt: () => void;
|
||||
onCancelReprompt: () => void;
|
||||
onRepromptPasswordChange: (value: string) => void;
|
||||
onConfirmDeletePasskey: () => void;
|
||||
onCancelDeletePasskey: () => void;
|
||||
}
|
||||
|
||||
export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
@@ -100,6 +109,8 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
message={t('txt_archive_item_message')}
|
||||
confirmText={t('txt_archive')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmArchive}
|
||||
onCancel={props.onCancelArchive}
|
||||
/>
|
||||
@@ -110,11 +121,22 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
|
||||
confirmText={t('txt_archive')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmBulkArchive}
|
||||
onCancel={props.onCancelBulkArchive}
|
||||
/>
|
||||
|
||||
<ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} />
|
||||
<ConfirmDialog
|
||||
open={props.pendingDeleteOpen}
|
||||
title={t('txt_delete_item')}
|
||||
message={t('txt_are_you_sure_you_want_to_delete_this_item')}
|
||||
danger
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmDelete}
|
||||
onCancel={props.onCancelDelete}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.bulkDeleteOpen}
|
||||
@@ -125,11 +147,23 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
|
||||
}
|
||||
danger
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmBulkDelete}
|
||||
onCancel={props.onCancelBulkDelete}
|
||||
/>
|
||||
|
||||
<ConfirmDialog open={props.moveOpen} title={t('txt_move_selected_items')} message={t('txt_choose_destination_folder')} confirmText={t('txt_move')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmMove} onCancel={props.onCancelMove}>
|
||||
<ConfirmDialog
|
||||
open={props.moveOpen}
|
||||
title={t('txt_move_selected_items')}
|
||||
message={t('txt_choose_destination_folder')}
|
||||
confirmText={t('txt_move')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmMove}
|
||||
onCancel={props.onCancelMove}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_folder')}</span>
|
||||
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}>
|
||||
@@ -143,13 +177,40 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog open={props.createFolderOpen} title={t('txt_create_folder')} message={t('txt_enter_a_folder_name')} confirmText={t('txt_create')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmCreateFolder} onCancel={props.onCancelCreateFolder}>
|
||||
<ConfirmDialog
|
||||
open={props.createFolderOpen}
|
||||
title={t('txt_create_folder')}
|
||||
message={t('txt_enter_a_folder_name')}
|
||||
confirmText={t('txt_create')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmCreateFolder}
|
||||
onCancel={props.onCancelCreateFolder}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_folder_name')}</span>
|
||||
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.renameFolderOpen}
|
||||
title={t('txt_edit')}
|
||||
message={t('txt_enter_a_folder_name')}
|
||||
confirmText={t('txt_save')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmRenameFolder}
|
||||
onCancel={props.onCancelRenameFolder}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_folder_name')}</span>
|
||||
<input className="input" value={props.renameFolderName} onInput={(e) => props.onRenameFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!props.pendingDeleteFolder}
|
||||
title={t('txt_delete_folder')}
|
||||
@@ -157,18 +218,53 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmDeleteFolder}
|
||||
onCancel={props.onCancelDeleteFolder}
|
||||
/>
|
||||
|
||||
<ConfirmDialog open={props.deleteAllFoldersOpen} title={t('txt_delete_all_folders')} message={t('txt_delete_all_folders_message')} confirmText={t('txt_delete')} cancelText={t('txt_cancel')} danger onConfirm={props.onConfirmDeleteAllFolders} onCancel={props.onCancelDeleteAllFolders} />
|
||||
<ConfirmDialog
|
||||
open={props.deleteAllFoldersOpen}
|
||||
title={t('txt_delete_all_folders')}
|
||||
message={t('txt_delete_all_folders_message')}
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmDeleteAllFolders}
|
||||
onCancel={props.onCancelDeleteAllFolders}
|
||||
/>
|
||||
|
||||
<ConfirmDialog open={props.repromptOpen} title={t('txt_unlock_item')} message={t('txt_enter_master_password_to_view_this_item')} confirmText={t('txt_unlock')} cancelText={t('txt_cancel')} showIcon={false} onConfirm={props.onConfirmReprompt} onCancel={props.onCancelReprompt}>
|
||||
<ConfirmDialog
|
||||
open={props.repromptOpen}
|
||||
title={t('txt_unlock_item')}
|
||||
message={t('txt_enter_master_password_to_view_this_item')}
|
||||
confirmText={t('txt_unlock')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
confirmDisabled={props.busy}
|
||||
cancelDisabled={props.busy}
|
||||
onConfirm={props.onConfirmReprompt}
|
||||
onCancel={props.onCancelReprompt}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.deletePasskeyOpen}
|
||||
title={t('txt_delete_passkey')}
|
||||
message={t('txt_are_you_sure_you_want_to_delete_this_passkey')}
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={props.onConfirmDeletePasskey}
|
||||
onCancel={props.onCancelDeletePasskey}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import type { JSX, RefObject } from 'preact';
|
||||
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import {
|
||||
@@ -20,7 +20,15 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers';
|
||||
import {
|
||||
CREATE_TYPE_OPTIONS,
|
||||
cipherTypeLabel,
|
||||
createEmptyLoginUri,
|
||||
formatAttachmentSize,
|
||||
formatHistoryTime,
|
||||
toBooleanFieldValue,
|
||||
WEBSITE_MATCH_OPTIONS,
|
||||
} from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface VaultEditorProps {
|
||||
draft: VaultDraft;
|
||||
@@ -44,6 +52,7 @@ interface VaultEditorProps {
|
||||
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
||||
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
|
||||
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
|
||||
onRequestDeleteLoginPasskey: (index: number) => void;
|
||||
onQueueAttachmentFiles: (list: FileList | null) => void;
|
||||
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
||||
onRemoveQueuedAttachment: (index: number) => void;
|
||||
@@ -71,6 +80,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.id,
|
||||
});
|
||||
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -89,7 +99,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||
className="btn btn-secondary small website-drag-btn"
|
||||
title={t('txt_drag_to_reorder')}
|
||||
aria-label={t('txt_drag_to_reorder')}
|
||||
{...attributes}
|
||||
{...dragButtonAttributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} className="btn-icon" />
|
||||
@@ -287,6 +297,42 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{props.draft.loginFido2Credentials.length > 0 && (
|
||||
<>
|
||||
<div className="section-head" style={{ marginTop: '18px' }}>
|
||||
<h4>{t('txt_passkeys')}</h4>
|
||||
</div>
|
||||
<div className="attachment-list">
|
||||
{props.draft.loginFido2Credentials.map((credential, index) => {
|
||||
const createdAt = String(credential?.creationDate || '').trim();
|
||||
const label = createdAt
|
||||
? t('txt_passkey_created_at_value', { value: formatHistoryTime(createdAt) })
|
||||
: t('txt_passkey');
|
||||
return (
|
||||
<div key={`login-passkey-${index}`} className="attachment-row">
|
||||
<div className="attachment-main">
|
||||
<div className="attachment-text">
|
||||
<strong>{t('txt_passkey')}</strong>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={props.busy}
|
||||
onClick={() => props.onRequestDeleteLoginPasskey(index)}
|
||||
>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -185,11 +185,10 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||
{!!props.filteredCiphers.length && (
|
||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||
{props.visibleCiphers.map((cipher, index) => (
|
||||
{props.visibleCiphers.map((cipher) => (
|
||||
<div
|
||||
key={cipher.id}
|
||||
className={`list-item stagger-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
|
||||
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
|
||||
className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
|
||||
onClick={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.row-check')) return;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
Pencil,
|
||||
ShieldUser,
|
||||
Star,
|
||||
StickyNote,
|
||||
@@ -28,6 +29,7 @@ interface VaultSidebarProps {
|
||||
onChangeFilter: (filter: SidebarFilter) => void;
|
||||
onOpenDeleteAllFolders: () => void;
|
||||
onOpenCreateFolder: () => void;
|
||||
onOpenRenameFolder: (folder: Folder) => void;
|
||||
onOpenDeleteFolder: (folder: Folder) => void;
|
||||
}
|
||||
|
||||
@@ -113,6 +115,20 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
||||
{folder.decName || folder.name || folder.id}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="folder-delete-btn folder-edit-btn"
|
||||
title={t('txt_edit')}
|
||||
aria-label={t('txt_edit')}
|
||||
disabled={props.busy}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
props.onOpenRenameFolder(folder);
|
||||
}}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="folder-delete-btn"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import {
|
||||
CreditCard,
|
||||
FileKey2,
|
||||
@@ -37,7 +37,7 @@ export const CREATE_TYPE_OPTIONS: TypeOption[] = [
|
||||
|
||||
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
||||
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||
export const VAULT_LIST_ROW_HEIGHT = 66;
|
||||
export const VAULT_LIST_ROW_HEIGHT = 74;
|
||||
export const VAULT_LIST_OVERSCAN = 10;
|
||||
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||
@@ -161,11 +161,11 @@ export function hostFromUri(uri: string): string {
|
||||
}
|
||||
|
||||
export function websiteIconUrl(host: string): string {
|
||||
return `/icons/${encodeURIComponent(host)}/icon.png`;
|
||||
return `/icons/${encodeURIComponent(host)}/icon.png?fallback=404`;
|
||||
}
|
||||
|
||||
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||
return { uri: '', match: null };
|
||||
return { uri: '', match: null, originalUri: '', extra: {} };
|
||||
}
|
||||
|
||||
export function websiteMatchLabel(value: number | null | undefined): string {
|
||||
@@ -313,6 +313,10 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
||||
draft.loginUris = (cipher.login.uris || []).map((x) => ({
|
||||
uri: x.decUri || x.uri || '',
|
||||
match: x.match ?? null,
|
||||
originalUri: x.decUri || x.uri || '',
|
||||
extra: Object.fromEntries(
|
||||
Object.entries(x as Record<string, unknown>).filter(([key]) => !['uri', 'match', 'decUri'].includes(key))
|
||||
),
|
||||
}));
|
||||
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||
@@ -429,6 +433,10 @@ export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||
const uri = firstCipherUri(cipher);
|
||||
const host = hostFromUri(uri);
|
||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||
useEffect(() => {
|
||||
setErrored(host ? failedIconHosts.has(host) : false);
|
||||
}, [host]);
|
||||
|
||||
if (host && !errored) {
|
||||
return (
|
||||
<img
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
deleteAuthorizedDevice,
|
||||
deriveLoginHash,
|
||||
getCurrentDeviceIdentifier,
|
||||
getApiKey,
|
||||
getTotpRecoveryCode,
|
||||
rotateApiKey,
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
setTotp,
|
||||
updateAuthorizedDeviceName,
|
||||
updateProfile,
|
||||
} from '@/lib/api/auth';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -147,10 +150,45 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
return code;
|
||||
},
|
||||
|
||||
async getApiKey(masterPassword: string): Promise<string> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const key = await getApiKey(authedFetch, derived.hash);
|
||||
if (!key) throw new Error(t('txt_api_key_is_empty'));
|
||||
return key;
|
||||
},
|
||||
|
||||
async rotateApiKey(masterPassword: string): Promise<string> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const key = await rotateApiKey(authedFetch, derived.hash);
|
||||
if (!key) throw new Error(t('txt_api_key_is_empty'));
|
||||
return key;
|
||||
},
|
||||
|
||||
async refreshAuthorizedDevices() {
|
||||
await refetchAuthorizedDevices();
|
||||
},
|
||||
|
||||
async renameAuthorizedDevice(device: AuthorizedDevice, name: string) {
|
||||
const normalized = String(name || '').trim();
|
||||
if (!normalized) {
|
||||
onNotify('error', t('txt_device_note_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAuthorizedDeviceName(authedFetch, device.identifier, normalized);
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_device_note_updated'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_device_note_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
openRevokeDeviceTrust(device: AuthorizedDevice) {
|
||||
onSetConfirm({
|
||||
title: t('txt_revoke_device_authorization'),
|
||||
@@ -159,9 +197,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_device_authorization_revoked'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_device_trust_failed'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
@@ -175,6 +217,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
||||
if (device.identifier === getCurrentDeviceIdentifier()) {
|
||||
onNotify('success', t('txt_device_removed'));
|
||||
@@ -183,6 +226,9 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
}
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_device_removed'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_remove_device_failed'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
@@ -196,9 +242,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
||||
await refetchAuthorizedDevices();
|
||||
onNotify('success', t('txt_all_device_authorizations_revoked'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_all_device_trust_failed'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
@@ -212,9 +262,13 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onConfirm: () => {
|
||||
onSetConfirm(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await deleteAllAuthorizedDevices(authedFetch);
|
||||
onNotify('success', t('txt_all_devices_removed'));
|
||||
onLogoutNow();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_remove_all_devices_failed'));
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type CiphersImportPayload,
|
||||
type ImportedCipherMapEntry,
|
||||
updateCipher,
|
||||
updateFolder,
|
||||
unarchiveCipher,
|
||||
uploadCipherAttachment,
|
||||
} from '@/lib/api/vault';
|
||||
@@ -340,6 +341,28 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
}
|
||||
},
|
||||
|
||||
async renameFolder(folderId: string, name: string) {
|
||||
const id = String(folderId || '').trim();
|
||||
const nextName = String(name || '').trim();
|
||||
if (!id) {
|
||||
onNotify('error', t('txt_folder_not_found'));
|
||||
return;
|
||||
}
|
||||
if (!nextName) {
|
||||
onNotify('error', t('txt_folder_name_is_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
await updateFolder(authedFetch, session, id, nextName);
|
||||
await refetchFolders();
|
||||
onNotify('success', t('txt_folder_updated'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed'));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async bulkRestoreVaultItems(ids: string[]) {
|
||||
try {
|
||||
await bulkRestoreCiphers(authedFetch, ids);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64ToBytes, decryptBw } from './crypto';
|
||||
import { base64ToBytes, decryptBw, toBufferSource } from './crypto';
|
||||
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
|
||||
import type { Profile, SessionState } from './types';
|
||||
|
||||
@@ -9,7 +9,7 @@ const AES_GCM_ALGORITHM = 'AES-GCM';
|
||||
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
pkcs8,
|
||||
toBufferSource(pkcs8),
|
||||
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
||||
false,
|
||||
['decrypt']
|
||||
@@ -17,7 +17,7 @@ async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
||||
}
|
||||
|
||||
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', keyBytes, { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
||||
return crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
||||
}
|
||||
|
||||
export async function decryptPortableBackupSettings(
|
||||
@@ -50,15 +50,15 @@ export async function decryptPortableBackupSettings(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: PORTABLE_ALGORITHM },
|
||||
privateKey,
|
||||
base64ToBytes(wrap.wrappedKey)
|
||||
toBufferSource(base64ToBytes(wrap.wrappedKey))
|
||||
)
|
||||
);
|
||||
const aesKey = await importPortableAesKey(portableDek);
|
||||
const plaintext = new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: AES_GCM_ALGORITHM, iv: base64ToBytes(portable.iv) },
|
||||
{ name: AES_GCM_ALGORITHM, iv: toBufferSource(base64ToBytes(portable.iv)) },
|
||||
aesKey,
|
||||
base64ToBytes(portable.ciphertext)
|
||||
toBufferSource(base64ToBytes(portable.ciphertext))
|
||||
)
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
|
||||
|
||||
@@ -10,8 +10,10 @@ import type {
|
||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||
|
||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||
const PROFILE_SNAPSHOT_KEY = 'nodewarden.web.profile-snapshot.v1';
|
||||
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
||||
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
||||
const WEB_SESSION_HEADER = 'X-NodeWarden-Web-Session';
|
||||
|
||||
export interface PreloginResult {
|
||||
hash: string;
|
||||
@@ -26,6 +28,24 @@ export interface PreloginKdfConfig {
|
||||
kdfParallelism: number | null;
|
||||
}
|
||||
|
||||
interface PersistedSessionState {
|
||||
email: string;
|
||||
authMode: 'token' | 'web-cookie';
|
||||
}
|
||||
|
||||
interface RefreshFailure {
|
||||
ok: false;
|
||||
transient: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface RefreshSuccess {
|
||||
ok: true;
|
||||
token: TokenSuccess;
|
||||
}
|
||||
|
||||
type RefreshResult = RefreshFailure | RefreshSuccess;
|
||||
|
||||
function randomHex(length: number): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||
@@ -66,12 +86,19 @@ export function loadSession(): SessionState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as SessionState;
|
||||
if (!parsed.accessToken || !parsed.refreshToken) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
|
||||
if (parsed.authMode === 'web-cookie' && parsed.email) {
|
||||
return {
|
||||
email: parsed.email,
|
||||
authMode: 'web-cookie',
|
||||
};
|
||||
}
|
||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
||||
return {
|
||||
accessToken: parsed.accessToken,
|
||||
refreshToken: parsed.refreshToken,
|
||||
email: parsed.email,
|
||||
authMode: 'token',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -83,14 +110,35 @@ export function saveSession(session: SessionState | null): void {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
return;
|
||||
}
|
||||
const persisted: SessionState = {
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
const persisted: PersistedSessionState = {
|
||||
email: session.email,
|
||||
authMode: session.authMode === 'token' ? 'token' : 'web-cookie',
|
||||
};
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
||||
}
|
||||
|
||||
export function loadProfileSnapshot(email?: string | null): Profile | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Profile;
|
||||
if (!parsed?.email || !parsed?.key) return null;
|
||||
if (email && parsed.email !== email) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveProfileSnapshot(profile: Profile | null): void {
|
||||
if (!profile) return;
|
||||
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile));
|
||||
}
|
||||
|
||||
export function clearProfileSnapshot(): void {
|
||||
localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
|
||||
}
|
||||
|
||||
export function getCurrentDeviceIdentifier(): string {
|
||||
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||
}
|
||||
@@ -170,7 +218,10 @@ export async function loginWithPassword(
|
||||
}
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
[WEB_SESSION_HEADER]: '1',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
@@ -183,18 +234,60 @@ export async function loginWithPassword(
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<TokenSuccess | null> {
|
||||
function isTransientRefreshStatus(status: number): boolean {
|
||||
return status === 0 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', 'refresh_token');
|
||||
body.set('refresh_token', refreshToken);
|
||||
if (session.authMode !== 'web-cookie' && session.refreshToken) {
|
||||
body.set('refresh_token', session.refreshToken);
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
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);
|
||||
return json || null;
|
||||
if (!json?.access_token) {
|
||||
return { ok: false, transient: false, error: 'Session refresh failed' };
|
||||
}
|
||||
return { ok: true, token: json };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
transient: true,
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
|
||||
const body = new URLSearchParams();
|
||||
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
||||
body.set('token', session.refreshToken);
|
||||
}
|
||||
await fetch('/identity/connect/revocation', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||
},
|
||||
body: body.toString(),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function registerAccount(args: {
|
||||
@@ -279,18 +372,22 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||
|
||||
let resp = await fetch(input, { ...init, headers });
|
||||
if (resp.status !== 401 || !session.refreshToken) return resp;
|
||||
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||
|
||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
||||
if (!refreshed?.access_token) {
|
||||
const refreshed = await refreshAccessToken(session);
|
||||
if (!refreshed.ok) {
|
||||
if (refreshed.transient) {
|
||||
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
||||
}
|
||||
setSession(null);
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
|
||||
const nextSession: SessionState = {
|
||||
...session,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
||||
accessToken: refreshed.token.access_token,
|
||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||
};
|
||||
setSession(nextSession);
|
||||
saveSession(nextSession);
|
||||
@@ -478,7 +575,50 @@ export async function deleteAuthorizedDevice(
|
||||
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
|
||||
}
|
||||
|
||||
export async function updateAuthorizedDeviceName(
|
||||
authedFetch: AuthedFetch,
|
||||
deviceIdentifier: string,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
const normalized = String(name || '').trim();
|
||||
if (!normalized) throw new Error(t('txt_device_note_required'));
|
||||
const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}/name`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: normalized }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
|
||||
}
|
||||
|
||||
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
|
||||
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
||||
}
|
||||
|
||||
export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
|
||||
const resp = await authedFetch('/api/accounts/api-key', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'Failed to get API key');
|
||||
}
|
||||
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
||||
return String(body.apiKey || '');
|
||||
}
|
||||
|
||||
export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
|
||||
const resp = await authedFetch('/api/accounts/rotate-api-key', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'Failed to rotate API key');
|
||||
}
|
||||
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
||||
return String(body.apiKey || '');
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type AuthedFetch,
|
||||
} from './shared';
|
||||
import { readResponseBytesWithProgress } from '../download';
|
||||
import { toBufferSource } from '../crypto';
|
||||
import { unzipSync, zipSync } from 'fflate';
|
||||
|
||||
export type {
|
||||
@@ -148,32 +149,21 @@ interface BackupExportManifest {
|
||||
|
||||
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);
|
||||
if (!match) return null;
|
||||
const datePart = match[1];
|
||||
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;
|
||||
return `${match[1]}_${match[2]}`;
|
||||
}
|
||||
|
||||
function buildBackupFileName(date: Date, checksumPrefix: string): string {
|
||||
const parts = [
|
||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||
date.getUTCDate().toString().padStart(2, '0'),
|
||||
date.getUTCHours().toString().padStart(2, '0'),
|
||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
||||
];
|
||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}_${checksumPrefix}.zip`;
|
||||
function buildBackupFileName(timestamp: string, checksumPrefix: string): string {
|
||||
return `nodewarden_backup_${timestamp}_${checksumPrefix}.zip`;
|
||||
}
|
||||
|
||||
async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> {
|
||||
const integrity = await verifyBackupFileIntegrity(bytes, fileName);
|
||||
const effectiveDate = parseBackupTimestampFromFileName(fileName) || new Date();
|
||||
return buildBackupFileName(effectiveDate, integrity.actualPrefix);
|
||||
const timestamp = extractBackupTimestampFromFileName(fileName);
|
||||
if (!timestamp) return fileName;
|
||||
return buildBackupFileName(timestamp, integrity.actualPrefix);
|
||||
}
|
||||
|
||||
export async function exportAdminBackup(
|
||||
@@ -378,7 +368,7 @@ export function extractBackupFileChecksumPrefix(fileName: string): string | null
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
|
||||
import type { Send, SendDraft, SessionState } from '../types';
|
||||
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
|
||||
import { loadVaultSyncSnapshot } from './vault-sync';
|
||||
|
||||
function toIsoDateFromDays(value: string, required: boolean): string | null {
|
||||
const raw = String(value || '').trim();
|
||||
@@ -61,10 +62,8 @@ function parseMaxAccessCountRaw(value: string): number | null {
|
||||
}
|
||||
|
||||
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
||||
const resp = await authedFetch('/api/sends');
|
||||
if (!resp.ok) throw new Error('Failed to load sends');
|
||||
const body = await parseJson<{ object: 'list'; data: Send[] }>(resp);
|
||||
return body?.data || [];
|
||||
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||
return body.sends || [];
|
||||
}
|
||||
|
||||
export async function createSend(
|
||||
@@ -152,10 +151,13 @@ export async function createSend(
|
||||
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
|
||||
const uploadUrl = uploadInfo?.url;
|
||||
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
||||
if (!session.accessToken) throw new Error('Unauthorized');
|
||||
const payload = new ArrayBuffer(encryptedFileBytes.byteLength);
|
||||
new Uint8Array(payload).set(encryptedFileBytes);
|
||||
const uploadResp = await uploadDirectEncryptedPayload({
|
||||
accessToken: session.accessToken,
|
||||
uploadUrl,
|
||||
payload: encryptedFileBytes,
|
||||
payload,
|
||||
fileUploadType: uploadInfo?.fileUploadType,
|
||||
unsupportedMessage: 'Unsupported send upload type',
|
||||
onProgress,
|
||||
|
||||
@@ -63,14 +63,14 @@ interface UploadWithProgressOptions {
|
||||
accessToken?: string;
|
||||
method?: string;
|
||||
headers?: HeadersInit;
|
||||
body?: Document | XMLHttpRequestBodyInit | null;
|
||||
body?: XMLHttpRequestBodyInit | null;
|
||||
onProgress?: (percent: number | null) => void;
|
||||
}
|
||||
|
||||
interface DirectEncryptedUploadOptions {
|
||||
accessToken: string;
|
||||
uploadUrl: string;
|
||||
payload: ArrayBuffer | Uint8Array;
|
||||
payload: XMLHttpRequestBodyInit;
|
||||
fileUploadType: number | null | undefined;
|
||||
unsupportedMessage: string;
|
||||
onProgress?: (percent: number | null) => void;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Cipher, Folder, Send } from '../types';
|
||||
import { parseJson, type AuthedFetch } from './shared';
|
||||
|
||||
interface VaultSyncResponse {
|
||||
ciphers?: Cipher[];
|
||||
folders?: Folder[];
|
||||
sends?: Send[];
|
||||
}
|
||||
|
||||
const pendingSyncRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>();
|
||||
|
||||
export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> {
|
||||
const existing = pendingSyncRequests.get(authedFetch);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = (async () => {
|
||||
const resp = await authedFetch('/api/sync', {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to load vault');
|
||||
const body = await parseJson<VaultSyncResponse>(resp);
|
||||
return body || {};
|
||||
})();
|
||||
|
||||
pendingSyncRequests.set(authedFetch, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (pendingSyncRequests.get(authedFetch) === request) {
|
||||
pendingSyncRequests.delete(authedFetch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto';
|
||||
import type {
|
||||
Cipher,
|
||||
CipherPasswordHistoryEntry,
|
||||
Folder,
|
||||
ListResponse,
|
||||
SessionState,
|
||||
VaultDraft,
|
||||
VaultDraftField,
|
||||
@@ -16,12 +16,11 @@ import {
|
||||
type AuthedFetch,
|
||||
} from './shared';
|
||||
import { readResponseBytesWithProgress } from '../download';
|
||||
import { loadVaultSyncSnapshot } from './vault-sync';
|
||||
|
||||
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
||||
const resp = await authedFetch('/api/folders');
|
||||
if (!resp.ok) throw new Error('Failed to load folders');
|
||||
const body = await parseJson<ListResponse<Folder>>(resp);
|
||||
return body?.data || [];
|
||||
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||
return body.folders || [];
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
@@ -93,10 +92,8 @@ export async function updateFolder(
|
||||
}
|
||||
|
||||
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
|
||||
const resp = await authedFetch('/api/ciphers?deleted=true');
|
||||
if (!resp.ok) throw new Error('Failed to load ciphers');
|
||||
const body = await parseJson<ListResponse<Cipher>>(resp);
|
||||
return body?.data || [];
|
||||
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||
return body.ciphers || [];
|
||||
}
|
||||
|
||||
export interface CiphersImportPayload {
|
||||
@@ -240,6 +237,7 @@ export async function uploadCipherAttachment(
|
||||
const attachmentId = String(meta.attachmentId || '').trim();
|
||||
const uploadUrl = String(meta.url || '').trim();
|
||||
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
|
||||
if (!session.accessToken) throw new Error('Unauthorized');
|
||||
|
||||
const payload = new ArrayBuffer(encryptedBytes.byteLength);
|
||||
new Uint8Array(payload).set(encryptedBytes);
|
||||
@@ -349,6 +347,61 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array)
|
||||
return encryptBw(new TextEncoder().encode(s), enc, mac);
|
||||
}
|
||||
|
||||
async function encryptPasswordHistory(
|
||||
entries: CipherPasswordHistoryEntry[] | null | undefined,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<CipherPasswordHistoryEntry[] | null> {
|
||||
if (!Array.isArray(entries) || entries.length === 0) return null;
|
||||
|
||||
const out: CipherPasswordHistoryEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
const rawPassword = String(entry?.password || '');
|
||||
const plainPassword = entry?.decPassword ?? rawPassword;
|
||||
const encryptedPassword = looksLikeCipherString(rawPassword)
|
||||
? rawPassword
|
||||
: await encryptTextValue(plainPassword, enc, mac);
|
||||
if (!encryptedPassword) continue;
|
||||
out.push({
|
||||
password: encryptedPassword,
|
||||
lastUsedDate: toIsoDateOrNow(entry?.lastUsedDate),
|
||||
});
|
||||
}
|
||||
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
async function buildUpdatedPasswordHistory(
|
||||
cipher: Cipher | null,
|
||||
draft: VaultDraft,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<CipherPasswordHistoryEntry[] | null> {
|
||||
const existingHistory = Array.isArray(cipher?.passwordHistory) ? cipher.passwordHistory : [];
|
||||
const currentPassword = String(cipher?.login?.decPassword || '');
|
||||
const nextPassword = String(draft.loginPassword || '');
|
||||
const passwordChanged = currentPassword !== nextPassword;
|
||||
const history = await encryptPasswordHistory(existingHistory, enc, mac);
|
||||
|
||||
if (!passwordChanged || !currentPassword.trim()) {
|
||||
return history;
|
||||
}
|
||||
|
||||
const encryptedCurrentPassword = await encryptTextValue(currentPassword, enc, mac);
|
||||
if (!encryptedCurrentPassword) {
|
||||
return history;
|
||||
}
|
||||
|
||||
const nextEntries: CipherPasswordHistoryEntry[] = [
|
||||
{
|
||||
password: encryptedCurrentPassword,
|
||||
lastUsedDate: new Date().toISOString(),
|
||||
},
|
||||
...(history || []),
|
||||
];
|
||||
return nextEntries.slice(0, 5);
|
||||
}
|
||||
|
||||
async function encryptCustomFields(
|
||||
fields: VaultDraftField[],
|
||||
enc: Uint8Array,
|
||||
@@ -371,12 +424,20 @@ async function encryptUris(
|
||||
uris: VaultDraft['loginUris'],
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<Array<{ uri: string | null; match: number | null }>> {
|
||||
const out: Array<{ uri: string | null; match: number | null }> = [];
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
for (const entry of uris || []) {
|
||||
const trimmed = String(entry?.uri || '').trim();
|
||||
if (!trimmed) continue;
|
||||
const preservedExtra =
|
||||
entry?.extra && typeof entry.extra === 'object'
|
||||
? { ...entry.extra }
|
||||
: {};
|
||||
if (String(entry?.originalUri || '').trim() !== trimmed) {
|
||||
delete preservedExtra.uriChecksum;
|
||||
}
|
||||
out.push({
|
||||
...preservedExtra,
|
||||
uri: await encryptTextValue(trimmed, enc, mac),
|
||||
match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
|
||||
});
|
||||
@@ -468,6 +529,7 @@ async function buildCipherPayload(
|
||||
const userMac = base64ToBytes(session.symMacKey);
|
||||
const keys = await getCipherKeys(cipher, userEnc, userMac);
|
||||
const type = Number(draft.type || cipher?.type || 1);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
type,
|
||||
@@ -482,6 +544,7 @@ async function buildCipherPayload(
|
||||
secureNote: null,
|
||||
sshKey: null,
|
||||
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
|
||||
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
|
||||
};
|
||||
|
||||
if (cipher?.id) {
|
||||
@@ -490,17 +553,25 @@ async function buildCipherPayload(
|
||||
}
|
||||
|
||||
if (type === 1) {
|
||||
const passwordChanged = String(cipher?.login?.decPassword || '') !== String(draft.loginPassword || '');
|
||||
const existingFido2 =
|
||||
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
|
||||
? (cipher.login as any).fido2Credentials
|
||||
: draft.loginFido2Credentials;
|
||||
const existingLogin =
|
||||
cipher?.login && typeof cipher.login === 'object'
|
||||
? { ...(cipher.login as Record<string, unknown>) }
|
||||
: {};
|
||||
payload.login = {
|
||||
...existingLogin,
|
||||
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
||||
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
||||
passwordRevisionDate: passwordChanged ? now : existingLogin.passwordRevisionDate ?? null,
|
||||
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
|
||||
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
||||
};
|
||||
payload.passwordHistory = await buildUpdatedPasswordHistory(cipher, draft, keys.enc, keys.mac);
|
||||
} else if (type === 3) {
|
||||
payload.card = {
|
||||
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createAuthedFetch,
|
||||
deriveLoginHashLocally,
|
||||
getProfile,
|
||||
loadProfileSnapshot,
|
||||
loadSession,
|
||||
loginWithPassword,
|
||||
refreshAccessToken,
|
||||
@@ -26,6 +27,7 @@ export interface BootstrapAppResult {
|
||||
session: SessionState | null;
|
||||
profile: Profile | null;
|
||||
phase: AppPhase;
|
||||
needsBackgroundHydration?: boolean;
|
||||
}
|
||||
|
||||
export interface InitialAppBootstrapState {
|
||||
@@ -51,8 +53,9 @@ export interface RecoverTwoFactorResult {
|
||||
newRecoveryCode: string | null;
|
||||
}
|
||||
|
||||
function decodeJwtExp(accessToken: string): number | null {
|
||||
function decodeJwtExp(accessToken: string | undefined): number | null {
|
||||
try {
|
||||
if (!accessToken) return null;
|
||||
const parts = accessToken.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
@@ -66,23 +69,24 @@ function decodeJwtExp(accessToken: string): number | null {
|
||||
}
|
||||
|
||||
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> {
|
||||
if (!session.refreshToken) return session;
|
||||
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null;
|
||||
const exp = decodeJwtExp(session.accessToken);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (exp !== null && exp - nowSeconds > 60) {
|
||||
if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
||||
if (!refreshed?.access_token) {
|
||||
return exp !== null && exp > nowSeconds ? session : null;
|
||||
const refreshed = await refreshAccessToken(session);
|
||||
if (!refreshed.ok) {
|
||||
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
||||
accessToken: refreshed.token.access_token,
|
||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,31 +201,51 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await maybeRefreshSession(loaded);
|
||||
if (!session) {
|
||||
throw new Error('Session expired');
|
||||
const cachedProfile = loadProfileSnapshot(loaded.email);
|
||||
if (cachedProfile) {
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session: loaded,
|
||||
profile: cachedProfile,
|
||||
phase: 'locked',
|
||||
needsBackgroundHydration: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session: loaded,
|
||||
profile: null,
|
||||
phase: 'locked',
|
||||
needsBackgroundHydration: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function hydrateLockedSession(
|
||||
session: SessionState,
|
||||
fallbackProfile: Profile | null = null
|
||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession?.accessToken) {
|
||||
return { session: null, profile: null };
|
||||
}
|
||||
try {
|
||||
const profile = await getProfile(
|
||||
createAuthedFetch(
|
||||
() => session,
|
||||
() => refreshedSession,
|
||||
() => {}
|
||||
)
|
||||
);
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session,
|
||||
session: refreshedSession,
|
||||
profile,
|
||||
phase: 'locked',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
defaultKdfIterations,
|
||||
jwtWarning: null,
|
||||
session: null,
|
||||
profile: null,
|
||||
phase: initial.phase === 'register' ? 'register' : 'login',
|
||||
session: refreshedSession,
|
||||
profile: fallbackProfile,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -236,6 +260,7 @@ export async function completeLogin(
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
email: normalizedEmail,
|
||||
authMode: token.web_session ? 'web-cookie' : 'token',
|
||||
};
|
||||
const tempFetch = createAuthedFetch(
|
||||
() => baseSession,
|
||||
@@ -359,3 +384,4 @@ export async function performUnlock(
|
||||
}
|
||||
return { ...refreshedSession, ...keys };
|
||||
}
|
||||
|
||||
|
||||
@@ -161,11 +161,6 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
||||
draft.loginUsername = asText(login.username);
|
||||
draft.loginPassword = asText(login.password);
|
||||
draft.loginTotp = asText(login.totp);
|
||||
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
|
||||
? login.fido2Credentials
|
||||
.filter((credential): credential is Record<string, unknown> => !!credential && typeof credential === 'object')
|
||||
.map((credential) => ({ ...credential }))
|
||||
: [];
|
||||
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
||||
const uris = urisRaw
|
||||
.map((u) => {
|
||||
@@ -175,10 +170,17 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
||||
return {
|
||||
uri,
|
||||
match: typeof matchRaw === 'number' && Number.isFinite(matchRaw) ? matchRaw : null,
|
||||
originalUri: uri,
|
||||
extra: Object.fromEntries(
|
||||
Object.entries(row).filter(([key]) => !['uri', 'match'].includes(key))
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((u) => !!u.uri);
|
||||
draft.loginUris = uris.length ? uris : [{ uri: '', match: null }];
|
||||
draft.loginUris = uris.length ? uris : [{ uri: '', match: null, originalUri: '', extra: {} }];
|
||||
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
|
||||
? login.fido2Credentials.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||
: [];
|
||||
} else if (type === 3) {
|
||||
const card = (cipher.card || {}) as Record<string, unknown>;
|
||||
draft.cardholderName = asText(card.cardholderName);
|
||||
|
||||
@@ -18,7 +18,7 @@ export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
return out;
|
||||
}
|
||||
|
||||
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
||||
export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
||||
return new Uint8Array(bytes).buffer;
|
||||
}
|
||||
|
||||
|
||||
@@ -293,7 +293,9 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
|
||||
)
|
||||
: [],
|
||||
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
||||
? await Promise.all(cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac)))
|
||||
? await Promise.all(
|
||||
cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac))
|
||||
)
|
||||
: [],
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -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_permanently: "Are you sure you want to permanently delete {count} selected items?",
|
||||
txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?",
|
||||
txt_are_you_sure_you_want_to_delete_this_passkey: "Are you sure you want to delete this passkey?",
|
||||
txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?",
|
||||
txt_authenticator_key: "Authenticator Key",
|
||||
txt_authorized_devices: "Authorized Devices",
|
||||
@@ -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_invites: "Delete all invites",
|
||||
txt_delete_item: "Delete Item",
|
||||
txt_delete_passkey: "Delete Passkey",
|
||||
txt_delete_item_failed: "Delete item failed",
|
||||
txt_delete_permanently: "Delete Permanently",
|
||||
txt_archive: "Archive",
|
||||
@@ -385,6 +387,9 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_device: "Device",
|
||||
txt_device_authorization_revoked: "Device trust revoked",
|
||||
txt_device_management: "Device Management",
|
||||
txt_device_note: "Device Note",
|
||||
txt_device_note_required: "Device name is required",
|
||||
txt_device_note_updated: "Device name updated",
|
||||
txt_device_removed: "Device removed",
|
||||
txt_load_devices_failed: "Failed to load devices",
|
||||
txt_disable_this_send: "Disable this send",
|
||||
@@ -457,6 +462,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_item_created: "Item created",
|
||||
txt_item_deleted: "Item deleted",
|
||||
txt_item_history: "Item History",
|
||||
txt_password_history: "Password History",
|
||||
txt_password_updated_value: "Password updated: {value}",
|
||||
txt_item_name_is_required: "Item name is required.",
|
||||
txt_item_updated: "Item updated",
|
||||
txt_last_edited_value: "Last edited: {value}",
|
||||
@@ -548,6 +555,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_not_trusted: "Not trusted",
|
||||
txt_note: "Note",
|
||||
txt_notes: "Notes",
|
||||
txt_replace_device_name_with_note: "Set a custom name for this device without changing its detected system type.",
|
||||
txt_number: "Number",
|
||||
txt_open: "Open",
|
||||
txt_opera_browser: "Opera Browser",
|
||||
@@ -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_load_failed: "Failed to load password hint",
|
||||
txt_password_hint_too_long: "Password hint must be 120 characters or fewer",
|
||||
txt_passkey: "Passkey",
|
||||
txt_passkeys: "Passkeys",
|
||||
txt_passkey_created_at_value: "Created on {value}",
|
||||
txt_phone: "Phone",
|
||||
txt_please_input_email_and_password: "Please input email and password",
|
||||
txt_please_input_master_password: "Please input master password",
|
||||
@@ -590,6 +601,21 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_recovery_code_copied: "Recovery code copied",
|
||||
txt_recovery_code_is_empty: "Recovery code is empty",
|
||||
txt_recovery_code_loaded: "Recovery code loaded",
|
||||
txt_api_key: "API Key",
|
||||
txt_view_api_key: "View API Key",
|
||||
txt_rotate_api_key: "Rotate API Key",
|
||||
txt_api_key_copied: "API key copied",
|
||||
txt_api_key_loaded: "API key loaded",
|
||||
txt_api_key_rotated: "API key rotated",
|
||||
txt_rotate_api_key_confirm: "Rotate API key? The current key will stop working immediately.",
|
||||
txt_api_key_is_empty: "API key is empty",
|
||||
txt_api_key_dialog_intro: "Your API key can be used to authenticate with the Bitwarden CLI.",
|
||||
txt_api_key_warning_body: "Your API key is an alternative authentication mechanism. Keep it secret.",
|
||||
txt_oauth_client_credentials: "OAuth 2.0 Client Credentials",
|
||||
txt_client_id: "client_id",
|
||||
txt_client_secret: "client_secret",
|
||||
txt_scope: "scope",
|
||||
txt_grant_type: "grant_type",
|
||||
txt_refresh: "Refresh",
|
||||
txt_refresh_in_seconds_s: "Refresh in {seconds}s",
|
||||
txt_regenerate: "Regenerate",
|
||||
@@ -613,6 +639,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_revoke_device_trust_failed: "Failed to revoke device trust",
|
||||
txt_revoke_all_device_trust_failed: "Failed to revoke all device trust",
|
||||
txt_revoke_trust: "Revoke Trust",
|
||||
txt_untrust: "Untrust",
|
||||
txt_update_device_note_failed: "Update device note failed",
|
||||
txt_role: "Role",
|
||||
txt_save: "Save",
|
||||
txt_save_profile: "Save Profile",
|
||||
@@ -671,8 +699,6 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_total_items_count: "{count} items",
|
||||
txt_totp_secret: "TOTP Secret",
|
||||
txt_totp_verify_failed: "TOTP verify failed",
|
||||
txt_passkey: "Passkey",
|
||||
txt_passkey_created_at_value: "Created at {value}",
|
||||
txt_attachments: "Attachments",
|
||||
txt_upload_attachments: "Upload attachments",
|
||||
txt_new_attachments: "New attachments",
|
||||
@@ -1064,7 +1090,10 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_additional_options: '附加选项',
|
||||
txt_custom_fields: '自定义字段',
|
||||
txt_notes: '备注',
|
||||
txt_replace_device_name_with_note: '为这台设备设置自定义名称,不会改变系统识别到的设备类型。',
|
||||
txt_item_history: '项目历史',
|
||||
txt_password_history: '密码历史记录',
|
||||
txt_password_updated_value: '密码新于: {value}',
|
||||
txt_last_edited_value: '最后编辑:{value}',
|
||||
txt_created_value: '创建于:{value}',
|
||||
txt_username: '用户名',
|
||||
@@ -1110,12 +1139,17 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_view_recovery_code: '查看恢复代码',
|
||||
txt_copy_code: '复制代码',
|
||||
txt_device_management: '设备管理',
|
||||
txt_device_note: '备注',
|
||||
txt_device_note_required: '设备名称不能为空',
|
||||
txt_device_note_updated: '设备名称已更新',
|
||||
txt_authorized_devices: '已授权设备',
|
||||
txt_device: '设备',
|
||||
txt_last_seen: '最后在线',
|
||||
txt_trusted_until: '信任至',
|
||||
txt_revoke_trust: '撤销信任',
|
||||
txt_untrust: '不信任',
|
||||
txt_remove_device_2: '移除设备',
|
||||
txt_update_device_note_failed: '更新设备备注失败',
|
||||
txt_not_trusted: '未信任',
|
||||
txt_unknown_device: '未知设备',
|
||||
txt_users: '用户',
|
||||
@@ -1163,6 +1197,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_no_name: '(无名称)',
|
||||
txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?',
|
||||
txt_delete_item: '删除项目',
|
||||
txt_delete_passkey: '删除通行密钥',
|
||||
txt_delete_selected_items: '删除所选项目',
|
||||
txt_move_selected_items: '移动所选项目',
|
||||
txt_create_folder: '创建文件夹',
|
||||
@@ -1226,6 +1261,7 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?',
|
||||
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: '确认永久删除所选的 {count} 个项目?',
|
||||
txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?',
|
||||
txt_are_you_sure_you_want_to_delete_this_passkey: '确认删除这个通行密钥?',
|
||||
txt_authenticator_key: '验证器密钥',
|
||||
txt_brand: '品牌',
|
||||
txt_bulk_delete_failed: '批量删除失败',
|
||||
@@ -1326,6 +1362,9 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。',
|
||||
txt_password_hint_load_failed: '加载密码提示失败',
|
||||
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
|
||||
txt_passkey: '通行密钥',
|
||||
txt_passkeys: '通行密钥',
|
||||
txt_passkey_created_at_value: '创建于 {value}',
|
||||
txt_phone: '电话',
|
||||
txt_please_input_email_and_password: '请输入邮箱和密码',
|
||||
txt_please_input_master_password: '请输入主密码',
|
||||
@@ -1339,6 +1378,21 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_recovery_code_copied: '恢复代码已复制',
|
||||
txt_recovery_code_is_empty: '恢复代码为空',
|
||||
txt_recovery_code_loaded: '恢复代码已加载',
|
||||
txt_api_key: 'API 密钥',
|
||||
txt_view_api_key: '查看 API 密钥',
|
||||
txt_rotate_api_key: '轮换 API 密钥',
|
||||
txt_api_key_copied: 'API 密钥已复制',
|
||||
txt_api_key_loaded: 'API 密钥已加载',
|
||||
txt_api_key_rotated: 'API 密钥已轮换',
|
||||
txt_rotate_api_key_confirm: '轮换 API 密钥?当前密钥将立即失效。',
|
||||
txt_api_key_is_empty: 'API 密钥为空',
|
||||
txt_api_key_dialog_intro: '您的 API 密钥可用于在 Bitwarden CLI 中进行身份验证。',
|
||||
txt_api_key_warning_body: '您的 API 密钥是一种替代身份验证机制。请严格保密。',
|
||||
txt_oauth_client_credentials: 'OAuth 2.0 客户端凭据',
|
||||
txt_client_id: 'client_id',
|
||||
txt_client_secret: 'client_secret',
|
||||
txt_scope: 'scope',
|
||||
txt_grant_type: 'grant_type',
|
||||
txt_refresh_in_seconds_s: '{seconds} 秒后刷新',
|
||||
txt_registration_succeeded_please_sign_in: '注册成功,请登录',
|
||||
txt_remove_device: '移除设备',
|
||||
@@ -1431,8 +1485,6 @@ zhCNOverrides.txt_lock = '锁定';
|
||||
zhCNOverrides.txt_menu = '菜单';
|
||||
zhCNOverrides.txt_settings = '设置';
|
||||
zhCNOverrides.txt_back = '返回';
|
||||
zhCNOverrides.txt_passkey = 'Passkey';
|
||||
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
|
||||
zhCNOverrides.txt_attachments = '附件';
|
||||
zhCNOverrides.txt_upload_attachments = '上传附件';
|
||||
zhCNOverrides.txt_new_attachments = '待上传附件';
|
||||
@@ -1493,7 +1545,9 @@ messages.en.txt_delete_all_folders = 'Delete All Folders';
|
||||
messages.en.txt_delete_all_folders_message = 'Delete all folders? Items inside will move to No Folder.';
|
||||
messages.en.txt_folder_not_found = 'Folder not found';
|
||||
messages.en.txt_folder_deleted = 'Folder deleted';
|
||||
messages.en.txt_folder_updated = 'Folder updated';
|
||||
messages.en.txt_folders_deleted = 'Folders deleted';
|
||||
messages.en.txt_update_folder_failed = 'Update folder failed';
|
||||
messages.en.txt_delete_folder_failed = 'Delete folder failed';
|
||||
messages.en.txt_delete_all_folders_failed = 'Delete all folders failed';
|
||||
messages.en.txt_other = 'Other';
|
||||
@@ -1575,7 +1629,9 @@ zhCNOverrides.txt_delete_all_folders = '删除全部文件夹';
|
||||
zhCNOverrides.txt_delete_all_folders_message = '确认删除全部文件夹吗?其中的项目将移至无文件夹。';
|
||||
zhCNOverrides.txt_folder_not_found = '文件夹不存在';
|
||||
zhCNOverrides.txt_folder_deleted = '文件夹已删除';
|
||||
zhCNOverrides.txt_folder_updated = '文件夹已重命名';
|
||||
zhCNOverrides.txt_folders_deleted = '文件夹已删除';
|
||||
zhCNOverrides.txt_update_folder_failed = '重命名文件夹失败';
|
||||
zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败';
|
||||
zhCNOverrides.txt_delete_all_folders_failed = '删除全部文件夹失败';
|
||||
zhCNOverrides.txt_other = '其他';
|
||||
@@ -1629,4 +1685,3 @@ export function setLocale(next: Locale): void {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ export function makeLoginCipher(): Record<string, unknown> {
|
||||
favorite: false,
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: { username: null, password: null, totp: null, fido2Credentials: null, uris: null },
|
||||
login: { username: null, password: null, totp: null, uris: null },
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: null,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export type AppPhase = 'register' | 'login' | 'locked' | 'app';
|
||||
|
||||
export interface SessionState {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
email: string;
|
||||
authMode?: 'token' | 'web-cookie';
|
||||
symEncKey?: string;
|
||||
symMacKey?: string;
|
||||
}
|
||||
@@ -28,13 +29,18 @@ export interface Folder {
|
||||
|
||||
export interface CipherLoginUri {
|
||||
uri?: string | null;
|
||||
uriChecksum?: string | null;
|
||||
match?: number | null;
|
||||
response?: unknown | null;
|
||||
decUri?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface VaultDraftLoginUri {
|
||||
uri: string;
|
||||
match: number | null;
|
||||
originalUri?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CipherAttachment {
|
||||
@@ -59,9 +65,14 @@ export interface CipherLogin {
|
||||
totp?: string | null;
|
||||
uris?: CipherLoginUri[] | null;
|
||||
fido2Credentials?: CipherLoginPasskey[] | null;
|
||||
autofillOnPageLoad?: boolean | null;
|
||||
uri?: string | null;
|
||||
passwordRevisionDate?: string | null;
|
||||
response?: unknown | null;
|
||||
decUsername?: string;
|
||||
decPassword?: string;
|
||||
decTotp?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CipherCard {
|
||||
@@ -137,6 +148,12 @@ export interface CipherField {
|
||||
decValue?: string;
|
||||
}
|
||||
|
||||
export interface CipherPasswordHistoryEntry {
|
||||
password?: string | null;
|
||||
lastUsedDate?: string | null;
|
||||
decPassword?: string;
|
||||
}
|
||||
|
||||
export interface Cipher {
|
||||
id: string;
|
||||
type: number;
|
||||
@@ -156,7 +173,7 @@ export interface Cipher {
|
||||
identity?: CipherIdentity | null;
|
||||
sshKey?: CipherSshKey | null;
|
||||
secureNote?: { type?: number | null } | null;
|
||||
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null;
|
||||
passwordHistory?: CipherPasswordHistoryEntry[] | null;
|
||||
fields?: CipherField[] | null;
|
||||
decName?: string;
|
||||
decNotes?: string;
|
||||
@@ -272,7 +289,8 @@ export interface WebBootstrapResponse {
|
||||
|
||||
export interface TokenSuccess {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
refresh_token?: string;
|
||||
web_session?: boolean;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
TwoFactorToken?: string;
|
||||
@@ -290,6 +308,10 @@ export interface TokenSuccess {
|
||||
unofficialServer?: boolean;
|
||||
UserDecryptionOptions?: unknown;
|
||||
userDecryptionOptions?: unknown;
|
||||
VaultKeys?: {
|
||||
symEncKey?: string;
|
||||
symMacKey?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TokenError {
|
||||
@@ -322,10 +344,14 @@ export interface AdminInvite {
|
||||
export interface AuthorizedDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
systemName?: string | null;
|
||||
deviceNote?: string | null;
|
||||
identifier: string;
|
||||
type: number;
|
||||
creationDate: string | null;
|
||||
revisionDate: string | null;
|
||||
lastSeenAt?: string | null;
|
||||
hasStoredDevice?: boolean;
|
||||
online: boolean;
|
||||
trusted: boolean;
|
||||
trustedTokenCount: number;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
.loading-screen {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
animation: fade-in-up var(--dur-panel) var(--ease-out-strong) both;
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.public-send-page {
|
||||
min-height: 80vh;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 30px;
|
||||
overflow: hidden;
|
||||
transform-origin: 50% 24%;
|
||||
animation: surface-enter 520ms var(--ease-out-strong) both;
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
margin: 0 0 4px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.standalone-shell {
|
||||
width: min(640px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
animation: fade-in-up 420ms var(--ease-out-strong) both;
|
||||
}
|
||||
|
||||
.standalone-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.standalone-brand-outside {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.standalone-brand-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
|
||||
}
|
||||
|
||||
.standalone-brand-wordmark {
|
||||
display: block;
|
||||
height: auto;
|
||||
width: clamp(200px, 30vw, 360px);
|
||||
max-width: 100%;
|
||||
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.18));
|
||||
}
|
||||
|
||||
.standalone-title {
|
||||
margin: 0 0 4px 0;
|
||||
text-align: left;
|
||||
font-size: 31px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.standalone-muted {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.jwt-warning-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
color: #b45309;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jwt-warning-box {
|
||||
border: 1px solid #f1d8a5;
|
||||
border-radius: 12px;
|
||||
background: #fffaf0;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.jwt-warning-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #92400e;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.jwt-warning-copy {
|
||||
margin: 0 0 14px;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.jwt-warning-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: #334155;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.jwt-inline-link {
|
||||
color: #1d4ed8;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.jwt-inline-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.jwt-secret-fields {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.jwt-secret-row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.jwt-secret-row > span {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.jwt-generator {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.jwt-generator-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.jwt-copy-hint {
|
||||
color: #15803d;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.standalone-footer {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.standalone-footer a {
|
||||
color: #1d4ed8;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.standalone-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.standalone-version {
|
||||
font-weight: 700;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--text);
|
||||
background: var(--bg-accent);
|
||||
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
transition:
|
||||
background-color var(--dur-medium) var(--ease-smooth),
|
||||
color var(--dur-medium) var(--ease-smooth);
|
||||
}
|
||||
|
||||
body.dialog-open {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
:root[data-theme='dark'] body,
|
||||
:root[data-theme='dark'] #root,
|
||||
:root[data-theme='dark'] .app-page,
|
||||
:root[data-theme='dark'] .auth-page {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .app-shell,
|
||||
:root[data-theme='dark'] .auth-card,
|
||||
:root[data-theme='dark'] .dialog,
|
||||
:root[data-theme='dark'] .jwt-warning-box,
|
||||
:root[data-theme='dark'] .backup-operations-sidebar,
|
||||
:root[data-theme='dark'] .backup-destination-sidebar,
|
||||
:root[data-theme='dark'] .backup-detail-panel,
|
||||
:root[data-theme='dark'] .settings-subcard,
|
||||
:root[data-theme='dark'] .list-panel,
|
||||
:root[data-theme='dark'] .card,
|
||||
:root[data-theme='dark'] .sidebar-block,
|
||||
:root[data-theme='dark'] .empty {
|
||||
background: var(--panel);
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .topbar,
|
||||
:root[data-theme='dark'] .mobile-tabbar,
|
||||
:root[data-theme='dark'] .sort-menu,
|
||||
:root[data-theme='dark'] .create-menu,
|
||||
:root[data-theme='dark'] .dialog-card,
|
||||
:root[data-theme='dark'] .mobile-sidebar-sheet,
|
||||
:root[data-theme='dark'] .mobile-detail-sheet {
|
||||
background: var(--panel-soft);
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .dialog-card.warning {
|
||||
border-color: rgba(248, 113, 113, 0.36);
|
||||
background: linear-gradient(180deg, rgba(39, 16, 16, 0.98), rgba(27, 12, 12, 0.98));
|
||||
box-shadow:
|
||||
0 36px 90px rgba(5, 5, 5, 0.56),
|
||||
0 0 0 1px rgba(248, 113, 113, 0.12) inset;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .dialog-mask.warning {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(127, 29, 29, 0.28), transparent 34%),
|
||||
linear-gradient(180deg, rgba(20, 12, 12, 0.64), rgba(2, 6, 23, 0.82));
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .dialog-warning-badge {
|
||||
background: linear-gradient(180deg, rgba(127, 29, 29, 0.8), rgba(69, 10, 10, 0.86));
|
||||
color: #fda4af;
|
||||
box-shadow:
|
||||
0 12px 30px rgba(0, 0, 0, 0.32),
|
||||
0 0 0 1px rgba(248, 113, 113, 0.14) inset;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .dialog-warning-kicker,
|
||||
:root[data-theme='dark'] .dialog-card.warning .dialog-title {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .dialog-message.warning {
|
||||
border-color: rgba(248, 113, 113, 0.18);
|
||||
background: linear-gradient(180deg, rgba(69, 10, 10, 0.54), rgba(67, 20, 7, 0.46));
|
||||
color: #fecdd3;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18) inset;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .app-side,
|
||||
:root[data-theme='dark'] .sidebar,
|
||||
:root[data-theme='dark'] .mobile-sidebar-sheet .sidebar-block {
|
||||
background: var(--panel-muted);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .auth-card {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .brand,
|
||||
:root[data-theme='dark'] .mobile-page-title,
|
||||
:root[data-theme='dark'] .detail-title,
|
||||
:root[data-theme='dark'] .dialog-title,
|
||||
:root[data-theme='dark'] .standalone-title,
|
||||
:root[data-theme='dark'] .kv-main strong,
|
||||
:root[data-theme='dark'] .list-title,
|
||||
:root[data-theme='dark'] .sidebar-title,
|
||||
:root[data-theme='dark'] h1,
|
||||
:root[data-theme='dark'] h2,
|
||||
:root[data-theme='dark'] h3,
|
||||
:root[data-theme='dark'] h4 {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .standalone-brand-wordmark,
|
||||
:root[data-theme='dark'] .brand-wordmark {
|
||||
text-shadow: 0 16px 28px rgba(2, 6, 23, 0.32);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .muted,
|
||||
:root[data-theme='dark'] .detail-sub,
|
||||
:root[data-theme='dark'] .field-help,
|
||||
:root[data-theme='dark'] .list-sub,
|
||||
:root[data-theme='dark'] .kv-label,
|
||||
:root[data-theme='dark'] .standalone-muted,
|
||||
:root[data-theme='dark'] .standalone-footer,
|
||||
:root[data-theme='dark'] .backup-inline-note,
|
||||
:root[data-theme='dark'] .backup-browser-empty,
|
||||
:root[data-theme='dark'] .or,
|
||||
:root[data-theme='dark'] .mobile-tab,
|
||||
:root[data-theme='dark'] .side-link,
|
||||
:root[data-theme='dark'] .user-chip,
|
||||
:root[data-theme='dark'] .list-count {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .user-chip {
|
||||
background: rgba(17, 34, 56, 0.94);
|
||||
border-color: var(--line);
|
||||
box-shadow: 0 12px 24px rgba(1, 7, 18, 0.24);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .side-link:hover,
|
||||
:root[data-theme='dark'] .mobile-tab:hover {
|
||||
background: rgba(132, 182, 255, 0.11);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .side-link.active,
|
||||
:root[data-theme='dark'] .mobile-tab.active,
|
||||
:root[data-theme='dark'] .sort-menu-item.active,
|
||||
:root[data-theme='dark'] .list-item.active {
|
||||
background: linear-gradient(135deg, rgba(132, 182, 255, 0.2), rgba(56, 189, 248, 0.08));
|
||||
border-color: rgba(132, 182, 255, 0.28);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .input,
|
||||
:root[data-theme='dark'] .textarea,
|
||||
:root[data-theme='dark'] select.input,
|
||||
:root[data-theme='dark'] .dialog input,
|
||||
:root[data-theme='dark'] .dialog textarea,
|
||||
:root[data-theme='dark'] .dialog select {
|
||||
background: rgba(13, 24, 40, 0.94);
|
||||
border-color: rgba(103, 136, 186, 0.36);
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .input::placeholder,
|
||||
:root[data-theme='dark'] .textarea::placeholder,
|
||||
:root[data-theme='dark'] input::placeholder,
|
||||
:root[data-theme='dark'] textarea::placeholder {
|
||||
color: #7488a8;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .input:focus,
|
||||
:root[data-theme='dark'] .textarea:focus,
|
||||
:root[data-theme='dark'] .search-input:focus,
|
||||
:root[data-theme='dark'] .dialog input:focus,
|
||||
:root[data-theme='dark'] .dialog textarea:focus,
|
||||
:root[data-theme='dark'] .dialog select:focus {
|
||||
border-color: rgba(132, 182, 255, 0.54);
|
||||
background-color: rgba(16, 30, 49, 0.98);
|
||||
box-shadow: 0 0 0 4px rgba(132, 182, 255, 0.12), 0 10px 22px rgba(5, 13, 28, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
:root[data-theme='dark'] .input-readonly {
|
||||
background: #0f1b2d;
|
||||
color: var(--muted-strong);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .input:disabled,
|
||||
:root[data-theme='dark'] .btn:disabled {
|
||||
background: #132033;
|
||||
border-color: #22334c;
|
||||
color: #70829d;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .btn-secondary {
|
||||
background: linear-gradient(180deg, rgba(22, 41, 66, 0.98), rgba(16, 31, 52, 0.98));
|
||||
border-color: rgba(132, 182, 255, 0.22);
|
||||
color: #a9cdff;
|
||||
box-shadow: 0 12px 22px rgba(1, 7, 18, 0.18);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .btn-secondary:hover {
|
||||
background: linear-gradient(180deg, rgba(26, 49, 79, 0.98), rgba(19, 37, 61, 0.98));
|
||||
border-color: rgba(132, 182, 255, 0.3);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .btn-danger {
|
||||
background: linear-gradient(180deg, rgba(45, 23, 33, 0.98), rgba(35, 18, 28, 0.98));
|
||||
border-color: rgba(255, 139, 168, 0.38);
|
||||
color: #ff9bb0;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .btn-danger:hover {
|
||||
background: linear-gradient(180deg, rgba(56, 27, 40, 0.98), rgba(41, 19, 31, 0.98));
|
||||
border-color: rgba(255, 171, 192, 0.42);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .btn-primary {
|
||||
background: linear-gradient(135deg, #79acff, #57c2ff 76%);
|
||||
border-color: rgba(176, 214, 255, 0.22);
|
||||
color: #061120;
|
||||
box-shadow: 0 18px 32px rgba(10, 26, 52, 0.34);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .btn-primary:hover {
|
||||
background: linear-gradient(135deg, #90bcff, #6accff 76%);
|
||||
box-shadow: 0 22px 36px rgba(10, 26, 52, 0.38);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .toolbar.actions,
|
||||
:root[data-theme='dark'] .list-head,
|
||||
:root[data-theme='dark'] .mobile-panel-head,
|
||||
:root[data-theme='dark'] .backup-recommendation-header,
|
||||
:root[data-theme='dark'] .backup-operations-sidebar,
|
||||
:root[data-theme='dark'] .backup-destination-sidebar,
|
||||
:root[data-theme='dark'] .backup-detail-panel,
|
||||
:root[data-theme='dark'] .detail-actions,
|
||||
:root[data-theme='dark'] .topbar,
|
||||
:root[data-theme='dark'] .app-side,
|
||||
:root[data-theme='dark'] .kv-row,
|
||||
:root[data-theme='dark'] .attachment-row,
|
||||
:root[data-theme='dark'] .backup-browser-row {
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .input,
|
||||
:root[data-theme='dark'] .search-input,
|
||||
:root[data-theme='dark'] .list-item,
|
||||
:root[data-theme='dark'] .sidebar-block {
|
||||
background: rgba(15, 28, 45, 0.94);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .sidebar,
|
||||
:root[data-theme='dark'] .content,
|
||||
:root[data-theme='dark'] .list-col,
|
||||
:root[data-theme='dark'] .detail-col {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .mobile-sidebar-mask,
|
||||
:root[data-theme='dark'] .dialog-mask {
|
||||
background: var(--overlay-strong);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .toast {
|
||||
background: linear-gradient(180deg, rgba(19, 34, 54, 0.98), rgba(14, 26, 42, 0.98));
|
||||
border-color: #263a57;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .toast.success {
|
||||
background: #0f2a1f;
|
||||
border-color: #1f5b44;
|
||||
color: #9be2bd;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .toast.error {
|
||||
background: #2a1720;
|
||||
border-color: #6c2b41;
|
||||
color: #ffb1c0;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .toast.warning {
|
||||
background: #2d2413;
|
||||
border-color: #7b6230;
|
||||
color: #f7d48b;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .jwt-warning-head,
|
||||
:root[data-theme='dark'] .jwt-warning-label,
|
||||
:root[data-theme='dark'] .jwt-warning-copy,
|
||||
:root[data-theme='dark'] .jwt-warning-list {
|
||||
color: #f4d48a;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .theme-switch-input:focus + .theme-switch-slider {
|
||||
box-shadow: 0 0 0 2px rgba(132, 182, 255, 0.24);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .search-input,
|
||||
:root[data-theme='dark'] .list-head .search-input,
|
||||
:root[data-theme='dark'] .mobile-settings-card,
|
||||
:root[data-theme='dark'] .mobile-settings-link,
|
||||
:root[data-theme='dark'] .table tr,
|
||||
:root[data-theme='dark'] .settings-subcard,
|
||||
:root[data-theme='dark'] .backup-operations-sidebar,
|
||||
:root[data-theme='dark'] .backup-destination-sidebar,
|
||||
:root[data-theme='dark'] .backup-detail-panel,
|
||||
:root[data-theme='dark'] .dialog-card,
|
||||
:root[data-theme='dark'] .backup-browser-path,
|
||||
:root[data-theme='dark'] .backup-browser-list,
|
||||
:root[data-theme='dark'] .create-menu,
|
||||
:root[data-theme='dark'] .create-menu-item,
|
||||
:root[data-theme='dark'] .sort-menu,
|
||||
:root[data-theme='dark'] .sort-menu-item,
|
||||
:root[data-theme='dark'] .backup-recommendation-card,
|
||||
:root[data-theme='dark'] .backup-recommendation-dav-item,
|
||||
:root[data-theme='dark'] .backup-destination-item,
|
||||
:root[data-theme='dark'] .totp-code-row,
|
||||
:root[data-theme='dark'] .list-item {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(18, 32, 52, 0.92), rgba(14, 26, 42, 0.92));
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .list-item:hover,
|
||||
:root[data-theme='dark'] .sort-menu-item:hover,
|
||||
:root[data-theme='dark'] .create-menu-item:hover,
|
||||
:root[data-theme='dark'] .mobile-settings-link:hover,
|
||||
:root[data-theme='dark'] .backup-destination-item:hover {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(24, 44, 70, 0.96), rgba(16, 31, 51, 0.96));
|
||||
border-color: rgba(118, 150, 197, 0.32);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .list-item.active {
|
||||
background: linear-gradient(135deg, rgba(132, 182, 255, 0.2), rgba(56, 189, 248, 0.1));
|
||||
border-color: rgba(122, 176, 255, 0.34);
|
||||
box-shadow: inset 0 0 0 1px rgba(200, 225, 255, 0.06), 0 12px 24px rgba(5, 13, 28, 0.18);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .list-item::before {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(132, 182, 255, 0.08), transparent 24%, transparent 76%, rgba(56, 189, 248, 0.08)),
|
||||
radial-gradient(circle at 18px 50%, rgba(255, 255, 255, 0.06), transparent 44%);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .backup-destination-item.active,
|
||||
:root[data-theme='dark'] .backup-interval-preset.active,
|
||||
:root[data-theme='dark'] .mobile-settings-link.active,
|
||||
:root[data-theme='dark'] .tree-btn.active {
|
||||
background: linear-gradient(135deg, rgba(132, 182, 255, 0.2), rgba(56, 189, 248, 0.1));
|
||||
border-color: rgba(132, 182, 255, 0.34);
|
||||
color: #f4f8ff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .theme-switch-slider {
|
||||
background: linear-gradient(180deg, #1d3659, #142845);
|
||||
border-color: rgba(120, 152, 198, 0.34);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .theme-switch-slider::before {
|
||||
background: linear-gradient(180deg, #f8fbff, #dce9ff);
|
||||
box-shadow: 0 3px 10px rgba(2, 8, 20, 0.28);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .theme-switch .moon svg {
|
||||
fill: #8db6ff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .theme-switch .sun svg {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .totp-code-name,
|
||||
:root[data-theme='dark'] .backup-destination-name,
|
||||
:root[data-theme='dark'] .backup-browser-entry,
|
||||
:root[data-theme='dark'] .mobile-settings-link,
|
||||
:root[data-theme='dark'] .backup-browser-path strong,
|
||||
:root[data-theme='dark'] .backup-option-label,
|
||||
:root[data-theme='dark'] .sort-menu-item,
|
||||
:root[data-theme='dark'] .create-menu-item,
|
||||
:root[data-theme='dark'] .tree-btn,
|
||||
:root[data-theme='dark'] .folder-add-btn,
|
||||
:root[data-theme='dark'] .list-icon-fallback,
|
||||
:root[data-theme='dark'] .totp-code-main strong,
|
||||
:root[data-theme='dark'] .totp-timer-value {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .totp-code-username,
|
||||
:root[data-theme='dark'] .backup-destination-meta,
|
||||
:root[data-theme='dark'] .backup-browser-meta,
|
||||
:root[data-theme='dark'] .table td::before,
|
||||
:root[data-theme='dark'] .backup-recommendation-step,
|
||||
:root[data-theme='dark'] .backup-recommendation-inline-note,
|
||||
:root[data-theme='dark'] .backup-recommendation-linked-item,
|
||||
:root[data-theme='dark'] .backup-inline-suffix,
|
||||
:root[data-theme='dark'] .folder-delete-btn,
|
||||
:root[data-theme='dark'] .folder-add-btn:hover,
|
||||
:root[data-theme='dark'] .tree-label,
|
||||
:root[data-theme='dark'] .list-sub {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .import-export-panel p,
|
||||
:root[data-theme='dark'] .dialog-message,
|
||||
:root[data-theme='dark'] .local-error,
|
||||
:root[data-theme='dark'] .status-ok {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .backup-destination-type {
|
||||
background: #1d3048;
|
||||
color: #c9d8eb;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .backup-help-trigger {
|
||||
border-color: #38618f;
|
||||
background: #173150;
|
||||
color: #9ec5ff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .backup-help-trigger:hover,
|
||||
:root[data-theme='dark'] .backup-help-trigger:focus-visible {
|
||||
border-color: #5f92d7;
|
||||
background: #20426a;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .backup-help-bubble {
|
||||
background: var(--panel);
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .backup-help-bubble::before {
|
||||
background: var(--panel);
|
||||
border-left-color: var(--line);
|
||||
border-top-color: var(--line);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .table td {
|
||||
border-bottom-color: #203047;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .local-error {
|
||||
color: #ff9bb0;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .status-ok {
|
||||
color: #9be2bd;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .totp-qr {
|
||||
background: #ffffff;
|
||||
border-color: rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .totp-qr svg,
|
||||
:root[data-theme='dark'] .totp-qr img {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
.muted {
|
||||
margin: 0 0 16px 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border: 1px solid rgba(74, 103, 150, 0.42);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-smooth),
|
||||
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||
background-color var(--dur-fast) var(--ease-smooth),
|
||||
transform var(--dur-fast) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
select.input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
padding-right: 42px;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, #365fa8 50%),
|
||||
linear-gradient(135deg, #365fa8 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 18px) calc(50% - 3px),
|
||||
calc(100% - 12px) calc(50% - 3px);
|
||||
background-size: 6px 6px, 6px 6px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
input[type='file'].input {
|
||||
height: auto;
|
||||
min-height: 48px;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
input[type='file'].input::file-selector-button {
|
||||
height: 32px;
|
||||
border: 1px solid #3f5b9e;
|
||||
border-radius: 999px;
|
||||
padding: 0 12px;
|
||||
background: #eef4ff;
|
||||
color: #1f4ea0;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
input[type='file'].input::file-selector-button:hover {
|
||||
background: #dfeaff;
|
||||
border-color: #2f5fd8;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 110px;
|
||||
height: auto;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: rgba(43, 102, 217, 0.6);
|
||||
background-color: #fbfdff;
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.11), 0 10px 20px rgba(37, 99, 235, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.input-readonly {
|
||||
background: #eef2f7;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
background: #e2e8f0;
|
||||
border-color: #cbd5e1;
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.password-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-wrap .input {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #275ac2;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.eye-btn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 9px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #334155;
|
||||
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.password-toggle:hover,
|
||||
.eye-btn:hover {
|
||||
color: var(--primary);
|
||||
transform: translateY(-1px) scale(1.04);
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 36px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
padding: 0 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
transform var(--dur-fast) var(--ease-out-soft),
|
||||
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||
background-color var(--dur-fast) var(--ease-smooth),
|
||||
border-color var(--dur-fast) var(--ease-smooth),
|
||||
color var(--dur-fast) var(--ease-smooth),
|
||||
opacity var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.topbar-actions .btn,
|
||||
.user-chip,
|
||||
.side-link,
|
||||
.mobile-tab {
|
||||
--mag-x: 0px;
|
||||
--mag-y: 0px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar-actions .btn::before,
|
||||
.user-chip::before,
|
||||
.side-link::before,
|
||||
.mobile-tab::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: var(--mx, 50%);
|
||||
top: var(--my, 50%);
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.36), rgba(255, 255, 255, 0.08) 42%, transparent 72%);
|
||||
transform: translate(-50%, -50%) scale(0.68);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity var(--dur-fast) var(--ease-smooth),
|
||||
transform var(--dur-medium) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.topbar-actions .btn:hover::before,
|
||||
.user-chip:hover::before,
|
||||
.side-link:hover::before,
|
||||
.mobile-tab:hover::before {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px) scale(1.01);
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translateY(0) scale(0.985);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn.full {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
font-size: 22px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #2563eb, #3b82f6 72%);
|
||||
border-color: rgba(15, 63, 152, 0.32);
|
||||
color: #fff;
|
||||
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #1d4ed8, #3377f0 72%);
|
||||
border-color: rgba(15, 63, 152, 0.38);
|
||||
box-shadow: 0 18px 34px rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--panel);
|
||||
border-color: rgba(37, 99, 235, 0.22);
|
||||
color: var(--primary-strong);
|
||||
box-shadow: 0 8px 18px rgba(13, 31, 68, 0.05);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f4f8ff;
|
||||
border-color: rgba(37, 99, 235, 0.34);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-color: rgba(217, 45, 87, 0.28);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(255, 241, 242, 0.96);
|
||||
border-color: rgba(217, 45, 87, 0.38);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background: #e2e8f0;
|
||||
border-color: #cbd5e1;
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.or {
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.auth-support-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: -2px 0 12px;
|
||||
}
|
||||
|
||||
.auth-link-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast) var(--ease-smooth), transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.auth-link-btn:hover {
|
||||
text-decoration: underline;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.auth-link-btn:disabled {
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
@keyframes toast-life {
|
||||
from {
|
||||
transform: scaleX(1);
|
||||
transform-origin: left center;
|
||||
}
|
||||
to {
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 16px, 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shell-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 18px, 0) scale(0.992);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes surface-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 20px, 0) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 10px, 0) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 18px, 0) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(18px, 0, 0) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stagger-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 18px, 0) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 10px, 0) scale(0.972);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes route-stage-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 14px, 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
.dialog-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 1200;
|
||||
padding: 20px;
|
||||
opacity: 0;
|
||||
animation: fade-in var(--dur-medium) var(--ease-smooth) both;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
width: min(460px, 100%);
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transform-origin: 50% 30%;
|
||||
animation: dialog-in 240ms var(--ease-out-strong) both;
|
||||
}
|
||||
|
||||
.dialog-mask.warning {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 237, 213, 0.32), transparent 34%),
|
||||
linear-gradient(180deg, rgba(127, 29, 29, 0.36), rgba(15, 23, 42, 0.72));
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dialog-card.warning {
|
||||
width: min(520px, 100%);
|
||||
border: 1px solid rgba(220, 38, 38, 0.22);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 246, 246, 0.98), rgba(255, 255, 255, 0.99));
|
||||
box-shadow:
|
||||
0 36px 90px rgba(69, 10, 10, 0.28),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.7) inset;
|
||||
}
|
||||
|
||||
.dialog-warning-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dialog-warning-badge {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, #fff1f2, #ffe4e6);
|
||||
color: #dc2626;
|
||||
box-shadow:
|
||||
0 12px 30px rgba(220, 38, 38, 0.18),
|
||||
0 0 0 1px rgba(220, 38, 38, 0.08) inset;
|
||||
}
|
||||
|
||||
.dialog-warning-kicker {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.dialog-mask.closing {
|
||||
animation: fade-out 220ms var(--ease-smooth) both;
|
||||
}
|
||||
|
||||
.dialog-card.closing {
|
||||
animation: dialog-out 220ms var(--ease-smooth) both;
|
||||
}
|
||||
|
||||
.dialog-card .field {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
margin: 6px 0;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.dialog-message {
|
||||
color: #475467;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dialog-card.warning .dialog-title {
|
||||
color: #7f1d1d;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dialog-message.warning {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(220, 38, 38, 0.16);
|
||||
background: linear-gradient(180deg, rgba(255, 241, 242, 0.94), rgba(255, 247, 237, 0.9));
|
||||
color: #7a2832;
|
||||
line-height: 1.65;
|
||||
box-shadow: 0 10px 28px rgba(248, 113, 113, 0.08) inset;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
font-size: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dialog-extra {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dialog-divider {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
|
||||
.import-summary-dialog {
|
||||
max-width: 520px;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.import-summary-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.import-summary-close:hover {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.import-summary-table-wrap {
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.import-summary-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.import-summary-table th,
|
||||
.import-summary-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.import-summary-table th {
|
||||
text-align: left;
|
||||
color: #475467;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.import-summary-table td:last-child,
|
||||
.import-summary-table th:last-child {
|
||||
text-align: right;
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.import-summary-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.import-summary-failed-list {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 10px;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.import-summary-failed-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.import-summary-failed-list ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.import-summary-failed-list li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.settings-twofactor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-subcard {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.settings-subcard h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 1400;
|
||||
width: min(420px, calc(100vw - 20px));
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #bbdfc6;
|
||||
background: #dff4e5;
|
||||
color: #0f5132;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
animation: toast-in 240ms var(--ease-out-strong) both;
|
||||
}
|
||||
|
||||
.toast-item.error {
|
||||
border-color: #f2b8c1;
|
||||
background: #fde7eb;
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
.toast-item.warning {
|
||||
border-color: #f2b8c1;
|
||||
background: #fde7eb;
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
.toast-text {
|
||||
font-weight: 700;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
color: inherit;
|
||||
transition: transform var(--dur-fast) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
transform: scale(1.08);
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.toast-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(15, 23, 42, 0.2);
|
||||
animation: toast-life 4.5s linear forwards;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 1ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 1ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled),
|
||||
.btn:active:not(:disabled),
|
||||
.side-link:hover,
|
||||
.tree-btn:hover,
|
||||
.list-item:hover,
|
||||
.list-item.active,
|
||||
.search-input:focus,
|
||||
.input:focus,
|
||||
.password-toggle:hover,
|
||||
.eye-btn:hover,
|
||||
.auth-link-btn:hover,
|
||||
.sort-menu-item:hover,
|
||||
.create-menu-item:hover,
|
||||
.toast-close:hover,
|
||||
.mobile-sidebar-close:hover {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
@media (max-width: 1180px) {
|
||||
.app-page {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: calc(100vh - 16px);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-side {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #d9e0ea;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
align-self: start;
|
||||
height: fit-content;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-side > .side-link {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vault-grid {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
.sidebar {
|
||||
max-height: 280px;
|
||||
}
|
||||
.totp-grid,
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.totp-copy-btn {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.import-export-panels,
|
||||
.backup-browser-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-twofactor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.standalone-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.standalone-footer {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.auth-page {
|
||||
padding: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.standalone-shell {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.standalone-brand-outside {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.standalone-brand-logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: 20px 16px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.btn.full {
|
||||
height: 48px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.auth-support-row {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.app-page {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
--mobile-topbar-height: 58px;
|
||||
--mobile-tabbar-height: 70px;
|
||||
height: 100dvh;
|
||||
max-width: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: var(--mobile-topbar-height);
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.brand {
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.mobile-page-title {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.topbar-actions .user-chip,
|
||||
.topbar-actions > .btn:not(.mobile-sidebar-toggle):not(.mobile-lock-btn),
|
||||
.topbar-actions > .theme-switch-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-sidebar-toggle,
|
||||
.mobile-lock-btn {
|
||||
display: inline-flex;
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
font-size: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.mobile-sidebar-toggle .btn-icon,
|
||||
.mobile-lock-btn .btn-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-theme-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-theme-btn .theme-switch {
|
||||
transform: scale(0.8);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-side {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-tabbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: var(--mobile-tabbar-height);
|
||||
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(248, 251, 255, 0.92);
|
||||
}
|
||||
|
||||
.mobile-tab {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 6px 4px;
|
||||
border-radius: 12px;
|
||||
transition:
|
||||
transform 220ms var(--ease-out-soft),
|
||||
background-color var(--dur-fast) var(--ease-smooth),
|
||||
color var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.mobile-tab:hover {
|
||||
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 1px), 0);
|
||||
}
|
||||
|
||||
.mobile-tab.active {
|
||||
color: var(--primary-strong);
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.16), rgba(59, 130, 246, 0.08));
|
||||
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.vault-grid {
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
top: calc(var(--mobile-topbar-height) + 10px);
|
||||
bottom: auto;
|
||||
max-height: calc(100dvh - 145px);
|
||||
z-index: 55;
|
||||
overflow: auto;
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate3d(0, 10px, 0) scale(0.98);
|
||||
transition:
|
||||
opacity 220ms var(--ease-smooth),
|
||||
transform 240ms var(--ease-out-soft),
|
||||
visibility 220ms var(--ease-smooth);
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
|
||||
.mobile-sidebar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mobile-sidebar-title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.mobile-sidebar-close {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid #d7dde6;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition:
|
||||
transform var(--dur-fast) var(--ease-out-soft),
|
||||
background-color var(--dur-fast) var(--ease-smooth),
|
||||
border-color var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.mobile-sidebar-close:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .sidebar-block {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .tree-btn {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .folder-row {
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .folder-row .tree-btn {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .sidebar-title,
|
||||
.mobile-sidebar-sheet .sidebar-title-row {
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .tree-btn {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .tree-btn.active {
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.mobile-sidebar-sheet .folder-delete-btn {
|
||||
width: 28px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.list-col {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-count {
|
||||
grid-column: auto;
|
||||
width: auto;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list-head .search-input-wrap {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list-head .search-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.list-icon-btn {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar.actions {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: unset;
|
||||
gap: var(--actions-gap);
|
||||
overflow: visible;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: var(--actions-gap);
|
||||
}
|
||||
|
||||
.toolbar.actions .btn.small {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-fab-wrap {
|
||||
position: fixed;
|
||||
right: 14px;
|
||||
bottom: calc(14px + var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
|
||||
z-index: 45;
|
||||
}
|
||||
|
||||
.mobile-fab-trigger {
|
||||
width: 36px;
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
font-size: 0;
|
||||
gap: 0;
|
||||
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
.mobile-fab-trigger .btn-icon {
|
||||
margin: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mobile-fab-wrap .create-menu {
|
||||
left: auto;
|
||||
right: 0;
|
||||
top: auto;
|
||||
bottom: calc(100% + 10px);
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
border-radius: 16px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.row-check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.vault-grid.mobile-panel-detail .sidebar,
|
||||
.vault-grid.mobile-panel-detail .list-col,
|
||||
.vault-grid.mobile-panel-edit .sidebar,
|
||||
.vault-grid.mobile-panel-edit .list-col {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-detail-sheet {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(var(--mobile-topbar-height) + env(safe-area-inset-top));
|
||||
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom));
|
||||
z-index: 35;
|
||||
overflow: auto;
|
||||
background: transparent;
|
||||
padding: 0 0 18px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate3d(0, 18px, 0);
|
||||
transition:
|
||||
opacity 220ms var(--ease-smooth),
|
||||
transform 260ms var(--ease-out-soft),
|
||||
visibility 220ms var(--ease-smooth);
|
||||
}
|
||||
|
||||
.mobile-detail-sheet.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.mobile-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 10px 10px;
|
||||
}
|
||||
|
||||
.mobile-panel-back {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.mobile-detail-sheet > .detail-switch-stage,
|
||||
.mobile-detail-sheet > .card,
|
||||
.mobile-detail-sheet > .empty {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.detail-col .card,
|
||||
.import-export-panel,
|
||||
.settings-subcard {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 14px 14px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-actions .actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-actions .actions .btn,
|
||||
.detail-delete-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kv-row {
|
||||
grid-template-columns: minmax(64px, 80px) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kv-line {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.kv-actions {
|
||||
width: auto;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.kv-actions .btn.small {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
gap: 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.kv-actions .btn.small .btn-icon {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.import-export-panels,
|
||||
.settings-twofactor-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.import-export-panel .actions .btn,
|
||||
.settings-subcard .actions .btn,
|
||||
.section-head .actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.totp-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.totp-qr {
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.totp-qr svg,
|
||||
.totp-qr img {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.invite-toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mobile-settings-card {
|
||||
min-height: calc(100dvh - 170px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-settings-subhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-settings-back {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.mobile-settings-links {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.mobile-settings-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 46px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dbe2ec;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mobile-settings-link.active {
|
||||
background: #e8f0ff;
|
||||
border-color: #b9cff6;
|
||||
color: #175ddc;
|
||||
}
|
||||
|
||||
.mobile-settings-logout {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.stack,
|
||||
.import-export-page,
|
||||
.totp-codes-page,
|
||||
.detail-col {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.invite-create-group {
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input.small {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table,
|
||||
.table tbody,
|
||||
.table tr,
|
||||
.table td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table tr {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.table td {
|
||||
border-bottom: 1px solid #edf1f6;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.table td:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.table td::before {
|
||||
display: block;
|
||||
content: attr(data-label);
|
||||
margin-bottom: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dialog-mask {
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
width: 90%;
|
||||
max-width: 460px;
|
||||
max-height: calc(100dvh - 10px);
|
||||
overflow: auto;
|
||||
border-radius: 22px;
|
||||
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.dialog-card.warning {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.dialog-warning-strip {
|
||||
margin: -18px -16px 16px;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.dialog-btn {
|
||||
height: 46px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.backup-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-operations-sidebar,
|
||||
.backup-destination-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.backup-interval-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-browser-row,
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-destination-top {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.backup-add-chooser {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.backup-name-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.backup-option-field {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.backup-help-bubble {
|
||||
left: 0;
|
||||
transform: translate(0, -4px);
|
||||
}
|
||||
|
||||
.backup-help-bubble::before {
|
||||
left: 16px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.backup-help-wrap:hover .backup-help-bubble,
|
||||
.backup-help-wrap:focus-within .backup-help-bubble,
|
||||
.backup-help-wrap.open .backup-help-bubble {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
.app-page {
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: calc(100vh - 40px);
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: shell-enter 560ms var(--ease-out-strong) both;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 58px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
color: #0f172a;
|
||||
background: rgba(244, 248, 255, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 18px;
|
||||
transition: background-color var(--dur-fast) var(--ease-smooth), border-color var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.brand-wordmark {
|
||||
display: block;
|
||||
height: auto;
|
||||
width: clamp(210px, 20vw, 290px);
|
||||
max-width: 100%;
|
||||
filter: drop-shadow(0 12px 24px rgba(43, 102, 217, 0.12));
|
||||
}
|
||||
|
||||
.mobile-page-title {
|
||||
display: none;
|
||||
min-width: 0;
|
||||
max-width: min(58vw, 240px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 19px;
|
||||
line-height: 1.2;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 10px 22px rgba(43, 102, 217, 0.22));
|
||||
transition: transform var(--dur-medium) var(--ease-out-soft), filter var(--dur-medium) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mobile-tabbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-lock-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-theme-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-switch-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.theme-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.theme-switch-input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.theme-switch-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, #dceaff, #c8dcff);
|
||||
border: 1px solid #9dbbec;
|
||||
transition:
|
||||
background var(--dur-medium) var(--ease-out-soft),
|
||||
border-color var(--dur-medium) var(--ease-smooth),
|
||||
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||
transform var(--dur-fast) var(--ease-out-soft);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.theme-switch-slider::before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
border-radius: 999px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
z-index: 2;
|
||||
background: linear-gradient(180deg, #ffffff, #edf4ff);
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.14);
|
||||
transition:
|
||||
transform var(--dur-medium) var(--ease-out-strong),
|
||||
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||
background var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.theme-switch .sun svg {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 32px;
|
||||
z-index: 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.95;
|
||||
transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.theme-switch .moon svg {
|
||||
fill: #5b86d6;
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 7px;
|
||||
z-index: 1;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.88;
|
||||
transition: transform var(--dur-medium) var(--ease-out-soft), opacity var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.theme-switch-input:checked + .theme-switch-slider {
|
||||
background: linear-gradient(180deg, #173150, #122742);
|
||||
border-color: #35527a;
|
||||
}
|
||||
|
||||
.theme-switch-input:focus + .theme-switch-slider {
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.22);
|
||||
}
|
||||
|
||||
.theme-switch-input:checked + .theme-switch-slider::before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.theme-switch:hover .theme-switch-slider {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.theme-switch:hover .sun svg,
|
||||
.theme-switch:hover .moon svg {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.topbar-actions .btn {
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transform: translate3d(var(--mag-x), var(--mag-y), 0);
|
||||
transition-duration: 220ms;
|
||||
}
|
||||
|
||||
.topbar-actions .btn:hover:not(:disabled) {
|
||||
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 2px), 0) scale(1.02);
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
background: rgba(249, 251, 255, 0.92);
|
||||
color: var(--muted-strong);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 10px 18px rgba(13, 31, 68, 0.05);
|
||||
transform: translate3d(var(--mag-x), var(--mag-y), 0);
|
||||
transition:
|
||||
transform 220ms var(--ease-out-soft),
|
||||
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||
border-color var(--dur-fast) var(--ease-smooth),
|
||||
background-color var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.user-chip:hover {
|
||||
transform: translate3d(var(--mag-x), calc(var(--mag-y) - 1px), 0);
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
}
|
||||
|
||||
.app-side {
|
||||
border-right: 1px solid var(--line-soft);
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.side-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 14px;
|
||||
color: var(--muted-strong);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease-smooth),
|
||||
border-color var(--dur-fast) var(--ease-smooth),
|
||||
color var(--dur-fast) var(--ease-smooth),
|
||||
transform var(--dur-fast) var(--ease-out-soft),
|
||||
box-shadow var(--dur-fast) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.side-link:hover {
|
||||
background: #ffffff;
|
||||
border-color: rgba(128, 152, 192, 0.18);
|
||||
color: var(--text);
|
||||
transform: translate3d(calc(var(--mag-x) + 3px), var(--mag-y), 0);
|
||||
box-shadow: 0 14px 24px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.side-link.active {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.18), rgba(59, 130, 246, 0.08));
|
||||
border-color: rgba(37, 99, 235, 0.28);
|
||||
color: var(--primary-strong);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.64), 0 10px 18px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 0;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.route-stage {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 901px) {
|
||||
.route-stage {
|
||||
animation: route-stage-in 240ms var(--ease-out-soft) both;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-sidebar-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.36);
|
||||
z-index: 54;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 220ms var(--ease-smooth),
|
||||
visibility 220ms var(--ease-smooth);
|
||||
}
|
||||
|
||||
.mobile-sidebar-mask.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.mobile-sidebar-head {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
:root {
|
||||
--bg-accent: #e7edf8;
|
||||
--panel: #f9fbff;
|
||||
--panel-soft: #f2f6fd;
|
||||
--panel-muted: #e8eff9;
|
||||
--line: rgba(128, 152, 192, 0.32);
|
||||
--line-soft: rgba(143, 167, 206, 0.18);
|
||||
--text: #0b1730;
|
||||
--muted: #60708b;
|
||||
--muted-strong: #334765;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--primary-strong: #0f3f98;
|
||||
--danger: #d92d57;
|
||||
--overlay-strong: rgba(15, 23, 42, 0.56);
|
||||
--shadow-sm: 0 10px 22px rgba(13, 31, 68, 0.045);
|
||||
--shadow-md: 0 22px 48px rgba(13, 31, 68, 0.08);
|
||||
--shadow-lg: 0 28px 76px rgba(13, 31, 68, 0.11);
|
||||
--ease-out-strong: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-out-soft: cubic-bezier(0.24, 0.8, 0.32, 1);
|
||||
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--dur-fast: 180ms;
|
||||
--dur-medium: 240ms;
|
||||
--dur-panel: 280ms;
|
||||
--actions-gap: clamp(0px, calc((100vw - 520px) * 1), 10px);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--bg-accent: #06111d;
|
||||
--panel: #0d192b;
|
||||
--panel-soft: #112136;
|
||||
--panel-muted: #0a1626;
|
||||
--line: rgba(108, 141, 190, 0.28);
|
||||
--line-soft: rgba(120, 152, 198, 0.16);
|
||||
--text: #edf4ff;
|
||||
--muted: #8fa6c6;
|
||||
--muted-strong: #c3d5ef;
|
||||
--primary: #84b6ff;
|
||||
--primary-hover: #a6ccff;
|
||||
--primary-strong: #f3f8ff;
|
||||
--danger: #ff8ba8;
|
||||
--overlay-strong: rgba(2, 8, 20, 0.84);
|
||||
--shadow-sm: 0 14px 28px rgba(1, 7, 18, 0.24);
|
||||
--shadow-md: 0 24px 52px rgba(1, 7, 18, 0.36);
|
||||
--shadow-lg: 0 34px 88px rgba(1, 7, 18, 0.46);
|
||||
}
|
||||
@@ -6,9 +6,8 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["../shared/*"]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||