mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Merge branch 'main' of https://github.com/shuaiplus/nodewarden
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
name: "Bug Report"
|
||||||
|
description: "Report a reproducible bug / 反馈可复现问题"
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for reporting. Please provide enough detail so maintainers can reproduce quickly.
|
||||||
|
感谢反馈,请尽量提供可复现信息,方便快速定位。
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Pre-check / 提交前确认
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and did not find a duplicate. / 我已搜索现有 issue,确认不是重复问题。
|
||||||
|
required: true
|
||||||
|
- label: I have read README and Project Wiki / 我已阅读 README 与 项目 Wiki。
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version / 版本
|
||||||
|
description: "Which version of NodeWarden are you using? Please provide the exact version or commit hash."
|
||||||
|
placeholder: "1.0.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduce_steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce / 复现步骤
|
||||||
|
placeholder: |
|
||||||
|
1. Start service with ...
|
||||||
|
2. Open ...
|
||||||
|
3. Click ...
|
||||||
|
4. Observe ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior / 预期行为
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior / 实际行为
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs and Screenshots / 日志与截图
|
||||||
|
description: "Please paste key logs (docker logs / browser console / network errors)."
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional Context / 补充信息
|
||||||
|
description: "Any workaround, frequency, impact scope, etc."
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Project Wiki/ 项目文档
|
||||||
|
url: https://github.com/shuaiplus/nodewarden/wiki
|
||||||
|
about: |
|
||||||
|
Please check the documentation for common questions and troubleshooting steps.
|
||||||
|
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
|
||||||
|
- name: Project Discussions / 讨论区
|
||||||
|
url: https://github.com/shuaiplus/nodewarden/discussions
|
||||||
|
about: |
|
||||||
|
For general questions, feature discussions, or if you're not sure which template to use, please post in the Discussions section.
|
||||||
|
如果你有一般性问题、功能讨论,或者不确定使用哪个模板,请在讨论区发帖。
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: "Feature Request"
|
||||||
|
description: "Suggest an improvement / 功能建议"
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: ["enhancement", "needs-triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Proposals with clear use-case and expected value are easier to evaluate.
|
||||||
|
说明清晰的使用场景和价值,有助于快速评估。
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Pre-check / 提交前确认
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues and this request is not duplicated. / 我已搜索现有 issue,确认不是重复建议。
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem Statement / 现存问题
|
||||||
|
description: "What is difficult or missing today?"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution / 建议方案
|
||||||
|
description: "Describe your expected behavior, UI flow, API changes, etc."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered / 备选方案
|
||||||
|
description: "Any alternatives or workarounds you've considered."
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: impact
|
||||||
|
attributes:
|
||||||
|
label: Expected Impact / 预期价值
|
||||||
|
description: "Who benefits? Any performance/security/maintenance concerns?"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: scope
|
||||||
|
attributes:
|
||||||
|
label: Scope (Optional) / 影响范围(可选)
|
||||||
|
placeholder: "frontend / backend / docs / deployment"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional Context / 补充信息
|
||||||
|
description: "Mockups, references, related links, etc."
|
||||||
@@ -4,6 +4,11 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: "0 3 * * *"
|
- cron: "0 3 * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target_commit:
|
||||||
|
description: 'Commit hash (leave blank to use latest commit)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -11,9 +16,8 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
sync:
|
sync:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -22,13 +26,118 @@ jobs:
|
|||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
- name: Sync main from upstream
|
- name: Add upstream
|
||||||
run: |
|
run: |
|
||||||
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
||||||
git fetch upstream
|
git fetch upstream --tags
|
||||||
git checkout main
|
|
||||||
git merge upstream/main
|
|
||||||
|
|
||||||
- name: Push synced main
|
- name: Resolve target commit
|
||||||
|
id: resolve
|
||||||
run: |
|
run: |
|
||||||
git push origin main
|
TRIGGER="${{ github.event_name }}"
|
||||||
|
MANUAL_INPUT="${{ github.event.inputs.target_commit }}"
|
||||||
|
|
||||||
|
if [ "$TRIGGER" = "schedule" ]; then
|
||||||
|
# Auto mode: resolve latest upstream release tag
|
||||||
|
LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name)
|
||||||
|
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
|
||||||
|
echo "No release found in upstream."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null)
|
||||||
|
if [ -z "$TARGET_SHA" ]; then
|
||||||
|
echo "Tag '$LATEST_TAG' not found after fetch."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "mode=auto" >> $GITHUB_OUTPUT
|
||||||
|
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
||||||
|
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
|
||||||
|
|
||||||
|
elif [ -n "$MANUAL_INPUT" ]; then
|
||||||
|
# Manual mode: use provided commit hash or tag
|
||||||
|
TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null)
|
||||||
|
if [ -z "$TARGET_SHA" ]; then
|
||||||
|
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "mode=manual" >> $GITHUB_OUTPUT
|
||||||
|
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
||||||
|
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
|
||||||
|
|
||||||
|
else
|
||||||
|
# Manual mode, blank input: use latest commit on upstream/main
|
||||||
|
TARGET_SHA=$(git rev-parse upstream/main)
|
||||||
|
echo "mode=manual" >> $GITHUB_OUTPUT
|
||||||
|
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
||||||
|
echo "Manual mode — latest commit: $TARGET_SHA"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check if update is needed
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
|
||||||
|
MODE="${{ steps.resolve.outputs.mode }}"
|
||||||
|
|
||||||
|
if [ "$MODE" = "manual" ]; then
|
||||||
|
# Manual: skip only if HEAD is exactly this commit
|
||||||
|
CURRENT_SHA=$(git rev-parse HEAD)
|
||||||
|
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
|
||||||
|
echo "Already at $TARGET_SHA — skipping."
|
||||||
|
echo "needs_update=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Switching to $TARGET_SHA"
|
||||||
|
echo "needs_update=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Auto: skip if target is already in ancestry
|
||||||
|
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
|
||||||
|
echo "Already up to date with $TARGET_SHA — skipping."
|
||||||
|
echo "needs_update=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Update needed — target: $TARGET_SHA"
|
||||||
|
echo "needs_update=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Apply update
|
||||||
|
if: steps.check.outputs.needs_update == 'true'
|
||||||
|
run: |
|
||||||
|
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
|
||||||
|
MODE="${{ steps.resolve.outputs.mode }}"
|
||||||
|
git checkout main
|
||||||
|
if [ "$MODE" = "manual" ]; then
|
||||||
|
# Hard reset allows both upgrade and rollback
|
||||||
|
git reset --hard "$TARGET_SHA"
|
||||||
|
else
|
||||||
|
git merge "$TARGET_SHA" --no-edit
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Restore workflow file
|
||||||
|
if: steps.check.outputs.needs_update == 'true'
|
||||||
|
run: |
|
||||||
|
# Always keep our own workflow file, never let upstream overwrite it
|
||||||
|
git checkout HEAD@{1} -- .github/workflows/sync-upstream.yml 2>/dev/null || true
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
git commit -m "chore: restore sync-upstream workflow after sync"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Push
|
||||||
|
if: steps.check.outputs.needs_update == 'true'
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then
|
||||||
|
git push origin main --force
|
||||||
|
else
|
||||||
|
git push origin main
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
|
||||||
|
echo "### Synced successfully" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
|
|
||||||
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
|
|
||||||
|
[文档首页](./nodewarden.wiki/Home.md) | [快速开始](./nodewarden.wiki/快速开始.md)
|
||||||
|
|
||||||
|
[Telegram 频道](https://t.me/NodeWarden_News) | [Telegram 群组](https://t.me/NodeWarden_Official)
|
||||||
|
|
||||||
English: [`README_EN.md`](./README_EN.md)
|
English: [`README_EN.md`](./README_EN.md)
|
||||||
|
|
||||||
> **免责声明**
|
> **免责声明**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
||||||
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
|
[Telegram Channel](https://t.me/NodeWarden_News) | [Telegram Group](https://t.me/NodeWarden_Official)
|
||||||
中文说明:[`README.md`](./README.md)
|
中文说明:[`README.md`](./README.md)
|
||||||
|
|
||||||
> **Disclaimer**
|
> **Disclaimer**
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS ciphers (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS folders (
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -106,6 +107,7 @@ CREATE TABLE IF NOT EXISTS sends (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date);
|
CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -130,6 +130,9 @@
|
|||||||
// Max total items (folders + ciphers) allowed in a single import.
|
// Max total items (folders + ciphers) allowed in a single import.
|
||||||
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
||||||
importItemLimit: 5000,
|
importItemLimit: 5000,
|
||||||
|
// Small fixed concurrency for blob/attachment batch cleanup work.
|
||||||
|
// 附件 / blob 批量清理时的保守并发数。
|
||||||
|
attachmentDeleteConcurrency: 4,
|
||||||
},
|
},
|
||||||
request: {
|
request: {
|
||||||
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ async function verifyUserSecret(
|
|||||||
|
|
||||||
function toProfile(user: User, env: Env): ProfileResponse {
|
function toProfile(user: User, env: Env): ProfileResponse {
|
||||||
void env;
|
void env;
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -100,7 +101,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
|||||||
twoFactorEnabled: !!user.totpSecret,
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys,
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ function formatSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runWithConcurrency<T>(
|
||||||
|
items: T[],
|
||||||
|
concurrency: number,
|
||||||
|
worker: (item: T) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const limit = Math.max(1, concurrency);
|
||||||
|
for (let index = 0; index < items.length; index += limit) {
|
||||||
|
await Promise.all(items.slice(index, index + limit).map(worker));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function processAttachmentUpload(
|
async function processAttachmentUpload(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
@@ -381,10 +393,9 @@ export async function deleteAllAttachmentsForCipher(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => {
|
||||||
for (const attachment of attachments) {
|
|
||||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
await deleteBlobObject(env, path);
|
await deleteBlobObject(env, path);
|
||||||
await storage.deleteAttachment(attachment.id);
|
await storage.deleteAttachment(attachment.id);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,10 +198,12 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
: ciphers.filter(c => !c.deletedAt);
|
: ciphers.filter(c => !c.deletedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
|
||||||
|
filteredCiphers.map((cipher) => cipher.id)
|
||||||
|
);
|
||||||
|
|
||||||
// Get attachments for all ciphers
|
// Build responses only for the current page to keep pagination cheap.
|
||||||
const cipherResponses = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||||
|
|||||||
@@ -327,6 +327,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
@@ -336,8 +338,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
AccountKeys: buildAccountKeys(user),
|
AccountKeys: accountKeys,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys: accountKeys,
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
@@ -350,8 +352,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseResponse = jsonResponse(response);
|
const baseResponse = jsonResponse(response);
|
||||||
@@ -449,6 +451,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
const { accessToken, user, device } = result;
|
const { accessToken, user, device } = result;
|
||||||
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
@@ -457,8 +461,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
|
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
AccountKeys: buildAccountKeys(user),
|
AccountKeys: accountKeys,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys: accountKeys,
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
@@ -471,8 +475,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseResponse = jsonResponse(response);
|
const baseResponse = jsonResponse(response);
|
||||||
|
|||||||
@@ -97,8 +97,9 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
|
|||||||
sends = await storage.getAllSends(userId);
|
sends = await storage.getAllSends(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendResponses = sends.map(sendToResponse);
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: sends.map(sendToResponse),
|
data: sendResponses,
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken,
|
continuationToken,
|
||||||
});
|
});
|
||||||
|
|||||||
+48
-111
@@ -10,87 +10,23 @@ import {
|
|||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
} from '../utils/user-decryption';
|
} from '../utils/user-decryption';
|
||||||
|
|
||||||
interface SyncCacheEntry {
|
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request {
|
||||||
userId: string;
|
const url = new URL(request.url);
|
||||||
revisionDate: string;
|
const cacheUrl = new URL(
|
||||||
body: string;
|
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`,
|
||||||
expiresAt: number;
|
url.origin
|
||||||
bytes: number;
|
);
|
||||||
|
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
|
||||||
let syncResponseCacheTotalBytes = 0;
|
const hit = await caches.default.match(cacheRequest);
|
||||||
const textEncoder = new TextEncoder();
|
|
||||||
|
|
||||||
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
|
|
||||||
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSyncCache(key: string): string | null {
|
|
||||||
const hit = syncResponseCache.get(key);
|
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
if (hit.expiresAt <= Date.now()) {
|
return new Response(hit.body, hit);
|
||||||
deleteSyncCacheEntry(key, hit);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return hit.body;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void {
|
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
|
||||||
const existing = entry ?? syncResponseCache.get(key);
|
await caches.default.put(cacheRequest, response.clone());
|
||||||
if (!existing) return;
|
|
||||||
syncResponseCache.delete(key);
|
|
||||||
syncResponseCacheTotalBytes = Math.max(0, syncResponseCacheTotalBytes - existing.bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneExpiredSyncCache(nowMs: number = Date.now()): void {
|
|
||||||
for (const [key, entry] of syncResponseCache.entries()) {
|
|
||||||
if (entry.expiresAt <= nowMs) {
|
|
||||||
deleteSyncCacheEntry(key, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneStaleUserSyncCache(userId: string, revisionDate: string): void {
|
|
||||||
for (const [key, entry] of syncResponseCache.entries()) {
|
|
||||||
if (entry.userId === userId && entry.revisionDate !== revisionDate) {
|
|
||||||
deleteSyncCacheEntry(key, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeSyncCache(userId: string, revisionDate: string, key: string, body: string): void {
|
|
||||||
const nowMs = Date.now();
|
|
||||||
pruneExpiredSyncCache(nowMs);
|
|
||||||
pruneStaleUserSyncCache(userId, revisionDate);
|
|
||||||
|
|
||||||
const bodyBytes = textEncoder.encode(body).byteLength;
|
|
||||||
if (bodyBytes > LIMITS.cache.syncResponseMaxBodyBytes) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = syncResponseCache.get(key);
|
|
||||||
if (existing) {
|
|
||||||
deleteSyncCacheEntry(key, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (
|
|
||||||
syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries ||
|
|
||||||
syncResponseCacheTotalBytes + bodyBytes > LIMITS.cache.syncResponseMaxTotalBytes
|
|
||||||
) {
|
|
||||||
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
|
|
||||||
if (!oldestKey) break;
|
|
||||||
deleteSyncCacheEntry(oldestKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
syncResponseCache.set(key, {
|
|
||||||
userId,
|
|
||||||
revisionDate,
|
|
||||||
body,
|
|
||||||
expiresAt: nowMs + LIMITS.cache.syncResponseTtlMs,
|
|
||||||
bytes: bodyBytes,
|
|
||||||
});
|
|
||||||
syncResponseCacheTotalBytes += bodyBytes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/sync
|
// GET /api/sync
|
||||||
@@ -99,28 +35,28 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return errorResponse('User not found', 404);
|
return errorResponse('User not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const revisionDate = await storage.getRevisionDate(userId);
|
const revisionDate = await storage.getRevisionDate(userId);
|
||||||
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
|
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains);
|
||||||
const cachedBody = readSyncCache(cacheKey);
|
const cachedResponse = await readSyncCache(cacheRequest);
|
||||||
if (cachedBody) {
|
if (cachedResponse) {
|
||||||
return new Response(cachedBody, {
|
return cachedResponse;
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ciphers = await storage.getAllCiphers(userId);
|
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
|
||||||
const folders = await storage.getAllFolders(userId);
|
storage.getAllCiphers(userId),
|
||||||
const sends = await storage.getAllSends(userId);
|
storage.getAllFolders(userId),
|
||||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
storage.getAllSends(userId),
|
||||||
|
storage.getAttachmentsByUserId(userId),
|
||||||
|
]);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
// Build profile response
|
|
||||||
const profile: ProfileResponse = {
|
const profile: ProfileResponse = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -134,7 +70,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
twoFactorEnabled: !!user.totpSecret,
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys,
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
@@ -146,23 +82,24 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
object: 'profile',
|
object: 'profile',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build cipher responses with attachments
|
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
for (const cipher of ciphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []));
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build folder responses
|
const folderResponses: FolderResponse[] = [];
|
||||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
for (const folder of folders) {
|
||||||
id: folder.id,
|
folderResponses.push({
|
||||||
name: folder.name,
|
id: folder.id,
|
||||||
revisionDate: folder.updatedAt,
|
name: folder.name,
|
||||||
object: 'folder',
|
revisionDate: folder.updatedAt,
|
||||||
}));
|
object: 'folder',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendResponses = sends.map(sendToResponse);
|
||||||
const syncResponse: SyncResponse = {
|
const syncResponse: SyncResponse = {
|
||||||
profile: profile,
|
profile,
|
||||||
folders: folderResponses,
|
folders: folderResponses,
|
||||||
collections: [],
|
collections: [],
|
||||||
ciphers: cipherResponses,
|
ciphers: cipherResponses,
|
||||||
@@ -174,25 +111,25 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
object: 'domains',
|
object: 'domains',
|
||||||
},
|
},
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: sends.map(sendToResponse),
|
sends: sendResponses,
|
||||||
UserDecryption: {
|
UserDecryption: {
|
||||||
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
|
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||||
TrustedDeviceOption: null,
|
TrustedDeviceOption: null,
|
||||||
KeyConnectorOption: null,
|
KeyConnectorOption: null,
|
||||||
Object: 'userDecryption',
|
Object: 'userDecryption',
|
||||||
},
|
},
|
||||||
// PascalCase for desktop/browser clients
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
|
||||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
|
||||||
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||||
object: 'sync',
|
object: 'sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = JSON.stringify(syncResponse);
|
const response = new Response(JSON.stringify(syncResponse), {
|
||||||
writeSyncCache(userId, revisionDate, cacheKey, body);
|
|
||||||
|
|
||||||
return new Response(body, {
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': `private, max-age=${Math.max(1, Math.floor(LIMITS.cache.syncResponseTtlMs / 1000))}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
await writeSyncCache(cacheRequest, response);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
@@ -47,6 +48,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id)',
|
||||||
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
||||||
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||||
|
|
||||||
|
|||||||
+26
-1
@@ -172,9 +172,11 @@ export default function App() {
|
|||||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||||
const [totpCode, setTotpCode] = useState('');
|
const [totpCode, setTotpCode] = useState('');
|
||||||
const [rememberDevice, setRememberDevice] = useState(true);
|
const [rememberDevice, setRememberDevice] = useState(true);
|
||||||
|
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
||||||
|
|
||||||
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
||||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||||
|
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
|
||||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
||||||
@@ -433,16 +435,20 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleTotpVerify() {
|
async function handleTotpVerify() {
|
||||||
|
if (totpSubmitting) return;
|
||||||
if (!pendingTotp) return;
|
if (!pendingTotp) return;
|
||||||
if (!totpCode.trim()) {
|
if (!totpCode.trim()) {
|
||||||
pushToast('error', t('txt_please_input_totp_code'));
|
pushToast('error', t('txt_please_input_totp_code'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setTotpSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||||
await finalizeLogin(login);
|
await finalizeLogin(login);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||||
|
} finally {
|
||||||
|
setTotpSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,11 +637,13 @@ export default function App() {
|
|||||||
onConfirmTotp={() => {}}
|
onConfirmTotp={() => {}}
|
||||||
onCancelTotp={() => {}}
|
onCancelTotp={() => {}}
|
||||||
onUseRecoveryCode={() => {}}
|
onUseRecoveryCode={() => {}}
|
||||||
|
totpSubmitting={false}
|
||||||
disableTotpOpen={false}
|
disableTotpOpen={false}
|
||||||
disableTotpPassword=""
|
disableTotpPassword=""
|
||||||
onDisableTotpPasswordChange={() => {}}
|
onDisableTotpPasswordChange={() => {}}
|
||||||
onConfirmDisableTotp={() => {}}
|
onConfirmDisableTotp={() => {}}
|
||||||
onCancelDisableTotp={() => {}}
|
onCancelDisableTotp={() => {}}
|
||||||
|
disableTotpSubmitting={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1288,21 +1296,25 @@ export default function App() {
|
|||||||
onRememberDeviceChange={setRememberDevice}
|
onRememberDeviceChange={setRememberDevice}
|
||||||
onConfirmTotp={() => void handleTotpVerify()}
|
onConfirmTotp={() => void handleTotpVerify()}
|
||||||
onCancelTotp={() => {
|
onCancelTotp={() => {
|
||||||
|
if (totpSubmitting) return;
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setRememberDevice(true);
|
setRememberDevice(true);
|
||||||
}}
|
}}
|
||||||
onUseRecoveryCode={() => {
|
onUseRecoveryCode={() => {
|
||||||
|
if (totpSubmitting) return;
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setRememberDevice(true);
|
setRememberDevice(true);
|
||||||
navigate('/recover-2fa');
|
navigate('/recover-2fa');
|
||||||
}}
|
}}
|
||||||
|
totpSubmitting={totpSubmitting}
|
||||||
disableTotpOpen={false}
|
disableTotpOpen={false}
|
||||||
disableTotpPassword=""
|
disableTotpPassword=""
|
||||||
onDisableTotpPasswordChange={() => {}}
|
onDisableTotpPasswordChange={() => {}}
|
||||||
onConfirmDisableTotp={() => {}}
|
onConfirmDisableTotp={() => {}}
|
||||||
onCancelDisableTotp={() => {}}
|
onCancelDisableTotp={() => {}}
|
||||||
|
disableTotpSubmitting={false}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -1341,14 +1353,27 @@ export default function App() {
|
|||||||
onConfirmTotp={() => {}}
|
onConfirmTotp={() => {}}
|
||||||
onCancelTotp={() => {}}
|
onCancelTotp={() => {}}
|
||||||
onUseRecoveryCode={() => {}}
|
onUseRecoveryCode={() => {}}
|
||||||
|
totpSubmitting={false}
|
||||||
disableTotpOpen={disableTotpOpen}
|
disableTotpOpen={disableTotpOpen}
|
||||||
disableTotpPassword={disableTotpPassword}
|
disableTotpPassword={disableTotpPassword}
|
||||||
onDisableTotpPasswordChange={setDisableTotpPassword}
|
onDisableTotpPasswordChange={setDisableTotpPassword}
|
||||||
onConfirmDisableTotp={() => void accountSecurityActions.disableTotp()}
|
onConfirmDisableTotp={() => {
|
||||||
|
if (disableTotpSubmitting) return;
|
||||||
|
void (async () => {
|
||||||
|
setDisableTotpSubmitting(true);
|
||||||
|
try {
|
||||||
|
await accountSecurityActions.disableTotp();
|
||||||
|
} finally {
|
||||||
|
setDisableTotpSubmitting(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
onCancelDisableTotp={() => {
|
onCancelDisableTotp={() => {
|
||||||
|
if (disableTotpSubmitting) return;
|
||||||
setDisableTotpOpen(false);
|
setDisableTotpOpen(false);
|
||||||
setDisableTotpPassword('');
|
setDisableTotpPassword('');
|
||||||
}}
|
}}
|
||||||
|
disableTotpSubmitting={disableTotpSubmitting}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ interface AppGlobalOverlaysProps {
|
|||||||
onConfirmTotp: () => void;
|
onConfirmTotp: () => void;
|
||||||
onCancelTotp: () => void;
|
onCancelTotp: () => void;
|
||||||
onUseRecoveryCode: () => void;
|
onUseRecoveryCode: () => void;
|
||||||
|
totpSubmitting: boolean;
|
||||||
disableTotpOpen: boolean;
|
disableTotpOpen: boolean;
|
||||||
disableTotpPassword: string;
|
disableTotpPassword: string;
|
||||||
onDisableTotpPasswordChange: (value: string) => void;
|
onDisableTotpPasswordChange: (value: string) => void;
|
||||||
onConfirmDisableTotp: () => void;
|
onConfirmDisableTotp: () => void;
|
||||||
onCancelDisableTotp: () => void;
|
onCancelDisableTotp: () => void;
|
||||||
|
disableTotpSubmitting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||||
@@ -57,12 +59,14 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
confirmText={t('txt_verify')}
|
confirmText={t('txt_verify')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={props.totpSubmitting}
|
||||||
|
cancelDisabled={props.totpSubmitting}
|
||||||
onConfirm={props.onConfirmTotp}
|
onConfirm={props.onConfirmTotp}
|
||||||
onCancel={props.onCancelTotp}
|
onCancel={props.onCancelTotp}
|
||||||
afterActions={(
|
afterActions={(
|
||||||
<div className="dialog-extra">
|
<div className="dialog-extra">
|
||||||
<div className="dialog-divider" />
|
<div className="dialog-divider" />
|
||||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onUseRecoveryCode}>
|
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
|
||||||
{t('txt_use_recovery_code')}
|
{t('txt_use_recovery_code')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,6 +90,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
danger
|
danger
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={props.disableTotpSubmitting}
|
||||||
|
cancelDisabled={props.disableTotpSubmitting}
|
||||||
onConfirm={props.onConfirmDisableTotp}
|
onConfirm={props.onConfirmDisableTotp}
|
||||||
onCancel={props.onCancelDisableTotp}
|
onCancel={props.onCancelDisableTotp}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -528,6 +528,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
allowChecksumMismatch: boolean = false,
|
allowChecksumMismatch: boolean = false,
|
||||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
) {
|
) {
|
||||||
|
if (importing) return;
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
const message = t('txt_backup_file_required');
|
const message = t('txt_backup_file_required');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
@@ -654,6 +655,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteRemote(path: string) {
|
async function handleDeleteRemote(path: string) {
|
||||||
|
if (deletingRemotePath) return;
|
||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
setDeletingRemotePath(path);
|
setDeletingRemotePath(path);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
@@ -723,6 +725,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
allowChecksumMismatch: boolean = false,
|
allowChecksumMismatch: boolean = false,
|
||||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
) {
|
) {
|
||||||
|
if (restoringRemotePath) return;
|
||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
setConfirmRemoteReplaceOpen(false);
|
setConfirmRemoteReplaceOpen(false);
|
||||||
setConfirmIntegrityWarningOpen(false);
|
setConfirmIntegrityWarningOpen(false);
|
||||||
@@ -896,9 +899,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')}
|
message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')}
|
||||||
confirmText={t('txt_backup_import')}
|
confirmText={t('txt_backup_import')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={importing}
|
||||||
|
cancelDisabled={importing}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => void runLocalRestore(false)}
|
onConfirm={() => void runLocalRestore(false)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
if (importing) return;
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
resetSelectedFile();
|
resetSelectedFile();
|
||||||
resetPendingIntegrityWarning();
|
resetPendingIntegrityWarning();
|
||||||
@@ -959,6 +965,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
variant="warning"
|
variant="warning"
|
||||||
confirmText={t('txt_backup_restore_checksum_warning_confirm')}
|
confirmText={t('txt_backup_restore_checksum_warning_confirm')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={importing || !!restoringRemotePath}
|
||||||
|
cancelDisabled={importing || !!restoringRemotePath}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (!pendingRestoreIntegrity) return;
|
if (!pendingRestoreIntegrity) return;
|
||||||
@@ -984,6 +992,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
|
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
|
||||||
confirmText={t('txt_delete')}
|
confirmText={t('txt_delete')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={!!deletingRemotePath}
|
||||||
|
cancelDisabled={!!deletingRemotePath}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
|
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
@@ -1001,6 +1011,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
})}
|
})}
|
||||||
confirmText={t('txt_delete')}
|
confirmText={t('txt_delete')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={savingSettings}
|
||||||
|
cancelDisabled={savingSettings}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => void handleDeleteDestination()}
|
onConfirm={() => void handleDeleteDestination()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handlePasswordImportConfirm() {
|
async function handlePasswordImportConfirm() {
|
||||||
|
if (isPasswordSubmitting) return;
|
||||||
if (!pendingPasswordImport) return;
|
if (!pendingPasswordImport) return;
|
||||||
setIsPasswordSubmitting(true);
|
setIsPasswordSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -486,6 +487,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleZipPasswordImportConfirm() {
|
async function handleZipPasswordImportConfirm() {
|
||||||
|
if (isZipPasswordSubmitting) return;
|
||||||
if (!pendingZipFile) return;
|
if (!pendingZipFile) return;
|
||||||
setIsZipPasswordSubmitting(true);
|
setIsZipPasswordSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -558,6 +560,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleExportConfirmPassword() {
|
async function handleExportConfirmPassword() {
|
||||||
|
if (isExporting) return;
|
||||||
const masterPassword = String(exportAuthPassword || '').trim();
|
const masterPassword = String(exportAuthPassword || '').trim();
|
||||||
if (!masterPassword) {
|
if (!masterPassword) {
|
||||||
onNotify('error', t('txt_master_password_is_required'));
|
onNotify('error', t('txt_master_password_is_required'));
|
||||||
@@ -736,6 +739,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
|
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={isExporting}
|
||||||
|
cancelDisabled={isExporting}
|
||||||
onConfirm={() => void handleExportConfirmPassword()}
|
onConfirm={() => void handleExportConfirmPassword()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (isExporting) return;
|
if (isExporting) return;
|
||||||
@@ -761,6 +766,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={isPasswordSubmitting}
|
||||||
|
cancelDisabled={isPasswordSubmitting}
|
||||||
onConfirm={() => void handlePasswordImportConfirm()}
|
onConfirm={() => void handlePasswordImportConfirm()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (isPasswordSubmitting) return;
|
if (isPasswordSubmitting) return;
|
||||||
@@ -787,6 +794,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
showIcon={false}
|
showIcon={false}
|
||||||
|
confirmDisabled={isZipPasswordSubmitting}
|
||||||
|
cancelDisabled={isZipPasswordSubmitting}
|
||||||
onConfirm={() => void handleZipPasswordImportConfirm()}
|
onConfirm={() => void handleZipPasswordImportConfirm()}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (isZipPasswordSubmitting) return;
|
if (isZipPasswordSubmitting) return;
|
||||||
|
|||||||
@@ -1009,6 +1009,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VaultDialogs
|
<VaultDialogs
|
||||||
|
busy={busy}
|
||||||
fieldModalOpen={fieldModalOpen}
|
fieldModalOpen={fieldModalOpen}
|
||||||
fieldType={fieldType}
|
fieldType={fieldType}
|
||||||
fieldLabel={fieldLabel}
|
fieldLabel={fieldLabel}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vaul
|
|||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface VaultDialogsProps {
|
interface VaultDialogsProps {
|
||||||
|
busy: boolean;
|
||||||
fieldModalOpen: boolean;
|
fieldModalOpen: boolean;
|
||||||
fieldType: CustomFieldType;
|
fieldType: CustomFieldType;
|
||||||
fieldLabel: string;
|
fieldLabel: string;
|
||||||
@@ -108,6 +109,8 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
message={t('txt_archive_item_message')}
|
message={t('txt_archive_item_message')}
|
||||||
confirmText={t('txt_archive')}
|
confirmText={t('txt_archive')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
onConfirm={props.onConfirmArchive}
|
onConfirm={props.onConfirmArchive}
|
||||||
onCancel={props.onCancelArchive}
|
onCancel={props.onCancelArchive}
|
||||||
/>
|
/>
|
||||||
@@ -118,11 +121,22 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
|
message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
|
||||||
confirmText={t('txt_archive')}
|
confirmText={t('txt_archive')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
onConfirm={props.onConfirmBulkArchive}
|
onConfirm={props.onConfirmBulkArchive}
|
||||||
onCancel={props.onCancelBulkArchive}
|
onCancel={props.onCancelBulkArchive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} />
|
<ConfirmDialog
|
||||||
|
open={props.pendingDeleteOpen}
|
||||||
|
title={t('txt_delete_item')}
|
||||||
|
message={t('txt_are_you_sure_you_want_to_delete_this_item')}
|
||||||
|
danger
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmDelete}
|
||||||
|
onCancel={props.onCancelDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={props.bulkDeleteOpen}
|
open={props.bulkDeleteOpen}
|
||||||
@@ -133,11 +147,23 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
|
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
|
||||||
}
|
}
|
||||||
danger
|
danger
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
onConfirm={props.onConfirmBulkDelete}
|
onConfirm={props.onConfirmBulkDelete}
|
||||||
onCancel={props.onCancelBulkDelete}
|
onCancel={props.onCancelBulkDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog open={props.moveOpen} title={t('txt_move_selected_items')} message={t('txt_choose_destination_folder')} confirmText={t('txt_move')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmMove} onCancel={props.onCancelMove}>
|
<ConfirmDialog
|
||||||
|
open={props.moveOpen}
|
||||||
|
title={t('txt_move_selected_items')}
|
||||||
|
message={t('txt_choose_destination_folder')}
|
||||||
|
confirmText={t('txt_move')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmMove}
|
||||||
|
onCancel={props.onCancelMove}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_folder')}</span>
|
<span>{t('txt_folder')}</span>
|
||||||
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}>
|
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}>
|
||||||
@@ -151,14 +177,34 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog open={props.createFolderOpen} title={t('txt_create_folder')} message={t('txt_enter_a_folder_name')} confirmText={t('txt_create')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmCreateFolder} onCancel={props.onCancelCreateFolder}>
|
<ConfirmDialog
|
||||||
|
open={props.createFolderOpen}
|
||||||
|
title={t('txt_create_folder')}
|
||||||
|
message={t('txt_enter_a_folder_name')}
|
||||||
|
confirmText={t('txt_create')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmCreateFolder}
|
||||||
|
onCancel={props.onCancelCreateFolder}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_folder_name')}</span>
|
<span>{t('txt_folder_name')}</span>
|
||||||
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
<ConfirmDialog open={props.renameFolderOpen} title={t('txt_edit')} message={t('txt_enter_a_folder_name')} confirmText={t('txt_save')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmRenameFolder} onCancel={props.onCancelRenameFolder}>
|
<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">
|
<label className="field">
|
||||||
<span>{t('txt_folder_name')}</span>
|
<span>{t('txt_folder_name')}</span>
|
||||||
<input className="input" value={props.renameFolderName} onInput={(e) => props.onRenameFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" value={props.renameFolderName} onInput={(e) => props.onRenameFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
@@ -172,13 +218,37 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
confirmText={t('txt_delete')}
|
confirmText={t('txt_delete')}
|
||||||
cancelText={t('txt_cancel')}
|
cancelText={t('txt_cancel')}
|
||||||
danger
|
danger
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
onConfirm={props.onConfirmDeleteFolder}
|
onConfirm={props.onConfirmDeleteFolder}
|
||||||
onCancel={props.onCancelDeleteFolder}
|
onCancel={props.onCancelDeleteFolder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog open={props.deleteAllFoldersOpen} title={t('txt_delete_all_folders')} message={t('txt_delete_all_folders_message')} confirmText={t('txt_delete')} cancelText={t('txt_cancel')} danger onConfirm={props.onConfirmDeleteAllFolders} onCancel={props.onCancelDeleteAllFolders} />
|
<ConfirmDialog
|
||||||
|
open={props.deleteAllFoldersOpen}
|
||||||
|
title={t('txt_delete_all_folders')}
|
||||||
|
message={t('txt_delete_all_folders_message')}
|
||||||
|
confirmText={t('txt_delete')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmDeleteAllFolders}
|
||||||
|
onCancel={props.onCancelDeleteAllFolders}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog open={props.repromptOpen} title={t('txt_unlock_item')} message={t('txt_enter_master_password_to_view_this_item')} confirmText={t('txt_unlock')} cancelText={t('txt_cancel')} showIcon={false} onConfirm={props.onConfirmReprompt} onCancel={props.onCancelReprompt}>
|
<ConfirmDialog
|
||||||
|
open={props.repromptOpen}
|
||||||
|
title={t('txt_unlock_item')}
|
||||||
|
message={t('txt_enter_master_password_to_view_this_item')}
|
||||||
|
confirmText={t('txt_unlock')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
confirmDisabled={props.busy}
|
||||||
|
cancelDisabled={props.busy}
|
||||||
|
onConfirm={props.onConfirmReprompt}
|
||||||
|
onCancel={props.onCancelReprompt}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_master_password')}</span>
|
<span>{t('txt_master_password')}</span>
|
||||||
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
|
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
|
||||||
import type { Send, SendDraft, SessionState } from '../types';
|
import type { Send, SendDraft, SessionState } from '../types';
|
||||||
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
|
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
|
||||||
|
import { loadVaultSyncSnapshot } from './vault-sync';
|
||||||
|
|
||||||
function toIsoDateFromDays(value: string, required: boolean): string | null {
|
function toIsoDateFromDays(value: string, required: boolean): string | null {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
@@ -61,10 +62,8 @@ function parseMaxAccessCountRaw(value: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
||||||
const resp = await authedFetch('/api/sends');
|
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||||
if (!resp.ok) throw new Error('Failed to load sends');
|
return body.sends || [];
|
||||||
const body = await parseJson<{ object: 'list'; data: Send[] }>(resp);
|
|
||||||
return body?.data || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSend(
|
export async function createSend(
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Cipher, Folder, Send } from '../types';
|
||||||
|
import { parseJson, type AuthedFetch } from './shared';
|
||||||
|
|
||||||
|
interface VaultSyncResponse {
|
||||||
|
ciphers?: Cipher[];
|
||||||
|
folders?: Folder[];
|
||||||
|
sends?: Send[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSyncRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>();
|
||||||
|
|
||||||
|
export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> {
|
||||||
|
const existing = pendingSyncRequests.get(authedFetch);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
const resp = await authedFetch('/api/sync');
|
||||||
|
if (!resp.ok) throw new Error('Failed to load vault');
|
||||||
|
const body = await parseJson<VaultSyncResponse>(resp);
|
||||||
|
return body || {};
|
||||||
|
})();
|
||||||
|
|
||||||
|
pendingSyncRequests.set(authedFetch, request);
|
||||||
|
try {
|
||||||
|
return await request;
|
||||||
|
} finally {
|
||||||
|
if (pendingSyncRequests.get(authedFetch) === request) {
|
||||||
|
pendingSyncRequests.delete(authedFetch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, enc
|
|||||||
import type {
|
import type {
|
||||||
Cipher,
|
Cipher,
|
||||||
Folder,
|
Folder,
|
||||||
ListResponse,
|
|
||||||
SessionState,
|
SessionState,
|
||||||
VaultDraft,
|
VaultDraft,
|
||||||
VaultDraftField,
|
VaultDraftField,
|
||||||
@@ -16,12 +15,11 @@ import {
|
|||||||
type AuthedFetch,
|
type AuthedFetch,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { readResponseBytesWithProgress } from '../download';
|
import { readResponseBytesWithProgress } from '../download';
|
||||||
|
import { loadVaultSyncSnapshot } from './vault-sync';
|
||||||
|
|
||||||
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
||||||
const resp = await authedFetch('/api/folders');
|
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||||
if (!resp.ok) throw new Error('Failed to load folders');
|
return body.folders || [];
|
||||||
const body = await parseJson<ListResponse<Folder>>(resp);
|
|
||||||
return body?.data || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFolder(
|
export async function createFolder(
|
||||||
@@ -93,10 +91,8 @@ export async function updateFolder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
|
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
|
||||||
const resp = await authedFetch('/api/ciphers?deleted=true');
|
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||||
if (!resp.ok) throw new Error('Failed to load ciphers');
|
return body.ciphers || [];
|
||||||
const body = await parseJson<ListResponse<Cipher>>(resp);
|
|
||||||
return body?.data || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CiphersImportPayload {
|
export interface CiphersImportPayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user