Compare commits

...
30 Commits
Author SHA1 Message Date
shuaiplus ce3674669e feat: update version to 1.7.2 in package.json, package-lock.json, and app-version.ts 2026-07-01 13:36:59 +08:00
shuaiplus aa7b87e041 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-01 13:28:26 +08:00
shuaiplus e4215b4025 feat: add fill-assist handlers and update device response type 2026-07-01 13:28:22 +08:00
dependabot[bot]andShuai 8d292ca7b8 chore(deps): bump the npm-minor-and-patch group across 1 directory with 15 updates
Bumps the npm-minor-and-patch group with 15 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@noble/hashes](https://github.com/paulmillr/noble-hashes) | `2.0.1` | `2.2.0` |
| [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) | `13.3.1` | `13.3.2` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.90.21` | `5.101.2` |
| [@zip.js/zip.js](https://github.com/gildas-lormeau/zip.js) | `2.8.22` | `2.8.26` |
| [fflate](https://github.com/101arrowz/fflate) | `0.8.2` | `0.8.3` |
| [preact](https://github.com/preactjs/preact) | `10.28.4` | `10.29.3` |
| [wouter](https://github.com/molefrog/wouter) | `3.9.0` | `3.10.0` |
| [@cloudflare/workers-types](https://github.com/cloudflare/workerd) | `4.20260609.1` | `4.20260630.1` |
| [@preact/preset-vite](https://github.com/preactjs/preset-vite) | `2.10.3` | `2.10.5` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.4.21` | `10.5.2` |
| [opencc-js](https://github.com/nk2028/opencc-js) | `1.0.5` | `1.3.2` |
| [postcss](https://github.com/postcss/postcss) | `8.5.15` | `8.5.16` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `3.4.17` | `3.4.19` |
| [tsx](https://github.com/privatenumber/tsx) | `4.21.0` | `4.22.4` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.98.0` | `4.105.0` |



Updates `@noble/hashes` from 2.0.1 to 2.2.0
- [Release notes](https://github.com/paulmillr/noble-hashes/releases)
- [Commits](https://github.com/paulmillr/noble-hashes/compare/2.0.1...2.2.0)

Updates `@simplewebauthn/server` from 13.3.1 to 13.3.2
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.2/packages/server)

Updates `@tanstack/react-query` from 5.90.21 to 5.101.2
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.2/packages/react-query)

Updates `@zip.js/zip.js` from 2.8.22 to 2.8.26
- [Release notes](https://github.com/gildas-lormeau/zip.js/releases)
- [Commits](https://github.com/gildas-lormeau/zip.js/compare/v2.8.22...v2.8.26)

Updates `fflate` from 0.8.2 to 0.8.3
- [Release notes](https://github.com/101arrowz/fflate/releases)
- [Changelog](https://github.com/101arrowz/fflate/blob/master/CHANGELOG.md)
- [Commits](https://github.com/101arrowz/fflate/compare/v0.8.2...v0.8.3)

Updates `preact` from 10.28.4 to 10.29.3
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.28.4...10.29.3)

Updates `wouter` from 3.9.0 to 3.10.0
- [Release notes](https://github.com/molefrog/wouter/releases)
- [Commits](https://github.com/molefrog/wouter/commits)

Updates `@cloudflare/workers-types` from 4.20260609.1 to 4.20260630.1
- [Release notes](https://github.com/cloudflare/workerd/releases)
- [Changelog](https://github.com/cloudflare/workerd/blob/main/RELEASE.md)
- [Commits](https://github.com/cloudflare/workerd/commits)

Updates `@preact/preset-vite` from 2.10.3 to 2.10.5
- [Release notes](https://github.com/preactjs/preset-vite/releases)
- [Commits](https://github.com/preactjs/preset-vite/compare/2.10.3...2.10.5)

Updates `autoprefixer` from 10.4.21 to 10.5.2
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.21...10.5.2)

Updates `opencc-js` from 1.0.5 to 1.3.2
- [Release notes](https://github.com/nk2028/opencc-js/releases)
- [Changelog](https://github.com/nk2028/opencc-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nk2028/opencc-js/compare/v1.0.5...v1.3.2)

Updates `postcss` from 8.5.15 to 8.5.16
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.16)

Updates `tailwindcss` from 3.4.17 to 3.4.19
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v3.4.19/packages/tailwindcss)

Updates `tsx` from 4.21.0 to 4.22.4
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.21.0...v4.22.4)

Updates `wrangler` from 4.98.0 to 4.105.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.105.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: "@cloudflare/workers-types"
  dependency-version: 4.20260630.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@noble/hashes"
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@preact/preset-vite"
  dependency-version: 2.10.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@zip.js/zip.js"
  dependency-version: 2.8.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: autoprefixer
  dependency-version: 10.5.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: fflate
  dependency-version: 0.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: opencc-js
  dependency-version: 1.3.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: postcss
  dependency-version: 8.5.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: preact
  dependency-version: 10.29.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: tailwindcss
  dependency-version: 3.4.19
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: tsx
  dependency-version: 4.22.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: wouter
  dependency-version: 3.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.105.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 13:55:20 +08:00
shuaiplus 5dd9dff045 ci: update actions/checkout and other actions to specific versions 2026-06-30 13:49:33 +08:00
shuaiplus 709a8c1768 Merge pull request #279 from dependabot/github_actions/github-actions-db669df06a 2026-06-30 13:44:53 +08:00
shuaiplus e2c3516ce9 Merge pull request #284 from dependabot/npm_and_yarn/lucide-preact-1.22.0 2026-06-30 13:43:17 +08:00
shuaiplus 55b5c57f9e Merge pull request #283 from dependabot/npm_and_yarn/typescript-6.0.3 2026-06-30 13:43:07 +08:00
shuaiplus b6fb62603b Merge pull request #282 from dependabot/npm_and_yarn/types/node-26.0.1 2026-06-30 13:42:57 +08:00
shuaiplus 35071c2719 ci: limit risky dependabot updates 2026-06-30 13:36:00 +08:00
dependabot[bot]andGitHub 5bd7dab277 chore(deps): bump lucide-preact from 0.575.0 to 1.22.0
Bumps [lucide-preact](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-preact) from 0.575.0 to 1.22.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.22.0/packages/lucide-preact)

---
updated-dependencies:
- dependency-name: lucide-preact
  dependency-version: 1.22.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 05:28:03 +00:00
dependabot[bot]andGitHub 99f2d7f444 chore(deps-dev): bump typescript from 5.9.3 to 6.0.3
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 05:28:02 +00:00
dependabot[bot]andGitHub fb9a2aeda1 chore(deps-dev): bump @types/node from 25.2.3 to 26.0.1
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.2.3 to 26.0.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 05:27:57 +00:00
dependabot[bot]andGitHub c87e6ac984 chore(deps): bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [actions/setup-node](https://github.com/actions/setup-node) and [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request).


Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

Updates `actions/setup-node` from 4 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

Updates `peter-evans/create-pull-request` from 6 to 8
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](https://github.com/peter-evans/create-pull-request/compare/v6...v8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: peter-evans/create-pull-request
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 05:26:28 +00:00
shuaiplus a6f1c6dea2 Merge branch 'beta' 2026-06-30 13:25:22 +08:00
shuaiplus 49c872a8ec ci: fix sync workflow shell lint 2026-06-30 13:18:02 +08:00
shuaiplus 78af1f9bdd ci: skip scorecard outside main 2026-06-30 13:14:23 +08:00
shuaiplus 32b3d2ade1 chore: override ws vulnerability 2026-06-30 13:11:07 +08:00
shuaiplus 64f26e76f6 chore: add security automation workflows 2026-06-30 13:05:04 +08:00
rootphantomerandShuai 68c42a0330 fix: preserve multiline values (e.g. SSH private keys) during CSV import
parseBitwardenCsvFieldLines previously discarded any field line that did not
contain the ': ' delimiter, truncating multiline values like OpenSSH private
keys to only their first line.

Replace the map+filter pipeline with a reduce that accumulates continuation
lines (lines without ': ') into the previous entry's value, joined by '\n'.
This preserves the full private key content through a CSV round-trip.

Fixes: CSV export to import of SSH key items where the private key body was
silently dropped.
2026-06-30 11:44:03 +08:00
shuaiplus 0d1bb196e2 feat: add functionality to delete invalid invites and update related components 2026-06-29 12:12:13 +08:00
shuaiplus e31f82c0d6 feat: update project wiki link and remove obsolete security scripts and workflows 2026-06-29 11:57:53 +08:00
shuaiplus f82dcc3c17 feat: rename revokeInvite to deleteInvite and update related functionality 2026-06-29 11:47:05 +08:00
shuaiplus 4378e1b430 feat: add pendingAuthRequestsRefreshing state to improve loading feedback in auth request components 2026-06-29 11:29:35 +08:00
shuaiplus 5eeaf4e32e feat: enhance Bitwarden CSV parsing with custom field handling and metadata restoration 2026-06-29 11:10:41 +08:00
shuaiplus 82f968e51f feat: add validFolderIds support for cipher responses and update folder handling in storage 2026-06-28 19:43:27 +08:00
shuaiplus a5ad16ac27 feat: add device selection and removal functionality in SecurityDevicesPage 2026-06-28 15:31:29 +08:00
shuaiplus 6a1a8357bf feat: refactor PRF extension handling in credential options 2026-06-28 14:13:06 +08:00
shuaiplus 31cfd19b6b feat: add support for excluding PRF extensions in credential options 2026-06-28 14:02:51 +08:00
shuaiplus 4cd9ad00d2 Add backup-related error messages and improve UI styles
- Updated English, Spanish, Russian, Simplified Chinese, and Traditional Chinese locale files to include new error messages related to backup and restore processes.
- Added prefix and suffix strings for the "cached empty" message to enhance clarity in user prompts.
- Enhanced the management CSS with new styles for the backup browser refresh prompt to improve layout and user experience.
2026-06-27 12:38:41 +08:00
46 changed files with 1628 additions and 917 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
blank_issues_enabled: false
contact_links:
- name: Project Wiki/ 项目文档
url: https://github.com/shuaiplus/nodewarden/wiki
url: https://nodewarden.app
about: |
Please check the documentation for common questions and troubleshooting steps.
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
+33
View File
@@ -0,0 +1,33 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "05:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 5
groups:
npm-minor-and-patch:
update-types:
- "minor"
- "patch"
ignore:
- dependency-name: "tailwindcss"
update-types:
- "version-update:semver-major"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "05:10"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 0
groups:
github-actions:
patterns:
- "*"
-467
View File
@@ -1,467 +0,0 @@
const fs = require('fs');
const path = require('path');
/**
* Security Report Generator (Node.js)
* Better, faster, and more maintainable than Bash.
*/
class SecurityReport {
constructor() {
this.results = {
codeql: { status: 'PASS', findings: [], alertCount: 0, rulesCount: 0 },
snyk: { status: 'PASS', findings: [], vulnCount: 0 },
gitleaks: { status: 'PASS', findings: [], leaksCount: 0 },
trivy: { status: 'PASS', findings: [], misconfigCount: 0 },
coverage: { actions: 0, js: 0, ts: 0 },
artifactUris: []
};
this.auditTime = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
this.runId = process.env.GITHUB_RUN_ID || '0';
this.repository = process.env.GITHUB_REPOSITORY || 'unknown/repo';
this.runUrl = `https://github.com/${this.repository}/actions/runs/${this.runId}`;
this.locales = {
zh: {
filename: 'security-report-cn.md',
switcher: '[English](security-report.md) | 中文',
title: '🛡️ 安全审计与透明度报告',
grade: '安全评级',
important: '> [!IMPORTANT]\n> 本报告由 **GitHub Actions** 自动生成。为确保数据主权的绝对透明度,所有核心模块的安全扫描结果均实时公开。',
auditTime: '📅 审计时间',
runId: '📝 运行 ID',
env: '🛠️ 环境',
dashboard: '📉 实时安全仪表盘',
tool: '工具',
status: '状态',
findings: '发现项',
leaks: '泄露',
vulns: '漏洞',
alerts: '告警',
coverageTitle: '🔍 扫描覆盖范围',
module: '模块',
auditedFiles: '已审计文件',
coverage: '覆盖率',
detailedFindings: '🔍 详细发现项',
gitleaksTitle: '🔑 凭据泄露检查 (Gitleaks)',
gitleaksDesc: '`检测代码历史记录中硬编码的 API 密钥、密码或其他敏感令牌。`',
gitleaksSafe: '✅ **安全**:未发现硬编码的敏感凭据。',
gitleaksScope: '`扫描范围:所有代码更改和 Git 历史记录 (Gitleaks 全量扫描)`',
snykTitle: '📦 第三方依赖',
snykSafe: '✅ **安全**:在依赖项中未发现已知漏洞。',
package: '软件包',
severity: '严重程度',
description: '描述',
fixPlan: '修复方案',
codeqlTitle: '💻 代码质量与安全 (CodeQL)',
codeqlSummary: '#### 摘要',
rulesChecked: '已检查规则',
totalAlerts: '告警总数',
codeqlSafe: '✅ **安全**:CodeQL 扫描清洁,未检测到问题。',
ruleId: '规则 ID',
level: '级别',
location: '位置',
auditedList: '📂 已审计文件列表',
guideTitle: '⚠️ 操作指南',
guideDesc: '如果您看到 **FAIL** 状态或严重的代码问题:',
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
footer: '💡 *由 NodeWarden 安全工作流生成。透明度是我们的承诺。*',
auditedIcon: '✅ **已审计**',
noFiles: '未检索到文件。',
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
trivyDesc: '`检测 Dockerfile 和容器配置中的安全风险与最佳实践。`',
trivySafe: '✅ **安全**:未发现容器配置缺陷。'
},
en: {
filename: 'security-report.md',
switcher: 'English | [中文](security-report-cn.md)',
title: '🛡️ Security Audit & Transparency Report',
grade: 'Security Grade',
important: '> [!IMPORTANT]\n> This report is automatically generated by **GitHub Actions**. To ensure absolute transparency of data sovereignty, all core module security scan results are made public in real-time.',
auditTime: '📅 Audit Time',
runId: '📝 Run ID',
env: '🛠️ Environment',
dashboard: '📉 Real-time Security Dashboard',
tool: 'Tool',
status: 'Status',
findings: 'Findings',
leaks: 'Leaks',
vulns: 'Vulns',
alerts: 'Alerts',
coverageTitle: '🔍 Scan Coverage',
module: 'Module',
auditedFiles: 'Audited Files',
coverage: 'Coverage',
detailedFindings: '🔍 Detailed Findings',
gitleaksTitle: '🔑 Credential Leak Check (Gitleaks)',
gitleaksDesc: '`This section detects hardcoded API Keys, passwords, or other sensitive tokens in the code history.`',
gitleaksSafe: '✅ **SAFE**: No hardcoded sensitive credentials found.',
gitleaksScope: '`Scan Scope: All code changes and Git history (Gitleaks Full Scan)`',
snykTitle: '📦 Third-party Dependencies',
snykSafe: '✅ **SAFE**: No known vulnerabilities found in dependencies.',
package: 'Package',
severity: 'Severity',
description: 'Description',
fixPlan: 'Fix Plan',
codeqlTitle: '💻 Code Quality & Safety (CodeQL)',
codeqlSummary: '#### Summary',
rulesChecked: 'Rules Checked',
totalAlerts: 'Total Alerts',
codeqlSafe: '✅ **SAFE**: CodeQL clean. No issues detected.',
ruleId: 'Rule ID',
level: 'Level',
location: 'Location',
auditedList: '📂 Audited File List',
guideTitle: '⚠️ Action Guide',
guideDesc: 'If you see a **FAIL** status or serious code issues:',
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
footer: '💡 *Generated by the NodeWarden security workflow. Transparency is our commitment.*',
auditedIcon: '✅ **Audited**',
noFiles: 'No files found.',
trivyTitle: '🛡️ Container Config Security (Trivy)',
trivyDesc: '`This section detects security risks and best practices in Dockerfile and container configurations.`',
trivySafe: '✅ **SAFE**: No container configuration defects found.'
}
};
}
// --- Data Parsers ---
async parseCodeQL() {
const sarifPath = 'sarif-results';
if (!fs.existsSync(sarifPath)) return;
const files = this.globFiles(sarifPath, '.sarif');
let totalAlerts = 0;
let rulesSet = new Set();
let findings = [];
let artifactUris = new Set();
for (const file of files) {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
for (const run of data.runs || []) {
// Collect Rules
(run.tool.driver.rules || []).forEach(r => rulesSet.add(r.id));
(run.tool.extensions || []).forEach(ext => {
(ext.rules || []).forEach(r => rulesSet.add(r.id));
});
// Collect Results
for (const res of run.results || []) {
totalAlerts++;
const loc = (res.locations && res.locations[0]?.physicalLocation) || {};
findings.push({
id: res.ruleId,
level: res.level || 'warning',
path: loc.artifactLocation?.uri || 'Global',
line: loc.region?.startLine || '-',
message: res.message?.text || 'No description'
});
}
// Track Coverage (Deduplicated)
(run.artifacts || []).forEach(art => {
const uri = art.location?.uri || '';
if (uri) artifactUris.add(uri);
});
}
}
this.results.artifactUris = Array.from(artifactUris).sort();
this.results.coverage.actions = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/')).length;
this.results.coverage.js = this.results.artifactUris.filter(u => u.endsWith('.js')).length;
this.results.coverage.ts = this.results.artifactUris.filter(u => u.endsWith('.ts')).length;
this.results.codeql.alertCount = totalAlerts;
this.results.codeql.rulesCount = rulesSet.size;
this.results.codeql.findings = findings;
if (totalAlerts > 0) this.results.codeql.status = 'INFO';
}
async parseSnyk() {
const jsonPath = 'snyk_result.json';
if (!fs.existsSync(jsonPath)) return;
try {
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
const projects = Array.isArray(data) ? data : [data];
let vulnTotal = 0;
let findings = [];
for (const proj of projects) {
const vulns = proj.vulnerabilities || [];
vulnTotal += vulns.length;
vulns.forEach(v => {
findings.push({
pkg: `${v.packageName}@${v.version}`,
severity: v.severity,
title: v.title,
url: v.url,
fixedIn: Array.isArray(v.fixedIn) ? v.fixedIn.join(', ') : (v.fixedIn || 'N/A')
});
});
}
this.results.snyk.vulnCount = vulnTotal;
this.results.snyk.findings = findings;
if (vulnTotal > 0) this.results.snyk.status = 'WARN';
} catch (e) {
console.error('Error parsing Snyk JSON:', e.message);
}
}
async parseGitleaks() {
const files = this.globFiles('.', 'results.sarif');
if (files.length === 0) return;
try {
const data = JSON.parse(fs.readFileSync(files[0], 'utf8'));
let leaks = 0;
let findings = [];
for (const run of data.runs || []) {
for (const res of run.results || []) {
leaks++;
findings.push({
id: res.ruleId,
message: res.message.text,
path: res.locations[0]?.physicalLocation?.artifactLocation?.uri || 'Unknown'
});
}
}
this.results.gitleaks.leaksCount = leaks;
this.results.gitleaks.findings = findings;
if (leaks > 0) this.results.gitleaks.status = 'FAIL';
} catch (e) {
console.error('Error parsing Gitleaks SARIF:', e.message);
}
}
async parseTrivy() {
const jsonPath = 'trivy_result.json';
if (!fs.existsSync(jsonPath)) return;
try {
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
let misconfigs = 0;
let findings = [];
(data.Results || []).forEach(res => {
(res.Misconfigurations || []).forEach(m => {
misconfigs++;
findings.push({
id: m.ID,
severity: m.Severity,
title: m.Title,
message: m.Message,
status: m.Status,
target: res.Target
});
});
});
this.results.trivy.misconfigCount = misconfigs;
this.results.trivy.findings = findings;
if (misconfigs > 0) this.results.trivy.status = 'WARN';
} catch (e) {
console.error('Error parsing Trivy JSON:', e.message);
}
}
generateTable(type, t) {
let files = [];
if (type === 'actions') files = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/'));
else if (type === 'js') files = this.results.artifactUris.filter(u => u.endsWith('.js'));
else if (type === 'ts') files = this.results.artifactUris.filter(u => u.endsWith('.ts'));
if (files.length === 0) return `> ${t.noFiles}\n`;
let table = `| ${t.module} | ${t.location} | ${t.status} |\n| :--- | :--- | :--- |\n`;
files.forEach(f => {
const filename = path.basename(f);
table += `| \`${filename}\` | \`${f}\` | ${t.auditedIcon} |\n`;
});
return table;
}
// --- Renderers ---
generateMarkdown(localeKey) {
const { codeql, snyk, gitleaks, coverage } = this.results;
const t = this.locales[localeKey];
// Calculate Grade
let grade = 'A+';
let gradeColor = 'success';
if (gitleaks.status === 'FAIL') { grade = 'D'; gradeColor = 'red'; }
else if (snyk.vulnCount > 10 || this.results.trivy.misconfigCount > 5) { grade = 'C'; gradeColor = 'orange'; }
else if (snyk.vulnCount > 0 || codeql.alertCount > 0 || this.results.trivy.misconfigCount > 0) { grade = 'B'; gradeColor = 'blue'; }
const badge = (label, value, color) => `![${label}](https://img.shields.io/badge/${label.replace(/ /g, '_')}-${value}-${color}?style=for-the-badge)`;
let md = `# ${t.title}\n\n`;
md += `${t.switcher}\n\n`;
md += `${badge(t.grade.replace(/ /g, '_'), grade, gradeColor)}\n\n`;
md += `${t.important}\n\n`;
md += `| ${t.auditTime} | ${t.runId} | ${t.env} |\n`;
md += `| :--- | :--- | :--- |\n`;
md += `| \`${this.auditTime}\` | [#${this.runId}](${this.runUrl}) | \`GitHub CI/CD\` |\n\n`;
md += `---\n\n## ${t.dashboard}\n\n`;
md += `| ${t.tool} | ${t.status} | ${t.findings} |\n`;
md += `| :--- | :--- | :--- |\n`;
md += `| **Credential Leak (Gitleaks)** | ${this.getBadge(gitleaks.status)} | \`${gitleaks.leaksCount}\` ${t.leaks} |\n`;
md += `| **Dependency Scan (Snyk)** | ${this.getBadge(snyk.status)} | \`${snyk.vulnCount}\` ${t.vulns} |\n`;
md += `| **Static Analysis (CodeQL)** | ${this.getBadge(codeql.status)} | \`${codeql.alertCount}\` ${t.alerts} |\n`;
md += `| **Container Scan (Trivy)** | ${this.getBadge(this.results.trivy.status)} | \`${this.results.trivy.misconfigCount}\` ${t.findings} |\n\n`;
md += `---\n\n## ${t.coverageTitle}\n\n`;
md += `| ${t.module} | ${t.auditedFiles} | ${t.coverage} |\n`;
md += `| :--- | :---: | :---: |\n`;
md += `| **GitHub Actions** | \`${coverage.actions}\` | ✨ **100%** |\n`;
md += `| **JavaScript (Frontend)** | \`${coverage.js}\` | ✨ **100%** |\n`;
md += `| **TypeScript (Backend)** | \`${coverage.ts}\` | ✨ **100%** |\n\n`;
md += `---\n\n## ${t.detailedFindings}\n\n`;
// Gitleaks Section
md += `### ${t.gitleaksTitle}\n`;
md += `${t.gitleaksDesc} ${t.gitleaksScope}\n\n`;
if (gitleaks.findings.length > 0) {
md += `| ${t.ruleId} | ${t.location} | ${t.description} |\n`;
md += `| :--- | :--- | :--- |\n`;
gitleaks.findings.forEach(f => {
md += `| \`${f.id}\` | \`${f.path}\` | ${f.message} |\n`;
});
} else {
md += `${t.gitleaksSafe}\n`;
}
// Trivy Section
md += `\n### ${t.trivyTitle}\n`;
md += `${t.trivyDesc}\n\n`;
if (this.results.trivy.findings.length > 0) {
md += `| ${t.ruleId} | ${t.severity} | ${t.location} | ${t.description} |\n`;
md += `| :--- | :---: | :--- | :--- |\n`;
this.results.trivy.findings.forEach(f => {
const icon = f.severity === 'CRITICAL' ? '🔴' : (f.severity === 'HIGH' ? '🟠' : '🟡');
md += `| \`${f.id}\` | ${icon} ${f.severity} | \`${f.target}\` | ${f.title}: ${f.message} |\n`;
});
} else {
md += `${t.trivySafe}\n`;
}
// Snyk Section
md += `\n### ${t.snykTitle}\n`;
if (snyk.findings.length > 0) {
md += `| ${t.package} | ${t.severity} | ${t.description} | ${t.fixPlan} |\n`;
md += `| :--- | :---: | :--- | :--- |\n`;
snyk.findings.forEach(f => {
const icon = f.severity === 'critical' ? '🔴' : (f.severity === 'high' ? '🟠' : '🟡');
md += `| \`${f.pkg}\` | ${icon} ${f.severity} | [${f.title}](${f.url}) | ${f.fixedIn === 'N/A' ? 'No fix' : `Upgrade to \`${f.fixedIn}\``} |\n`;
});
} else {
md += `${t.snykSafe}\n`;
}
// CodeQL Section
md += `\n### ${t.codeqlTitle}\n`;
if (codeql.findings.length > 0) {
md += `${t.codeqlSummary}\n- **${t.rulesChecked}**: \`${codeql.rulesCount}\`\n- **${t.totalAlerts}**: \`${codeql.alertCount}\`\n\n`;
md += `| ${t.ruleId} | ${t.level} | ${t.location} | ${t.description} |\n`;
md += `| :--- | :---: | :--- | :--- |\n`;
codeql.findings.forEach(f => {
const icon = f.level === 'error' ? '🔴' : (f.level === 'warning' ? '🟠' : '🔵');
const prefix = f.id.split('/')[0];
const langMap = {
'js': 'javascript',
'actions': 'github-actions',
'cpp': 'cpp',
'cs': 'csharp',
'go': 'go',
'java': 'java',
'py': 'python',
'rb': 'ruby',
'swift': 'swift'
};
const langPath = langMap[prefix] || 'javascript';
md += `| [${f.id}](https://codeql.github.com/codeql-query-help/${langPath}/${f.id.replace(/\//g, '-')}/) | ${icon} ${f.level} | \`${f.path}:${f.line}\` | ${f.message} |\n`;
});
} else {
md += `${t.codeqlSafe}\n`;
}
// Audited Files List
md += `\n### ${t.auditedList}\n`;
md += `<details>\n<summary><b>GitHub Actions (${this.results.coverage.actions})</b></summary>\n\n`;
md += this.generateTable('actions', t);
md += `\n</details>\n\n`;
md += `<details>\n<summary><b>JavaScript (${this.results.coverage.js})</b></summary>\n\n`;
md += this.generateTable('js', t);
md += `\n</details>\n\n`;
md += `<details>\n<summary><b>TypeScript (${this.results.coverage.ts})</b></summary>\n\n`;
md += this.generateTable('ts', t);
md += `\n</details>\n\n`;
// Action Guide
md += `--- \n\n## ${t.guideTitle}\n\n`;
md += `${t.guideDesc}\n`;
md += `${t.guideStep1}\n`;
md += `${t.guideStep2}\n`;
md += `${t.guideStep3}\n\n`;
md += `--- \n\n${t.footer}`;
return md;
}
// --- Helpers ---
getBadge(status) {
if (status === 'PASS') return '![Pass](https://img.shields.io/badge/Status-PASS-success?style=for-the-badge)';
if (status === 'WARN' || status === 'INFO') return '![Warning](https://img.shields.io/badge/Status-NOTICE-orange?style=for-the-badge)';
return '![Fail](https://img.shields.io/badge/Status-FAIL-red?style=for-the-badge)';
}
globFiles(dir, ext) {
let results = [];
const list = fs.readdirSync(dir);
for (const file of list) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat && stat.isDirectory()) {
results = results.concat(this.globFiles(fullPath, ext));
} else if (file.endsWith(ext)) {
results.push(fullPath);
}
}
return results;
}
async run() {
console.log('--- Security Report Generation Started ---');
await this.parseCodeQL();
await this.parseSnyk();
await this.parseGitleaks();
await this.parseTrivy();
for (const localeKey of Object.keys(this.locales)) {
const locale = this.locales[localeKey];
const markdown = this.generateMarkdown(localeKey);
fs.writeFileSync(locale.filename, markdown);
console.log(`Report generated successfully at ${locale.filename}`);
}
}
}
new SecurityReport().run().catch(err => {
console.error('Report generation failed:', err);
process.exit(1);
});
+44
View File
@@ -0,0 +1,44 @@
name: "CodeQL Advanced"
on:
push:
branches:
- "**"
permissions:
contents: read
actions: read
security-events: write
packages: read
jobs:
analyze:
name: CodeQL Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
category: "/language:${{ matrix.language }}"
+200
View File
@@ -0,0 +1,200 @@
name: "Extra Security Scan"
on:
push:
branches:
- "**"
permissions:
contents: read
jobs:
gitleaks:
name: Gitleaks Secret Scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout full history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
fetch-depth: 0
persist-credentials: false
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_ENABLE_SUMMARY: "true"
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: "true"
# 如果仓库属于 GitHub Organization,需要在 Settings -> Secrets 里加 GITLEAKS_LICENSE
# GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
osv:
name: OSV Dependency Scan
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2
permissions:
contents: read
actions: read
security-events: write
with:
scan-args: |-
--recursive
./
upload-sarif: true
fail-on-vuln: true
pnpm-audit:
name: pnpm audit
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 22
- name: Run pnpm audit
shell: bash
run: |
if [ ! -f pnpm-lock.yaml ]; then
echo "pnpm-lock.yaml not found, skip pnpm audit."
exit 0
fi
corepack enable
corepack prepare pnpm@10 --activate
pnpm audit --audit-level=high
semgrep:
name: Semgrep CE Scan
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run Semgrep CE
shell: bash
run: |
docker run --rm \
-v "${PWD}:/src" \
-w /src \
semgrep/semgrep:latest \
semgrep scan --config p/default --sarif --output semgrep.sarif . || true
if [ ! -f semgrep.sarif ]; then
cat > semgrep.sarif <<'EOF'
{
"version": "2.1.0",
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"runs": [
{
"tool": {
"driver": {
"name": "Semgrep",
"informationUri": "https://semgrep.dev",
"rules": []
}
},
"results": []
}
]
}
EOF
fi
- name: Upload Semgrep SARIF
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
sarif_file: semgrep.sarif
category: semgrep
actionlint:
name: GitHub Actions Syntax Scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run actionlint
shell: bash
run: |
docker run --rm \
-v "${PWD}:/repo" \
-w /repo \
rhysd/actionlint:latest
zizmor:
name: GitHub Actions Security Scan
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa
with:
persona: auditor
min-severity: medium
min-confidence: medium
scorecard:
name: OpenSSF Scorecard
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run OpenSSF Scorecard
uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2
with:
results_file: scorecard.sarif
results_format: sarif
publish_results: false
- name: Upload Scorecard SARIF
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
sarif_file: scorecard.sarif
category: openssf-scorecard
-142
View File
@@ -1,142 +0,0 @@
name: Security Scan
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
env:
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Initialize CodeQL
if: env.ACT != 'true'
continue-on-error: true
uses: github/codeql-action/init@v4
with:
languages: javascript-typescript, actions
build-mode: none
queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
if: env.ACT != 'true'
continue-on-error: true
uses: github/codeql-action/analyze@v4
with:
upload: true
output: sarif-results
- name: Install Gitleaks
if: env.ACT != 'true'
continue-on-error: true
run: |
GITLEAKS_VERSION="8.28.0"
curl -sSL -o gitleaks.tar.gz "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
tar -xzf gitleaks.tar.gz gitleaks
chmod +x gitleaks
sudo mv gitleaks /usr/local/bin/gitleaks
- name: Secret Detection
if: env.ACT != 'true'
continue-on-error: true
run: |
gitleaks git . --report-format sarif --report-path results.sarif --no-banner || true
- name: Install Project Dependencies
if: env.SECURITY_SNYK_TOKEN != ''
env:
SECURITY_PACKAGE: ${{ vars.SECURITY_PACKAGE || '' }}
run: |
echo "Preparing dependency lock files for security scanning..."
if [ -z "$SECURITY_PACKAGE" ]; then
echo "SECURITY_PACKAGE is empty, installing in root..."
npm install --package-lock-only
else
echo "SECURITY_PACKAGE is set to: $SECURITY_PACKAGE"
# Split by comma and install
IFS=',' read -ra PACKAGES <<< "$SECURITY_PACKAGE"
for pkg in "${PACKAGES[@]}"; do
if [ -d "$pkg" ]; then
echo "Installing in "$pkg"..."
npm install --prefix "$pkg" --package-lock-only
else
echo "Warning: Directory $pkg not found, skipping."
fi
done
fi
- name: Dependency Scan
id: snyk
if: env.SECURITY_SNYK_TOKEN != ''
continue-on-error: true
run: |
npm install -g snyk
snyk auth ${{ secrets.SECURITY_SNYK_TOKEN }}
snyk test --all-projects --json-file-output=snyk_result.json > snyk_result.txt || true
env:
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
- name: Check for Dockerfile
id: check_docker
run: |
if [ -f "Dockerfile" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Container Security Scan (Trivy)
if: steps.check_docker.outputs.exists == 'true'
continue-on-error: true
run: |
VERSION="0.56.1"
echo "Installing Trivy $VERSION..."
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin "v$VERSION"
trivy config . --format json --output trivy_result.json --severity CRITICAL,HIGH || true
- name: Generate Security Report
run: |
# Gitleaks typically produces results.sarif if configured or by default in some versions
# We'll ensure it exists for our reporter
node .github/scripts/security.cjs
# Also append to step summary for immediate visibility in GHA UI
cat security-report.md >> $GITHUB_STEP_SUMMARY
echo -e "\n---\n" >> $GITHUB_STEP_SUMMARY
cat security-report-cn.md >> $GITHUB_STEP_SUMMARY
- name: Upload Gitleaks Results to GitHub Security
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: results.sarif
category: gitleaks
- name: Upload Security Report Artifacts
if: always()
uses: actions/upload-artifact@v6
with:
name: security-report
if-no-files-found: ignore
path: |
security-report.md
security-report-cn.md
snyk_result.txt
snyk_result.json
trivy_result.json
results.sarif
sarif-results/*.sarif
+3 -3
View File
@@ -19,9 +19,9 @@ jobs:
sync-global-domains:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-node@v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 22
@@ -32,7 +32,7 @@ jobs:
run: git diff --exit-code -- src/static/global_domains.custom.json
- name: Create pull request
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
with:
branch: chore/sync-bitwarden-global-domains
delete-branch: true
+26 -18
View File
@@ -17,7 +17,7 @@ jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
fetch-depth: 0
@@ -49,9 +49,11 @@ jobs:
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 "mode=auto"
echo "latest_tag=$LATEST_TAG"
echo "target_sha=$TARGET_SHA"
} >> "$GITHUB_OUTPUT"
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
elif [ -n "$MANUAL_INPUT" ]; then
@@ -61,15 +63,19 @@ jobs:
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
exit 1
fi
echo "mode=manual" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
{
echo "mode=manual"
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 "mode=manual"
echo "target_sha=$TARGET_SHA"
} >> "$GITHUB_OUTPUT"
echo "Manual mode — latest commit: $TARGET_SHA"
fi
@@ -84,19 +90,19 @@ jobs:
CURRENT_SHA=$(git rev-parse HEAD)
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
echo "Already at $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT
echo "needs_update=false" >> "$GITHUB_OUTPUT"
else
echo "Switching to $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT
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
echo "needs_update=false" >> "$GITHUB_OUTPUT"
else
echo "Update needed — target: $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT
echo "needs_update=true" >> "$GITHUB_OUTPUT"
fi
fi
@@ -117,7 +123,7 @@ jobs:
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
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
@@ -134,10 +140,12 @@ jobs:
- 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
{
echo "### Synced successfully"
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}"
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}"
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`"
} >> "$GITHUB_STEP_SUMMARY"
else
echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY
echo "### Nothing to update" >> "$GITHUB_STEP_SUMMARY"
fi
+1
View File
@@ -56,6 +56,7 @@ NodeWarden-compat/
.codex-upstream/bitwarden-browser/
.reasonix/
.upstream/
# Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md
+197 -185
View File
@@ -1,36 +1,36 @@
{
"name": "nodewarden",
"version": "1.7.1",
"version": "1.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodewarden",
"version": "1.7.1",
"version": "1.7.2",
"license": "LGPL-3.0",
"dependencies": {
"@noble/hashes": "^2.0.1",
"@simplewebauthn/server": "^13.3.1",
"@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22",
"fflate": "^0.8.2",
"lucide-preact": "^0.575.0",
"preact": "^10.28.4",
"@noble/hashes": "^2.2.0",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "^5.101.2",
"@zip.js/zip.js": "^2.8.26",
"fflate": "^0.8.3",
"lucide-preact": "^1.22.0",
"preact": "^10.29.3",
"qrcode-generator": "^2.0.4",
"wouter": "^3.9.0"
"wouter": "^3.10.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0",
"@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.3",
"autoprefixer": "^10.4.21",
"opencc-js": "^1.0.5",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"@cloudflare/workers-types": "^4.20260630.1",
"@preact/preset-vite": "^2.10.5",
"@types/node": "^26.0.1",
"autoprefixer": "^10.5.2",
"opencc-js": "^1.3.2",
"postcss": "^8.5.16",
"tailwindcss": "^3.4.19",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "^7.3.1",
"wrangler": "^4.71.0"
"wrangler": "^4.105.0"
}
},
"node_modules/@alloc/quick-lru": {
@@ -612,9 +612,9 @@
}
},
"node_modules/@cloudflare/workerd-darwin-64": {
"version": "1.20260603.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260603.1.tgz",
"integrity": "sha512-cEXDWu6V3ZrpmwWkM4OJE9AeXjdAgOY5rh8EHhcBVCuP5rxnzUbPzLtrVOHx0UUUAcCrFq0Xsa6mZKL1VUZsKQ==",
"version": "1.20260625.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260625.1.tgz",
"integrity": "sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A==",
"cpu": [
"x64"
],
@@ -629,9 +629,9 @@
}
},
"node_modules/@cloudflare/workerd-darwin-arm64": {
"version": "1.20260603.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260603.1.tgz",
"integrity": "sha512-uBPK4LaWJNbbCYwPnUAehlHbbVulhVZPZsdcAhBPfZhHb3QAuAEPAQepO/P67R3V6Cni4YGx1fLbL8A5wwoaNA==",
"version": "1.20260625.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260625.1.tgz",
"integrity": "sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw==",
"cpu": [
"arm64"
],
@@ -646,9 +646,9 @@
}
},
"node_modules/@cloudflare/workerd-linux-64": {
"version": "1.20260603.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260603.1.tgz",
"integrity": "sha512-ht9l6/8Tk7Rp6kA4S9oFZ4X8u0VjnnFdmU/6B3fnABYKREYTKh2RdOqXqXxcp5eNJseireKnWik/hQOPK1CutQ==",
"version": "1.20260625.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260625.1.tgz",
"integrity": "sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ==",
"cpu": [
"x64"
],
@@ -663,9 +663,9 @@
}
},
"node_modules/@cloudflare/workerd-linux-arm64": {
"version": "1.20260603.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260603.1.tgz",
"integrity": "sha512-LJZ6x00rAjSrobV4m0ZW0TpH5ilBbKcWBzlH+y+KOUsIE/CpTuhAzKV43TbSnFLRX5+jrWKiz2v0hO91lPXy6A==",
"version": "1.20260625.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260625.1.tgz",
"integrity": "sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ==",
"cpu": [
"arm64"
],
@@ -680,9 +680,9 @@
}
},
"node_modules/@cloudflare/workerd-windows-64": {
"version": "1.20260603.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260603.1.tgz",
"integrity": "sha512-DvwqkXMAJRPoDN4PxapAwhlz/6ouD+6R1ttbAEK3cWD/QBvFF5STx7Ds/9Irf+rBly3np3uHWkeX+wZnNFEuzA==",
"version": "1.20260625.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260625.1.tgz",
"integrity": "sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow==",
"cpu": [
"x64"
],
@@ -697,9 +697,9 @@
}
},
"node_modules/@cloudflare/workers-types": {
"version": "4.20260609.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260609.1.tgz",
"integrity": "sha512-krGHtwSApCFBjTe1NTx/TFQ0P5i/bHGQOqCPnCLssb8rOKaAG4JkPFJZsossr0z/ZTMnpP2Tid5jWju+/i0hCA==",
"version": "4.20260630.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260630.1.tgz",
"integrity": "sha512-yl+c9vwvko9UZ0frmtsHuwOh3BRHvNjLrfelAp5Akpqe1+Ho1UWekr3nmjJ1D64CH0Yb0K0oRMV4i7npOFzsog==",
"dev": true,
"license": "MIT OR Apache-2.0"
},
@@ -717,9 +717,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1770,9 +1770,9 @@
"license": "MIT"
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@@ -2017,9 +2017,9 @@
"license": "MIT"
},
"node_modules/@preact/preset-vite": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.3.tgz",
"integrity": "sha512-1SiS+vFItpkNdBs7q585PSAIln0wBeBdcpJYbzPs1qipsb/FssnkUioNXuRsb8ZnU8YEQHr+3v8+/mzWSnTQmg==",
"version": "2.10.5",
"resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz",
"integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2029,12 +2029,14 @@
"@rollup/pluginutils": "^5.0.0",
"babel-plugin-transform-hook-names": "^1.0.2",
"debug": "^4.4.3",
"magic-string": "^0.30.21",
"picocolors": "^1.1.1",
"vite-prerender-plugin": "^0.5.8"
"vite-prerender-plugin": "^0.5.8",
"zimmerframe": "^1.1.4"
},
"peerDependencies": {
"@babel/core": "7.x",
"vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x"
"vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x"
}
},
"node_modules/@prefresh/babel-plugin": {
@@ -2480,9 +2482,9 @@
]
},
"node_modules/@simplewebauthn/server": {
"version": "13.3.1",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.1.tgz",
"integrity": "sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w==",
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
"integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
@@ -2512,16 +2514,16 @@
}
},
"node_modules/@speed-highlight/core": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.16.tgz",
"integrity": "sha512-yNm/fYEcnpRjYduLMaddTK9XKYil6xB88+qFg79ZdZhHu1PadfoQmFW7pVTx7FZqMBNcUuThiAhxhENgtAO2/w==",
"version": "1.2.17",
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz",
"integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/@tanstack/query-core": {
"version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
"integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2529,12 +2531,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"version": "5.101.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz",
"integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.20"
"@tanstack/query-core": "5.101.2"
},
"funding": {
"type": "github",
@@ -2566,19 +2568,19 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"version": "26.0.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz",
"integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
"undici-types": "~8.3.0"
}
},
"node_modules/@zip.js/zip.js": {
"version": "2.8.22",
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.22.tgz",
"integrity": "sha512-0KlzbVR6r8irIX2o3zvUlosBDef62VDl47oUfa1U/qgEs67h4/eGBrX/6HWa1RQbt+J6sAeVmtyFKbTHNdF8qQ==",
"version": "2.8.26",
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz",
"integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==",
"license": "BSD-3-Clause",
"engines": {
"bun": ">=0.7.0",
@@ -2642,9 +2644,9 @@
}
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz",
"integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==",
"dev": true,
"funding": [
{
@@ -2662,10 +2664,9 @@
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.24.4",
"caniuse-lite": "^1.0.30001702",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
"browserslist": "^4.28.4",
"caniuse-lite": "^1.0.30001799",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
@@ -2679,6 +2680,40 @@
"postcss": "^8.1.0"
}
},
"node_modules/autoprefixer/node_modules/browserslist": {
"version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
"electron-to-chromium": "^1.5.376",
"node-releases": "^2.0.48",
"update-browserslist-db": "^1.2.3"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/babel-plugin-transform-hook-names": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
@@ -2690,9 +2725,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -2787,9 +2822,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001774",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
"integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"dev": true,
"funding": [
{
@@ -3021,9 +3056,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
"version": "1.5.381",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz",
"integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==",
"dev": true,
"license": "ISC"
},
@@ -3188,9 +3223,9 @@
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fill-range": {
@@ -3207,16 +3242,16 @@
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "patreon",
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
@@ -3255,19 +3290,6 @@
"node": ">=6.9.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -3468,9 +3490,9 @@
}
},
"node_modules/lucide-preact": {
"version": "0.575.0",
"resolved": "https://registry.npmjs.org/lucide-preact/-/lucide-preact-0.575.0.tgz",
"integrity": "sha512-W8JZyQEkYv6DlbRrEgmZxVWFKL3zjoyEkFOOSxiX2VEU6Gou8cOqXZ5IAGmqAL4KiPx1tWgGT9awNjAH7MFknw==",
"version": "1.22.0",
"resolved": "https://registry.npmjs.org/lucide-preact/-/lucide-preact-1.22.0.tgz",
"integrity": "sha512-zFaBtoaWQgvapVEI96M3b5iUOlAEvpUfDuW3Gs8K1RHDyoOTrnXm+Cz5tupm8StKbRKn3W/YKIPolllf5voVDw==",
"license": "ISC",
"peerDependencies": {
"preact": "^10.27.2"
@@ -3524,17 +3546,17 @@
}
},
"node_modules/miniflare": {
"version": "4.20260603.0",
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260603.0.tgz",
"integrity": "sha512-+kMQYB82gC8MPOuojHur3icQsUeZUEJ+Sphuo5rVC3Ri9txBLAW/mH33b9OVrpmkogQeaaqPS4tPtugJZhk5Kw==",
"version": "4.20260625.0",
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz",
"integrity": "sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "0.8.1",
"sharp": "0.34.5",
"undici": "7.24.8",
"workerd": "1.20260603.1",
"ws": "8.20.1",
"undici": "7.28.0",
"workerd": "1.20260625.1",
"ws": "8.21.0",
"youch": "4.1.0-beta.10"
},
"bin": {
@@ -3600,11 +3622,14 @@
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
@@ -3616,16 +3641,6 @@
"node": ">=0.10.0"
}
},
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -3674,11 +3689,11 @@
}
},
"node_modules/opencc-js": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.0.5.tgz",
"integrity": "sha512-LD+1SoNnZdlRwtYTjnQdFrSVCAaYpuDqL5CkmOaHOkKoKh7mFxUicLTRVNLU5C+Jmi1vXQ3QL4jWdgSaa4sKjg==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.3.2.tgz",
"integrity": "sha512-lO4Kq8J4TcPTa8qHcx5qazQCn+NM68kNwLJOQ58DG9MV8v25XJxByMB74zDGmr3LjJMGqDdrvQfoCi3FO0SC2A==",
"dev": true,
"license": "MIT"
"license": "MIT AND Apache-2.0"
},
"node_modules/path-parse": {
"version": "1.0.7",
@@ -3742,9 +3757,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [
{
@@ -3898,9 +3913,9 @@
"license": "MIT"
},
"node_modules/preact": {
"version": "10.28.4",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz",
"integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",
"version": "10.29.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.3.tgz",
"integrity": "sha512-D9NL1GAnJZhc3RndVs4gDdxEeU9TcHgywMrhhOsnpdlvFjdbx0gAsLUnH6JEhlJH5giL7Tx5biWPUSEXE/HPzw==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -4035,16 +4050,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -4273,9 +4278,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4287,7 +4292,7 @@
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.21.6",
"jiti": "^1.21.7",
"lilconfig": "^3.1.3",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
@@ -4296,7 +4301,7 @@
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
"postcss-load-config": "^4.0.2",
"postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
"postcss-nested": "^6.2.0",
"postcss-selector-parser": "^6.1.2",
"resolve": "^1.22.8",
@@ -4377,14 +4382,13 @@
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
@@ -4415,9 +4419,9 @@
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4439,9 +4443,9 @@
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
@@ -4596,9 +4600,9 @@
}
},
"node_modules/workerd": {
"version": "1.20260603.1",
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260603.1.tgz",
"integrity": "sha512-NPcbhI1++CS+fnELyXtsIR52en+5kwr/OrKeiQeYXGy10HxmPdsQBv9N+DU7hJIOOmBHhOGAAsoGDjyiQ2YCaA==",
"version": "1.20260625.1",
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260625.1.tgz",
"integrity": "sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
@@ -4609,17 +4613,17 @@
"node": ">=16"
},
"optionalDependencies": {
"@cloudflare/workerd-darwin-64": "1.20260603.1",
"@cloudflare/workerd-darwin-arm64": "1.20260603.1",
"@cloudflare/workerd-linux-64": "1.20260603.1",
"@cloudflare/workerd-linux-arm64": "1.20260603.1",
"@cloudflare/workerd-windows-64": "1.20260603.1"
"@cloudflare/workerd-darwin-64": "1.20260625.1",
"@cloudflare/workerd-darwin-arm64": "1.20260625.1",
"@cloudflare/workerd-linux-64": "1.20260625.1",
"@cloudflare/workerd-linux-arm64": "1.20260625.1",
"@cloudflare/workerd-windows-64": "1.20260625.1"
}
},
"node_modules/wouter": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.9.0.tgz",
"integrity": "sha512-sF/od/PIgqEQBQcrN7a2x3MX6MQE6nW0ygCfy9hQuUkuB28wEZuu/6M5GyqkrrEu9M6jxdkgE12yDFsQMKos4Q==",
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.10.0.tgz",
"integrity": "sha512-zTfddD80zc2/J5l8JKcdvzOK6AwP0kpyHEI3DxRN2bn8U1oJPnrSVm8v+X3WwDamvLAOxTO7ZvkxkpRWlyeJ1Q==",
"license": "Unlicense",
"dependencies": {
"mitt": "^3.0.1",
@@ -4631,22 +4635,23 @@
}
},
"node_modules/wrangler": {
"version": "4.98.0",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.98.0.tgz",
"integrity": "sha512-cXfFUuF4rMIvE0hiMnXjEAB27ERryaCgquBJdUoPIjFzYYE1rbRdMUkEdQ18qDPUtsPvhJdqxLntixT9OfSzQw==",
"version": "4.105.0",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.105.0.tgz",
"integrity": "sha512-7dXFH6OLj1Fv0y6ZeRPUxFTkp+duWD7/xxVi/1c0vfOeEYwIFKWB7cdqnY05DvY1Ta3BnqAwRkXfLs8PDj538g==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
"@cloudflare/kv-asset-handler": "0.5.0",
"@cloudflare/unenv-preset": "2.16.1",
"blake3-wasm": "2.1.5",
"esbuild": "0.27.3",
"miniflare": "4.20260603.0",
"esbuild": "0.28.1",
"miniflare": "4.20260625.0",
"path-to-regexp": "6.3.0",
"unenv": "2.0.0-rc.24",
"workerd": "1.20260603.1"
"workerd": "1.20260625.1"
},
"bin": {
"cf-wrangler": "bin/cf-wrangler.js",
"wrangler": "bin/wrangler.js",
"wrangler2": "bin/wrangler.js"
},
@@ -4657,7 +4662,7 @@
"fsevents": "2.3.3"
},
"peerDependencies": {
"@cloudflare/workers-types": "^4.20260603.1"
"@cloudflare/workers-types": "^4.20260625.1"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
@@ -4666,9 +4671,9 @@
}
},
"node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4727,6 +4732,13 @@
"@poppinss/exception": "^1.2.2",
"error-stack-parser-es": "^1.0.5"
}
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
}
}
}
+21 -20
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.7.1",
"version": "1.7.2",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -45,30 +45,31 @@
"overrides": {
"undici": ">=7.28.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1"
"esbuild": ">=0.28.1",
"ws": "8.21.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0",
"@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.3",
"autoprefixer": "^10.4.21",
"opencc-js": "^1.0.5",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"@cloudflare/workers-types": "^4.20260630.1",
"@preact/preset-vite": "^2.10.5",
"@types/node": "^26.0.1",
"autoprefixer": "^10.5.2",
"opencc-js": "^1.3.2",
"postcss": "^8.5.16",
"tailwindcss": "^3.4.19",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "^7.3.1",
"wrangler": "^4.71.0"
"wrangler": "^4.105.0"
},
"dependencies": {
"@noble/hashes": "^2.0.1",
"@simplewebauthn/server": "^13.3.1",
"@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22",
"fflate": "^0.8.2",
"lucide-preact": "^0.575.0",
"preact": "^10.28.4",
"@noble/hashes": "^2.2.0",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "^5.101.2",
"@zip.js/zip.js": "^2.8.26",
"fflate": "^0.8.3",
"lucide-preact": "^1.22.0",
"preact": "^10.29.3",
"qrcode-generator": "^2.0.4",
"wouter": "^3.9.0"
"wouter": "^3.10.0"
}
}
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.1';
export const APP_VERSION = '1.7.2';
+17 -6
View File
@@ -249,7 +249,7 @@ export async function handleAdminListInvites(
}
// DELETE /api/admin/invites/:code
export async function handleAdminRevokeInvite(
export async function handleAdminDeleteInvite(
request: Request,
env: Env,
actorUser: User,
@@ -260,12 +260,14 @@ export async function handleAdminRevokeInvite(
}
const storage = new StorageService(env.DB);
const revoked = await storage.revokeInvite(code);
if (!revoked) {
return errorResponse('Invite not found or already inactive', 404);
const deleted = await storage.deleteInvite(code);
if (!deleted) {
return errorResponse('Invite not found', 404);
}
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', null, null, request);
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete', 'invite', null, {
code,
}, request);
return new Response(null, { status: 204 });
}
@@ -275,12 +277,21 @@ export async function handleAdminDeleteAllInvites(
env: Env,
actorUser: User
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const url = new URL(request.url);
if (url.searchParams.get('scope') === 'invalid') {
const deleted = await storage.deleteInvalidInvites();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_invalid', 'invite', null, {
deleted,
}, request);
return jsonResponse({ deleted }, 200);
}
const deleted = await storage.deleteAllInvites();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
deleted,
+10 -2
View File
@@ -32,6 +32,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
// attachments, import/export, and current official clients.
export interface CipherResponseOptions {
preserveRepairableUris?: boolean;
validFolderIds?: ReadonlySet<string>;
}
export function shouldPreserveRepairableCipherUris(request: Request): boolean {
@@ -48,6 +49,12 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : null;
}
function normalizeResponseFolderId(folderId: unknown, validFolderIds?: ReadonlySet<string>): string | null {
const normalized = normalizeOptionalId(folderId);
if (!normalized) return null;
return validFolderIds && !validFolderIds.has(normalized) ? null : normalized;
}
function readBooleanOrFallback(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
@@ -727,7 +734,7 @@ export function cipherToResponse(
// Pass through ALL stored cipher fields (known + unknown)
...passthrough,
// Server-computed / enforced fields (always override)
folderId: normalizeOptionalId(cipher.folderId),
folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
type: Number(cipher.type) || 1,
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
@@ -785,9 +792,10 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
filteredCiphers.map((cipher) => cipher.id)
);
const validFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
// Build responses only for the current page to keep pagination cheap.
const responseOptions = cipherResponseOptionsForRequest(request);
const responseOptions = { ...cipherResponseOptionsForRequest(request), validFolderIds };
const cipherResponses: CipherResponse[] = [];
for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || [];
+2
View File
@@ -48,6 +48,8 @@ function buildDeviceResponse(device: Device): DeviceResponse {
creationDate: device.createdAt,
RevisionDate: device.updatedAt,
revisionDate: device.updatedAt,
LastActivityDate: device.lastSeenAt,
lastActivityDate: device.lastSeenAt,
LastSeenAt: device.lastSeenAt,
lastSeenAt: device.lastSeenAt,
HasStoredDevice: true,
+36
View File
@@ -0,0 +1,36 @@
const EMPTY_FORMS_FILENAME = 'forms.v1.json';
const EMPTY_FORMS_BODY = JSON.stringify({
schemaVersion: '1.0.0',
hosts: {},
});
const EMPTY_MANIFEST_BODY = JSON.stringify({
maps: {
forms: {
v1: {
filename: EMPTY_FORMS_FILENAME,
cid: 'sha256:nodewarden-empty-fill-assist-v1',
},
},
},
});
function fillAssistJsonResponse(body: string): Response {
return new Response(body, {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
},
});
}
export function handleFillAssistManifest(): Response {
return fillAssistJsonResponse(EMPTY_MANIFEST_BODY);
}
export function handleFillAssistForms(filename: string): Response {
if (String(filename || '').trim() !== EMPTY_FORMS_FILENAME) {
return new Response('Not found', { status: 404 });
}
return fillAssistJsonResponse(EMPTY_FORMS_BODY);
}
+2 -1
View File
@@ -88,12 +88,13 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
.map(buildWebAuthnPrfOption)
.filter((option): option is NonNullable<typeof option> => !!option);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
const validFolderIds = new Set(folders.map((folder) => folder.id));
const profile: ProfileResponse = buildProfileResponse(user, env);
const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) {
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris });
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris, validFolderIds });
if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response);
}
+2 -2
View File
@@ -4,7 +4,7 @@ import {
handleAdminCreateInvite,
handleAdminListInvites,
handleAdminDeleteAllInvites,
handleAdminRevokeInvite,
handleAdminDeleteInvite,
handleAdminSetUserStatus,
handleAdminDeleteUser,
handleAdminListAuditLogs,
@@ -52,7 +52,7 @@ export async function handleAdminRoute(
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
if (adminInviteMatch && method === 'DELETE') {
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
return handleAdminRevokeInvite(request, env, actorUser, inviteCode);
return handleAdminDeleteInvite(request, env, actorUser, inviteCode);
}
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
+18 -1
View File
@@ -8,6 +8,7 @@ import {
handleDownloadSendFile,
} from './handlers/sends';
import { handleKnownDevice } from './handlers/devices';
import { handleFillAssistForms, handleFillAssistManifest } from './handlers/fill-assist';
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
import {
@@ -97,6 +98,7 @@ function buildIconServiceCsp(origin: string): string {
}
function buildConfigResponse(origin: string) {
const fillAssistBase = `${origin}/fill-assist`;
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
@@ -109,7 +111,7 @@ function buildConfigResponse(origin: string) {
notifications: origin + '/notifications',
icons: origin,
sso: '',
fillAssistRules: null,
fillAssistRules: fillAssistBase,
},
push: {
pushTechnology: 0,
@@ -125,8 +127,10 @@ function buildConfigResponse(origin: string) {
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'duo-redirect': true,
'email-verification': true,
'fill-assist-targeting-rules': true,
'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true,
'pm-4516-devices-add-last-activity-date': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true,
'web-push': false,
@@ -343,6 +347,19 @@ export async function handlePublicRoute(
return jsonResponse(await buildWebBootstrapResponse(env));
}
if (path === '/fill-assist/manifest.json' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleFillAssistManifest();
}
const fillAssistFormsMatch = path.match(/^\/fill-assist\/([^/]+)$/i);
if (fillAssistFormsMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleFillAssistForms(fillAssistFormsMatch[1]);
}
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
+12 -4
View File
@@ -151,15 +151,23 @@ export async function revertInviteUsed(db: D1Database, code: string, userId: str
return (result.meta.changes ?? 0) > 0;
}
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> {
const now = new Date().toISOString();
export async function deleteInvite(db: D1Database, code: string): Promise<boolean> {
const result = await db
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'")
.bind(now, code)
.prepare('DELETE FROM invites WHERE code = ?')
.bind(code)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function deleteInvalidInvites(db: D1Database): Promise<number> {
const now = new Date().toISOString();
const result = await db
.prepare("DELETE FROM invites WHERE status != 'active' OR expires_at <= ?")
.bind(now)
.run();
return Number(result.meta.changes ?? 0);
}
export async function deleteAllInvites(db: D1Database): Promise<number> {
const result = await db.prepare('DELETE FROM invites').run();
return Number(result.meta.changes ?? 0);
+14 -4
View File
@@ -44,9 +44,14 @@ export async function clearFolderFromCiphers(
`UPDATE ciphers
SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id = ?`
WHERE user_id = ?
AND (
folder_id = ?
OR json_extract(data, '$.folderId') = ?
OR json_extract(data, '$.folder_id') = ?
)`
)
.bind(now, userId, folderId)
.bind(now, userId, folderId, folderId, folderId)
.run();
}
@@ -71,9 +76,14 @@ export async function bulkDeleteFolders(
`UPDATE ciphers
SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id IN (${placeholders})`
WHERE user_id = ?
AND (
folder_id IN (${placeholders})
OR json_extract(data, '$.folderId') IN (${placeholders})
OR json_extract(data, '$.folder_id') IN (${placeholders})
)`
)
.bind(now, userId, ...chunk)
.bind(now, userId, ...chunk, ...chunk, ...chunk)
.run();
await db
+8 -3
View File
@@ -24,6 +24,8 @@ import {
clearAuditLogs as clearStoredAuditLogs,
assignInviteUsedBy as assignStoredInviteUsedBy,
createInvite as createStoredInvite,
deleteInvite as deleteStoredInvite,
deleteInvalidInvites as deleteStoredInvalidInvites,
deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite,
listAuditLogs as listStoredAuditLogs,
@@ -32,7 +34,6 @@ import {
pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revertInviteUsed as revertStoredInviteUsed,
revokeInvite as revokeStoredInvite,
} from './storage-admin-repo';
import {
bulkDeleteFolders as deleteStoredFolders,
@@ -329,8 +330,12 @@ export class StorageService {
return revertStoredInviteUsed(this.db, code, userId);
}
async revokeInvite(code: string): Promise<boolean> {
return revokeStoredInvite(this.db, code);
async deleteInvite(code: string): Promise<boolean> {
return deleteStoredInvite(this.db, code);
}
async deleteInvalidInvites(): Promise<number> {
return deleteStoredInvalidInvites(this.db);
}
async deleteAllInvites(): Promise<number> {
+1
View File
@@ -307,6 +307,7 @@ export interface DeviceResponse {
type: number;
creationDate: string;
revisionDate: string;
lastActivityDate?: string | null;
lastSeenAt?: string | null;
hasStoredDevice?: boolean;
isTrusted: boolean;
+6 -4
View File
@@ -1115,8 +1115,6 @@ export default function App() {
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
staleTime: 5_000,
refetchInterval: 15_000,
refetchIntervalInBackground: true,
});
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
const latestPendingAuthRequest = pendingAuthRequests[0] || null;
@@ -1959,6 +1957,7 @@ export default function App() {
lockTimeoutMinutes,
sessionTimeoutAction,
authorizedDevices: authorizedDevicesQuery.data || [],
currentDeviceIdentifier: getCurrentDeviceIdentifier(),
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
@@ -2014,7 +2013,8 @@ export default function App() {
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
pendingAuthRequests,
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isFetching,
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading,
pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading,
onRefreshPendingAuthRequests: async () => {
await pendingAuthRequestsQuery.refetch();
},
@@ -2031,14 +2031,16 @@ export default function App() {
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
onRemoveDevice: accountSecurityActions.openRemoveDevice,
onRemoveSelectedDevices: accountSecurityActions.openRemoveSelectedDevices,
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
onRefreshAdmin: adminActions.refreshAdmin,
onCreateInvite: adminActions.createInvite,
onDeleteInvalidInvites: adminActions.deleteInvalidInvites,
onDeleteAllInvites: adminActions.deleteAllInvites,
onToggleUserStatus: adminActions.toggleUserStatus,
onDeleteUser: adminActions.deleteUser,
onRevokeInvite: adminActions.revokeInvite,
onDeleteInvite: adminActions.deleteInvite,
onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters),
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
+8 -6
View File
@@ -13,10 +13,11 @@ interface AdminPageProps {
error: string;
onRefresh: () => void;
onCreateInvite: (hours: number) => Promise<void>;
onDeleteInvalidInvites: () => Promise<void>;
onDeleteAllInvites: () => Promise<void>;
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>;
onDeleteInvite: (code: string) => Promise<void>;
}
export default function AdminPage(props: AdminPageProps) {
@@ -134,7 +135,10 @@ export default function AdminPage(props: AdminPageProps) {
<h3>{t('txt_invites')}</h3>
<div className="actions admin-invites-head-actions">
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
</button>
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteInvalidInvites()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_invalid')}
</button>
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
@@ -184,11 +188,9 @@ export default function AdminPage(props: AdminPageProps) {
>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
</button>
{invite.status === 'active' && (
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_revoke')}
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteInvite(invite.code)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
</button>
)}
</div>
</td>
</tr>
+10 -2
View File
@@ -57,6 +57,7 @@ export interface AppMainRoutesProps {
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[];
currentDeviceIdentifier: string;
authorizedDevicesLoading: boolean;
authorizedDevicesError: string;
domainRules: DomainRules | null;
@@ -118,6 +119,7 @@ export interface AppMainRoutesProps {
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
pendingAuthRequestsRefreshing: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
@@ -130,14 +132,16 @@ export interface AppMainRoutesProps {
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onTrustDevicePermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
onRevokeAllDeviceTrust: () => void;
onRemoveAllDevices: () => void;
onCreateInvite: (hours: number) => Promise<void>;
onRefreshAdmin: () => void;
onDeleteInvalidInvites: () => Promise<void>;
onDeleteAllInvites: () => Promise<void>;
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>;
onDeleteInvite: (code: string) => Promise<void>;
onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
@@ -347,10 +351,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<Suspense fallback={<RouteContentFallback />}>
<SecurityDevicesPage
devices={props.authorizedDevices}
currentDeviceIdentifier={props.currentDeviceIdentifier}
loading={props.authorizedDevicesLoading}
error={props.authorizedDevicesError}
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
pendingAuthRequestsRefreshing={props.pendingAuthRequestsRefreshing}
onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
@@ -359,6 +365,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onRevokeTrust={props.onRevokeDeviceTrust}
onTrustPermanently={props.onTrustDevicePermanently}
onRemoveDevice={props.onRemoveDevice}
onRemoveSelectedDevices={props.onRemoveSelectedDevices}
onRevokeAll={props.onRevokeAllDeviceTrust}
onRemoveAll={props.onRemoveAllDevices}
/>
@@ -407,10 +414,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
error={props.adminError}
onRefresh={props.onRefreshAdmin}
onCreateInvite={props.onCreateInvite}
onDeleteInvalidInvites={props.onDeleteInvalidInvites}
onDeleteAllInvites={props.onDeleteAllInvites}
onToggleUserStatus={props.onToggleUserStatus}
onDeleteUser={props.onDeleteUser}
onRevokeInvite={props.onRevokeInvite}
onDeleteInvite={props.onDeleteInvite}
/>
</Suspense>
</div>
+1 -2
View File
@@ -193,7 +193,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
const [restoringRemotePath, setRestoringRemotePath] = useState('');
const [deletingRemotePath, setDeletingRemotePath] = useState('');
const [localError, setLocalError] = useState('');
const [, setLocalError] = useState('');
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
@@ -974,7 +974,6 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}}
/>
{localError ? <div className="local-error">{localError}</div> : null}
{restoreProgress && typeof document !== 'undefined' ? createPortal((
<div className="restore-progress-overlay" aria-live="polite">
<section className="restore-progress-card restore-progress-modal">
@@ -7,6 +7,7 @@ import { t } from '@/lib/i18n';
interface PendingAuthRequestsPanelProps {
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
pendingAuthRequestsRefreshing?: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
@@ -22,6 +23,7 @@ function formatDateTime(value: string | null | undefined): string {
export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanelProps) {
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
const refreshing = props.pendingAuthRequestsLoading || !!props.pendingAuthRequestsRefreshing;
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (authRequestSubmittingId) return;
@@ -50,10 +52,10 @@ export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanel
<button
type="button"
className="btn btn-secondary small"
disabled={props.pendingAuthRequestsLoading}
disabled={refreshing}
onClick={() => void props.onRefreshPendingAuthRequests()}
>
<RefreshCw size={14} className="btn-icon" />
<RefreshCw size={14} className={`btn-icon${refreshing ? ' btn-icon-spin' : ''}`} />
{t('txt_refresh')}
</button>
</div>
+61 -3
View File
@@ -1,5 +1,5 @@
import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import { CheckSquare, Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import LoadingState from '@/components/LoadingState';
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
@@ -8,10 +8,12 @@ import { t } from '@/lib/i18n';
interface SecurityDevicesPageProps {
devices: AuthorizedDevice[];
currentDeviceIdentifier: string;
loading: boolean;
error: string;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
pendingAuthRequestsRefreshing: boolean;
onRefresh: () => void;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
@@ -20,6 +22,7 @@ interface SecurityDevicesPageProps {
onRevokeTrust: (device: AuthorizedDevice) => void;
onTrustPermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
onRevokeAll: () => void;
onRemoveAll: () => void;
}
@@ -62,6 +65,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
const [deviceNote, setDeviceNote] = useState('');
const [savingNote, setSavingNote] = useState(false);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const currentDeviceIdentifier = props.currentDeviceIdentifier;
const selectableDevices = props.devices.filter((device) => (
device.identifier !== currentDeviceIdentifier
));
const selectedDeviceIdSet = new Set(selectedDeviceIds);
const selectedDevices = selectableDevices.filter((device) => selectedDeviceIdSet.has(device.identifier));
const allSelectableSelected = selectableDevices.length > 0 && selectedDevices.length === selectableDevices.length;
async function handleSaveDeviceNote(): Promise<void> {
if (!editingDevice || savingNote) return;
@@ -75,6 +86,19 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
}
}
function toggleSelectAllDevices(): void {
setSelectedDeviceIds(allSelectableSelected ? [] : selectableDevices.map((device) => device.identifier));
}
function toggleSelectedDevice(device: AuthorizedDevice): void {
if (device.identifier === currentDeviceIdentifier) return;
setSelectedDeviceIds((current) => (
current.includes(device.identifier)
? current.filter((id) => id !== device.identifier)
: [...current, device.identifier]
));
}
return (
<>
<div className="stack">
@@ -83,6 +107,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
loadingVariant="compact"
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
pendingAuthRequestsRefreshing={props.pendingAuthRequestsRefreshing}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
@@ -101,6 +126,27 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
<button
type="button"
className="btn btn-secondary small"
disabled={props.loading || selectableDevices.length === 0}
onClick={toggleSelectAllDevices}
>
<CheckSquare size={14} className="btn-icon" />
{allSelectableSelected ? t('txt_clear_selection') : t('txt_select_all')}
</button>
<button
type="button"
className="btn btn-danger small"
disabled={selectedDevices.length === 0}
onClick={() => {
props.onRemoveSelectedDevices(selectedDevices);
setSelectedDeviceIds([]);
}}
>
<Trash2 size={14} className="btn-icon" />
{t('txt_remove_selected_devices', { count: selectedDevices.length })}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
<ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_all_trusted')}
@@ -122,6 +168,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
)}
<table className="table authorized-devices-table">
<colgroup>
<col className="authorized-devices-col-select" />
<col className="authorized-devices-col-device" />
<col className="authorized-devices-col-type" />
<col className="authorized-devices-col-status" />
@@ -132,6 +179,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</colgroup>
<thead>
<tr>
<th>{t('txt_select')}</th>
<th>{t('txt_device')}</th>
<th>{t('txt_type')}</th>
<th>{t('txt_status')}</th>
@@ -144,6 +192,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<tbody>
{props.devices.map((device) => (
<tr key={device.identifier}>
<td data-label={t('txt_select')}>
<input
type="checkbox"
className="authorized-device-checkbox"
checked={selectedDeviceIdSet.has(device.identifier)}
disabled={device.identifier === currentDeviceIdentifier}
aria-label={t('txt_select_device_name', { name: device.name || t('txt_unknown_device') })}
onChange={() => toggleSelectedDevice(device)}
/>
</td>
<td data-label={t('txt_device')}>
<div>{device.name || t('txt_unknown_device')}</div>
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
@@ -216,14 +274,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
))}
{props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={7}>
<td colSpan={8}>
<LoadingState lines={5} compact />
</td>
</tr>
)}
{!props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={7}>
<td colSpan={8}>
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
</td>
</tr>
@@ -32,6 +32,18 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
: t('txt_downloading_percent', { percent: props.downloadingRemotePercent });
};
const renderRefreshPrompt = () => (
<div className="backup-browser-empty">
<span className="backup-browser-refresh-prompt">
<span>{t('txt_backup_remote_cached_empty_prefix')}</span>
<button type="button" className="btn btn-secondary small" disabled={!props.canBrowse || props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
{t('txt_backup_remote_refresh')}
</button>
<span>{t('txt_backup_remote_cached_empty_suffix')}</span>
</span>
</div>
);
return (
<>
<div className="backup-divider" />
@@ -42,8 +54,10 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
{!props.destinationIsSaved ? (
<div className="backup-browser-empty">{t('txt_backup_remote_save_first')}</div>
) : props.loadingRemoteBrowser && !props.remoteBrowser ? (
<div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div>
) : !props.remoteBrowser ? (
<div className="backup-browser-empty">{t('txt_backup_remote_cached_empty')}</div>
renderRefreshPrompt()
) : (
<>
<div className="backup-browser-path">
@@ -3,6 +3,7 @@ import {
changeMasterPassword,
deleteAllAuthorizedDevices,
deleteAuthorizedDevice,
deleteAuthorizedDevices,
deriveLoginHash,
deleteAccountPasskey as deleteAccountPasskeyApi,
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
@@ -389,6 +390,38 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
});
},
openRemoveSelectedDevices(devices: AuthorizedDevice[]) {
const selectedDevices = devices.filter((device) => String(device.identifier || '').trim());
if (selectedDevices.length === 0) {
onNotify('warning', t('txt_no_devices_selected'));
return;
}
const includesCurrentDevice = selectedDevices.some((device) => device.identifier === getCurrentDeviceIdentifier());
onSetConfirm({
title: t('txt_remove_selected_devices', { count: selectedDevices.length }),
message: includesCurrentDevice
? t('txt_remove_selected_devices_and_sign_out_current', { count: selectedDevices.length })
: t('txt_remove_selected_devices_confirm', { count: selectedDevices.length }),
danger: true,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
try {
await deleteAuthorizedDevices(authedFetch, selectedDevices);
onNotify('success', t('txt_selected_devices_removed', { count: selectedDevices.length }));
if (includesCurrentDevice) {
onLogoutNow();
return;
}
await refetchAuthorizedDevices();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_remove_selected_devices_failed'));
}
})();
},
});
},
openRevokeAllDeviceTrust() {
onSetConfirm({
title: t('txt_revoke_all_trusted_devices'),
+35 -5
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'preact/hooks';
import { createInvite, deleteAllInvites, deleteUser, revokeInvite, setUserStatus } from '@/lib/api/admin';
import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared';
@@ -45,14 +45,44 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
}
},
async revokeInvite(code: string) {
async deleteInvite(code: string) {
onSetConfirm({
title: t('txt_delete_invite'),
message: t('txt_delete_invite_confirm_message'),
danger: true,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
try {
await revokeInvite(authedFetch, code);
await deleteInvite(authedFetch, code);
await refetchInvites();
onNotify('success', t('txt_invite_revoked'));
onNotify('success', t('txt_invite_deleted'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_invite_failed'));
onNotify('error', error instanceof Error ? error.message : t('txt_delete_invite_failed'));
}
})();
},
});
},
async deleteInvalidInvites() {
onSetConfirm({
title: t('txt_delete_invalid_invites'),
message: t('txt_delete_invalid_invites_confirm_message'),
danger: true,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
try {
await deleteInvalidInvites(authedFetch);
await refetchInvites();
onNotify('success', t('txt_invalid_invites_deleted'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_invalid_invites_failed'));
}
})();
},
});
},
async deleteAllInvites() {
+18 -4
View File
@@ -136,6 +136,19 @@ function withPrfExtension(
};
}
function withoutCreatePrfExtension(options: PublicKeyCredentialCreationOptions): PublicKeyCredentialCreationOptions {
const extensions = { ...(((options as any).extensions || {}) as Record<string, unknown>) };
delete extensions.prf;
if (!Object.keys(extensions).length) {
const { extensions: _extensions, ...rest } = options as any;
return rest as PublicKeyCredentialCreationOptions;
}
return {
...options,
extensions: extensions as any,
};
}
function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | undefined {
const result = (credential.getClientExtensionResults() as any).prf?.results?.first;
return result instanceof ArrayBuffer ? result : undefined;
@@ -288,6 +301,7 @@ export async function createAccountPasskeyCredential(
throw new Error(t('txt_passkey_browser_not_supported'));
}
const nativeOptions = cloneCreationOptions(response.options);
const noPrfOptions = withoutCreatePrfExtension(nativeOptions);
const createWithOptions = async (options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential> => {
const credential = await navigator.credentials.create({ publicKey: options });
if (!(credential instanceof PublicKeyCredential)) {
@@ -298,9 +312,9 @@ export async function createAccountPasskeyCredential(
let credential: PublicKeyCredential;
if (requestPrf && await canRequestPrfExtension()) {
const prfOptions: PublicKeyCredentialCreationOptions = {
...nativeOptions,
...noPrfOptions,
extensions: {
...((nativeOptions as any).extensions || {}),
...((noPrfOptions as any).extensions || {}),
prf: {},
} as any,
};
@@ -308,10 +322,10 @@ export async function createAccountPasskeyCredential(
credential = await createWithOptions(prfOptions);
} catch (error) {
if (!shouldRetryCreateWithoutPrf(error)) throw error;
credential = await createWithOptions(nativeOptions);
credential = await createWithOptions(noPrfOptions);
}
} else {
credential = await createWithOptions(nativeOptions);
credential = await createWithOptions(noPrfOptions);
}
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created'));
+7 -2
View File
@@ -24,9 +24,14 @@ export async function createInvite(authedFetch: AuthedFetch, hours: number): Pro
if (!resp.ok) throw new Error('Create invite failed');
}
export async function revokeInvite(authedFetch: AuthedFetch, code: string): Promise<void> {
export async function deleteInvite(authedFetch: AuthedFetch, code: string): Promise<void> {
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Revoke invite failed');
if (!resp.ok) throw new Error('Delete invite failed');
}
export async function deleteInvalidInvites(authedFetch: AuthedFetch): Promise<void> {
const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' });
if (!resp.ok) throw new Error('Delete invalid invites failed');
}
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> {
+14
View File
@@ -885,6 +885,20 @@ export async function deleteAuthorizedDevice(
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
}
export async function deleteAuthorizedDevices(
authedFetch: AuthedFetch,
devices: Array<Pick<AuthorizedDevice, 'identifier' | 'hasStoredDevice'>>
): Promise<void> {
const uniqueDevices = Array.from(
new Map(devices.map((device) => [String(device.identifier || '').trim(), device])).values()
).filter((device) => String(device.identifier || '').trim());
await Promise.all(uniqueDevices.map((device) => (
device.hasStoredDevice === false
? revokeAuthorizedDeviceTrust(authedFetch, device.identifier)
: deleteAuthorizedDevice(authedFetch, device.identifier)
)));
}
export async function updateAuthorizedDeviceName(
authedFetch: AuthedFetch,
deviceIdentifier: string,
+10 -5
View File
@@ -1127,6 +1127,13 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onRefreshAdmin: () => {
notify('success', t('txt_demo_admin_refreshed'));
},
onDeleteInvalidInvites: async () => {
const now = Date.now();
state.setInvites((prev) => prev.filter((invite) => (
invite.status === 'active' && (!invite.expiresAt || new Date(invite.expiresAt).getTime() > now)
)));
notify('success', t('txt_invalid_invites_deleted'));
},
onDeleteAllInvites: async () => {
state.setInvites([]);
notify('success', t('txt_all_invites_deleted'));
@@ -1141,11 +1148,9 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
state.setUsers((prev) => prev.filter((user) => user.id !== userId));
notify('success', t('txt_user_deleted'));
},
onRevokeInvite: async (code) => {
state.setInvites((prev) => prev.map((invite) => (
invite.code === code ? { ...invite, status: 'inactive' } : invite
)));
notify('success', t('txt_invite_revoked'));
onDeleteInvite: async (code) => {
state.setInvites((prev) => prev.filter((invite) => invite.code !== code));
notify('success', t('txt_invite_deleted'));
},
onLoadAuditLogSettings: async () => ({ retentionDays: 90, maxEntries: null }),
onSaveAuditLogSettings: async (settings) => {
+40 -2
View File
@@ -449,6 +449,43 @@ function appendRecordFieldLines(lines: string[], prefix: string, value: unknown)
}
}
const BITWARDEN_CSV_OBJECT_FIELDS: Record<string, readonly string[]> = {
card: ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code'],
identity: [
'title',
'firstName',
'middleName',
'lastName',
'username',
'company',
'ssn',
'passportNumber',
'licenseNumber',
'email',
'phone',
'address1',
'address2',
'address3',
'city',
'state',
'postalCode',
'country',
],
sshKey: ['privateKey', 'publicKey', 'keyFingerprint', 'fingerprint'],
};
function appendKnownRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
if (!isRecord(value)) return;
const keys = BITWARDEN_CSV_OBJECT_FIELDS[prefix];
if (!keys) {
appendRecordFieldLines(lines, prefix, value);
return;
}
for (const key of keys) {
appendFieldLine(lines, `${prefix}.${key}`, value[key]);
}
}
function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): string {
const lines: string[] = [];
const fields = Array.isArray(item.fields) ? item.fields : [];
@@ -457,8 +494,9 @@ function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): s
appendFieldLine(lines, field.name, field.value);
}
if (type !== 1 && type !== 2) {
appendFieldLine(lines, 'nodewardenType', sourceTypeLabel(type));
appendRecordFieldLines(lines, sourceTypeLabel(type), item[sourceTypeLabel(type)]);
const sourceLabel = sourceTypeLabel(type);
appendFieldLine(lines, 'nodewardenType', sourceLabel);
appendKnownRecordFieldLines(lines, sourceLabel, item[sourceLabel]);
}
return lines.join('\n');
}
+88
View File
@@ -113,27 +113,115 @@ export function translateServerError(message: string | null | undefined, fallbac
return t('txt_rate_limit_try_again_seconds', { seconds: rateLimitMatch[1] });
}
const backupDestinationLimitMatch = normalized.match(/^You can save up to (\d+) backup destinations$/i);
if (backupDestinationLimitMatch) {
return t('txt_backup_error_destination_limit', { count: backupDestinationLimitMatch[1] });
}
const backupArchiveVerificationMatch = normalized.match(/^Backup archive upload verification failed after (\d+) attempts: (.+)$/i);
if (backupArchiveVerificationMatch) {
return t('txt_backup_error_archive_upload_verification_failed_attempts', {
count: backupArchiveVerificationMatch[1],
reason: translateServerError(backupArchiveVerificationMatch[2], backupArchiveVerificationMatch[2]),
});
}
const remoteAttachmentStatusMatch = normalized.match(/^Remote attachment (download|batch download) failed: (\d+)$/i);
if (remoteAttachmentStatusMatch) {
return t(
remoteAttachmentStatusMatch[1].toLowerCase() === 'batch download'
? 'txt_backup_error_remote_attachment_batch_download_failed_status'
: 'txt_backup_error_remote_attachment_download_failed_status',
{ status: remoteAttachmentStatusMatch[2] }
);
}
const providerStatusMatch = normalized.match(/^(WebDAV|S3) (directory creation|upload|listing|download|delete|existence check) failed: (\d+)$/i);
if (providerStatusMatch) {
const provider = providerStatusMatch[1].toLowerCase() === 'webdav' ? 'webdav' : 's3';
const actionKey = providerStatusMatch[2].toLowerCase().replace(/\s+/g, '_');
return t(`txt_backup_error_${provider}_${actionKey}_failed_status`, { status: providerStatusMatch[3] });
}
const key = {
'Account is disabled': 'txt_server_error_account_disabled',
'Another backup or restore run is already in progress': 'txt_backup_error_another_backup_or_restore_running',
'Another backup run is already in progress': 'txt_backup_error_another_backup_running',
'Backup archive upload failed': 'txt_backup_error_archive_upload_failed',
'Backup attachment blob is invalid': 'txt_backup_error_attachment_blob_invalid',
'Backup attachment blob is required': 'txt_backup_error_attachment_blob_required',
'Backup attachment blob not found': 'txt_backup_error_attachment_blob_not_found',
'Backup attachment download failed': 'txt_backup_error_attachment_download_failed',
'Backup destination is invalid': 'txt_backup_error_destination_invalid',
'Backup destination not found': 'txt_backup_error_destination_not_found',
'Backup destination ids must be unique': 'txt_backup_error_destination_ids_unique',
'Backup destination type is invalid': 'txt_backup_error_destination_type_invalid',
'Backup destinations are invalid': 'txt_backup_error_destinations_invalid',
'Backup export payload is invalid': 'txt_backup_error_export_payload_invalid',
'Backup file checksum does not match its filename': 'txt_backup_error_file_checksum_mismatch',
'Backup file is required': 'txt_backup_error_file_required',
'Backup interval hours must be between 1 and 99': 'txt_backup_error_interval_hours_range',
'Backup retention count must be between 1 and 1000': 'txt_backup_error_retention_count_range',
'Backup run failed': 'txt_backup_error_run_failed',
'Backup run payload is invalid': 'txt_backup_error_run_payload_invalid',
'Backup run response is invalid': 'txt_backup_error_run_response_invalid',
'Backup settings are invalid': 'txt_backup_error_settings_invalid',
'Backup settings could not be loaded': 'txt_backup_error_settings_load_failed',
'Backup settings envelope is invalid': 'txt_backup_error_settings_envelope_invalid',
'Backup settings need administrator reactivation after restore': 'txt_backup_error_settings_need_reactivation',
'Backup settings payload is invalid': 'txt_backup_error_settings_payload_invalid',
'Backup settings repair payload is invalid': 'txt_backup_error_settings_repair_payload_invalid',
'Backup settings repair state could not be loaded': 'txt_backup_error_settings_repair_state_load_failed',
'Backup start time must be in HH:mm format': 'txt_backup_error_start_time_format',
'Client IP is required': 'txt_server_error_client_ip_required',
'ClientId or clientSecret is incorrect. Try again': 'txt_server_error_client_credentials_incorrect',
'Content-Type must be multipart/form-data': 'txt_backup_error_multipart_required',
'Email already registered': 'txt_server_error_email_already_registered',
'Email and password are required': 'txt_server_error_email_password_required',
'Email is required': 'txt_server_error_email_required',
'Forbidden': 'txt_server_error_forbidden',
'Invite code is invalid or expired': 'txt_server_error_invite_invalid_or_expired',
'Invite code is required': 'txt_server_error_invite_required',
'Invalid backup timezone': 'txt_backup_error_timezone_invalid',
'Invalid password': 'txt_server_error_invalid_password',
'Invalid refresh token': 'txt_server_error_invalid_refresh_token',
'Invalid remote backup path': 'txt_backup_error_remote_path_invalid',
'Invalid request payload': 'txt_server_error_invalid_request_payload',
'Invalid user verification token': 'txt_server_error_invalid_user_verification_token',
'JWT_SECRET is not set': 'txt_server_error_jwt_secret_missing',
'JWT_SECRET is using the default/sample value. Please change it.': 'txt_server_error_jwt_secret_default',
'JWT_SECRET must be at least 32 characters': 'txt_server_error_jwt_secret_too_short',
'Parameter error': 'txt_server_error_parameter_error',
'Please select a backup file': 'txt_backup_error_select_backup_file',
'Please select a backup ZIP file': 'txt_backup_error_select_backup_zip_file',
'Refresh token is required': 'txt_server_error_refresh_token_required',
'Remote backup ZIP checksum verification failed': 'txt_backup_error_remote_zip_checksum_failed',
'Remote backup ZIP size verification failed': 'txt_backup_error_remote_zip_size_failed',
'Remote backup delete failed': 'txt_backup_error_remote_delete_failed',
'Remote backup download failed': 'txt_backup_error_remote_download_failed',
'Remote backup download payload is invalid': 'txt_backup_error_remote_download_payload_invalid',
'Remote backup integrity inspection failed': 'txt_backup_error_remote_integrity_failed',
'Remote backup listing failed': 'txt_backup_error_remote_listing_failed',
'Remote restore payload is invalid': 'txt_backup_error_remote_restore_payload_invalid',
'Registration is temporarily unavailable, retry once': 'txt_server_error_registration_retry',
'S3 access key is required': 'txt_backup_error_s3_access_key_required',
'S3 bucket is required': 'txt_backup_error_s3_bucket_required',
'S3 endpoint is required': 'txt_backup_error_s3_endpoint_required',
'S3 endpoint must start with http:// or https://': 'txt_backup_error_s3_endpoint_protocol',
'S3 secret key is required': 'txt_backup_error_s3_secret_key_required',
'TOTP token is required': 'txt_server_error_totp_token_required',
'Two factor required.': 'txt_server_error_two_factor_required',
'Two-step token is invalid. Try again.': 'txt_server_error_two_factor_invalid',
'Unable to read backup file': 'txt_backup_error_read_backup_file_failed',
'Unsupported backup destination type': 'txt_backup_error_destination_type_unsupported',
'Username or password is incorrect. Try again': 'txt_server_error_username_password_incorrect',
'WebDAV password is required': 'txt_backup_error_webdav_password_required',
'WebDAV remote backup path is too deep for safe attachment batching': 'txt_backup_error_webdav_path_too_deep',
'WebDAV server URL is required': 'txt_backup_error_webdav_url_required',
'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol',
'WebDAV username is required': 'txt_backup_error_webdav_username_required',
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
}[normalized];
return key ? t(key) : normalized;
+94
View File
@@ -224,6 +224,8 @@ const en: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "The server is performing final validation and then switching the verified restore data into the live tables.",
"txt_backup_remote_loading": "Loading remote backups...",
"txt_backup_remote_cached_empty": "Click Refresh to load this destination.",
"txt_backup_remote_cached_empty_prefix": "Click",
"txt_backup_remote_cached_empty_suffix": "to load this destination.",
"txt_backup_remote_empty": "No backup files found in this folder.",
"txt_backup_remote_folder": "Folder",
"txt_backup_remote_unknown_time": "Unknown time",
@@ -247,6 +249,74 @@ const en: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "Invalid remote backup run response",
"txt_backup_settings_invalid_response": "Invalid backup settings response",
"txt_backup_import_invalid_response": "Invalid backup import response",
"txt_backup_error_another_backup_or_restore_running": "Another backup or restore task is already running.",
"txt_backup_error_another_backup_running": "Another backup task is already running.",
"txt_backup_error_archive_upload_failed": "Backup archive upload failed.",
"txt_backup_error_archive_upload_verification_failed_attempts": "Backup upload verification failed after {count} attempt(s): {reason}",
"txt_backup_error_attachment_blob_invalid": "Backup attachment blob is invalid.",
"txt_backup_error_attachment_blob_required": "Backup attachment blob is required.",
"txt_backup_error_attachment_blob_not_found": "Backup attachment blob not found.",
"txt_backup_error_attachment_download_failed": "Backup attachment download failed.",
"txt_backup_error_destination_invalid": "Backup destination is invalid.",
"txt_backup_error_destination_limit": "You can save up to {count} backup destinations.",
"txt_backup_error_destination_not_found": "Backup destination not found.",
"txt_backup_error_destination_ids_unique": "Backup destination IDs must be unique.",
"txt_backup_error_destination_type_invalid": "Backup destination type is invalid.",
"txt_backup_error_destination_type_unsupported": "Unsupported backup destination type.",
"txt_backup_error_destinations_invalid": "Backup destinations are invalid.",
"txt_backup_error_export_payload_invalid": "Backup export payload is invalid.",
"txt_backup_error_file_checksum_mismatch": "Backup file checksum does not match its filename.",
"txt_backup_error_file_required": "Backup file is required.",
"txt_backup_error_interval_hours_range": "Backup interval must be between 1 and 99 hours.",
"txt_backup_error_multipart_required": "The upload request must use multipart/form-data.",
"txt_backup_error_read_backup_file_failed": "Unable to read backup file.",
"txt_backup_error_remote_attachment_batch_download_failed_status": "Remote attachment batch download failed: HTTP {status}.",
"txt_backup_error_remote_attachment_download_failed_status": "Remote attachment download failed: HTTP {status}.",
"txt_backup_error_remote_delete_failed": "Remote backup delete failed.",
"txt_backup_error_remote_download_failed": "Remote backup download failed.",
"txt_backup_error_remote_download_payload_invalid": "Remote backup download request is invalid.",
"txt_backup_error_remote_integrity_failed": "Remote backup integrity inspection failed.",
"txt_backup_error_remote_listing_failed": "Remote backup listing failed.",
"txt_backup_error_remote_path_invalid": "Remote backup path is invalid.",
"txt_backup_error_remote_restore_payload_invalid": "Remote restore request is invalid.",
"txt_backup_error_remote_zip_checksum_failed": "Remote backup ZIP checksum verification failed.",
"txt_backup_error_remote_zip_size_failed": "Remote backup ZIP size verification failed.",
"txt_backup_error_retention_count_range": "Backup retention count must be between 1 and 1000.",
"txt_backup_error_run_failed": "Backup run failed.",
"txt_backup_error_run_payload_invalid": "Backup run request is invalid.",
"txt_backup_error_run_response_invalid": "Backup run response is invalid.",
"txt_backup_error_s3_access_key_required": "S3 access key is required.",
"txt_backup_error_s3_bucket_required": "S3 bucket is required.",
"txt_backup_error_s3_delete_failed_status": "S3 delete failed: HTTP {status}.",
"txt_backup_error_s3_download_failed_status": "S3 download failed: HTTP {status}.",
"txt_backup_error_s3_endpoint_required": "S3 endpoint is required.",
"txt_backup_error_s3_endpoint_protocol": "S3 endpoint must start with http:// or https://.",
"txt_backup_error_s3_existence_check_failed_status": "S3 existence check failed: HTTP {status}.",
"txt_backup_error_s3_listing_failed_status": "S3 listing failed: HTTP {status}.",
"txt_backup_error_s3_secret_key_required": "S3 secret key is required.",
"txt_backup_error_s3_upload_failed_status": "S3 upload failed: HTTP {status}.",
"txt_backup_error_select_backup_file": "Please select a backup file.",
"txt_backup_error_select_backup_zip_file": "Please select a backup ZIP file.",
"txt_backup_error_settings_envelope_invalid": "Backup settings envelope is invalid.",
"txt_backup_error_settings_invalid": "Backup settings are invalid.",
"txt_backup_error_settings_load_failed": "Backup settings could not be loaded.",
"txt_backup_error_settings_need_reactivation": "Backup settings need administrator reactivation after restore.",
"txt_backup_error_settings_payload_invalid": "Backup settings request is invalid.",
"txt_backup_error_settings_repair_payload_invalid": "Backup settings repair request is invalid.",
"txt_backup_error_settings_repair_state_load_failed": "Backup settings repair state could not be loaded.",
"txt_backup_error_start_time_format": "Backup start time must be in HH:mm format.",
"txt_backup_error_timezone_invalid": "Backup timezone is invalid.",
"txt_backup_error_webdav_delete_failed_status": "WebDAV delete failed: HTTP {status}.",
"txt_backup_error_webdav_directory_creation_failed_status": "WebDAV directory creation failed: HTTP {status}.",
"txt_backup_error_webdav_download_failed_status": "WebDAV download failed: HTTP {status}.",
"txt_backup_error_webdav_existence_check_failed_status": "WebDAV existence check failed: HTTP {status}.",
"txt_backup_error_webdav_listing_failed_status": "WebDAV listing failed: HTTP {status}.",
"txt_backup_error_webdav_password_required": "WebDAV password is required.",
"txt_backup_error_webdav_path_too_deep": "WebDAV remote backup path is too deep for safe attachment batching.",
"txt_backup_error_webdav_upload_failed_status": "WebDAV upload failed: HTTP {status}.",
"txt_backup_error_webdav_url_required": "WebDAV server URL is required.",
"txt_backup_error_webdav_url_protocol": "WebDAV server URL must start with http:// or https://.",
"txt_backup_error_webdav_username_required": "WebDAV username is required.",
"txt_backup_destination": "Backup Destination",
"txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3",
@@ -512,8 +582,17 @@ const en: Record<string, string> = {
"txt_identity_details": "Identity Details",
"txt_ie_browser": "IE Browser",
"txt_create_invite_failed": "Failed to create invite",
"txt_delete_invalid": "Delete Invalid",
"txt_delete_invalid_invites": "Delete invalid invites",
"txt_delete_invalid_invites_confirm_message": "Delete all invalid invite codes? Active, unexpired invite codes will be kept.",
"txt_delete_invalid_invites_failed": "Failed to delete invalid invites",
"txt_delete_invite": "Delete invite",
"txt_delete_invite_confirm_message": "Delete this invite code? This cannot be undone.",
"txt_delete_invite_failed": "Failed to delete invite",
"txt_invite_code_required": "Invite Code (Required)",
"txt_invite_created": "Invite created",
"txt_invite_deleted": "Invite deleted",
"txt_invalid_invites_deleted": "Invalid invites deleted",
"txt_invite_revoked": "Invite revoked",
"txt_revoke_invite_failed": "Failed to revoke invite",
"txt_invite_validity_hours": "Invite validity (hours)",
@@ -522,16 +601,21 @@ const en: Record<string, string> = {
"txt_server_error_account_disabled": "Account is disabled",
"txt_server_error_client_credentials_incorrect": "Client ID or client secret is incorrect. Try again.",
"txt_server_error_client_ip_required": "Client IP is required",
"txt_server_error_forbidden": "You do not have permission to perform this action.",
"txt_server_error_email_already_registered": "Email already registered",
"txt_server_error_email_password_required": "Email and password are required",
"txt_server_error_email_required": "Email is required",
"txt_server_error_invalid_password": "Invalid password.",
"txt_server_error_invalid_refresh_token": "Session expired. Please sign in again.",
"txt_server_error_invalid_user_verification_token": "Invalid user verification token.",
"txt_server_error_invalid_request_payload": "Invalid request payload",
"txt_server_error_invite_invalid_or_expired": "Invite code is invalid or expired",
"txt_server_error_invite_required": "Invite code is required",
"txt_server_error_jwt_secret_default": "JWT_SECRET is using the default/sample value. Please change it.",
"txt_server_error_jwt_secret_missing": "JWT_SECRET is not set",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET must be at least 32 characters",
"txt_server_error_master_password_hash_required": "Master password verification is required.",
"txt_server_error_master_password_or_verification_required": "Master password or user verification token is required.",
"txt_server_error_parameter_error": "Parameter error",
"txt_server_error_refresh_token_required": "Session is missing. Please sign in again.",
"txt_server_error_registration_retry": "Registration is temporarily unavailable. Please retry once.",
@@ -763,6 +847,11 @@ const en: Record<string, string> = {
"txt_remove_all_devices": "Remove all devices",
"txt_remove_all_devices_and_clear_all_2fa_trust": "Remove all devices and clear all 2FA trust?",
"txt_remove_all_devices_and_sign_out_all_sessions": "Remove all devices, clear all trust, and sign out every device?",
"txt_remove_selected_devices": "Remove selected ({count})",
"txt_remove_selected_devices_confirm": "Remove {count} selected devices, clear their trust, and sign them out?",
"txt_remove_selected_devices_and_sign_out_current": "Remove {count} selected devices, clear their trust, and sign out this device too?",
"txt_selected_devices_removed": "Selected devices removed",
"txt_remove_selected_devices_failed": "Failed to remove selected devices",
"txt_remove_device_name_and_clear_its_2fa_trust": "Remove device \"{name}\" and clear its 2FA trust?",
"txt_remove_device_and_sign_out_name": "Remove device \"{name}\", clear its trust, and sign it out?",
"txt_reveal": "Reveal",
@@ -804,6 +893,9 @@ const en: Record<string, string> = {
"txt_security_code": "Security Code",
"txt_security_code_cvv": "Security Code (CVV)",
"txt_select_all": "Select All",
"txt_clear_selection": "Clear selection",
"txt_select_device_name": "Select {name}",
"txt_no_devices_selected": "No devices selected",
"txt_select": "Select",
"txt_select_duplicate_items": "Select Duplicates",
"txt_select_an_item": "Select an item",
@@ -1073,7 +1165,9 @@ const en: Record<string, string> = {
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
"txt_log_action_admin_backup_settings_update": "Update backup settings",
"txt_log_action_admin_invite_create": "Create invite",
"txt_log_action_admin_invite_delete": "Delete invite",
"txt_log_action_admin_invite_delete_all": "Clear invites",
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites",
"txt_log_action_admin_invite_revoke": "Revoke invite",
"txt_log_action_admin_user_delete": "Delete user",
"txt_log_action_admin_user_status": "Change user status",
+94
View File
@@ -224,6 +224,8 @@ const es: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "El servidor está realizando la validación final y luego cambiando los datos de restauración verificados a las tablas activas.",
"txt_backup_remote_loading": "Cargando copias remotas...",
"txt_backup_remote_cached_empty": "Haga clic en Actualizar para cargar este destino.",
"txt_backup_remote_cached_empty_prefix": "Haga clic en",
"txt_backup_remote_cached_empty_suffix": "para cargar este destino.",
"txt_backup_remote_empty": "No se encontraron archivos de copia de seguridad en esta carpeta.",
"txt_backup_remote_folder": "Carpeta",
"txt_backup_remote_unknown_time": "Hora desconocida",
@@ -247,6 +249,74 @@ const es: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "Respuesta de ejecución de copia de seguridad remota no válida",
"txt_backup_settings_invalid_response": "Respuesta de configuración de copia de seguridad no válida",
"txt_backup_import_invalid_response": "Respuesta de importación de copia de seguridad no válida",
"txt_backup_error_another_backup_or_restore_running": "Ya hay una tarea de copia o restauración en curso.",
"txt_backup_error_another_backup_running": "Ya hay una tarea de copia en curso.",
"txt_backup_error_archive_upload_failed": "No se pudo subir el archivo de copia.",
"txt_backup_error_archive_upload_verification_failed_attempts": "La verificación de subida falló tras {count} intento(s): {reason}",
"txt_backup_error_attachment_blob_invalid": "El objeto de adjunto de copia no es válido.",
"txt_backup_error_attachment_blob_required": "Falta el objeto de adjunto de copia.",
"txt_backup_error_attachment_blob_not_found": "No se encontró el objeto de adjunto de copia.",
"txt_backup_error_attachment_download_failed": "No se pudo descargar el adjunto de copia.",
"txt_backup_error_destination_invalid": "El destino de copia no es válido.",
"txt_backup_error_destination_limit": "Puede guardar hasta {count} destinos de copia.",
"txt_backup_error_destination_not_found": "No se encontró el destino de copia.",
"txt_backup_error_destination_ids_unique": "Los ID de destino de copia no pueden repetirse.",
"txt_backup_error_destination_type_invalid": "El tipo de destino de copia no es válido.",
"txt_backup_error_destination_type_unsupported": "Tipo de destino de copia no compatible.",
"txt_backup_error_destinations_invalid": "La lista de destinos de copia no es válida.",
"txt_backup_error_export_payload_invalid": "La solicitud de exportación de copia no es válida.",
"txt_backup_error_file_checksum_mismatch": "La suma de verificación de la copia no coincide con el nombre del archivo.",
"txt_backup_error_file_required": "Seleccione un archivo de copia.",
"txt_backup_error_interval_hours_range": "El intervalo de copia debe estar entre 1 y 99 horas.",
"txt_backup_error_multipart_required": "La solicitud de subida debe usar multipart/form-data.",
"txt_backup_error_read_backup_file_failed": "No se pudo leer el archivo de copia.",
"txt_backup_error_remote_attachment_batch_download_failed_status": "Error al descargar adjuntos remotos por lotes: HTTP {status}.",
"txt_backup_error_remote_attachment_download_failed_status": "Error al descargar adjunto remoto: HTTP {status}.",
"txt_backup_error_remote_delete_failed": "No se pudo eliminar la copia remota.",
"txt_backup_error_remote_download_failed": "No se pudo descargar la copia remota.",
"txt_backup_error_remote_download_payload_invalid": "La solicitud de descarga remota no es válida.",
"txt_backup_error_remote_integrity_failed": "No se pudo inspeccionar la integridad de la copia remota.",
"txt_backup_error_remote_listing_failed": "No se pudo leer la lista de copias remotas.",
"txt_backup_error_remote_path_invalid": "La ruta de copia remota no es válida.",
"txt_backup_error_remote_restore_payload_invalid": "La solicitud de restauración remota no es válida.",
"txt_backup_error_remote_zip_checksum_failed": "Falló la verificación de suma del ZIP remoto.",
"txt_backup_error_remote_zip_size_failed": "Falló la verificación de tamaño del ZIP remoto.",
"txt_backup_error_retention_count_range": "La retención debe estar entre 1 y 1000.",
"txt_backup_error_run_failed": "La ejecución de copia falló.",
"txt_backup_error_run_payload_invalid": "La solicitud de ejecución de copia no es válida.",
"txt_backup_error_run_response_invalid": "La respuesta de ejecución de copia no es válida.",
"txt_backup_error_s3_access_key_required": "La clave de acceso S3 es obligatoria.",
"txt_backup_error_s3_bucket_required": "El bucket S3 es obligatorio.",
"txt_backup_error_s3_delete_failed_status": "Eliminación S3 fallida: HTTP {status}.",
"txt_backup_error_s3_download_failed_status": "Descarga S3 fallida: HTTP {status}.",
"txt_backup_error_s3_endpoint_required": "El endpoint S3 es obligatorio.",
"txt_backup_error_s3_endpoint_protocol": "El endpoint S3 debe empezar por http:// o https://.",
"txt_backup_error_s3_existence_check_failed_status": "Comprobación de existencia S3 fallida: HTTP {status}.",
"txt_backup_error_s3_listing_failed_status": "Listado S3 fallido: HTTP {status}.",
"txt_backup_error_s3_secret_key_required": "La clave secreta S3 es obligatoria.",
"txt_backup_error_s3_upload_failed_status": "Subida S3 fallida: HTTP {status}.",
"txt_backup_error_select_backup_file": "Seleccione un archivo de copia.",
"txt_backup_error_select_backup_zip_file": "Seleccione un archivo ZIP de copia.",
"txt_backup_error_settings_envelope_invalid": "El contenedor cifrado de configuración de copia no es válido.",
"txt_backup_error_settings_invalid": "La configuración de copia no es válida.",
"txt_backup_error_settings_load_failed": "No se pudo cargar la configuración de copia.",
"txt_backup_error_settings_need_reactivation": "La configuración de copia requiere reactivación de administrador tras la restauración.",
"txt_backup_error_settings_payload_invalid": "La solicitud de configuración de copia no es válida.",
"txt_backup_error_settings_repair_payload_invalid": "La solicitud de reparación de configuración no es válida.",
"txt_backup_error_settings_repair_state_load_failed": "No se pudo cargar el estado de reparación de configuración.",
"txt_backup_error_start_time_format": "La hora de inicio debe tener formato HH:mm.",
"txt_backup_error_timezone_invalid": "La zona horaria de copia no es válida.",
"txt_backup_error_webdav_delete_failed_status": "Eliminación WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_directory_creation_failed_status": "Creación de directorio WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_download_failed_status": "Descarga WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_existence_check_failed_status": "Comprobación de existencia WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_listing_failed_status": "Listado WebDAV fallido: HTTP {status}.",
"txt_backup_error_webdav_password_required": "La contraseña WebDAV es obligatoria.",
"txt_backup_error_webdav_path_too_deep": "La ruta remota WebDAV es demasiado profunda para procesar adjuntos por lotes de forma segura.",
"txt_backup_error_webdav_upload_failed_status": "Subida WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_url_required": "La URL del servidor WebDAV es obligatoria.",
"txt_backup_error_webdav_url_protocol": "La URL WebDAV debe empezar por http:// o https://.",
"txt_backup_error_webdav_username_required": "El usuario WebDAV es obligatorio.",
"txt_backup_destination": "Destino de copia",
"txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3",
@@ -512,8 +582,17 @@ const es: Record<string, string> = {
"txt_identity_details": "Detalles de identidad",
"txt_ie_browser": "Navegador Internet Explorer",
"txt_create_invite_failed": "Error al crear invitación",
"txt_delete_invalid": "Eliminar inválidas",
"txt_delete_invalid_invites": "Eliminar invitaciones inválidas",
"txt_delete_invalid_invites_confirm_message": "¿Eliminar todos los códigos de invitación inválidos? Se conservarán los códigos activos y no vencidos.",
"txt_delete_invalid_invites_failed": "Error al eliminar invitaciones inválidas",
"txt_delete_invite": "Eliminar invitación",
"txt_delete_invite_confirm_message": "¿Eliminar este código de invitación? Esta acción no se puede deshacer.",
"txt_delete_invite_failed": "Error al eliminar invitación",
"txt_invite_code_required": "Código de invitación (obligatorio)",
"txt_invite_created": "Invitación creada",
"txt_invite_deleted": "Invitación eliminada",
"txt_invalid_invites_deleted": "Invitaciones inválidas eliminadas",
"txt_invite_revoked": "Invitación revocada",
"txt_revoke_invite_failed": "Error al revocar invitación",
"txt_invite_validity_hours": "Validez de la invitación en horas",
@@ -522,16 +601,21 @@ const es: Record<string, string> = {
"txt_server_error_account_disabled": "La cuenta está deshabilitada",
"txt_server_error_client_credentials_incorrect": "El ID de cliente o el secreto de cliente no son correctos. Inténtalo de nuevo.",
"txt_server_error_client_ip_required": "Se requiere la IP del cliente",
"txt_server_error_forbidden": "No tiene permiso para realizar esta acción.",
"txt_server_error_email_already_registered": "Este correo ya está registrado",
"txt_server_error_email_password_required": "Correo y contraseña son obligatorios",
"txt_server_error_email_required": "El correo es obligatorio",
"txt_server_error_invalid_password": "Contraseña no válida.",
"txt_server_error_invalid_refresh_token": "La sesión caducó. Inicia sesión de nuevo.",
"txt_server_error_invalid_user_verification_token": "Token de verificación de usuario no válido.",
"txt_server_error_invalid_request_payload": "Solicitud no válida",
"txt_server_error_invite_invalid_or_expired": "El código de invitación no es válido o ha caducado",
"txt_server_error_invite_required": "El código de invitación es obligatorio",
"txt_server_error_jwt_secret_default": "JWT_SECRET usa el valor predeterminado/de ejemplo. Cámbialo.",
"txt_server_error_jwt_secret_missing": "JWT_SECRET no está configurado",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET debe tener al menos 32 caracteres",
"txt_server_error_master_password_hash_required": "Se requiere verificación de la contraseña maestra.",
"txt_server_error_master_password_or_verification_required": "Se requiere contraseña maestra o token de verificación de usuario.",
"txt_server_error_parameter_error": "Error de parámetros",
"txt_server_error_refresh_token_required": "Falta la sesión. Inicia sesión de nuevo.",
"txt_server_error_registration_retry": "El registro no está disponible temporalmente. Inténtalo una vez más.",
@@ -763,6 +847,11 @@ const es: Record<string, string> = {
"txt_remove_all_devices": "Quitar todos los dispositivos",
"txt_remove_all_devices_and_clear_all_2fa_trust": "¿Quitar todos los dispositivos y limpiar toda la confianza 2FA?",
"txt_remove_all_devices_and_sign_out_all_sessions": "¿Quitar todos los dispositivos, limpiar toda la confianza y cerrar sesión en todos los dispositivos?",
"txt_remove_selected_devices": "Quitar seleccionados ({count})",
"txt_remove_selected_devices_confirm": "¿Quitar {count} dispositivos seleccionados, limpiar su confianza y cerrar sesión?",
"txt_remove_selected_devices_and_sign_out_current": "¿Quitar {count} dispositivos seleccionados, limpiar su confianza y cerrar también esta sesión?",
"txt_selected_devices_removed": "Dispositivos seleccionados quitados",
"txt_remove_selected_devices_failed": "Error al quitar los dispositivos seleccionados",
"txt_remove_device_name_and_clear_its_2fa_trust": "¿Quitar dispositivo \"{name}\" y limpiar su confianza 2FA?",
"txt_remove_device_and_sign_out_name": "¿Quitar dispositivo \"{name}\", limpiar su confianza y cerrar sesión?",
"txt_reveal": "Mostrar",
@@ -804,6 +893,9 @@ const es: Record<string, string> = {
"txt_security_code": "Código de seguridad",
"txt_security_code_cvv": "Código de seguridad (CVV)",
"txt_select_all": "Seleccionar todo",
"txt_clear_selection": "Borrar selección",
"txt_select_device_name": "Seleccionar {name}",
"txt_no_devices_selected": "No hay dispositivos seleccionados",
"txt_select": "Seleccionar",
"txt_select_duplicate_items": "Seleccionar duplicados",
"txt_select_an_item": "Seleccione un elemento",
@@ -1073,7 +1165,9 @@ const es: Record<string, string> = {
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
"txt_log_action_admin_backup_settings_update": "Update backup settings",
"txt_log_action_admin_invite_create": "Create invite",
"txt_log_action_admin_invite_delete": "Delete invite",
"txt_log_action_admin_invite_delete_all": "Clear invites",
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites",
"txt_log_action_admin_invite_revoke": "Revoke invite",
"txt_log_action_admin_user_delete": "Delete user",
"txt_log_action_admin_user_status": "Change user status",
+94
View File
@@ -224,6 +224,8 @@ const ru: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "Сервер выполняет окончательную проверку, а затем переключает проверенные данные восстановления в живые таблицы.",
"txt_backup_remote_loading": "Загрузка удаленных резервных копий...",
"txt_backup_remote_cached_empty": "Нажмите «Обновить», чтобы загрузить это место назначения.",
"txt_backup_remote_cached_empty_prefix": "Нажмите",
"txt_backup_remote_cached_empty_suffix": "чтобы загрузить это место назначения.",
"txt_backup_remote_empty": "В этой папке не найдено файлов резервных копий.",
"txt_backup_remote_folder": "Папка",
"txt_backup_remote_unknown_time": "Неизвестное время",
@@ -247,6 +249,74 @@ const ru: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "Неверный ответ на удаленное резервное копирование.",
"txt_backup_settings_invalid_response": "Неверный ответ на настройки резервного копирования",
"txt_backup_import_invalid_response": "Неверный ответ на импорт резервной копии",
"txt_backup_error_another_backup_or_restore_running": "Уже выполняется задача резервного копирования или восстановления.",
"txt_backup_error_another_backup_running": "Уже выполняется задача резервного копирования.",
"txt_backup_error_archive_upload_failed": "Не удалось загрузить архив резервной копии.",
"txt_backup_error_archive_upload_verification_failed_attempts": "Проверка загрузки не прошла после {count} попыток: {reason}",
"txt_backup_error_attachment_blob_invalid": "Объект вложения резервной копии недействителен.",
"txt_backup_error_attachment_blob_required": "Требуется объект вложения резервной копии.",
"txt_backup_error_attachment_blob_not_found": "Объект вложения резервной копии не найден.",
"txt_backup_error_attachment_download_failed": "Не удалось скачать вложение резервной копии.",
"txt_backup_error_destination_invalid": "Место назначения резервной копии недействительно.",
"txt_backup_error_destination_limit": "Можно сохранить не более {count} мест назначения резервной копии.",
"txt_backup_error_destination_not_found": "Место назначения резервной копии не найдено.",
"txt_backup_error_destination_ids_unique": "ID мест назначения резервной копии должны быть уникальными.",
"txt_backup_error_destination_type_invalid": "Тип места назначения резервной копии недействителен.",
"txt_backup_error_destination_type_unsupported": "Неподдерживаемый тип места назначения резервной копии.",
"txt_backup_error_destinations_invalid": "Список мест назначения резервной копии недействителен.",
"txt_backup_error_export_payload_invalid": "Запрос экспорта резервной копии недействителен.",
"txt_backup_error_file_checksum_mismatch": "Контрольная сумма файла резервной копии не совпадает с именем файла.",
"txt_backup_error_file_required": "Выберите файл резервной копии.",
"txt_backup_error_interval_hours_range": "Интервал резервного копирования должен быть от 1 до 99 часов.",
"txt_backup_error_multipart_required": "Запрос загрузки должен использовать multipart/form-data.",
"txt_backup_error_read_backup_file_failed": "Не удалось прочитать файл резервной копии.",
"txt_backup_error_remote_attachment_batch_download_failed_status": "Пакетное скачивание удаленных вложений не удалось: HTTP {status}.",
"txt_backup_error_remote_attachment_download_failed_status": "Скачивание удаленного вложения не удалось: HTTP {status}.",
"txt_backup_error_remote_delete_failed": "Не удалось удалить удаленную резервную копию.",
"txt_backup_error_remote_download_failed": "Не удалось скачать удаленную резервную копию.",
"txt_backup_error_remote_download_payload_invalid": "Запрос скачивания удаленной резервной копии недействителен.",
"txt_backup_error_remote_integrity_failed": "Не удалось проверить целостность удаленной резервной копии.",
"txt_backup_error_remote_listing_failed": "Не удалось получить список удаленных резервных копий.",
"txt_backup_error_remote_path_invalid": "Путь удаленной резервной копии недействителен.",
"txt_backup_error_remote_restore_payload_invalid": "Запрос удаленного восстановления недействителен.",
"txt_backup_error_remote_zip_checksum_failed": "Проверка контрольной суммы удаленного ZIP не прошла.",
"txt_backup_error_remote_zip_size_failed": "Проверка размера удаленного ZIP не прошла.",
"txt_backup_error_retention_count_range": "Количество сохраняемых копий должно быть от 1 до 1000.",
"txt_backup_error_run_failed": "Запуск резервного копирования не удался.",
"txt_backup_error_run_payload_invalid": "Запрос запуска резервного копирования недействителен.",
"txt_backup_error_run_response_invalid": "Ответ запуска резервного копирования недействителен.",
"txt_backup_error_s3_access_key_required": "Требуется ключ доступа S3.",
"txt_backup_error_s3_bucket_required": "Требуется bucket S3.",
"txt_backup_error_s3_delete_failed_status": "Удаление S3 не удалось: HTTP {status}.",
"txt_backup_error_s3_download_failed_status": "Скачивание S3 не удалось: HTTP {status}.",
"txt_backup_error_s3_endpoint_required": "Требуется endpoint S3.",
"txt_backup_error_s3_endpoint_protocol": "Endpoint S3 должен начинаться с http:// или https://.",
"txt_backup_error_s3_existence_check_failed_status": "Проверка существования S3 не удалась: HTTP {status}.",
"txt_backup_error_s3_listing_failed_status": "Получение списка S3 не удалось: HTTP {status}.",
"txt_backup_error_s3_secret_key_required": "Требуется секретный ключ S3.",
"txt_backup_error_s3_upload_failed_status": "Загрузка S3 не удалась: HTTP {status}.",
"txt_backup_error_select_backup_file": "Выберите файл резервной копии.",
"txt_backup_error_select_backup_zip_file": "Выберите ZIP-файл резервной копии.",
"txt_backup_error_settings_envelope_invalid": "Зашифрованный контейнер настроек резервного копирования недействителен.",
"txt_backup_error_settings_invalid": "Настройки резервного копирования недействительны.",
"txt_backup_error_settings_load_failed": "Не удалось загрузить настройки резервного копирования.",
"txt_backup_error_settings_need_reactivation": "После восстановления настройки резервного копирования нужно повторно активировать администратором.",
"txt_backup_error_settings_payload_invalid": "Запрос настроек резервного копирования недействителен.",
"txt_backup_error_settings_repair_payload_invalid": "Запрос восстановления настроек резервного копирования недействителен.",
"txt_backup_error_settings_repair_state_load_failed": "Не удалось загрузить состояние восстановления настроек резервного копирования.",
"txt_backup_error_start_time_format": "Время начала резервного копирования должно быть в формате HH:mm.",
"txt_backup_error_timezone_invalid": "Часовой пояс резервного копирования недействителен.",
"txt_backup_error_webdav_delete_failed_status": "Удаление WebDAV не удалось: HTTP {status}.",
"txt_backup_error_webdav_directory_creation_failed_status": "Создание каталога WebDAV не удалось: HTTP {status}.",
"txt_backup_error_webdav_download_failed_status": "Скачивание WebDAV не удалось: HTTP {status}.",
"txt_backup_error_webdav_existence_check_failed_status": "Проверка существования WebDAV не удалась: HTTP {status}.",
"txt_backup_error_webdav_listing_failed_status": "Получение списка WebDAV не удалось: HTTP {status}.",
"txt_backup_error_webdav_password_required": "Требуется пароль WebDAV.",
"txt_backup_error_webdav_path_too_deep": "Удаленный путь WebDAV слишком глубокий для безопасной пакетной обработки вложений.",
"txt_backup_error_webdav_upload_failed_status": "Загрузка WebDAV не удалась: HTTP {status}.",
"txt_backup_error_webdav_url_required": "Требуется URL сервера WebDAV.",
"txt_backup_error_webdav_url_protocol": "URL WebDAV должен начинаться с http:// или https://.",
"txt_backup_error_webdav_username_required": "Требуется имя пользователя WebDAV.",
"txt_backup_destination": "Место назначения резервного копирования",
"txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3",
@@ -512,8 +582,17 @@ const ru: Record<string, string> = {
"txt_identity_details": "Данные личности",
"txt_ie_browser": "IE-браузер",
"txt_create_invite_failed": "Не удалось создать приглашение",
"txt_delete_invalid": "Удалить недействительные",
"txt_delete_invalid_invites": "Удалить недействительные приглашения",
"txt_delete_invalid_invites_confirm_message": "Удалить все недействительные пригласительные коды? Активные и не истекшие коды будут сохранены.",
"txt_delete_invalid_invites_failed": "Не удалось удалить недействительные приглашения",
"txt_delete_invite": "Удалить приглашение",
"txt_delete_invite_confirm_message": "Удалить этот пригласительный код? Это действие нельзя отменить.",
"txt_delete_invite_failed": "Не удалось удалить приглашение",
"txt_invite_code_required": "Пригласительный код (обязательно)",
"txt_invite_created": "Приглашение создано",
"txt_invite_deleted": "Приглашение удалено",
"txt_invalid_invites_deleted": "Недействительные приглашения удалены",
"txt_invite_revoked": "Приглашение отозвано",
"txt_revoke_invite_failed": "Не удалось отозвать приглашение",
"txt_invite_validity_hours": "Срок действия приглашения (часы)",
@@ -522,16 +601,21 @@ const ru: Record<string, string> = {
"txt_server_error_account_disabled": "Учетная запись отключена",
"txt_server_error_client_credentials_incorrect": "ID клиента или секрет клиента неверны. Повторите попытку.",
"txt_server_error_client_ip_required": "Требуется IP клиента",
"txt_server_error_forbidden": "У вас нет прав для выполнения этого действия.",
"txt_server_error_email_already_registered": "Этот адрес электронной почты уже зарегистрирован",
"txt_server_error_email_password_required": "Требуются адрес электронной почты и пароль",
"txt_server_error_email_required": "Требуется адрес электронной почты",
"txt_server_error_invalid_password": "Неверный пароль.",
"txt_server_error_invalid_refresh_token": "Сеанс истек. Войдите снова.",
"txt_server_error_invalid_user_verification_token": "Недействительный токен проверки пользователя.",
"txt_server_error_invalid_request_payload": "Недопустимый запрос",
"txt_server_error_invite_invalid_or_expired": "Код приглашения недействителен или истек",
"txt_server_error_invite_required": "Требуется код приглашения",
"txt_server_error_jwt_secret_default": "JWT_SECRET использует значение по умолчанию/пример. Измените его.",
"txt_server_error_jwt_secret_missing": "JWT_SECRET не настроен",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET должен содержать не менее 32 символов",
"txt_server_error_master_password_hash_required": "Требуется проверка мастер-пароля.",
"txt_server_error_master_password_or_verification_required": "Требуется мастер-пароль или токен проверки пользователя.",
"txt_server_error_parameter_error": "Ошибка параметров",
"txt_server_error_refresh_token_required": "Сеанс отсутствует. Войдите снова.",
"txt_server_error_registration_retry": "Регистрация временно недоступна. Повторите попытку один раз.",
@@ -763,6 +847,11 @@ const ru: Record<string, string> = {
"txt_remove_all_devices": "Удалить все устройства",
"txt_remove_all_devices_and_clear_all_2fa_trust": "Удалить все устройства и очистить все доверие 2FA?",
"txt_remove_all_devices_and_sign_out_all_sessions": "Удалить все устройства, отменить все доверительные отношения и выйти из системы на каждом устройстве?",
"txt_remove_selected_devices": "Удалить выбранные ({count})",
"txt_remove_selected_devices_confirm": "Удалить {count} выбранных устройств, очистить их доверие и выйти из системы на них?",
"txt_remove_selected_devices_and_sign_out_current": "Удалить {count} выбранных устройств, очистить их доверие и также выйти из системы на этом устройстве?",
"txt_selected_devices_removed": "Выбранные устройства удалены",
"txt_remove_selected_devices_failed": "Не удалось удалить выбранные устройства",
"txt_remove_device_name_and_clear_its_2fa_trust": "Удалить устройство «{name}» и очистить его доверие 2FA?",
"txt_remove_device_and_sign_out_name": "Удалить устройство «{name}», очистить его доверие и выйти из системы?",
"txt_reveal": "Раскрыть",
@@ -804,6 +893,9 @@ const ru: Record<string, string> = {
"txt_security_code": "Код безопасности",
"txt_security_code_cvv": "Код безопасности (CVV)",
"txt_select_all": "Выбрать все",
"txt_clear_selection": "Очистить выбор",
"txt_select_device_name": "Выбрать {name}",
"txt_no_devices_selected": "Устройства не выбраны",
"txt_select": "Выбрать",
"txt_select_duplicate_items": "Выберите дубликаты",
"txt_select_an_item": "Выберите элемент",
@@ -1073,7 +1165,9 @@ const ru: Record<string, string> = {
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
"txt_log_action_admin_backup_settings_update": "Update backup settings",
"txt_log_action_admin_invite_create": "Create invite",
"txt_log_action_admin_invite_delete": "Delete invite",
"txt_log_action_admin_invite_delete_all": "Clear invites",
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites",
"txt_log_action_admin_invite_revoke": "Revoke invite",
"txt_log_action_admin_user_delete": "Delete user",
"txt_log_action_admin_user_status": "Change user status",
+94
View File
@@ -224,6 +224,8 @@ const zhCN: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。",
"txt_backup_remote_loading": "正在读取远端备份...",
"txt_backup_remote_cached_empty": "点击“刷新”后读取",
"txt_backup_remote_cached_empty_prefix": "点击",
"txt_backup_remote_cached_empty_suffix": "后读取",
"txt_backup_remote_empty": "这个目录下还没有备份文件",
"txt_backup_remote_folder": "文件夹",
"txt_backup_remote_unknown_time": "未知时间",
@@ -247,6 +249,74 @@ const zhCN: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "远端备份执行响应无效",
"txt_backup_settings_invalid_response": "备份设置响应无效",
"txt_backup_import_invalid_response": "备份还原响应无效",
"txt_backup_error_another_backup_or_restore_running": "已有备份或还原任务正在执行。",
"txt_backup_error_another_backup_running": "已有备份任务正在执行。",
"txt_backup_error_archive_upload_failed": "备份压缩包上传失败。",
"txt_backup_error_archive_upload_verification_failed_attempts": "备份上传校验在 {count} 次尝试后仍失败:{reason}",
"txt_backup_error_attachment_blob_invalid": "备份附件对象无效。",
"txt_backup_error_attachment_blob_required": "缺少备份附件对象。",
"txt_backup_error_attachment_blob_not_found": "未找到备份附件对象。",
"txt_backup_error_attachment_download_failed": "备份附件下载失败。",
"txt_backup_error_destination_invalid": "备份地点无效。",
"txt_backup_error_destination_limit": "最多只能保存 {count} 个备份地点。",
"txt_backup_error_destination_not_found": "未找到备份地点。",
"txt_backup_error_destination_ids_unique": "备份地点 ID 不能重复。",
"txt_backup_error_destination_type_invalid": "备份地点类型无效。",
"txt_backup_error_destination_type_unsupported": "不支持的备份地点类型。",
"txt_backup_error_destinations_invalid": "备份地点列表无效。",
"txt_backup_error_export_payload_invalid": "备份导出请求无效。",
"txt_backup_error_file_checksum_mismatch": "备份文件校验值与文件名不一致。",
"txt_backup_error_file_required": "请选择备份文件。",
"txt_backup_error_interval_hours_range": "备份间隔必须在 1 到 99 小时之间。",
"txt_backup_error_multipart_required": "上传请求必须使用 multipart/form-data。",
"txt_backup_error_read_backup_file_failed": "无法读取备份文件。",
"txt_backup_error_remote_attachment_batch_download_failed_status": "远端附件批量下载失败:HTTP {status}。",
"txt_backup_error_remote_attachment_download_failed_status": "远端附件下载失败:HTTP {status}。",
"txt_backup_error_remote_delete_failed": "远端备份删除失败。",
"txt_backup_error_remote_download_failed": "远端备份下载失败。",
"txt_backup_error_remote_download_payload_invalid": "远端备份下载请求无效。",
"txt_backup_error_remote_integrity_failed": "远端备份完整性检查失败。",
"txt_backup_error_remote_listing_failed": "远端备份列表读取失败。",
"txt_backup_error_remote_path_invalid": "远端备份路径无效。",
"txt_backup_error_remote_restore_payload_invalid": "远端还原请求无效。",
"txt_backup_error_remote_zip_checksum_failed": "远端备份 ZIP 校验失败。",
"txt_backup_error_remote_zip_size_failed": "远端备份 ZIP 大小校验失败。",
"txt_backup_error_retention_count_range": "备份保留数量必须在 1 到 1000 之间。",
"txt_backup_error_run_failed": "备份执行失败。",
"txt_backup_error_run_payload_invalid": "备份执行请求无效。",
"txt_backup_error_run_response_invalid": "备份执行响应无效。",
"txt_backup_error_s3_access_key_required": "请填写 S3 访问 ID。",
"txt_backup_error_s3_bucket_required": "请填写 S3 存储桶名称。",
"txt_backup_error_s3_delete_failed_status": "S3 删除失败:HTTP {status}。",
"txt_backup_error_s3_download_failed_status": "S3 下载失败:HTTP {status}。",
"txt_backup_error_s3_endpoint_required": "请填写 S3 端点 URL。",
"txt_backup_error_s3_endpoint_protocol": "S3 端点 URL 必须以 http:// 或 https:// 开头。",
"txt_backup_error_s3_existence_check_failed_status": "S3 文件存在性检查失败:HTTP {status}。",
"txt_backup_error_s3_listing_failed_status": "S3 列表读取失败:HTTP {status}。",
"txt_backup_error_s3_secret_key_required": "请填写 S3 访问密码。",
"txt_backup_error_s3_upload_failed_status": "S3 上传失败:HTTP {status}。",
"txt_backup_error_select_backup_file": "请选择备份文件。",
"txt_backup_error_select_backup_zip_file": "请选择备份 ZIP 文件。",
"txt_backup_error_settings_envelope_invalid": "备份设置加密封装无效。",
"txt_backup_error_settings_invalid": "备份设置无效。",
"txt_backup_error_settings_load_failed": "无法加载备份设置。",
"txt_backup_error_settings_need_reactivation": "还原后需要管理员重新激活备份设置。",
"txt_backup_error_settings_payload_invalid": "备份设置请求无效。",
"txt_backup_error_settings_repair_payload_invalid": "备份设置修复请求无效。",
"txt_backup_error_settings_repair_state_load_failed": "无法加载备份设置修复状态。",
"txt_backup_error_start_time_format": "备份开始时间必须是 HH:mm 格式。",
"txt_backup_error_timezone_invalid": "备份时区无效。",
"txt_backup_error_webdav_delete_failed_status": "WebDAV 删除失败:HTTP {status}。",
"txt_backup_error_webdav_directory_creation_failed_status": "WebDAV 目录创建失败:HTTP {status}。",
"txt_backup_error_webdav_download_failed_status": "WebDAV 下载失败:HTTP {status}。",
"txt_backup_error_webdav_existence_check_failed_status": "WebDAV 文件存在性检查失败:HTTP {status}。",
"txt_backup_error_webdav_listing_failed_status": "WebDAV 列表读取失败:HTTP {status}。",
"txt_backup_error_webdav_password_required": "请填写 WebDAV 密码。",
"txt_backup_error_webdav_path_too_deep": "WebDAV 远端备份路径过深,无法安全分批处理附件。",
"txt_backup_error_webdav_upload_failed_status": "WebDAV 上传失败:HTTP {status}。",
"txt_backup_error_webdav_url_required": "请填写 WebDAV 服务地址。",
"txt_backup_error_webdav_url_protocol": "WebDAV 服务地址必须以 http:// 或 https:// 开头。",
"txt_backup_error_webdav_username_required": "请填写 WebDAV 用户名。",
"txt_backup_destination": "备份地点",
"txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3",
@@ -512,8 +582,17 @@ const zhCN: Record<string, string> = {
"txt_identity_details": "身份详情",
"txt_ie_browser": "IE 浏览器",
"txt_create_invite_failed": "创建邀请码失败",
"txt_delete_invalid": "删除无效",
"txt_delete_invalid_invites": "删除无效邀请码",
"txt_delete_invalid_invites_confirm_message": "确定删除所有无效邀请码吗?仍有效且未过期的邀请码会保留。",
"txt_delete_invalid_invites_failed": "删除无效邀请码失败",
"txt_delete_invite": "删除邀请码",
"txt_delete_invite_confirm_message": "确定删除该邀请码吗?删除后无法恢复。",
"txt_delete_invite_failed": "删除邀请码失败",
"txt_invite_code_required": "邀请码(必填)",
"txt_invite_created": "邀请码已创建",
"txt_invite_deleted": "邀请码已删除",
"txt_invalid_invites_deleted": "无效邀请码已删除",
"txt_invite_revoked": "邀请码已撤销",
"txt_revoke_invite_failed": "撤销邀请码失败",
"txt_invite_validity_hours": "邀请码有效期(小时)",
@@ -522,16 +601,21 @@ const zhCN: Record<string, string> = {
"txt_server_error_account_disabled": "账号已被禁用",
"txt_server_error_client_credentials_incorrect": "客户端 ID 或客户端密钥不正确,请重试",
"txt_server_error_client_ip_required": "无法获取客户端 IP",
"txt_server_error_forbidden": "你没有权限执行此操作。",
"txt_server_error_email_already_registered": "该邮箱已注册",
"txt_server_error_email_password_required": "邮箱和密码不能为空",
"txt_server_error_email_required": "邮箱不能为空",
"txt_server_error_invalid_password": "密码无效。",
"txt_server_error_invalid_refresh_token": "登录状态已失效,请重新登录",
"txt_server_error_invalid_user_verification_token": "用户验证令牌无效。",
"txt_server_error_invalid_request_payload": "请求内容无效",
"txt_server_error_invite_invalid_or_expired": "邀请码无效或已过期",
"txt_server_error_invite_required": "邀请码不能为空",
"txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默认示例值,请修改后再继续",
"txt_server_error_jwt_secret_missing": "JWT_SECRET 未设置",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 个字符",
"txt_server_error_master_password_hash_required": "需要验证主密码。",
"txt_server_error_master_password_or_verification_required": "需要主密码或用户验证令牌。",
"txt_server_error_parameter_error": "请求参数错误",
"txt_server_error_refresh_token_required": "登录状态缺失,请重新登录",
"txt_server_error_registration_retry": "注册暂时不可用,请重试一次",
@@ -763,6 +847,11 @@ const zhCN: Record<string, string> = {
"txt_remove_all_devices": "移除所有设备",
"txt_remove_all_devices_and_clear_all_2fa_trust": "确认移除所有设备并清除全部 2FA 信任吗?",
"txt_remove_all_devices_and_sign_out_all_sessions": "确认移除所有设备、清除全部信任,并让所有设备重新登录吗?",
"txt_remove_selected_devices": "移除已选({count}",
"txt_remove_selected_devices_confirm": "确认移除选中的 {count} 台设备、清除其信任,并让它们重新登录吗?",
"txt_remove_selected_devices_and_sign_out_current": "确认移除选中的 {count} 台设备、清除其信任,并同时退出本设备吗?",
"txt_selected_devices_removed": "已移除选中设备",
"txt_remove_selected_devices_failed": "移除选中设备失败",
"txt_remove_device_name_and_clear_its_2fa_trust": "确认移除设备“{name}”并清除其 2FA 信任吗?",
"txt_remove_device_and_sign_out_name": "确认移除设备“{name}”,清除其信任,并让它重新登录吗?",
"txt_reveal": "显示",
@@ -804,6 +893,9 @@ const zhCN: Record<string, string> = {
"txt_security_code": "安全码",
"txt_security_code_cvv": "安全码 (CVV)",
"txt_select_all": "全选",
"txt_clear_selection": "取消选择",
"txt_select_device_name": "选择 {name}",
"txt_no_devices_selected": "未选择设备",
"txt_select": "请选择",
"txt_select_duplicate_items": "选择重复项",
"txt_select_an_item": "请选择一个项目",
@@ -1073,7 +1165,9 @@ const zhCN: Record<string, string> = {
"txt_log_action_admin_backup_settings_repair": "修复备份设置",
"txt_log_action_admin_backup_settings_update": "更新备份设置",
"txt_log_action_admin_invite_create": "创建邀请",
"txt_log_action_admin_invite_delete": "删除邀请",
"txt_log_action_admin_invite_delete_all": "清空邀请",
"txt_log_action_admin_invite_delete_invalid": "删除无效邀请",
"txt_log_action_admin_invite_revoke": "撤销邀请",
"txt_log_action_admin_user_delete": "删除用户",
"txt_log_action_admin_user_status": "修改用户状态",
+94
View File
@@ -224,6 +224,8 @@ const zhTW: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "服務器正在執行最終校驗,校驗通過後會把已驗證的數據切換為正式數據。",
"txt_backup_remote_loading": "正在讀取遠端備份...",
"txt_backup_remote_cached_empty": "點擊“刷新”後讀取",
"txt_backup_remote_cached_empty_prefix": "點擊",
"txt_backup_remote_cached_empty_suffix": "後讀取",
"txt_backup_remote_empty": "這個目錄下還沒有備份文件",
"txt_backup_remote_folder": "文件夾",
"txt_backup_remote_unknown_time": "未知時間",
@@ -247,6 +249,74 @@ const zhTW: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "遠端備份執行響應無效",
"txt_backup_settings_invalid_response": "備份設置響應無效",
"txt_backup_import_invalid_response": "備份還原響應無效",
"txt_backup_error_another_backup_or_restore_running": "已有備份或還原任務正在執行。",
"txt_backup_error_another_backup_running": "已有備份任務正在執行。",
"txt_backup_error_archive_upload_failed": "備份壓縮包上傳失敗。",
"txt_backup_error_archive_upload_verification_failed_attempts": "備份上傳校驗在 {count} 次嘗試後仍失敗:{reason}",
"txt_backup_error_attachment_blob_invalid": "備份附件對象無效。",
"txt_backup_error_attachment_blob_required": "缺少備份附件對象。",
"txt_backup_error_attachment_blob_not_found": "未找到備份附件對象。",
"txt_backup_error_attachment_download_failed": "備份附件下載失敗。",
"txt_backup_error_destination_invalid": "備份地點無效。",
"txt_backup_error_destination_limit": "最多只能保存 {count} 個備份地點。",
"txt_backup_error_destination_not_found": "未找到備份地點。",
"txt_backup_error_destination_ids_unique": "備份地點 ID 不能重複。",
"txt_backup_error_destination_type_invalid": "備份地點類型無效。",
"txt_backup_error_destination_type_unsupported": "不支持的備份地點類型。",
"txt_backup_error_destinations_invalid": "備份地點列表無效。",
"txt_backup_error_export_payload_invalid": "備份導出請求無效。",
"txt_backup_error_file_checksum_mismatch": "備份文件校驗值與文件名不一致。",
"txt_backup_error_file_required": "請選擇備份文件。",
"txt_backup_error_interval_hours_range": "備份間隔必須在 1 到 99 小時之間。",
"txt_backup_error_multipart_required": "上傳請求必須使用 multipart/form-data。",
"txt_backup_error_read_backup_file_failed": "無法讀取備份文件。",
"txt_backup_error_remote_attachment_batch_download_failed_status": "遠端附件批量下載失敗:HTTP {status}。",
"txt_backup_error_remote_attachment_download_failed_status": "遠端附件下載失敗:HTTP {status}。",
"txt_backup_error_remote_delete_failed": "遠端備份刪除失敗。",
"txt_backup_error_remote_download_failed": "遠端備份下載失敗。",
"txt_backup_error_remote_download_payload_invalid": "遠端備份下載請求無效。",
"txt_backup_error_remote_integrity_failed": "遠端備份完整性檢查失敗。",
"txt_backup_error_remote_listing_failed": "遠端備份列表讀取失敗。",
"txt_backup_error_remote_path_invalid": "遠端備份路徑無效。",
"txt_backup_error_remote_restore_payload_invalid": "遠端還原請求無效。",
"txt_backup_error_remote_zip_checksum_failed": "遠端備份 ZIP 校驗失敗。",
"txt_backup_error_remote_zip_size_failed": "遠端備份 ZIP 大小校驗失敗。",
"txt_backup_error_retention_count_range": "備份保留數量必須在 1 到 1000 之間。",
"txt_backup_error_run_failed": "備份執行失敗。",
"txt_backup_error_run_payload_invalid": "備份執行請求無效。",
"txt_backup_error_run_response_invalid": "備份執行響應無效。",
"txt_backup_error_s3_access_key_required": "請填寫 S3 存取 ID。",
"txt_backup_error_s3_bucket_required": "請填寫 S3 儲存桶名稱。",
"txt_backup_error_s3_delete_failed_status": "S3 刪除失敗:HTTP {status}。",
"txt_backup_error_s3_download_failed_status": "S3 下載失敗:HTTP {status}。",
"txt_backup_error_s3_endpoint_required": "請填寫 S3 端點 URL。",
"txt_backup_error_s3_endpoint_protocol": "S3 端點 URL 必須以 http:// 或 https:// 開頭。",
"txt_backup_error_s3_existence_check_failed_status": "S3 文件存在性檢查失敗:HTTP {status}。",
"txt_backup_error_s3_listing_failed_status": "S3 列表讀取失敗:HTTP {status}。",
"txt_backup_error_s3_secret_key_required": "請填寫 S3 存取密碼。",
"txt_backup_error_s3_upload_failed_status": "S3 上傳失敗:HTTP {status}。",
"txt_backup_error_select_backup_file": "請選擇備份文件。",
"txt_backup_error_select_backup_zip_file": "請選擇備份 ZIP 文件。",
"txt_backup_error_settings_envelope_invalid": "備份設置加密封裝無效。",
"txt_backup_error_settings_invalid": "備份設置無效。",
"txt_backup_error_settings_load_failed": "無法加載備份設置。",
"txt_backup_error_settings_need_reactivation": "還原後需要管理員重新激活備份設置。",
"txt_backup_error_settings_payload_invalid": "備份設置請求無效。",
"txt_backup_error_settings_repair_payload_invalid": "備份設置修復請求無效。",
"txt_backup_error_settings_repair_state_load_failed": "無法加載備份設置修復狀態。",
"txt_backup_error_start_time_format": "備份開始時間必須是 HH:mm 格式。",
"txt_backup_error_timezone_invalid": "備份時區無效。",
"txt_backup_error_webdav_delete_failed_status": "WebDAV 刪除失敗:HTTP {status}。",
"txt_backup_error_webdav_directory_creation_failed_status": "WebDAV 目錄創建失敗:HTTP {status}。",
"txt_backup_error_webdav_download_failed_status": "WebDAV 下載失敗:HTTP {status}。",
"txt_backup_error_webdav_existence_check_failed_status": "WebDAV 文件存在性檢查失敗:HTTP {status}。",
"txt_backup_error_webdav_listing_failed_status": "WebDAV 列表讀取失敗:HTTP {status}。",
"txt_backup_error_webdav_password_required": "請填寫 WebDAV 密碼。",
"txt_backup_error_webdav_path_too_deep": "WebDAV 遠端備份路徑過深,無法安全分批處理附件。",
"txt_backup_error_webdav_upload_failed_status": "WebDAV 上傳失敗:HTTP {status}。",
"txt_backup_error_webdav_url_required": "請填寫 WebDAV 服務地址。",
"txt_backup_error_webdav_url_protocol": "WebDAV 服務地址必須以 http:// 或 https:// 開頭。",
"txt_backup_error_webdav_username_required": "請填寫 WebDAV 用戶名。",
"txt_backup_destination": "備份地點",
"txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3",
@@ -512,8 +582,17 @@ const zhTW: Record<string, string> = {
"txt_identity_details": "身份詳情",
"txt_ie_browser": "IE 瀏覽器",
"txt_create_invite_failed": "創建邀請碼失敗",
"txt_delete_invalid": "刪除無效",
"txt_delete_invalid_invites": "刪除無效邀請碼",
"txt_delete_invalid_invites_confirm_message": "確定刪除所有無效邀請碼嗎?仍有效且未過期的邀請碼會保留。",
"txt_delete_invalid_invites_failed": "刪除無效邀請碼失敗",
"txt_delete_invite": "刪除邀請碼",
"txt_delete_invite_confirm_message": "確定刪除此邀請碼嗎?刪除後無法復原。",
"txt_delete_invite_failed": "刪除邀請碼失敗",
"txt_invite_code_required": "邀請碼(必填)",
"txt_invite_created": "邀請碼已創建",
"txt_invite_deleted": "邀請碼已刪除",
"txt_invalid_invites_deleted": "無效邀請碼已刪除",
"txt_invite_revoked": "邀請碼已撤銷",
"txt_revoke_invite_failed": "撤銷邀請碼失敗",
"txt_invite_validity_hours": "邀請碼有效期(小時)",
@@ -522,16 +601,21 @@ const zhTW: Record<string, string> = {
"txt_server_error_account_disabled": "帳號已被禁用",
"txt_server_error_client_credentials_incorrect": "客戶端 ID 或客戶端密鑰不正確,請重試",
"txt_server_error_client_ip_required": "無法獲取客戶端 IP",
"txt_server_error_forbidden": "你沒有權限執行此操作。",
"txt_server_error_email_already_registered": "該郵箱已註冊",
"txt_server_error_email_password_required": "郵箱和密碼不能為空",
"txt_server_error_email_required": "郵箱不能為空",
"txt_server_error_invalid_password": "密碼無效。",
"txt_server_error_invalid_refresh_token": "登入狀態已失效,請重新登入",
"txt_server_error_invalid_user_verification_token": "用戶驗證令牌無效。",
"txt_server_error_invalid_request_payload": "請求內容無效",
"txt_server_error_invite_invalid_or_expired": "邀請碼無效或已過期",
"txt_server_error_invite_required": "邀請碼不能為空",
"txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默認示例值,請修改後再繼續",
"txt_server_error_jwt_secret_missing": "JWT_SECRET 未設置",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 個字符",
"txt_server_error_master_password_hash_required": "需要驗證主密碼。",
"txt_server_error_master_password_or_verification_required": "需要主密碼或用戶驗證令牌。",
"txt_server_error_parameter_error": "請求參數錯誤",
"txt_server_error_refresh_token_required": "登入狀態缺失,請重新登入",
"txt_server_error_registration_retry": "註冊暫時不可用,請重試一次",
@@ -763,6 +847,11 @@ const zhTW: Record<string, string> = {
"txt_remove_all_devices": "移除所有設備",
"txt_remove_all_devices_and_clear_all_2fa_trust": "確認移除所有設備並清除全部 2FA 信任嗎?",
"txt_remove_all_devices_and_sign_out_all_sessions": "確認移除所有設備、清除全部信任,並讓所有設備重新登錄嗎?",
"txt_remove_selected_devices": "移除已選({count}",
"txt_remove_selected_devices_confirm": "確認移除選中的 {count} 臺設備、清除其信任,並讓它們重新登錄嗎?",
"txt_remove_selected_devices_and_sign_out_current": "確認移除選中的 {count} 臺設備、清除其信任,並同時退出本設備嗎?",
"txt_selected_devices_removed": "已移除選中設備",
"txt_remove_selected_devices_failed": "移除選中設備失敗",
"txt_remove_device_name_and_clear_its_2fa_trust": "確認移除設備“{name}”並清除其 2FA 信任嗎?",
"txt_remove_device_and_sign_out_name": "確認移除設備“{name}”,清除其信任,並讓它重新登錄嗎?",
"txt_reveal": "顯示",
@@ -804,6 +893,9 @@ const zhTW: Record<string, string> = {
"txt_security_code": "安全碼",
"txt_security_code_cvv": "安全碼 (CVV)",
"txt_select_all": "全選",
"txt_clear_selection": "取消選擇",
"txt_select_device_name": "選擇 {name}",
"txt_no_devices_selected": "未選擇設備",
"txt_select": "請選擇",
"txt_select_duplicate_items": "選擇重複項",
"txt_select_an_item": "請選擇一個項目",
@@ -1073,7 +1165,9 @@ const zhTW: Record<string, string> = {
"txt_log_action_admin_backup_settings_repair": "修復備份設定",
"txt_log_action_admin_backup_settings_update": "更新備份設定",
"txt_log_action_admin_invite_create": "建立邀請",
"txt_log_action_admin_invite_delete": "刪除邀請",
"txt_log_action_admin_invite_delete_all": "清空邀請",
"txt_log_action_admin_invite_delete_invalid": "刪除無效邀請",
"txt_log_action_admin_invite_revoke": "撤銷邀請",
"txt_log_action_admin_user_delete": "刪除使用者",
"txt_log_action_admin_user_status": "修改使用者狀態",
+135 -13
View File
@@ -1,6 +1,114 @@
import type { CiphersImportPayload } from '@/lib/api/vault';
import { addFolder, cardBrand, makeLoginCipher, nameFromUrl, normalizeUri, parseCsv, parseSerializedUris, processKvp, txt, val } from '@/lib/import-format-shared';
type BitwardenCsvFieldLine = {
key: string;
value: string;
};
const NODEWARDEN_CSV_TYPE_FIELD = 'nodewardenType';
const NODEWARDEN_CSV_PREFIX_TYPES: Record<string, number> = {
card: 3,
identity: 4,
sshkey: 5,
};
const NODEWARDEN_CSV_TYPE_PREFIXES: Record<number, 'card' | 'identity' | 'sshKey'> = {
3: 'card',
4: 'identity',
5: 'sshKey',
};
const NODEWARDEN_CSV_OBJECT_FIELDS: Record<'card' | 'identity' | 'sshKey', readonly string[]> = {
card: ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code'],
identity: [
'title',
'firstName',
'middleName',
'lastName',
'username',
'company',
'ssn',
'passportNumber',
'licenseNumber',
'email',
'phone',
'address1',
'address2',
'address3',
'city',
'state',
'postalCode',
'country',
],
sshKey: ['privateKey', 'publicKey', 'keyFingerprint', 'fingerprint'],
};
// Parse the `fields` CSV column into key-value pairs.
// Lines without a `: ` delimiter are treated as continuations of the previous
// line's value, preserving multiline content such as SSH private keys.
function parseBitwardenCsvFieldLines(rawFields: unknown): BitwardenCsvFieldLine[] {
return String(rawFields || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.reduce<BitwardenCsvFieldLine[]>((acc, line) => {
const delim = line.lastIndexOf(': ');
if (delim < 0) {
// Continuation line — append to the previous entry's value.
if (acc.length > 0) {
acc[acc.length - 1].value += '\n' + line;
}
return acc;
}
// New key-value line.
const key = txt(line.slice(0, delim));
const value = txt(line.slice(delim + 2));
if (key && value) {
acc.push({ key, value });
}
return acc;
}, []);
}
function getNodeWardenCsvType(lines: BitwardenCsvFieldLine[]): number | null {
const typeLine = lines.find((line) => line.key === NODEWARDEN_CSV_TYPE_FIELD);
const normalized = txt(typeLine?.value).toLowerCase().replace(/[\s_-]+/g, '');
const type = NODEWARDEN_CSV_PREFIX_TYPES[normalized] ?? null;
if (!type) return null;
const prefix = NODEWARDEN_CSV_TYPE_PREFIXES[type];
return lines.some((line) => line.key.startsWith(`${prefix}.`)) ? type : null;
}
function applyBitwardenCustomFields(cipher: Record<string, unknown>, lines: BitwardenCsvFieldLine[]): void {
for (const line of lines) {
processKvp(cipher, line.key, line.value, false);
}
}
function restoreNodeWardenObject(lines: BitwardenCsvFieldLine[], prefix: 'card' | 'identity' | 'sshKey'): Record<string, unknown> {
const out: Record<string, unknown> = {};
const fieldPrefix = `${prefix}.`;
const allowedKeys = new Set(NODEWARDEN_CSV_OBJECT_FIELDS[prefix]);
for (const line of lines) {
if (!line.key.startsWith(fieldPrefix)) continue;
const key = line.key.slice(fieldPrefix.length);
if (!allowedKeys.has(key)) continue;
out[key] = line.value;
}
return out;
}
function nodeWardenMetadataLines(lines: BitwardenCsvFieldLine[]): Set<BitwardenCsvFieldLine> {
return new Set(
lines.filter(
(line) =>
line.key === NODEWARDEN_CSV_TYPE_FIELD ||
line.key.startsWith('card.') ||
line.key.startsWith('identity.') ||
line.key.startsWith('sshKey.')
)
);
}
export function parseChromeCsv(textRaw: string): CiphersImportPayload {
const rows = parseCsv(textRaw);
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
@@ -62,19 +170,33 @@ export function parseSafariCsv(textRaw: string): CiphersImportPayload {
export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
const rows = parseCsv(textRaw);
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
const applyBitwardenCustomFields = (cipher: Record<string, unknown>, rawFields: unknown) => {
const lines = String(rawFields || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const delim = line.lastIndexOf(': ');
if (delim < 0) continue;
processKvp(cipher, line.slice(0, delim), line.slice(delim + 2), false);
}
};
for (const row of rows) {
const type = txt(row.type).toLowerCase() || 'login';
const fieldLines = parseBitwardenCsvFieldLines(row.fields);
const restoredNodeWardenType = type === 'note' ? getNodeWardenCsvType(fieldLines) : null;
if (restoredNodeWardenType === 3 || restoredNodeWardenType === 4 || restoredNodeWardenType === 5) {
const metadataLines = nodeWardenMetadataLines(fieldLines);
const customLines = fieldLines.filter((line) => !metadataLines.has(line));
const cipher: Record<string, unknown> = {
type: restoredNodeWardenType,
name: val(row.name, '--'),
notes: val(row.notes),
favorite: txt(row.favorite) === '1',
reprompt: Number(row.reprompt ?? 0) || 0,
key: null,
login: null,
card: restoredNodeWardenType === 3 ? restoreNodeWardenObject(fieldLines, 'card') : null,
identity: restoredNodeWardenType === 4 ? restoreNodeWardenObject(fieldLines, 'identity') : null,
secureNote: null,
fields: [],
passwordHistory: null,
sshKey: restoredNodeWardenType === 5 ? restoreNodeWardenObject(fieldLines, 'sshKey') : null,
};
applyBitwardenCustomFields(cipher, customLines);
const idx = result.ciphers.push(cipher) - 1;
addFolder(result, row.folder, idx);
continue;
}
if (type === 'note' || type === 'secure note' || type === 'securenote') {
const cipher = {
type: 2,
@@ -91,7 +213,7 @@ export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
passwordHistory: null,
sshKey: null,
};
applyBitwardenCustomFields(cipher, row.fields);
applyBitwardenCustomFields(cipher, fieldLines);
const idx = result.ciphers.push(cipher) - 1;
addFolder(result, row.folder, idx);
continue;
@@ -101,7 +223,7 @@ export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
cipher.notes = val(row.notes);
cipher.favorite = txt(row.favorite) === '1';
cipher.reprompt = Number(row.reprompt ?? 0) || 0;
applyBitwardenCustomFields(cipher, row.fields);
applyBitwardenCustomFields(cipher, fieldLines);
const login = cipher.login as Record<string, unknown>;
login.username = val(row.login_username, val(row.username));
login.password = val(row.login_password, val(row.password));
+4
View File
@@ -172,6 +172,10 @@ input[type='file'].input::file-selector-button:hover {
@apply shrink-0;
}
.btn-icon-spin {
animation: spin 0.9s linear infinite;
}
.btn.full {
@apply my-2.5 h-12 w-full;
font-size: var(--font-md);
+15 -1
View File
@@ -443,6 +443,10 @@
color: #64748b;
}
.backup-browser-refresh-prompt {
@apply inline-flex flex-wrap items-center justify-center gap-2;
}
.backup-inline-note {
@apply m-0 mb-3 leading-[1.5];
color: #64748b;
@@ -1516,8 +1520,12 @@
table-layout: fixed;
}
.authorized-devices-col-select {
width: 4%;
}
.authorized-devices-col-device {
width: 28%;
width: 26%;
}
.authorized-devices-col-type {
@@ -1540,6 +1548,12 @@
width: 26%;
}
.authorized-device-checkbox {
width: 16px;
height: 16px;
accent-color: #2563eb;
}
.authorized-devices-table td:first-child {
overflow-wrap: anywhere;
}