mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 14:50:11 +00:00
Compare commits
62
Commits
v1.7.0
...
94b5f3e975
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94b5f3e975 | ||
|
|
062c966e14 | ||
|
|
e73ae3d5ea | ||
|
|
c019c93726 | ||
|
|
f63b745d05 | ||
|
|
c7eb6c663d | ||
|
|
1ec6ed44a1 | ||
|
|
6284c632de | ||
|
|
60dd298dee | ||
|
|
439683d350 | ||
|
|
1545881eae | ||
|
|
680e287c8d | ||
|
|
baf569983d | ||
|
|
73bbe8b268 | ||
|
|
d024798548 | ||
|
|
b0a679b1c2 | ||
|
|
ce3674669e | ||
|
|
aa7b87e041 | ||
|
|
e4215b4025 | ||
|
|
8d292ca7b8 | ||
|
|
5dd9dff045 | ||
|
|
709a8c1768 | ||
|
|
e2c3516ce9 | ||
|
|
55b5c57f9e | ||
|
|
b6fb62603b | ||
|
|
35071c2719 | ||
|
|
5bd7dab277 | ||
|
|
99f2d7f444 | ||
|
|
fb9a2aeda1 | ||
|
|
c87e6ac984 | ||
|
|
a6f1c6dea2 | ||
|
|
49c872a8ec | ||
|
|
78af1f9bdd | ||
|
|
32b3d2ade1 | ||
|
|
64f26e76f6 | ||
|
|
68c42a0330 | ||
|
|
0d1bb196e2 | ||
|
|
e31f82c0d6 | ||
|
|
f82dcc3c17 | ||
|
|
4378e1b430 | ||
|
|
5eeaf4e32e | ||
|
|
82f968e51f | ||
|
|
a5ad16ac27 | ||
|
|
6a1a8357bf | ||
|
|
31cfd19b6b | ||
|
|
4cd9ad00d2 | ||
|
|
31dcc76ee2 | ||
|
|
bf6ac7b405 | ||
|
|
1bfb9a647d | ||
|
|
e9272ec29a | ||
|
|
8942e5bd49 | ||
|
|
d722815999 | ||
|
|
ff85698edb | ||
|
|
c3dc53bac1 | ||
|
|
1acc31eda0 | ||
|
|
c694f1bfce | ||
|
|
bf51309fbb | ||
|
|
23b23f39b9 | ||
|
|
0daad46591 | ||
|
|
a2a8f1c7b6 | ||
|
|
850fe0f044 | ||
|
|
7279668955 |
@@ -1,5 +0,0 @@
|
||||
# JWT Secret for signing tokens (required)
|
||||
# IMPORTANT: change this value before any real deployment.
|
||||
# Generate one with: openssl rand -hex 32
|
||||
# (Example only, 64 hex chars = 32 bytes)
|
||||
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
|
||||
@@ -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.
|
||||
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
|
||||
|
||||
@@ -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:
|
||||
- "*"
|
||||
@@ -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) => `}-${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 '';
|
||||
if (status === 'WARN' || status === 'INFO') return '';
|
||||
return '';
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -19,20 +19,29 @@ 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
|
||||
|
||||
- name: Sync generated Bitwarden domains
|
||||
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}"
|
||||
env:
|
||||
BITWARDEN_REF: ${{ inputs.bitwarden_ref || 'main' }}
|
||||
run: |
|
||||
case "$BITWARDEN_REF" in
|
||||
"" | *[!A-Za-z0-9._/-]* )
|
||||
echo "Invalid bitwarden_ref"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
npm run domains:sync -- --ref "$BITWARDEN_REF"
|
||||
|
||||
- name: Verify custom domains were not touched
|
||||
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
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
name: Sync upstream
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_commit:
|
||||
description: 'Commit hash (leave blank to use latest commit)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Add upstream
|
||||
run: |
|
||||
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
||||
git fetch upstream --tags
|
||||
|
||||
- name: Resolve target commit
|
||||
id: resolve
|
||||
run: |
|
||||
TRIGGER="${{ github.event_name }}"
|
||||
MANUAL_INPUT="${{ github.event.inputs.target_commit }}"
|
||||
|
||||
if [ "$TRIGGER" = "schedule" ]; then
|
||||
# Auto mode: resolve latest upstream release tag
|
||||
LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name)
|
||||
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
|
||||
echo "No release found in upstream."
|
||||
exit 1
|
||||
fi
|
||||
TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null)
|
||||
if [ -z "$TARGET_SHA" ]; then
|
||||
echo "Tag '$LATEST_TAG' not found after fetch."
|
||||
exit 1
|
||||
fi
|
||||
echo "mode=auto" >> $GITHUB_OUTPUT
|
||||
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
||||
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
|
||||
|
||||
elif [ -n "$MANUAL_INPUT" ]; then
|
||||
# Manual mode: use provided commit hash or tag
|
||||
TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null)
|
||||
if [ -z "$TARGET_SHA" ]; then
|
||||
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
|
||||
exit 1
|
||||
fi
|
||||
echo "mode=manual" >> $GITHUB_OUTPUT
|
||||
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
||||
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
|
||||
|
||||
else
|
||||
# Manual mode, blank input: use latest commit on upstream/main
|
||||
TARGET_SHA=$(git rev-parse upstream/main)
|
||||
echo "mode=manual" >> $GITHUB_OUTPUT
|
||||
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
|
||||
echo "Manual mode — latest commit: $TARGET_SHA"
|
||||
fi
|
||||
|
||||
- name: Check if update is needed
|
||||
id: check
|
||||
run: |
|
||||
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
|
||||
MODE="${{ steps.resolve.outputs.mode }}"
|
||||
|
||||
if [ "$MODE" = "manual" ]; then
|
||||
# Manual: skip only if HEAD is exactly this commit
|
||||
CURRENT_SHA=$(git rev-parse HEAD)
|
||||
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
|
||||
echo "Already at $TARGET_SHA — skipping."
|
||||
echo "needs_update=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Switching to $TARGET_SHA"
|
||||
echo "needs_update=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
# Auto: skip if target is already in ancestry
|
||||
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
|
||||
echo "Already up to date with $TARGET_SHA — skipping."
|
||||
echo "needs_update=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Update needed — target: $TARGET_SHA"
|
||||
echo "needs_update=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Apply update
|
||||
if: steps.check.outputs.needs_update == 'true'
|
||||
run: |
|
||||
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
|
||||
MODE="${{ steps.resolve.outputs.mode }}"
|
||||
git checkout main
|
||||
if [ "$MODE" = "manual" ]; then
|
||||
# Hard reset allows both upgrade and rollback
|
||||
git reset --hard "$TARGET_SHA"
|
||||
else
|
||||
git merge "$TARGET_SHA" --no-edit
|
||||
fi
|
||||
|
||||
- name: Restore workflow file
|
||||
if: steps.check.outputs.needs_update == 'true'
|
||||
run: |
|
||||
# Always keep our own workflow file, never let upstream overwrite it
|
||||
git checkout HEAD@{1} -- .github/workflows/sync-upstream.yml 2>/dev/null || true
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: restore sync-upstream workflow after sync"
|
||||
fi
|
||||
|
||||
- name: Push
|
||||
if: steps.check.outputs.needs_update == 'true'
|
||||
run: |
|
||||
if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then
|
||||
git push origin main --force
|
||||
else
|
||||
git push origin main
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
|
||||
echo "### Synced successfully" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -56,9 +56,11 @@ NodeWarden-compat/
|
||||
.codex-upstream/bitwarden-browser/
|
||||
|
||||
.reasonix/
|
||||
.upstream/
|
||||
|
||||
# Compatibility analysis documents
|
||||
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
||||
security-audits/
|
||||
.mcp.json
|
||||
opencode.jsonc
|
||||
.cursor/
|
||||
|
||||
@@ -228,9 +228,20 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
||||
ON trusted_two_factor_device_tokens(user_id, device_identifier);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS totp_login_replays (
|
||||
user_id TEXT NOT NULL,
|
||||
time_counter INTEGER NOT NULL,
|
||||
consumed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, time_counter),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
|
||||
ON totp_login_replays(consumed_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL DEFAULT 'login',
|
||||
name TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
|
||||
Generated
+817
-585
File diff suppressed because it is too large
Load Diff
+26
-19
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.2",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
@@ -42,28 +42,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"undici": ">=7.28.0",
|
||||
"@babel/core": ">=7.29.6",
|
||||
"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",
|
||||
"jsqr": "1.4.0",
|
||||
"lucide-preact": "^1.22.0",
|
||||
"preact": "^10.29.3",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"wouter": "^3.9.0"
|
||||
"wouter": "^3.10.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.7.0';
|
||||
export const APP_VERSION = '1.7.2';
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||
export const BACKUP_DEFAULT_S3_REGION = 'auto';
|
||||
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
||||
export const BACKUP_DEFAULT_S3_ROOT_PATH = '';
|
||||
export const BACKUP_DEFAULT_WEBDAV_REMOTE_PATH = 'nodewarden';
|
||||
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||
|
||||
@@ -109,14 +110,14 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
||||
region: BACKUP_DEFAULT_S3_REGION,
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||
rootPath: BACKUP_DEFAULT_S3_ROOT_PATH,
|
||||
};
|
||||
}
|
||||
return {
|
||||
baseUrl: '',
|
||||
username: '',
|
||||
password: '',
|
||||
remotePath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||
remotePath: BACKUP_DEFAULT_WEBDAV_REMOTE_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@ export class BackupTransferRunner {
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
const failures: Array<{ destinationId: string; error: string }> = [];
|
||||
try {
|
||||
await this.touchJob(token);
|
||||
const storage = new StorageService(this.env.DB);
|
||||
@@ -230,21 +231,30 @@ export class BackupTransferRunner {
|
||||
scanStartMs = now.getTime();
|
||||
for (const destination of dueDestinations) {
|
||||
await this.touchJob(token);
|
||||
await executeConfiguredBackup(
|
||||
this.env,
|
||||
storage,
|
||||
null,
|
||||
'scheduled',
|
||||
destination.id,
|
||||
() => this.touchJob(token)
|
||||
);
|
||||
completed += 1;
|
||||
try {
|
||||
await executeConfiguredBackup(
|
||||
this.env,
|
||||
storage,
|
||||
null,
|
||||
'scheduled',
|
||||
destination.id,
|
||||
() => this.touchJob(token)
|
||||
);
|
||||
completed += 1;
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
destinationId: destination.id,
|
||||
error: error instanceof Error ? error.message : 'Scheduled backup failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
completed,
|
||||
failed: failures.length,
|
||||
failures,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
@@ -318,7 +328,8 @@ export class BackupTransferRunner {
|
||||
replaceExisting,
|
||||
!checksumOk,
|
||||
body.auditMetadata || null,
|
||||
targetDeviceIdentifier
|
||||
targetDeviceIdentifier,
|
||||
() => this.touchJob(token)
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify(result.result), {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { bytesToBase64Url } from '../utils/passkey';
|
||||
import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
|
||||
import {
|
||||
accountPasskeyCredentialToResponse,
|
||||
accountPasskeyPrfStatus,
|
||||
@@ -29,8 +29,10 @@ import {
|
||||
verifyAccountPasskeyToken,
|
||||
} from '../utils/account-passkeys';
|
||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||
import { createRecoveryCode } from '../utils/recovery-code';
|
||||
|
||||
const MAX_ACCOUNT_PASSKEYS = 5;
|
||||
const MAX_TWO_FACTOR_PASSKEYS = 5;
|
||||
|
||||
function parseBodyObject(body: unknown): Record<string, any> {
|
||||
return body && typeof body === 'object' ? body as Record<string, any> : {};
|
||||
@@ -81,6 +83,43 @@ function hasCompletePrfKeySet(body: Record<string, any>): boolean {
|
||||
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
|
||||
}
|
||||
|
||||
function twoFactorWebAuthnResponse(credentials: AccountPasskeyCredential[]): Record<string, unknown> {
|
||||
return {
|
||||
Enabled: credentials.length > 0,
|
||||
enabled: credentials.length > 0,
|
||||
Keys: credentials.map((credential, index) => ({
|
||||
Id: index + 1,
|
||||
id: index + 1,
|
||||
Name: credential.name,
|
||||
name: credential.name,
|
||||
Migrated: false,
|
||||
migrated: false,
|
||||
})),
|
||||
keys: credentials.map((credential, index) => ({
|
||||
Id: index + 1,
|
||||
id: index + 1,
|
||||
Name: credential.name,
|
||||
name: credential.name,
|
||||
Migrated: false,
|
||||
migrated: false,
|
||||
})),
|
||||
Object: 'twoFactorWebAuthn',
|
||||
object: 'twoFactorWebAuthn',
|
||||
};
|
||||
}
|
||||
|
||||
function readRegistrationChallenge(response: ReturnType<typeof normalizeRegistrationResponse>): string | null {
|
||||
if (!response) return null;
|
||||
const clientData = parseClientDataJSON(response.response.clientDataJSON);
|
||||
return String(clientData?.challenge || '').trim() || null;
|
||||
}
|
||||
|
||||
function readAuthenticationChallenge(response: ReturnType<typeof normalizeAuthenticationResponse>): string | null {
|
||||
if (!response) return null;
|
||||
const clientData = parseClientDataJSON(response.response.clientDataJSON);
|
||||
return String(clientData?.challenge || '').trim() || null;
|
||||
}
|
||||
|
||||
function readPrfKeySet(body: Record<string, any>): {
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
@@ -176,6 +215,9 @@ export async function assertAccountPasskeyCredential(
|
||||
if (payload.userId && credential.userId !== payload.userId) {
|
||||
throw new Error('Passkey does not belong to this user');
|
||||
}
|
||||
if (credential.purpose !== 'login') {
|
||||
throw new Error('Passkey is not registered for login');
|
||||
}
|
||||
|
||||
const userHandleUserId = userHandleToUserId(response.response.userHandle);
|
||||
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
|
||||
@@ -225,6 +267,268 @@ export async function handleGetAccountPasskeyCredentials(request: Request, env:
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildTwoFactorPasskeyAssertionOptions(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
user: User
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
if (!credentials.length) return null;
|
||||
|
||||
const { rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
allowCredentials: credentials.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: (credential.transports || undefined) as any,
|
||||
})),
|
||||
userVerification: 'discouraged',
|
||||
timeout: 60000,
|
||||
});
|
||||
await saveChallenge(storage, 'TwoFactorAuthentication', options.challenge, user.id);
|
||||
return options as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function assertTwoFactorPasskeyCredential(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
user: User,
|
||||
deviceResponse: unknown
|
||||
): Promise<AccountPasskeyCredential> {
|
||||
const response = normalizeAuthenticationResponse(deviceResponse);
|
||||
if (!response) {
|
||||
throw new Error('Invalid passkey assertion response');
|
||||
}
|
||||
|
||||
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
|
||||
if (!credential || credential.userId !== user.id || credential.purpose !== 'twoFactor') {
|
||||
throw new Error('Passkey is not registered for two-step login');
|
||||
}
|
||||
|
||||
const challenge = readAuthenticationChallenge(response);
|
||||
if (!challenge) {
|
||||
throw new Error('Passkey assertion challenge is missing');
|
||||
}
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||
await sha256Base64Url(challenge),
|
||||
'TwoFactorAuthentication',
|
||||
user.id,
|
||||
Date.now()
|
||||
);
|
||||
if (!consumed) {
|
||||
throw new Error('Passkey challenge has expired or was already used');
|
||||
}
|
||||
|
||||
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rpId,
|
||||
credential: toSimpleWebAuthnCredential(credential),
|
||||
requireUserVerification: false,
|
||||
});
|
||||
if (!verification.verified) {
|
||||
throw new Error('Passkey assertion could not be verified');
|
||||
}
|
||||
|
||||
await storage.updateAccountPasskeyCounter(
|
||||
credential.userId,
|
||||
credential.credentialId,
|
||||
verification.authenticationInfo.newCounter,
|
||||
new Date().toISOString()
|
||||
);
|
||||
credential.counter = verification.authenticationInfo.newCounter;
|
||||
return credential;
|
||||
}
|
||||
|
||||
export async function handleGetTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
return jsonResponse(twoFactorWebAuthnResponse(credentials));
|
||||
}
|
||||
|
||||
export async function handleGetTwoFactorWebAuthnChallenge(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
if (credentials.length >= MAX_TWO_FACTOR_PASSKEYS) {
|
||||
return errorResponse('Maximum WebAuthn credential count reached.', 400);
|
||||
}
|
||||
|
||||
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpID: rpId,
|
||||
rpName,
|
||||
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
|
||||
userName: user.email,
|
||||
userDisplayName: user.name || user.email,
|
||||
attestationType: 'none',
|
||||
timeout: 60000,
|
||||
excludeCredentials: credentials.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: (credential.transports || undefined) as any,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'discouraged',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'discouraged',
|
||||
},
|
||||
});
|
||||
await saveChallenge(storage, 'TwoFactorCreate', options.challenge, userId);
|
||||
return jsonResponse(options);
|
||||
}
|
||||
|
||||
export async function handlePutTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
if (currentCount >= MAX_TWO_FACTOR_PASSKEYS) {
|
||||
return errorResponse('Maximum WebAuthn credential count reached.', 400);
|
||||
}
|
||||
|
||||
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
|
||||
if (!registrationResponse) {
|
||||
return errorResponse('Invalid passkey registration response', 400);
|
||||
}
|
||||
const challenge = readRegistrationChallenge(registrationResponse);
|
||||
if (!challenge) {
|
||||
return errorResponse('Passkey challenge is missing', 400);
|
||||
}
|
||||
const consumed = await storage.consumeAccountPasskeyChallenge(
|
||||
await sha256Base64Url(challenge),
|
||||
'TwoFactorCreate',
|
||||
userId,
|
||||
Date.now()
|
||||
);
|
||||
if (!consumed) {
|
||||
return errorResponse('Passkey challenge has expired or was already used', 400);
|
||||
}
|
||||
|
||||
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
|
||||
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response: registrationResponse,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rpId,
|
||||
requireUserPresence: true,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch {
|
||||
return errorResponse('Passkey registration could not be verified', 400);
|
||||
}
|
||||
if (!verification.verified) {
|
||||
return errorResponse('Passkey registration could not be verified', 400);
|
||||
}
|
||||
|
||||
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
|
||||
if (existing) {
|
||||
return errorResponse('Passkey is already registered', 409);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const transports = normalizeTransports(registrationResponse.response.transports);
|
||||
await storage.saveAccountPasskeyCredential({
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
purpose: 'twoFactor',
|
||||
name: normalizeAccountPasskeyName(body.name || `Passkey ${currentCount + 1}`),
|
||||
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||
credentialId: verification.registrationInfo.credential.id,
|
||||
counter: verification.registrationInfo.credential.counter,
|
||||
type: verification.registrationInfo.credentialType || 'public-key',
|
||||
aaGuid: verification.registrationInfo.aaguid || null,
|
||||
transports,
|
||||
encryptedUserKey: null,
|
||||
encryptedPublicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
supportsPrf: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
if (!user.totpRecoveryCode) {
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
user.updatedAt = now;
|
||||
await storage.saveUser(user);
|
||||
}
|
||||
await storage.deleteRefreshTokensByUserId(userId);
|
||||
AuthService.invalidateUserCache(userId);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.webauthn_2fa.enable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: null,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
return jsonResponse(twoFactorWebAuthnResponse(credentials));
|
||||
}
|
||||
|
||||
export async function handleDeleteTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
if (!(await verifyUserSecret(env, user, body))) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
|
||||
const requestedId = Number(body.id ?? body.Id);
|
||||
if (!Number.isInteger(requestedId) || requestedId <= 0) {
|
||||
return errorResponse('Invalid key id', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
|
||||
if (credentials.length < 2) {
|
||||
return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||
}
|
||||
const credential = credentials[requestedId - 1];
|
||||
if (!credential) {
|
||||
return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||
}
|
||||
|
||||
const deleted = await storage.deleteAccountPasskeyCredential(userId, credential.id, 'twoFactor');
|
||||
if (!deleted) return errorResponse('Unable to delete WebAuthn credential.', 400);
|
||||
await storage.deleteRefreshTokensByUserId(userId);
|
||||
AuthService.invalidateUserCache(userId);
|
||||
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: userId,
|
||||
action: 'account.webauthn_2fa.delete',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'accountPasskey',
|
||||
targetId: credential.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse(twoFactorWebAuthnResponse(await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor')));
|
||||
}
|
||||
|
||||
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
@@ -380,6 +684,7 @@ export async function handleCreateAccountPasskeyCredential(request: Request, env
|
||||
const credential: AccountPasskeyCredential = {
|
||||
id: generateUUID(),
|
||||
userId,
|
||||
purpose: 'login',
|
||||
name: normalizeAccountPasskeyName(body.name),
|
||||
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
|
||||
credentialId: verification.registrationInfo.credential.id,
|
||||
|
||||
+317
-43
@@ -1,4 +1,4 @@
|
||||
import { Env, User, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { Env, User } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
@@ -6,14 +6,20 @@ import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../s
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||
import { hashApiKey } from '../utils/api-key';
|
||||
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
|
||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||
import { buildAccountKeys } from '../utils/user-decryption';
|
||||
import { buildProfileResponse } from '../utils/profile-response';
|
||||
import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp';
|
||||
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
|
||||
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
||||
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
|
||||
|
||||
// CONTRACT:
|
||||
// users.master_password_hash is server-side login verification only. It does
|
||||
@@ -149,10 +155,9 @@ function normalizeMasterPasswordHint(input: string | null | undefined): string |
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret) return 'missing';
|
||||
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||
return null;
|
||||
}
|
||||
@@ -193,6 +198,31 @@ function readNestedNumber(source: unknown, path: string[]): number | undefined {
|
||||
return typeof current === 'number' ? current : undefined;
|
||||
}
|
||||
|
||||
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
|
||||
const fromEnv = yubicoCredentialsFromEnv(env);
|
||||
if (fromEnv) return fromEnv;
|
||||
const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim();
|
||||
if (!clientId) return null;
|
||||
const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim();
|
||||
return { clientId, secretKey };
|
||||
}
|
||||
|
||||
async function ensureStoredYubicoCredentials(
|
||||
storage: StorageService,
|
||||
env: Env,
|
||||
email: string,
|
||||
otp: string
|
||||
): Promise<YubicoApiCredentials | null> {
|
||||
const existing = await getStoredYubicoCredentials(storage, env);
|
||||
if (existing) return existing;
|
||||
|
||||
const credentials = await requestYubicoApiCredentials(email, otp);
|
||||
if (!credentials) return null;
|
||||
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
|
||||
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
@@ -241,9 +271,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
if (unsafe) {
|
||||
const message = unsafe === 'missing'
|
||||
? 'JWT_SECRET is not set'
|
||||
: unsafe === 'default'
|
||||
? 'JWT_SECRET is using the default/sample value. Please change it.'
|
||||
: 'JWT_SECRET must be at least 32 characters';
|
||||
: 'JWT_SECRET must be at least 32 characters';
|
||||
return errorResponse(message, 400);
|
||||
}
|
||||
|
||||
@@ -324,6 +352,12 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
verifyDevices: true,
|
||||
totpSecret: null,
|
||||
totpRecoveryCode: null,
|
||||
yubikeyKey1: null,
|
||||
yubikeyKey2: null,
|
||||
yubikeyKey3: null,
|
||||
yubikeyKey4: null,
|
||||
yubikeyKey5: null,
|
||||
yubikeyNfc: false,
|
||||
apiKey: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -353,20 +387,31 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
return errorResponse('Invite code is required', 403);
|
||||
}
|
||||
|
||||
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
|
||||
if (!inviteMarked) {
|
||||
return errorResponse('Invite code is invalid or expired', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
await storage.createUser(user);
|
||||
} catch (error) {
|
||||
await storage.revertInviteUsed(inviteCode, user.id);
|
||||
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||
if (msg.includes('unique') || msg.includes('constraint')) {
|
||||
return errorResponse('Email already registered', 409);
|
||||
}
|
||||
console.error('Registration failed after invite reservation:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
|
||||
if (!inviteMarked) {
|
||||
await storage.deleteUserById(user.id);
|
||||
return errorResponse('Invite code is invalid or expired', 403);
|
||||
try {
|
||||
const assigned = await storage.assignInviteUsedBy(inviteCode, user.id);
|
||||
if (!assigned) {
|
||||
console.warn('Invite used_by was not assigned after registration', { inviteCode, userId: user.id });
|
||||
}
|
||||
} catch (error) {
|
||||
// The invite is already consumed. Do not reactivate it after the user row exists.
|
||||
console.error('Invite used_by assignment failed after registration:', error);
|
||||
}
|
||||
|
||||
await writeAuditEvent(storage, {
|
||||
@@ -753,6 +798,29 @@ function twoFactorAuthenticatorResponse(
|
||||
};
|
||||
}
|
||||
|
||||
function yubiKeyResponse(user: User): Record<string, unknown> {
|
||||
return {
|
||||
Enabled: isYubiKeyEnabled(user),
|
||||
Key1: user.yubikeyKey1,
|
||||
Key2: user.yubikeyKey2,
|
||||
Key3: user.yubikeyKey3,
|
||||
Key4: user.yubikeyKey4,
|
||||
Key5: user.yubikeyKey5,
|
||||
Nfc: !!user.yubikeyNfc,
|
||||
Object: 'twoFactorYubiKey',
|
||||
};
|
||||
}
|
||||
|
||||
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
|
||||
const credentials = await getStoredYubicoCredentials(storage, env);
|
||||
return {
|
||||
...yubiKeyResponse(user),
|
||||
YubicoConfigured: !!credentials?.clientId,
|
||||
YubicoClientId: credentials?.clientId ?? '',
|
||||
YubicoSecretKey: credentials?.secretKey ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/two-factor
|
||||
export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
void request;
|
||||
@@ -760,9 +828,11 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
const data = user.totpSecret
|
||||
? [twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)]
|
||||
: [];
|
||||
const data = [];
|
||||
if (isTotpEnabled(user.totpSecret)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true));
|
||||
if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true));
|
||||
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
if (webAuthnCredentials.length > 0) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_WEBAUTHN, true));
|
||||
|
||||
return jsonResponse({
|
||||
Data: data,
|
||||
@@ -794,6 +864,27 @@ export async function handleGetTwoFactorAuthenticator(request: Request, env: Env
|
||||
return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken));
|
||||
}
|
||||
|
||||
// POST /api/two-factor/get-yubikey
|
||||
export async function handleGetTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||
}
|
||||
|
||||
// PUT/POST /api/two-factor/authenticator
|
||||
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
@@ -817,7 +908,10 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 400);
|
||||
if (!await verifyTotpToken(key, token)) return errorResponse('Invalid token.', 400);
|
||||
const matchedCounter = await findMatchingTotpCounter(key, token);
|
||||
if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
|
||||
return errorResponse('Invalid token.', 400);
|
||||
}
|
||||
|
||||
user.totpSecret = key;
|
||||
if (!user.totpRecoveryCode) {
|
||||
@@ -840,6 +934,141 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env
|
||||
return jsonResponse(twoFactorAuthenticatorResponse(true, key));
|
||||
}
|
||||
|
||||
// PUT/POST /api/two-factor/yubikey
|
||||
export async function handlePutTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
const keys = [
|
||||
readBodyString(body, ['key1', 'Key1']),
|
||||
readBodyString(body, ['key2', 'Key2']),
|
||||
readBodyString(body, ['key3', 'Key3']),
|
||||
readBodyString(body, ['key4', 'Key4']),
|
||||
readBodyString(body, ['key5', 'Key5']),
|
||||
];
|
||||
const publicIds: Array<string | null> = [];
|
||||
let credentials = await getStoredYubicoCredentials(storage, env);
|
||||
let apiKeyBootstrapOtpIndex: number | null = null;
|
||||
for (const key of keys) {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) {
|
||||
publicIds.push(null);
|
||||
continue;
|
||||
}
|
||||
const publicId = yubiKeyPublicIdFromOtp(trimmed);
|
||||
if (!publicId) return errorResponse('Invalid YubiKey OTP.', 400);
|
||||
if (isYubiKeyPublicId(trimmed)) {
|
||||
publicIds.push(publicId);
|
||||
continue;
|
||||
}
|
||||
if (!credentials) {
|
||||
credentials = await ensureStoredYubicoCredentials(storage, env, user.email, trimmed);
|
||||
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
|
||||
apiKeyBootstrapOtpIndex = publicIds.length;
|
||||
}
|
||||
if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) {
|
||||
return errorResponse('Invalid YubiKey OTP.', 400);
|
||||
}
|
||||
publicIds.push(publicId);
|
||||
}
|
||||
if (!publicIds.some(Boolean)) return errorResponse('At least one YubiKey OTP is required.', 400);
|
||||
|
||||
user.yubikeyKey1 = publicIds[0] ?? null;
|
||||
user.yubikeyKey2 = publicIds[1] ?? null;
|
||||
user.yubikeyKey3 = publicIds[2] ?? null;
|
||||
user.yubikeyKey4 = publicIds[3] ?? null;
|
||||
user.yubikeyKey5 = publicIds[4] ?? null;
|
||||
user.yubikeyNfc = !!(body.nfc ?? body.Nfc);
|
||||
if (!user.totpRecoveryCode) {
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.yubikey.enable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||
}
|
||||
|
||||
// PUT/POST /api/two-factor/yubikey/config
|
||||
export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim();
|
||||
const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).trim();
|
||||
if (!clientId) return errorResponse('Yubico Client ID is required.', 400);
|
||||
|
||||
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, clientId);
|
||||
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, secretKey);
|
||||
|
||||
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||
}
|
||||
|
||||
// POST /api/two-factor/yubikey/bootstrap
|
||||
export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const auth = new AuthService(env);
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await readRequestBody(request);
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']);
|
||||
const verified = await verifyUserSecret(auth, user, secret);
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim();
|
||||
if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400);
|
||||
const credentials = await requestYubicoApiCredentials(user.email, otp);
|
||||
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
|
||||
|
||||
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
|
||||
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
|
||||
|
||||
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
|
||||
}
|
||||
|
||||
// DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable
|
||||
export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
@@ -856,7 +1085,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
||||
|
||||
const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
||||
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10);
|
||||
if (type !== TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||
if (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY, TWO_FACTOR_PROVIDER_WEBAUTHN].includes(type)) {
|
||||
return errorResponse('Two-factor provider is not supported by this server.', 400);
|
||||
}
|
||||
|
||||
@@ -872,14 +1101,32 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
||||
}
|
||||
if (!verified) return errorResponse('User verification failed.', 400);
|
||||
|
||||
user.totpSecret = null;
|
||||
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||
user.totpSecret = null;
|
||||
} else if (type === TWO_FACTOR_PROVIDER_YUBIKEY) {
|
||||
user.yubikeyKey1 = null;
|
||||
user.yubikeyKey2 = null;
|
||||
user.yubikeyKey3 = null;
|
||||
user.yubikeyKey4 = null;
|
||||
user.yubikeyKey5 = null;
|
||||
user.yubikeyNfc = false;
|
||||
} else {
|
||||
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
for (const credential of credentials) {
|
||||
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
|
||||
}
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.totp.disable',
|
||||
action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR
|
||||
? 'account.totp.disable'
|
||||
: type === TWO_FACTOR_PROVIDER_YUBIKEY
|
||||
? 'account.yubikey.disable'
|
||||
: 'account.webauthn_2fa.disable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
@@ -887,11 +1134,11 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, false));
|
||||
return jsonResponse(twoFactorProviderResponse(type, false));
|
||||
}
|
||||
|
||||
// PUT /api/accounts/totp
|
||||
// enable: { enabled: true, secret: "...", token: "123456" }
|
||||
// enable: { enabled: true, secret: "...", token: "123456", masterPasswordHash?: "...", userVerificationToken?: "..." }
|
||||
// disable: { enabled: false, masterPasswordHash: "..." }
|
||||
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
@@ -899,7 +1146,13 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) return errorResponse('User not found', 404);
|
||||
|
||||
let body: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string };
|
||||
let body: {
|
||||
enabled?: boolean;
|
||||
secret?: string;
|
||||
token?: string;
|
||||
masterPasswordHash?: string;
|
||||
userVerificationToken?: string;
|
||||
};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
@@ -908,14 +1161,26 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
|
||||
|
||||
if (body.enabled === true) {
|
||||
const normalizedSecret = normalizeTotpSecret(body.secret || '');
|
||||
const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']);
|
||||
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
|
||||
if (!isTotpEnabled(normalizedSecret)) {
|
||||
return errorResponse('Invalid TOTP secret', 400);
|
||||
}
|
||||
if (!body.token) {
|
||||
return errorResponse('TOTP token is required', 400);
|
||||
}
|
||||
const verified = await verifyTotpToken(normalizedSecret, body.token);
|
||||
if (!verified) {
|
||||
let verifiedUser = false;
|
||||
if (userVerificationToken) {
|
||||
verifiedUser = await verifyTotpUserVerificationToken(env, user, normalizedSecret, userVerificationToken);
|
||||
}
|
||||
if (!verifiedUser && masterPasswordHash) {
|
||||
verifiedUser = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
|
||||
}
|
||||
if (!verifiedUser) {
|
||||
return errorResponse('User verification failed.', 400);
|
||||
}
|
||||
const matchedCounter = await findMatchingTotpCounter(normalizedSecret, body.token);
|
||||
if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
|
||||
return errorResponse('Invalid TOTP token', 400);
|
||||
}
|
||||
user.totpSecret = normalizedSecret;
|
||||
@@ -1063,6 +1328,16 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
||||
}
|
||||
|
||||
user.totpSecret = null;
|
||||
user.yubikeyKey1 = null;
|
||||
user.yubikeyKey2 = null;
|
||||
user.yubikeyKey3 = null;
|
||||
user.yubikeyKey4 = null;
|
||||
user.yubikeyKey5 = null;
|
||||
user.yubikeyNfc = false;
|
||||
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
for (const credential of webAuthnCredentials) {
|
||||
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
|
||||
}
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
user.securityStamp = generateUUID();
|
||||
user.updatedAt = new Date().toISOString();
|
||||
@@ -1165,29 +1440,28 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
|
||||
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
|
||||
if (!valid) return errorResponse('Invalid password', 400);
|
||||
|
||||
if (rotate || user.apiKey === null) {
|
||||
// Upstream apikeys are 30-character random alphanumeric strings
|
||||
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
||||
if (rotate) {
|
||||
user.securityStamp = generateUUID();
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create',
|
||||
category: 'security',
|
||||
level: rotate ? 'security' : 'info',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
// Only the fresh secret is returned once; the database stores a hash.
|
||||
const plainApiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
|
||||
user.apiKey = await hashApiKey(plainApiKey);
|
||||
if (rotate) {
|
||||
user.securityStamp = generateUUID();
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create',
|
||||
category: 'security',
|
||||
level: rotate ? 'security' : 'info',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
apiKey: user.apiKey,
|
||||
apiKey: plainApiKey,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'apiKey',
|
||||
});
|
||||
|
||||
+18
-7
@@ -76,7 +76,7 @@ export async function handleAdminListUsers(
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5),
|
||||
creationDate: user.createdAt,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'user',
|
||||
@@ -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,
|
||||
|
||||
+21
-22
@@ -1,9 +1,10 @@
|
||||
import { Env, Attachment, Cipher, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { Env, Attachment, Cipher } from '../types';
|
||||
import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { sanitizeDownloadContentType } from '../utils/content-type';
|
||||
import {
|
||||
createAttachmentUploadToken,
|
||||
createFileDownloadToken,
|
||||
@@ -166,7 +167,7 @@ export async function handleCreateAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
@@ -204,7 +205,7 @@ export async function handleCreateAttachment(
|
||||
await storage.saveAttachment(attachment);
|
||||
|
||||
// Add attachment to cipher
|
||||
await storage.addAttachmentToCipher(cipherId, attachmentId);
|
||||
await storage.addAttachmentToCipherForUser(cipherId, attachmentId, userId);
|
||||
|
||||
// Update cipher revision date
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
@@ -214,7 +215,7 @@ export async function handleCreateAttachment(
|
||||
}
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(cipherId);
|
||||
const updatedCipher = await storage.getCipherForUser(cipherId, userId);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
const jwtSecret = getSafeJwtSecret(env);
|
||||
if (!jwtSecret) {
|
||||
@@ -243,13 +244,13 @@ export async function handleUploadAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
@@ -282,12 +283,12 @@ export async function handlePublicUploadAttachment(
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, claims.userId);
|
||||
if (!cipher || cipher.userId !== claims.userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, claims.userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
@@ -307,13 +308,13 @@ export async function handleGetAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
@@ -348,12 +349,12 @@ export async function handleUpdateAttachmentMetadata(
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
@@ -404,10 +405,8 @@ export async function handlePublicDownloadAttachment(
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
return errorResponse('Server configuration error', 500);
|
||||
}
|
||||
const secret = getSafeJwtSecret(env);
|
||||
if (!secret) return errorResponse('Server configuration error', 500);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('token');
|
||||
@@ -417,7 +416,7 @@ export async function handlePublicDownloadAttachment(
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET);
|
||||
const claims = await verifyFileDownloadToken(token, secret);
|
||||
if (!claims) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
@@ -449,7 +448,7 @@ export async function handlePublicDownloadAttachment(
|
||||
|
||||
return new Response(object.body, {
|
||||
headers: {
|
||||
'Content-Type': object.contentType || 'application/octet-stream',
|
||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||
'Content-Length': String(object.size),
|
||||
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
|
||||
'Cache-Control': 'private, no-cache',
|
||||
@@ -470,13 +469,13 @@ export async function handleDeleteAttachment(
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
const cipher = await storage.getCipherForUser(cipherId, userId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
const attachment = await storage.getAttachmentForUser(attachmentId, userId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
@@ -485,7 +484,7 @@ export async function handleDeleteAttachment(
|
||||
await deleteBlobObject(env, path);
|
||||
|
||||
// Delete attachment metadata
|
||||
await storage.deleteAttachment(attachmentId);
|
||||
await storage.deleteAttachmentForUser(attachmentId, userId);
|
||||
|
||||
// Update cipher revision date
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
@@ -500,7 +499,7 @@ export async function handleDeleteAttachment(
|
||||
}
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(cipherId);
|
||||
const updatedCipher = await storage.getCipherForUser(cipherId, userId);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
const cipherResponse = cipherToResponse(updatedCipher!, attachments);
|
||||
|
||||
|
||||
@@ -14,6 +14,19 @@ function normalizeText(value: unknown, maxLength: number): string {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function isSerializedEncString(value: unknown): value is string {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return false;
|
||||
const parts = text.split('.');
|
||||
if (parts.length !== 2) return false;
|
||||
const type = Number(parts[0]);
|
||||
const bodyParts = parts[1].split('|');
|
||||
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
|
||||
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
|
||||
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
|
||||
return false;
|
||||
}
|
||||
|
||||
function getClientIp(request: Request): string | null {
|
||||
return (
|
||||
request.headers.get('CF-Connecting-IP') ||
|
||||
@@ -188,7 +201,7 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
|
||||
|
||||
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const authRequest = await storage.getAuthRequestById(id);
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
|
||||
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||
}
|
||||
@@ -226,7 +239,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
||||
const body = await readJsonBody(request);
|
||||
if (!body) return errorResponse('Invalid request payload', 400);
|
||||
|
||||
const authRequest = await storage.getAuthRequestById(id);
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
|
||||
return errorResponse('Not found', 404);
|
||||
}
|
||||
@@ -251,6 +264,9 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
||||
if (approved && !key) {
|
||||
return errorResponse('Encrypted key is required to approve the request.', 400);
|
||||
}
|
||||
if (approved && !isSerializedEncString(key)) {
|
||||
return errorResponse('Encrypted key is not a valid encrypted string.', 400);
|
||||
}
|
||||
|
||||
const updated = await storage.updateAuthRequestResponse(id, userId, {
|
||||
approved,
|
||||
@@ -259,7 +275,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
||||
masterPasswordHash,
|
||||
});
|
||||
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
||||
const updatedRequest = await storage.getAuthRequestById(id);
|
||||
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||
// Match Bitwarden upstream behavior: only approval wakes the originating anonymous
|
||||
// client. Denials are not pushed to avoid leaking that a login attempt was rejected.
|
||||
if (approved) {
|
||||
|
||||
+141
-38
@@ -21,6 +21,7 @@ import {
|
||||
repairBackupSettings,
|
||||
requireBackupDestination,
|
||||
saveBackupSettings,
|
||||
updateBackupDestinationRuntime,
|
||||
} from '../services/backup-config';
|
||||
import {
|
||||
type BackupImportExecutionResult,
|
||||
@@ -40,15 +41,51 @@ import {
|
||||
uploadBackupArchive,
|
||||
} from '../services/backup-uploader';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
import { getBlobObject } from '../services/blob-store';
|
||||
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
function isAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
}
|
||||
|
||||
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
|
||||
const normalized = String(masterPasswordHash || '').trim();
|
||||
if (!normalized) {
|
||||
return errorResponse('masterPasswordHash is required', 400);
|
||||
}
|
||||
const auth = new AuthService(env);
|
||||
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
|
||||
if (!valid) {
|
||||
return errorResponse('Invalid password', 400);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function requireBackupRepairVerification(
|
||||
actorUser: User,
|
||||
body: { masterPasswordHash?: string; userVerificationToken?: string },
|
||||
env: Env
|
||||
): Promise<Response | null> {
|
||||
const masterPasswordHash = String(body.masterPasswordHash || '').trim();
|
||||
if (masterPasswordHash) {
|
||||
return requireBackupUserVerification(actorUser, masterPasswordHash, env);
|
||||
}
|
||||
|
||||
const userVerificationToken = String(body.userVerificationToken || '').trim();
|
||||
if (!userVerificationToken) {
|
||||
return errorResponse('masterPasswordHash or userVerificationToken is required', 400);
|
||||
}
|
||||
const valid = await verifyPasskeyUserVerificationToken(env, userVerificationToken, actorUser.id, 'backup.settings.repair');
|
||||
if (!valid) {
|
||||
return errorResponse('Invalid user verification token', 400);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function writeAuditLog(
|
||||
storage: StorageService,
|
||||
actorUserId: string | null,
|
||||
@@ -224,6 +261,30 @@ async function uploadRemoteAttachmentChunk(
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyUploadedBackupArchive(
|
||||
session: RemoteBackupTransferSession,
|
||||
archive: BackupArchiveBundle
|
||||
): Promise<'metadata' | 'download'> {
|
||||
try {
|
||||
const stat = await session.stat(archive.fileName);
|
||||
if (stat?.size === archive.bytes.byteLength) {
|
||||
return 'metadata';
|
||||
}
|
||||
} catch {
|
||||
// Fall through to a full read-back verification when lightweight metadata is unavailable.
|
||||
}
|
||||
|
||||
const remoteFile = await session.download(archive.fileName);
|
||||
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
|
||||
if (!checksumOk) {
|
||||
throw new Error('Remote backup ZIP checksum verification failed');
|
||||
}
|
||||
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
|
||||
throw new Error('Remote backup ZIP size verification failed');
|
||||
}
|
||||
return 'download';
|
||||
}
|
||||
|
||||
export async function executeConfiguredBackup(
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
@@ -251,12 +312,14 @@ export async function executeConfiguredBackup(
|
||||
const destination = requireBackupDestination(currentSettings, destinationId);
|
||||
|
||||
const now = new Date();
|
||||
destination.runtime.lastAttemptAt = now.toISOString();
|
||||
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||
destination.runtime.lastErrorAt = null;
|
||||
destination.runtime.lastErrorMessage = null;
|
||||
await touchLease();
|
||||
await saveBackupSettings(storage, env, currentSettings);
|
||||
destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
|
||||
...runtime,
|
||||
lastAttemptAt: now.toISOString(),
|
||||
lastAttemptLocalDate: getBackupLocalDateKey(now, destination.schedule.timezone),
|
||||
lastErrorAt: null,
|
||||
lastErrorMessage: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
await touchLease();
|
||||
@@ -318,6 +381,7 @@ export async function executeConfiguredBackup(
|
||||
}
|
||||
}
|
||||
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||
let uploadVerificationMethod: 'metadata' | 'download' | null = null;
|
||||
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||
await touchLease();
|
||||
await progress?.({
|
||||
@@ -337,14 +401,7 @@ export async function executeConfiguredBackup(
|
||||
stageTitle: 'txt_backup_remote_run_progress_verify_title',
|
||||
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
|
||||
});
|
||||
const remoteFile = await remoteSession.download(archive.fileName);
|
||||
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
|
||||
if (!checksumOk) {
|
||||
throw new Error('Remote backup ZIP checksum verification failed');
|
||||
}
|
||||
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
|
||||
throw new Error('Remote backup ZIP size verification failed');
|
||||
}
|
||||
uploadVerificationMethod = await verifyUploadedBackupArchive(remoteSession, archive);
|
||||
break;
|
||||
} catch (error) {
|
||||
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
|
||||
@@ -373,14 +430,16 @@ export async function executeConfiguredBackup(
|
||||
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
||||
}
|
||||
|
||||
destination.runtime.lastSuccessAt = new Date().toISOString();
|
||||
destination.runtime.lastErrorAt = null;
|
||||
destination.runtime.lastErrorMessage = null;
|
||||
destination.runtime.lastUploadedFileName = archive.fileName;
|
||||
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
|
||||
destination.runtime.lastUploadedDestination = upload.remotePath;
|
||||
await touchLease();
|
||||
await saveBackupSettings(storage, env, currentSettings);
|
||||
destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
|
||||
...runtime,
|
||||
lastSuccessAt: new Date().toISOString(),
|
||||
lastErrorAt: null,
|
||||
lastErrorMessage: null,
|
||||
lastUploadedFileName: archive.fileName,
|
||||
lastUploadedSizeBytes: archive.bytes.byteLength,
|
||||
lastUploadedDestination: upload.remotePath,
|
||||
}));
|
||||
|
||||
await touchLease();
|
||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
||||
@@ -390,6 +449,7 @@ export async function executeConfiguredBackup(
|
||||
fileName: archive.fileName,
|
||||
fileBytes: archive.bytes.byteLength,
|
||||
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
||||
uploadVerificationMethod,
|
||||
prunedFileCount,
|
||||
pruneError: pruneErrorMessage,
|
||||
...(auditMetadata || {}),
|
||||
@@ -412,15 +472,18 @@ export async function executeConfiguredBackup(
|
||||
provider: upload.provider,
|
||||
};
|
||||
} catch (error) {
|
||||
destination.runtime.lastErrorAt = new Date().toISOString();
|
||||
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
||||
const errorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
||||
await touchLease();
|
||||
await saveBackupSettings(storage, env, currentSettings);
|
||||
destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
|
||||
...runtime,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
lastErrorMessage: errorMessage,
|
||||
}));
|
||||
|
||||
await touchLease();
|
||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
||||
...getBackupDestinationSummary(destination),
|
||||
error: destination.runtime.lastErrorMessage,
|
||||
error: errorMessage,
|
||||
...(auditMetadata || {}),
|
||||
});
|
||||
await progress?.({
|
||||
@@ -431,7 +494,7 @@ export async function executeConfiguredBackup(
|
||||
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
|
||||
done: true,
|
||||
ok: false,
|
||||
error: destination.runtime.lastErrorMessage,
|
||||
error: errorMessage,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -619,12 +682,18 @@ export async function importAndAuditRemoteBackupFile(
|
||||
replaceExisting: boolean,
|
||||
checksumMismatchAccepted: boolean,
|
||||
auditMetadata: Record<string, unknown> | null = null,
|
||||
targetDeviceIdentifier: string | null = null
|
||||
targetDeviceIdentifier: string | null = null,
|
||||
keepAlive?: (() => Promise<void>) | null
|
||||
): Promise<BackupImportExecutionResult> {
|
||||
const touchLease = async () => {
|
||||
await keepAlive?.();
|
||||
};
|
||||
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
|
||||
await touchLease();
|
||||
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
|
||||
const externalAttachmentCache = new Map<string, Uint8Array | null>();
|
||||
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||
await touchLease();
|
||||
await notifyUserBackupRestoreProgress(
|
||||
env,
|
||||
actorUserId,
|
||||
@@ -642,6 +711,7 @@ export async function importAndAuditRemoteBackupFile(
|
||||
replaceExisting,
|
||||
{
|
||||
loadAttachment: async (blobName) => {
|
||||
await touchLease();
|
||||
const normalized = String(blobName || '').trim();
|
||||
if (!normalized) return null;
|
||||
if (externalAttachmentCache.has(normalized)) {
|
||||
@@ -664,6 +734,7 @@ export async function importAndAuditRemoteBackupFile(
|
||||
} catch {
|
||||
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
|
||||
}
|
||||
await touchLease();
|
||||
return externalAttachmentCache.get(normalized) || null;
|
||||
},
|
||||
},
|
||||
@@ -787,13 +858,16 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
|
||||
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
let body: BackupSettingsInput;
|
||||
let body: BackupSettingsInput & { masterPasswordHash?: string };
|
||||
try {
|
||||
body = await request.json<BackupSettingsInput>();
|
||||
body = await request.json<BackupSettingsInput & { masterPasswordHash?: string }>();
|
||||
} catch {
|
||||
return errorResponse('Backup settings payload is invalid', 400);
|
||||
}
|
||||
|
||||
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||
if (verificationError) return verificationError;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
let previous;
|
||||
try {
|
||||
@@ -837,13 +911,16 @@ export async function handleGetAdminBackupSettingsRepairState(request: Request,
|
||||
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
let body: BackupSettingsInput;
|
||||
let body: BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string };
|
||||
try {
|
||||
body = await request.json<BackupSettingsInput>();
|
||||
body = await request.json<BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string }>();
|
||||
} catch {
|
||||
return errorResponse('Backup settings repair payload is invalid', 400);
|
||||
}
|
||||
|
||||
const verificationError = await requireBackupRepairVerification(actorUser, body, env);
|
||||
if (verificationError) return verificationError;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
let previous;
|
||||
try {
|
||||
@@ -871,15 +948,18 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
try {
|
||||
let body: { destinationId?: string } | null = null;
|
||||
let body: { destinationId?: string; masterPasswordHash?: string } | null = null;
|
||||
try {
|
||||
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||
body = await request.json<{ destinationId?: string }>();
|
||||
body = await request.json<{ destinationId?: string; masterPasswordHash?: string }>();
|
||||
}
|
||||
} catch {
|
||||
return errorResponse('Backup run payload is invalid', 400);
|
||||
}
|
||||
|
||||
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
|
||||
if (verificationError) return verificationError;
|
||||
|
||||
const outcome = await runConfiguredBackupInDurableObject(env, {
|
||||
actorUserId: actorUser.id,
|
||||
auditMetadata: auditRequestMetadata(request),
|
||||
@@ -928,12 +1008,21 @@ export async function handleListAdminRemoteBackups(request: Request, env: Env, a
|
||||
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
|
||||
try {
|
||||
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
|
||||
} catch {
|
||||
return errorResponse('Remote backup download payload is invalid', 400);
|
||||
}
|
||||
|
||||
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||
if (verificationError) return verificationError;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
try {
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
const url = new URL(request.url);
|
||||
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
||||
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||
return new Response(remoteFile.bytes, {
|
||||
status: 200,
|
||||
@@ -994,13 +1083,22 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
|
||||
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||
|
||||
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
|
||||
let body: {
|
||||
destinationId?: string;
|
||||
path?: string;
|
||||
replaceExisting?: boolean;
|
||||
allowChecksumMismatch?: boolean;
|
||||
masterPasswordHash?: string;
|
||||
};
|
||||
try {
|
||||
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
||||
} catch {
|
||||
return errorResponse('Remote restore payload is invalid', 400);
|
||||
}
|
||||
|
||||
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
|
||||
if (verificationError) return verificationError;
|
||||
|
||||
try {
|
||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||
@@ -1028,14 +1126,16 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||
let body: { includeAttachments?: boolean } | null = null;
|
||||
let body: { includeAttachments?: boolean; masterPasswordHash?: string } | null = null;
|
||||
try {
|
||||
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||
body = await request.json<{ includeAttachments?: boolean }>();
|
||||
body = await request.json<{ includeAttachments?: boolean; masterPasswordHash?: string }>();
|
||||
}
|
||||
} catch {
|
||||
return errorResponse('Backup export payload is invalid', 400);
|
||||
}
|
||||
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
|
||||
if (verificationError) return verificationError;
|
||||
let archive: BackupArchiveBundle;
|
||||
try {
|
||||
const progress = async (event: {
|
||||
@@ -1140,6 +1240,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
|
||||
return errorResponse('Backup file is required', 400);
|
||||
}
|
||||
|
||||
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
|
||||
if (verificationError) return verificationError;
|
||||
|
||||
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
||||
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
|
||||
let archiveBytes: Uint8Array;
|
||||
|
||||
+49
-13
@@ -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;
|
||||
}
|
||||
@@ -347,6 +354,34 @@ export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): s
|
||||
if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate FIDO2 credentials — all encrypted-string fields, both required and optional, must be valid.
|
||||
if (Array.isArray(login.fido2Credentials)) {
|
||||
const fido2EncryptedKeys = ['credentialId', 'keyType', 'keyAlgorithm', 'keyCurve', 'keyValue', 'rpId', 'counter', 'discoverable', 'userHandle', 'userName', 'rpName', 'userDisplayName'];
|
||||
for (const cred of login.fido2Credentials) {
|
||||
if (!cred || typeof cred !== 'object') continue;
|
||||
for (const key of fido2EncryptedKeys) {
|
||||
if (cred[key] != null && !isValidEncString(cred[key])) return `FIDO2 credential ${key} must be an encrypted string.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate SSH key fields — all three must be encrypted strings.
|
||||
const sshKey = cipher.sshKey as any;
|
||||
if (sshKey && typeof sshKey === 'object') {
|
||||
if (sshKey.privateKey != null && !isValidEncString(sshKey.privateKey)) return 'SSH key private key must be an encrypted string.';
|
||||
if (sshKey.publicKey != null && !isValidEncString(sshKey.publicKey)) return 'SSH key public key must be an encrypted string.';
|
||||
const fingerprint = sshKey.keyFingerprint ?? sshKey.fingerprint;
|
||||
if (fingerprint != null && !isValidEncString(fingerprint)) return 'SSH key fingerprint must be an encrypted string.';
|
||||
}
|
||||
|
||||
// Validate password history — each password must be an encrypted string.
|
||||
if (Array.isArray(cipher.passwordHistory)) {
|
||||
for (const entry of cipher.passwordHistory) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
if (entry.password != null && !isValidEncString(entry.password)) return 'Password history entry must be an encrypted string.';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -727,7 +762,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 +820,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) || [];
|
||||
@@ -804,7 +840,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
// GET /api/ciphers/:id
|
||||
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -819,8 +855,8 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
||||
|
||||
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
|
||||
if (!folderId) return true;
|
||||
const folder = await storage.getFolder(folderId);
|
||||
return !!(folder && folder.userId === userId);
|
||||
const folder = await storage.getFolderForUser(folderId, userId);
|
||||
return !!folder;
|
||||
}
|
||||
|
||||
// POST /api/ciphers
|
||||
@@ -901,7 +937,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
// PUT /api/ciphers/:id
|
||||
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const existingCipher = await storage.getCipher(id);
|
||||
const existingCipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!existingCipher || existingCipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1012,7 +1048,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
// DELETE /api/ciphers/:id
|
||||
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1044,7 +1080,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
||||
// - If item is already soft-deleted -> hard delete.
|
||||
export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1071,7 +1107,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
|
||||
// DELETE /api/ciphers/:id (permanent)
|
||||
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1096,7 +1132,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
||||
// PUT /api/ciphers/:id/restore
|
||||
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1118,7 +1154,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
||||
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1210,7 +1246,7 @@ function parseCipherIdList(body: { ids?: unknown }): string[] | null {
|
||||
// PUT/POST /api/ciphers/:id/archive
|
||||
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
@@ -1236,7 +1272,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
||||
// PUT/POST /api/ciphers/:id/unarchive
|
||||
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const cipher = await storage.getCipher(id);
|
||||
const cipher = await storage.getCipherForUser(id, userId);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export async function handleGetFolders(request: Request, env: Env, userId: strin
|
||||
// GET /api/folders/:id
|
||||
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const folder = await storage.getFolder(id);
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
@@ -129,7 +129,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
||||
// PUT /api/folders/:id
|
||||
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const folder = await storage.getFolder(id);
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
@@ -163,7 +163,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
||||
// DELETE /api/folders/:id
|
||||
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const folder = await storage.getFolder(id);
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
@@ -204,8 +204,8 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
|
||||
|
||||
const folders = (
|
||||
await Promise.all(ids.map(async (id) => {
|
||||
const folder = await storage.getFolder(id);
|
||||
return folder && folder.userId === userId ? folder : null;
|
||||
const folder = await storage.getFolderForUser(id, userId);
|
||||
return folder;
|
||||
}))
|
||||
).filter((folder): folder is Folder => !!folder);
|
||||
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||
|
||||
+102
-27
@@ -1,10 +1,10 @@
|
||||
import { Env, TokenResponse } from '../types';
|
||||
import { Env, TokenResponse, User } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
|
||||
import { createRefreshToken } from '../utils/jwt';
|
||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||
@@ -18,15 +18,24 @@ import {
|
||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||
import {
|
||||
assertAccountPasskeyCredential,
|
||||
assertTwoFactorPasskeyCredential,
|
||||
buildAccountPasskeyTokenUserDecryptionOption,
|
||||
buildTwoFactorPasskeyAssertionOptions,
|
||||
} from './account-passkeys';
|
||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
||||
import { constantTimeEquals, verifyApiKey } from '../utils/api-key';
|
||||
import { isYubiKeyEnabled, userYubiKeyPublicIds, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
|
||||
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
||||
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
|
||||
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
|
||||
// Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
|
||||
// the official Identity provider enum (RecoveryCode = 8), while request parsing remains
|
||||
// compatible with older/local provider values.
|
||||
@@ -105,18 +114,6 @@ function parseCookieValue(request: Request, name: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function constantTimeEquals(a: string, b: string): boolean {
|
||||
const encA = new TextEncoder().encode(a);
|
||||
const encB = new TextEncoder().encode(b);
|
||||
if (encA.length !== encB.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < encA.length; i++) {
|
||||
diff |= encA[i] ^ encB[i];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
|
||||
for (const name of names) {
|
||||
const value = body[name];
|
||||
@@ -125,6 +122,15 @@ function readBodyValue(body: Record<string, string>, names: string[]): string |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
|
||||
const fromEnv = yubicoCredentialsFromEnv(env);
|
||||
if (fromEnv) return fromEnv;
|
||||
const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim();
|
||||
if (!clientId) return null;
|
||||
const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim();
|
||||
return { clientId, secretKey };
|
||||
}
|
||||
|
||||
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||
const isHttps = new URL(request.url).protocol === 'https:';
|
||||
const parts = [
|
||||
@@ -193,13 +199,32 @@ function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
|
||||
};
|
||||
}
|
||||
|
||||
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
||||
async function twoFactorRequiredResponse(
|
||||
request: Request,
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
user?: User,
|
||||
message: string = 'Two factor required.'
|
||||
): Promise<Response> {
|
||||
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
|
||||
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to
|
||||
// parse the challenge if an unknown recovery provider key such as "8" is included.
|
||||
const providers = [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
|
||||
const providers2: Record<string, { Email: null }> = {};
|
||||
for (const provider of providers) providers2[provider] = { Email: null };
|
||||
const providers: string[] = [];
|
||||
let webAuthnOptions: Record<string, unknown> | null = null;
|
||||
if (!user || resolveTotpSecret(user.totpSecret)) providers.push(String(TWO_FACTOR_PROVIDER_AUTHENTICATOR));
|
||||
if (user && isYubiKeyEnabled(user)) providers.push(String(TWO_FACTOR_PROVIDER_YUBIKEY));
|
||||
if (user) {
|
||||
webAuthnOptions = await buildTwoFactorPasskeyAssertionOptions(request, env, storage, user) as Record<string, unknown> | null;
|
||||
if (webAuthnOptions) providers.push(String(TWO_FACTOR_PROVIDER_WEBAUTHN));
|
||||
}
|
||||
const providers2: Record<string, Record<string, unknown> | null> = {};
|
||||
for (const provider of providers) {
|
||||
providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY)
|
||||
? { Nfc: user?.yubikeyNfc ?? false }
|
||||
: provider === String(TWO_FACTOR_PROVIDER_WEBAUTHN) && webAuthnOptions
|
||||
? webAuthnOptions
|
||||
: null;
|
||||
}
|
||||
const customResponse = {
|
||||
TwoFactorProviders: providers,
|
||||
TwoFactorProviders2: providers2,
|
||||
@@ -336,10 +361,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
|
||||
let validatedAuthRequestId: string | null = null;
|
||||
let authRequestLoginKey: string | null = null;
|
||||
let valid = false;
|
||||
const normalizedAuthRequestId = String(authRequestId || '').trim();
|
||||
if (normalizedAuthRequestId) {
|
||||
const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId);
|
||||
const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user.id);
|
||||
valid = !!(
|
||||
authRequest &&
|
||||
authRequest.userId === user.id &&
|
||||
@@ -348,10 +374,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
authRequest.responseDate &&
|
||||
!authRequest.authenticationDate &&
|
||||
!isAuthRequestExpired(authRequest) &&
|
||||
!!authRequest.key &&
|
||||
constantTimeEquals(authRequest.accessCode, passwordHash)
|
||||
);
|
||||
if (valid) {
|
||||
validatedAuthRequestId = authRequest!.id;
|
||||
authRequestLoginKey = authRequest!.key;
|
||||
}
|
||||
} else {
|
||||
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
||||
@@ -377,10 +405,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
);
|
||||
}
|
||||
|
||||
// Optional 2FA: enabled only by per-user secret.
|
||||
// Optional 2FA: enabled by any supported per-user provider.
|
||||
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
|
||||
if (effectiveTotpSecret) {
|
||||
const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user);
|
||||
const effectiveWebAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
|
||||
if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0 || effectiveWebAuthnCredentials.length > 0) {
|
||||
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
||||
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
|
||||
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
||||
@@ -390,7 +420,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
|
||||
// respond with a 2FA challenge payload.
|
||||
if (!hasProvider || !hasToken) {
|
||||
return twoFactorRequiredResponse('Two factor required.');
|
||||
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
|
||||
}
|
||||
|
||||
let passedByRememberToken = false;
|
||||
@@ -405,11 +435,42 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
|
||||
if (!passedByRememberToken) {
|
||||
return twoFactorRequiredResponse('Two factor required.');
|
||||
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||
if (!totpOk) {
|
||||
if (!effectiveTotpSecret) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||
if (matchedCounter == null) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
const consumed = await storage.consumeTotpLoginCounter(user.id, matchedCounter);
|
||||
if (!consumed) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_YUBIKEY)) {
|
||||
const publicId = yubiKeyPublicIdFromOtp(normalizedTwoFactorToken);
|
||||
if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
const credentials = await getStoredYubicoCredentials(storage, env);
|
||||
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) {
|
||||
if (!effectiveWebAuthnCredentials.length) {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
let deviceResponse: unknown;
|
||||
try {
|
||||
deviceResponse = JSON.parse(normalizedTwoFactorToken);
|
||||
} catch {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
try {
|
||||
await assertTwoFactorPasskeyCredential(request, env, storage, user, deviceResponse);
|
||||
} catch {
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
} else if (
|
||||
@@ -421,10 +482,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
}
|
||||
user.totpSecret = null;
|
||||
user.yubikeyKey1 = null;
|
||||
user.yubikeyKey2 = null;
|
||||
user.yubikeyKey3 = null;
|
||||
user.yubikeyKey4 = null;
|
||||
user.yubikeyKey5 = null;
|
||||
user.yubikeyNfc = false;
|
||||
for (const credential of effectiveWebAuthnCredentials) {
|
||||
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
|
||||
}
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
user.securityStamp = generateUUID();
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
rememberRequested = false;
|
||||
} else {
|
||||
// Unsupported provider for this server profile behaves as an invalid 2FA attempt.
|
||||
@@ -488,7 +560,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
token_type: 'Bearer',
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||
Key: user.key,
|
||||
Key: authRequestLoginKey || user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: accountKeys,
|
||||
accountKeys: accountKeys,
|
||||
@@ -583,6 +655,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
|
||||
@@ -621,6 +694,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserVerificationToken: userVerificationToken,
|
||||
userVerificationToken,
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
@@ -677,7 +752,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
|
||||
if (!user.apiKey || !(await verifyApiKey(clientSecret, user.apiKey))) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
|
||||
@@ -134,7 +134,7 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
|
||||
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
@@ -401,7 +401,7 @@ export async function handleGetSendFileUpload(
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
@@ -436,7 +436,7 @@ export async function handleUploadSendFile(
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found. Unable to save the file.', 404);
|
||||
}
|
||||
@@ -472,7 +472,7 @@ export async function handlePublicUploadSendFile(
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, claims.userId);
|
||||
if (!send || send.userId !== claims.userId) {
|
||||
return errorResponse('Send not found. Unable to save the file.', 404);
|
||||
}
|
||||
@@ -485,7 +485,7 @@ export async function handlePublicUploadSendFile(
|
||||
|
||||
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
@@ -632,7 +632,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
||||
|
||||
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
@@ -698,7 +698,7 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
|
||||
|
||||
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
@@ -719,7 +719,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
|
||||
|
||||
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
const send = await storage.getSendForUser(sendId, userId);
|
||||
if (!send || send.userId !== userId) {
|
||||
return errorResponse('Send not found', 404);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Env, SendType } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { sanitizeDownloadContentType } from '../utils/content-type';
|
||||
import {
|
||||
createSendAccessToken,
|
||||
createSendFileDownloadToken,
|
||||
@@ -112,10 +112,9 @@ export async function handleAccessSendFile(
|
||||
idOrAccessId: string,
|
||||
fileId: string
|
||||
): Promise<Response> {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||
return errorResponse('Server configuration error', 500);
|
||||
}
|
||||
const safeSecret = getSafeJwtSecret(env);
|
||||
if (!safeSecret.ok) return safeSecret.response;
|
||||
const { secret } = safeSecret;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
|
||||
@@ -306,7 +305,7 @@ export async function handleDownloadSendFile(
|
||||
|
||||
return new Response(object.body, {
|
||||
headers: {
|
||||
'Content-Type': object.contentType || 'application/octet-stream',
|
||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||
'Content-Length': String(object.size),
|
||||
'Content-Disposition': contentDispositionAttachment(fileName),
|
||||
'Cache-Control': 'private, no-cache',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
|
||||
import {
|
||||
notifyUserSendCreate,
|
||||
notifyUserSendDelete,
|
||||
@@ -371,7 +371,7 @@ export function hasEmailAuth(send: Send): boolean {
|
||||
|
||||
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||
return { ok: false, response: errorResponse('Server configuration error', 500) };
|
||||
}
|
||||
return { ok: true, secret };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function handleAdminBackupRoute(
|
||||
return handleListAdminRemoteBackups(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/download' && method === 'GET') {
|
||||
if (path === '/api/admin/backup/remote/download' && method === 'POST') {
|
||||
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
handleGetTwoFactorProviders,
|
||||
handleGetTwoFactorAuthenticator,
|
||||
handlePutTwoFactorAuthenticator,
|
||||
handleGetTwoFactorYubiKey,
|
||||
handlePutTwoFactorYubiKey,
|
||||
handlePutTwoFactorYubiKeyConfig,
|
||||
handleBootstrapTwoFactorYubiKeyConfig,
|
||||
handleDisableTwoFactorProvider,
|
||||
handleGetApiKey,
|
||||
handleRotateApiKey,
|
||||
@@ -74,9 +78,13 @@ import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
||||
import {
|
||||
handleCreateAccountPasskeyCredential,
|
||||
handleDeleteAccountPasskeyCredential,
|
||||
handleDeleteTwoFactorWebAuthn,
|
||||
handleGetAccountPasskeyAttestationOptions,
|
||||
handleGetAccountPasskeyCredentials,
|
||||
handleGetAccountPasskeyUpdateAssertionOptions,
|
||||
handleGetTwoFactorWebAuthn,
|
||||
handleGetTwoFactorWebAuthnChallenge,
|
||||
handlePutTwoFactorWebAuthn,
|
||||
handleUpdateAccountPasskeyEncryption,
|
||||
} from './handlers/account-passkeys';
|
||||
import {
|
||||
@@ -141,12 +149,44 @@ export async function handleAuthenticatedRoute(
|
||||
return handleGetTwoFactorAuthenticator(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/get-yubikey' || path === '/api/two-factor/get-yubi-key') && method === 'POST') {
|
||||
return handleGetTwoFactorYubiKey(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/get-webauthn' && method === 'POST') {
|
||||
return handleGetTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/get-webauthn-challenge' && method === 'POST') {
|
||||
return handleGetTwoFactorWebAuthnChallenge(request, env, userId, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/authenticator') {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
|
||||
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/yubikey' || path === '/api/two-factor/yubi-key')) {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorYubiKey(request, env, userId);
|
||||
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/webauthn') {
|
||||
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||
if (method === 'DELETE') return handleDeleteTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||
return errorResponse('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/yubikey/config' || path === '/api/two-factor/yubi-key/config') && (method === 'PUT' || method === 'POST')) {
|
||||
return handlePutTwoFactorYubiKeyConfig(request, env, userId);
|
||||
}
|
||||
|
||||
if ((path === '/api/two-factor/yubikey/bootstrap' || path === '/api/two-factor/yubi-key/bootstrap') && method === 'POST') {
|
||||
return handleBootstrapTwoFactorYubiKeyConfig(request, env, userId);
|
||||
}
|
||||
|
||||
if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) {
|
||||
return handleDisableTwoFactorProvider(request, env, userId);
|
||||
}
|
||||
|
||||
+25
-8
@@ -1,5 +1,4 @@
|
||||
import { LIMITS } from './config/limits';
|
||||
import { DEFAULT_DEV_SECRET } from './types';
|
||||
import {
|
||||
handleAccessSend,
|
||||
handleAccessSendFile,
|
||||
@@ -8,6 +7,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 {
|
||||
@@ -27,12 +27,13 @@ import {
|
||||
handleNotificationsNegotiate,
|
||||
} from './handlers/notifications';
|
||||
import { handlePublicUploadSendFile } from './handlers/sends';
|
||||
import { isSafeWebsiteIconContentType } from './utils/content-type';
|
||||
import { jsonResponse } from './utils/response';
|
||||
import { StorageService } from './services/storage';
|
||||
import type { Env } from './types';
|
||||
|
||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||
type JwtUnsafeReason = 'missing' | 'default' | 'too_short' | null;
|
||||
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
||||
|
||||
export interface WebBootstrapResponse {
|
||||
defaultKdfIterations: number;
|
||||
@@ -96,6 +97,7 @@ function buildIconServiceCsp(origin: string): string {
|
||||
}
|
||||
|
||||
function buildConfigResponse(origin: string) {
|
||||
const fillAssistBase = `${origin}/fill-assist`;
|
||||
return {
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
@@ -108,7 +110,7 @@ function buildConfigResponse(origin: string) {
|
||||
notifications: origin + '/notifications',
|
||||
icons: origin,
|
||||
sso: '',
|
||||
fillAssistRules: null,
|
||||
fillAssistRules: fillAssistBase,
|
||||
},
|
||||
push: {
|
||||
pushTechnology: 0,
|
||||
@@ -124,8 +126,11 @@ 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,
|
||||
},
|
||||
@@ -241,6 +246,7 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
|
||||
headers: {
|
||||
'Content-Type': contentType || 'image/png',
|
||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
|
||||
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -272,7 +278,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
|
||||
|
||||
if (!resp.ok) continue;
|
||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||
if (!contentType.startsWith('image/')) continue;
|
||||
if (!isSafeWebsiteIconContentType(contentType)) continue;
|
||||
|
||||
const contentLength = getPositiveContentLength(resp.headers);
|
||||
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
|
||||
@@ -301,9 +307,7 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
|
||||
const jwtUnsafeReason =
|
||||
!secret
|
||||
? 'missing'
|
||||
: secret === DEFAULT_DEV_SECRET
|
||||
? 'default'
|
||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||
? 'too_short'
|
||||
: null;
|
||||
const storage = new StorageService(env.DB);
|
||||
@@ -340,6 +344,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);
|
||||
@@ -467,7 +484,7 @@ export async function handlePublicRoute(
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const origin = new URL(request.url).origin;
|
||||
return jsonResponse(buildConfigResponse(origin));
|
||||
return jsonResponse(buildConfigResponse(origin), 200, { 'Cache-Control': 'no-store' });
|
||||
}
|
||||
|
||||
if (path === '/api/version' && method === 'GET') {
|
||||
|
||||
+17
-7
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_DEV_SECRET, Env } from './types';
|
||||
import { Env } from './types';
|
||||
import { AuthService } from './services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
|
||||
import { handleCors, errorResponse } from './utils/response';
|
||||
@@ -6,14 +6,24 @@ import { LIMITS } from './config/limits';
|
||||
import { handleAuthenticatedRoute } from './router-authenticated';
|
||||
import { handlePublicRoute } from './router-public';
|
||||
|
||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret) return 'missing';
|
||||
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||
return null;
|
||||
}
|
||||
|
||||
function canServeWithUnsafeJwtSecret(path: string, method: string): boolean {
|
||||
if (method === 'OPTIONS') return true;
|
||||
if (method === 'GET' && (path === '/api/web-bootstrap' || path === '/web-bootstrap')) return true;
|
||||
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
|
||||
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
|
||||
if (method === 'GET' && path === '/fill-assist/manifest.json') return true;
|
||||
if (method === 'GET' && /^\/fill-assist\/[^/]+$/i.test(path)) return true;
|
||||
if (method === 'GET' && /^\/icons\/[^/]+\/icon\.png$/i.test(path)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
|
||||
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
|
||||
|
||||
@@ -85,14 +95,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
}
|
||||
}
|
||||
|
||||
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
|
||||
if (publicResponse) return publicResponse;
|
||||
|
||||
const secretIssue = jwtSecretUnsafeReason(env);
|
||||
if (secretIssue) {
|
||||
if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
|
||||
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
|
||||
}
|
||||
|
||||
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
|
||||
if (publicResponse) return publicResponse;
|
||||
|
||||
const auth = new AuthService(env);
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
const verified = await auth.verifyAccessTokenWithUser(authHeader);
|
||||
|
||||
@@ -427,7 +427,7 @@ export async function buildBackupArchive(
|
||||
const encoder = new TextEncoder();
|
||||
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
|
||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '../../shared/backup-schema';
|
||||
|
||||
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
||||
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
|
||||
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
||||
const MAX_BACKUP_DESTINATIONS = 24;
|
||||
|
||||
@@ -324,6 +325,47 @@ function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<strin
|
||||
return new Map(destinations.map((destination) => [destination.id, destination]));
|
||||
}
|
||||
|
||||
function stripRuntimeFromSettings(settings: BackupSettings): BackupSettings {
|
||||
return {
|
||||
destinations: settings.destinations.map((destination) => ({
|
||||
...destination,
|
||||
runtime: normalizeRuntime(null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeRuntimeState(settings: BackupSettings): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
destinations: Object.fromEntries(
|
||||
settings.destinations.map((destination) => [destination.id, normalizeRuntime(destination.runtime)])
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBackupRuntimeStates(storage: StorageService): Promise<Map<string, BackupRuntimeState>> {
|
||||
const raw = await storage.getConfigValue(BACKUP_RUNTIME_CONFIG_KEY);
|
||||
if (!raw) return new Map();
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { destinations?: Record<string, unknown> };
|
||||
const entries = Object.entries(parsed.destinations || {})
|
||||
.filter(([id]) => !!asTrimmedString(id))
|
||||
.map(([id, runtime]) => [id, normalizeRuntime(runtime)] as const);
|
||||
return new Map(entries);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRuntimeStates(settings: BackupSettings, runtimes: Map<string, BackupRuntimeState>): BackupSettings {
|
||||
return {
|
||||
destinations: settings.destinations.map((destination) => ({
|
||||
...destination,
|
||||
runtime: runtimes.get(destination.id) || normalizeRuntime(destination.runtime),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
|
||||
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
|
||||
}
|
||||
@@ -387,27 +429,30 @@ export function normalizeBackupSettingsInput(
|
||||
}
|
||||
|
||||
export function serializeBackupSettings(settings: BackupSettings): string {
|
||||
return JSON.stringify(settings);
|
||||
return JSON.stringify(stripRuntimeFromSettings(settings));
|
||||
}
|
||||
|
||||
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
|
||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
|
||||
mergeRuntimeStates(settings, await loadBackupRuntimeStates(storage))
|
||||
);
|
||||
if (!raw) {
|
||||
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return settings;
|
||||
return mergeRuntime(settings);
|
||||
}
|
||||
|
||||
const envelope = parseBackupSettingsEnvelope(raw);
|
||||
if (!envelope) {
|
||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return settings;
|
||||
return mergeRuntime(settings);
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||
return parseBackupSettings(decrypted, fallbackTimezone);
|
||||
return mergeRuntime(parseBackupSettings(decrypted, fallbackTimezone));
|
||||
} catch {
|
||||
throw new Error('Backup settings need administrator reactivation after restore');
|
||||
}
|
||||
@@ -417,6 +462,27 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
|
||||
const users = await storage.getAllUsers();
|
||||
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
||||
await saveBackupRuntimeStates(storage, settings);
|
||||
}
|
||||
|
||||
export async function saveBackupRuntimeStates(storage: StorageService, settings: BackupSettings): Promise<void> {
|
||||
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, serializeRuntimeState(settings));
|
||||
}
|
||||
|
||||
export async function updateBackupDestinationRuntime(
|
||||
storage: StorageService,
|
||||
destinationId: string,
|
||||
mutator: (runtime: BackupRuntimeState) => BackupRuntimeState
|
||||
): Promise<BackupRuntimeState> {
|
||||
const runtimes = await loadBackupRuntimeStates(storage);
|
||||
const current = runtimes.get(destinationId) || normalizeRuntime(null);
|
||||
const next = normalizeRuntime(mutator(current));
|
||||
runtimes.set(destinationId, next);
|
||||
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, JSON.stringify({
|
||||
version: 1,
|
||||
destinations: Object.fromEntries(runtimes.entries()),
|
||||
}));
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
||||
@@ -596,9 +662,9 @@ export function hasBackupSlotBetween(
|
||||
const endMs = endExclusive.getTime();
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
||||
|
||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||
? lastAttemptAt.getTime()
|
||||
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||
? lastSuccessAt.getTime()
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
|
||||
const dayCursor = new Date(startMs);
|
||||
@@ -620,7 +686,7 @@ export function hasBackupSlotBetween(
|
||||
for (const slotStart of slotStarts) {
|
||||
const slotStartMs = slotStart.getTime();
|
||||
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
||||
if (lastAttemptMs >= slotStartMs) continue;
|
||||
if (lastSuccessMs >= slotStartMs) continue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -637,9 +703,9 @@ export function isBackupDueNow(
|
||||
): boolean {
|
||||
if (!destination.schedule.enabled) return false;
|
||||
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||
? lastAttemptAt.getTime()
|
||||
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||
? lastSuccessAt.getTime()
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||
@@ -652,7 +718,7 @@ export function isBackupDueNow(
|
||||
for (const slotStart of slotStarts) {
|
||||
const slotStartMs = slotStart.getTime();
|
||||
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
|
||||
if (lastAttemptMs >= slotStartMs) return false;
|
||||
if (lastSuccessMs >= slotStartMs) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -297,6 +297,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
||||
users: cloneRows(payload.users || []).map((row) => ({
|
||||
...row,
|
||||
verify_devices: row.verify_devices ?? 1,
|
||||
yubikey_nfc: row.yubikey_nfc ?? 0,
|
||||
})),
|
||||
domain_settings: cloneRows(payload.domain_settings || []),
|
||||
user_revisions: cloneRows(payload.user_revisions || []),
|
||||
@@ -619,7 +620,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
||||
buildInsertStatements(
|
||||
db,
|
||||
tableName('users'),
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'yubikey_key1', 'yubikey_key2', 'yubikey_key3', 'yubikey_key4', 'yubikey_key5', 'yubikey_nfc', 'created_at', 'updated_at'],
|
||||
payload.users || []
|
||||
)
|
||||
);
|
||||
|
||||
@@ -33,6 +33,13 @@ export interface RemoteBackupFile {
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export interface RemoteBackupFileStat {
|
||||
provider: BackupDestinationType;
|
||||
remotePath: string;
|
||||
size: number | null;
|
||||
modifiedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteBackupFilePutOptions {
|
||||
contentType?: string;
|
||||
}
|
||||
@@ -433,6 +440,10 @@ async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: s
|
||||
}
|
||||
|
||||
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
|
||||
return (await statWebDavFile(config, relativePath)) !== null;
|
||||
}
|
||||
|
||||
async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
|
||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||
const remotePath = webDavFullPath(config, relativePath);
|
||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||
@@ -441,11 +452,17 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
if (response.status === 404) return false;
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`WebDAV existence check failed: ${response.status}`);
|
||||
}
|
||||
return true;
|
||||
const size = Number(response.headers.get('Content-Length') || '');
|
||||
return {
|
||||
provider: 'webdav',
|
||||
remotePath: normalizeRelativePath(relativePath),
|
||||
size: Number.isFinite(size) ? size : null,
|
||||
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
|
||||
@@ -540,61 +557,68 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
|
||||
const currentPath = normalizeRelativePath(relativePath);
|
||||
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
||||
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||
const url = s3BucketBaseUrl(config);
|
||||
url.searchParams.set('list-type', '2');
|
||||
url.searchParams.set('delimiter', '/');
|
||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||
|
||||
const response = await signedS3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 listing failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const rootPrefix = trimSlashes(config.rootPath);
|
||||
const items: RemoteBackupItem[] = [];
|
||||
let continuationToken = '';
|
||||
|
||||
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
||||
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
||||
if (!fullPrefix) continue;
|
||||
const relative = rootPrefix
|
||||
? fullPrefix === rootPrefix
|
||||
? ''
|
||||
: fullPrefix.startsWith(`${rootPrefix}/`)
|
||||
? fullPrefix.slice(rootPrefix.length + 1)
|
||||
do {
|
||||
const url = s3BucketBaseUrl(config);
|
||||
url.searchParams.set('list-type', '2');
|
||||
url.searchParams.set('delimiter', '/');
|
||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||
if (continuationToken) url.searchParams.set('continuation-token', continuationToken);
|
||||
|
||||
const response = await signedS3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 listing failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
|
||||
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
||||
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
||||
if (!fullPrefix) continue;
|
||||
const relative = rootPrefix
|
||||
? fullPrefix === rootPrefix
|
||||
? ''
|
||||
: fullPrefix.startsWith(`${rootPrefix}/`)
|
||||
? fullPrefix.slice(rootPrefix.length + 1)
|
||||
: ''
|
||||
: fullPrefix;
|
||||
const normalizedRelative = trimSlashes(relative);
|
||||
if (!normalizedRelative) continue;
|
||||
const itemPath = normalizedRelative.replace(/\/+$/, '');
|
||||
if ((parentPath(itemPath) || '') !== currentPath) continue;
|
||||
items.push({
|
||||
path: itemPath,
|
||||
name: basename(itemPath) || itemPath,
|
||||
isDirectory: true,
|
||||
size: null,
|
||||
modifiedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const content of extractXmlBlocks(xml, 'Contents')) {
|
||||
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
|
||||
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
|
||||
const relative = rootPrefix
|
||||
? fullKey.startsWith(`${rootPrefix}/`)
|
||||
? fullKey.slice(rootPrefix.length + 1)
|
||||
: ''
|
||||
: fullPrefix;
|
||||
const normalizedRelative = trimSlashes(relative);
|
||||
if (!normalizedRelative) continue;
|
||||
const itemPath = normalizedRelative.replace(/\/+$/, '');
|
||||
if ((parentPath(itemPath) || '') !== currentPath) continue;
|
||||
items.push({
|
||||
path: itemPath,
|
||||
name: basename(itemPath) || itemPath,
|
||||
isDirectory: true,
|
||||
size: null,
|
||||
modifiedAt: null,
|
||||
});
|
||||
}
|
||||
: fullKey;
|
||||
const normalizedRelative = trimSlashes(relative);
|
||||
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
|
||||
items.push({
|
||||
path: normalizedRelative,
|
||||
name: basename(normalizedRelative) || normalizedRelative,
|
||||
isDirectory: false,
|
||||
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
|
||||
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const content of extractXmlBlocks(xml, 'Contents')) {
|
||||
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
|
||||
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
|
||||
const relative = rootPrefix
|
||||
? fullKey.startsWith(`${rootPrefix}/`)
|
||||
? fullKey.slice(rootPrefix.length + 1)
|
||||
: ''
|
||||
: fullKey;
|
||||
const normalizedRelative = trimSlashes(relative);
|
||||
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
|
||||
items.push({
|
||||
path: normalizedRelative,
|
||||
name: basename(normalizedRelative) || normalizedRelative,
|
||||
isDirectory: false,
|
||||
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
|
||||
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
|
||||
});
|
||||
}
|
||||
continuationToken = extractXmlFirst(xml, 'NextContinuationToken') || '';
|
||||
} while (continuationToken);
|
||||
|
||||
const deduped = new Map<string, RemoteBackupItem>();
|
||||
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
||||
@@ -637,14 +661,24 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
|
||||
}
|
||||
|
||||
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
||||
return (await statS3File(config, relativePath)) !== null;
|
||||
}
|
||||
|
||||
async function statS3File(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
|
||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||
const url = s3ObjectUrl(config, objectKey);
|
||||
const response = await signedS3Request(config, 'HEAD', url);
|
||||
if (response.status === 404) return false;
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`S3 existence check failed: ${response.status}`);
|
||||
}
|
||||
return true;
|
||||
const size = Number(response.headers.get('Content-Length') || '');
|
||||
return {
|
||||
provider: 's3',
|
||||
remotePath: normalizeRelativePath(relativePath),
|
||||
size: Number.isFinite(size) ? size : null,
|
||||
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
|
||||
};
|
||||
}
|
||||
|
||||
interface ConfiguredDestinationAdapter {
|
||||
@@ -656,6 +690,7 @@ interface ConfiguredDestinationAdapter {
|
||||
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
||||
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||
stat: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFileStat | null>;
|
||||
}
|
||||
|
||||
export interface RemoteBackupTransferSession {
|
||||
@@ -666,6 +701,7 @@ export interface RemoteBackupTransferSession {
|
||||
download(relativePath: string): Promise<RemoteBackupFile>;
|
||||
deleteFile(relativePath: string): Promise<void>;
|
||||
exists(relativePath: string): Promise<boolean>;
|
||||
stat(relativePath: string): Promise<RemoteBackupFileStat | null>;
|
||||
}
|
||||
|
||||
function resolveConfiguredDestinationAdapter(
|
||||
@@ -683,6 +719,7 @@ function resolveConfiguredDestinationAdapter(
|
||||
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
||||
stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath),
|
||||
};
|
||||
}
|
||||
if (destination.type === 's3') {
|
||||
@@ -695,6 +732,7 @@ function resolveConfiguredDestinationAdapter(
|
||||
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
|
||||
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
|
||||
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
|
||||
stat: (config, relativePath) => statS3File(config as S3BackupDestination, relativePath),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -730,6 +768,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
|
||||
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
||||
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
||||
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
||||
stat: async (relativePath: string) => adapter.stat(adapter.config, normalizeRelativePath(relativePath)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ let accountPasskeySchemaReady = false;
|
||||
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
|
||||
{ name: 'id', sql: 'id TEXT' },
|
||||
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'purpose', sql: "purpose TEXT NOT NULL DEFAULT 'login'" },
|
||||
{ name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
|
||||
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
|
||||
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
|
||||
@@ -42,7 +43,7 @@ async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
"id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT 'login', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, " +
|
||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
|
||||
@@ -100,6 +101,7 @@ function parseTransports(value: string | null): string[] | null {
|
||||
function mapCredentialRow(row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
purpose?: string | null;
|
||||
name: string;
|
||||
public_key: string;
|
||||
credential_id: string;
|
||||
@@ -117,6 +119,7 @@ function mapCredentialRow(row: {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
purpose: row.purpose === 'twoFactor' ? 'twoFactor' : 'login',
|
||||
name: row.name,
|
||||
publicKey: row.public_key,
|
||||
credentialId: row.credential_id,
|
||||
@@ -160,16 +163,17 @@ export async function saveAccountPasskeyCredential(
|
||||
await safeBind(
|
||||
db.prepare(
|
||||
'INSERT INTO webauthn_credentials(' +
|
||||
'id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
|
||||
'id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
|
||||
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
|
||||
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
|
||||
'purpose=excluded.purpose, name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
|
||||
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
|
||||
'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at'
|
||||
),
|
||||
credential.id,
|
||||
credential.userId,
|
||||
credential.purpose,
|
||||
credential.name,
|
||||
credential.publicKey,
|
||||
credential.credentialId,
|
||||
@@ -188,12 +192,13 @@ export async function saveAccountPasskeyCredential(
|
||||
|
||||
export async function listAccountPasskeyCredentialsByUserId(
|
||||
db: D1Database,
|
||||
userId: string
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<AccountPasskeyCredential[]> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const rows = await db
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC')
|
||||
.bind(userId)
|
||||
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND purpose = ? ORDER BY created_at ASC')
|
||||
.bind(userId, purpose)
|
||||
.all<any>();
|
||||
return (rows.results || []).map(mapCredentialRow);
|
||||
}
|
||||
@@ -225,12 +230,13 @@ export async function getAccountPasskeyCredentialByCredentialId(
|
||||
|
||||
export async function countAccountPasskeyCredentialsByUserId(
|
||||
db: D1Database,
|
||||
userId: string
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<number> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?')
|
||||
.bind(userId)
|
||||
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?')
|
||||
.bind(userId, purpose)
|
||||
.first<{ count: number }>();
|
||||
return Number(row?.count || 0);
|
||||
}
|
||||
@@ -272,12 +278,13 @@ export async function updateAccountPasskeyEncryption(
|
||||
export async function deleteAccountPasskeyCredential(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
id: string
|
||||
id: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<boolean> {
|
||||
await ensureAccountPasskeySchema(db);
|
||||
const result = await db
|
||||
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?')
|
||||
.bind(userId, id)
|
||||
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?')
|
||||
.bind(userId, id, purpose)
|
||||
.run();
|
||||
return Number(result.meta.changes || 0) > 0;
|
||||
}
|
||||
|
||||
@@ -117,25 +117,57 @@ export async function listInvites(db: D1Database, includeInactive: boolean = fal
|
||||
}
|
||||
|
||||
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
void userId;
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
|
||||
"UPDATE invites SET status = 'used', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
|
||||
)
|
||||
.bind(userId, now, code, now)
|
||||
.bind(now, code, now)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> {
|
||||
export async function assignInviteUsedBy(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'")
|
||||
.prepare(
|
||||
"UPDATE invites SET used_by = ?, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
|
||||
)
|
||||
.bind(userId, now, code)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||
void userId;
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.prepare(
|
||||
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
|
||||
)
|
||||
.bind(now, code)
|
||||
.run();
|
||||
return (result.meta.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function deleteInvite(db: D1Database, code: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.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);
|
||||
|
||||
@@ -22,10 +22,35 @@ export async function getAttachment(db: D1Database, id: string): Promise<Attachm
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAttachmentForUser(db: D1Database, id: string, userId: string): Promise<Attachment | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key
|
||||
FROM attachments a
|
||||
INNER JOIN ciphers c ON c.id = a.cipher_id
|
||||
WHERE a.id = ? AND c.user_id = ?`
|
||||
)
|
||||
.bind(id, userId)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
cipherId: row.cipher_id,
|
||||
fileName: row.file_name,
|
||||
size: row.size,
|
||||
sizeName: row.size_name,
|
||||
key: row.key,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key'
|
||||
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key ' +
|
||||
'WHERE EXISTS (' +
|
||||
'SELECT 1 FROM ciphers current_cipher INNER JOIN ciphers next_cipher ON next_cipher.id = excluded.cipher_id ' +
|
||||
'WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = next_cipher.user_id' +
|
||||
')'
|
||||
);
|
||||
await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
|
||||
}
|
||||
@@ -34,6 +59,20 @@ export async function deleteAttachment(db: D1Database, id: string): Promise<void
|
||||
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
export async function deleteAttachmentForUser(db: D1Database, id: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`DELETE FROM attachments
|
||||
WHERE id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ciphers c
|
||||
WHERE c.id = attachments.cipher_id AND c.user_id = ?
|
||||
)`
|
||||
)
|
||||
.bind(id, userId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function bulkDeleteAttachmentsByIds(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
@@ -135,6 +174,30 @@ export async function addAttachmentToCipher(db: D1Database, cipherId: string, at
|
||||
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
|
||||
}
|
||||
|
||||
export async function addAttachmentToCipherForUser(
|
||||
db: D1Database,
|
||||
cipherId: string,
|
||||
attachmentId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE attachments
|
||||
SET cipher_id = ?
|
||||
WHERE id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ciphers target_cipher
|
||||
WHERE target_cipher.id = ? AND target_cipher.user_id = ?
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ciphers current_cipher
|
||||
WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = ?
|
||||
)`
|
||||
)
|
||||
.bind(cipherId, attachmentId, cipherId, userId, userId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
|
||||
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@ export async function getAuthRequestById(db: D1Database, id: string): Promise<Au
|
||||
return row ? mapAuthRequestRow(row) : null;
|
||||
}
|
||||
|
||||
export async function getAuthRequestByIdForUser(db: D1Database, id: string, userId: string): Promise<AuthRequestRecord | null> {
|
||||
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? AND user_id = ? LIMIT 1`).bind(id, userId).first<any>();
|
||||
return row ? mapAuthRequestRow(row) : null;
|
||||
}
|
||||
|
||||
export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> {
|
||||
const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>();
|
||||
return (res.results || []).map(mapAuthRequestRow);
|
||||
|
||||
@@ -107,6 +107,14 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
|
||||
return parseCipherRow(row);
|
||||
}
|
||||
|
||||
export async function getCipherForUser(db: D1Database, id: string, userId: string): Promise<Cipher | null> {
|
||||
const row = await db
|
||||
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE id = ? AND user_id = ?`)
|
||||
.bind(id, userId)
|
||||
.first<CipherRow>();
|
||||
return parseCipherRow(row);
|
||||
}
|
||||
|
||||
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||
const folderId = normalizeOptionalId(cipher.folderId);
|
||||
const data = buildCipherData(cipher, folderId);
|
||||
@@ -114,7 +122,8 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
|
||||
'type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at ' +
|
||||
'WHERE user_id=excluded.user_id'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
|
||||
@@ -19,11 +19,20 @@ export async function getFolder(db: D1Database, id: string): Promise<Folder | nu
|
||||
return mapFolderRow(row);
|
||||
}
|
||||
|
||||
export async function getFolderForUser(db: D1Database, id: string, userId: string): Promise<Folder | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ? AND user_id = ?')
|
||||
.bind(id, userId)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return mapFolderRow(row);
|
||||
}
|
||||
|
||||
export async function saveFolder(db: D1Database, folder: Folder): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at'
|
||||
'ON CONFLICT(id) DO UPDATE SET name=excluded.name, updated_at=excluded.updated_at WHERE user_id=excluded.user_id'
|
||||
)
|
||||
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
|
||||
.run();
|
||||
@@ -44,9 +53,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 +85,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
|
||||
|
||||
@@ -14,13 +14,19 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key2 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key3 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key4 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_key5 TEXT',
|
||||
'ALTER TABLE users ADD COLUMN yubikey_nfc INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS domain_settings (' +
|
||||
@@ -78,6 +84,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
|
||||
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
|
||||
'ALTER TABLE invites ADD COLUMN used_by TEXT',
|
||||
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
|
||||
|
||||
@@ -126,11 +133,18 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS totp_login_replays (' +
|
||||
'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' +
|
||||
'PRIMARY KEY (user_id, time_counter), ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT \'login\', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'ALTER TABLE webauthn_credentials ADD COLUMN purpose TEXT NOT NULL DEFAULT \'login\'',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
|
||||
|
||||
@@ -40,15 +40,27 @@ export async function getSend(db: D1Database, id: string): Promise<Send | null>
|
||||
return mapSendRow(row);
|
||||
}
|
||||
|
||||
export async function getSendForUser(db: D1Database, id: string, userId: string): Promise<Send | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ? AND user_id = ?'
|
||||
)
|
||||
.bind(id, userId)
|
||||
.first<any>();
|
||||
if (!row) return null;
|
||||
return mapSendRow(row);
|
||||
}
|
||||
|
||||
export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> {
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
|
||||
'type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
|
||||
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
|
||||
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
|
||||
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date'
|
||||
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date ' +
|
||||
'WHERE user_id=excluded.user_id'
|
||||
);
|
||||
|
||||
await safeBind(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
|
||||
|
||||
export async function consumeTotpLoginCounter(
|
||||
db: D1Database,
|
||||
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
|
||||
lastCleanupAt: number,
|
||||
cleanupIntervalMs: number,
|
||||
userId: string,
|
||||
timeCounter: number,
|
||||
consumedAtMs: number,
|
||||
markerTtlMs: number
|
||||
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
|
||||
let cleanedUpAt: number | null = null;
|
||||
|
||||
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
|
||||
await db
|
||||
.prepare('DELETE FROM totp_login_replays WHERE consumed_at < ?')
|
||||
.bind(consumedAtMs - markerTtlMs)
|
||||
.run();
|
||||
cleanedUpAt = consumedAtMs;
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.prepare(
|
||||
'INSERT INTO totp_login_replays(user_id, time_counter, consumed_at) VALUES(?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, time_counter) DO NOTHING'
|
||||
)
|
||||
.bind(userId, timeCounter, consumedAtMs)
|
||||
.run();
|
||||
|
||||
return {
|
||||
consumed: (result.meta.changes ?? 0) > 0,
|
||||
cleanedUpAt,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
|
||||
const USER_SELECT_COLUMNS =
|
||||
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
||||
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
||||
'totp_secret, totp_recovery_code, api_key, created_at, updated_at';
|
||||
'totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at';
|
||||
|
||||
function mapUserRow(row: any): User {
|
||||
return {
|
||||
@@ -26,6 +26,12 @@ function mapUserRow(row: any): User {
|
||||
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
yubikeyKey1: row.yubikey_key1 ?? null,
|
||||
yubikeyKey2: row.yubikey_key2 ?? null,
|
||||
yubikeyKey3: row.yubikey_key3 ?? null,
|
||||
yubikeyKey4: row.yubikey_key4 ?? null,
|
||||
yubikeyKey5: row.yubikey_key5 ?? null,
|
||||
yubikeyNfc: !!row.yubikey_nfc,
|
||||
apiKey: row.api_key ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
@@ -65,11 +71,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, yubikey_key1=excluded.yubikey_key1, yubikey_key2=excluded.yubikey_key2, yubikey_key3=excluded.yubikey_key3, yubikey_key4=excluded.yubikey_key4, yubikey_key5=excluded.yubikey_key5, yubikey_nfc=excluded.yubikey_nfc, api_key=excluded.api_key, updated_at=excluded.updated_at'
|
||||
);
|
||||
await safeBind(
|
||||
stmt,
|
||||
@@ -91,6 +97,12 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
user.yubikeyNfc ? 1 : 0,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
@@ -104,8 +116,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
|
||||
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await safeBind(
|
||||
@@ -128,6 +140,12 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
|
||||
user.verifyDevices ? 1 : 0,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
user.yubikeyNfc ? 1 : 0,
|
||||
user.apiKey,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
|
||||
+95
-11
@@ -22,7 +22,10 @@ import {
|
||||
type AuditLogListOptions,
|
||||
createAuditLog as createStoredAuditLog,
|
||||
clearAuditLogs as clearStoredAuditLogs,
|
||||
assignInviteUsedBy as assignStoredInviteUsedBy,
|
||||
createInvite as createStoredInvite,
|
||||
deleteInvite as deleteStoredInvite,
|
||||
deleteInvalidInvites as deleteStoredInvalidInvites,
|
||||
deleteAllInvites as deleteStoredInvites,
|
||||
getInvite as findStoredInvite,
|
||||
listAuditLogs as listStoredAuditLogs,
|
||||
@@ -30,7 +33,7 @@ import {
|
||||
markInviteUsed as markStoredInviteUsed,
|
||||
pruneAuditLogs as pruneStoredAuditLogs,
|
||||
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
|
||||
revokeInvite as revokeStoredInvite,
|
||||
revertInviteUsed as revertStoredInviteUsed,
|
||||
} from './storage-admin-repo';
|
||||
import {
|
||||
bulkDeleteFolders as deleteStoredFolders,
|
||||
@@ -38,6 +41,7 @@ import {
|
||||
deleteFolder as deleteStoredFolder,
|
||||
getAllFolders as listStoredFolders,
|
||||
getFolder as findStoredFolder,
|
||||
getFolderForUser as findStoredFolderForUser,
|
||||
getFoldersPage as listStoredFoldersPage,
|
||||
saveFolder as saveStoredFolder,
|
||||
} from './storage-folder-repo';
|
||||
@@ -50,6 +54,7 @@ import {
|
||||
bulkUnarchiveCiphers as unarchiveStoredCiphers,
|
||||
getAllCiphers as listStoredCiphers,
|
||||
getCipher as findStoredCipher,
|
||||
getCipherForUser as findStoredCipherForUser,
|
||||
getCiphersByIds as listStoredCiphersByIds,
|
||||
getCiphersPage as listStoredCiphersPage,
|
||||
saveCipher as saveStoredCipher,
|
||||
@@ -57,10 +62,13 @@ import {
|
||||
} from './storage-cipher-repo';
|
||||
import {
|
||||
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
||||
addAttachmentToCipherForUser as attachStoredAttachmentToCipherForUser,
|
||||
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
|
||||
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
|
||||
deleteAttachment as deleteStoredAttachment,
|
||||
deleteAttachmentForUser as deleteStoredAttachmentForUser,
|
||||
getAttachment as findStoredAttachment,
|
||||
getAttachmentForUser as findStoredAttachmentForUser,
|
||||
getAttachmentsByCipher as listStoredAttachmentsByCipher,
|
||||
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
|
||||
getAttachmentsByUserId as listStoredAttachmentsByUserId,
|
||||
@@ -72,6 +80,7 @@ import {
|
||||
deleteSend as deleteStoredSend,
|
||||
getAllSends as listStoredSends,
|
||||
getSend as findStoredSend,
|
||||
getSendForUser as findStoredSendForUser,
|
||||
getSendsByIds as listStoredSendsByIds,
|
||||
getSendsPage as listStoredSendsPage,
|
||||
incrementSendAccessCount as incrementStoredSendAccessCount,
|
||||
@@ -111,6 +120,7 @@ import {
|
||||
import {
|
||||
createAuthRequest as createStoredAuthRequest,
|
||||
getAuthRequestById as findStoredAuthRequestById,
|
||||
getAuthRequestByIdForUser as findStoredAuthRequestByIdForUser,
|
||||
listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
|
||||
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
|
||||
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
|
||||
@@ -121,6 +131,9 @@ import {
|
||||
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
||||
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
|
||||
} from './storage-attachment-token-repo';
|
||||
import {
|
||||
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
|
||||
} from './storage-totp-replay-repo';
|
||||
import {
|
||||
getRevisionDate as getStoredRevisionDate,
|
||||
updateRevisionDate as updateStoredRevisionDate,
|
||||
@@ -148,8 +161,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||
// differs from config.schema.version.
|
||||
const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications';
|
||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
|
||||
const STORAGE_SCHEMA_VERSION = '2026-07-05-passkey-2fa';
|
||||
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
@@ -162,10 +175,13 @@ export class StorageService {
|
||||
private static schemaVerified = false;
|
||||
private static lastRefreshTokenCleanupAt = 0;
|
||||
private static lastAttachmentTokenCleanupAt = 0;
|
||||
private static lastTotpReplayCleanupAt = 0;
|
||||
private static readonly MAX_D1_SQL_VARIABLES = 100;
|
||||
|
||||
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
|
||||
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
|
||||
private static readonly TOTP_REPLAY_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
||||
private static readonly TOTP_REPLAY_MARKER_TTL_MS = 5 * 60 * 1000;
|
||||
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
|
||||
|
||||
constructor(private db: D1Database) {}
|
||||
@@ -313,8 +329,20 @@ export class StorageService {
|
||||
return markStoredInviteUsed(this.db, code, userId);
|
||||
}
|
||||
|
||||
async revokeInvite(code: string): Promise<boolean> {
|
||||
return revokeStoredInvite(this.db, code);
|
||||
async assignInviteUsedBy(code: string, userId: string): Promise<boolean> {
|
||||
return assignStoredInviteUsedBy(this.db, code, userId);
|
||||
}
|
||||
|
||||
async revertInviteUsed(code: string, userId: string): Promise<boolean> {
|
||||
return revertStoredInviteUsed(this.db, code, userId);
|
||||
}
|
||||
|
||||
async deleteInvite(code: string): Promise<boolean> {
|
||||
return deleteStoredInvite(this.db, code);
|
||||
}
|
||||
|
||||
async deleteInvalidInvites(): Promise<number> {
|
||||
return deleteStoredInvalidInvites(this.db);
|
||||
}
|
||||
|
||||
async deleteAllInvites(): Promise<number> {
|
||||
@@ -370,8 +398,11 @@ export class StorageService {
|
||||
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
|
||||
}
|
||||
|
||||
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> {
|
||||
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
||||
async getAccountPasskeyCredentialsByUserId(
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<AccountPasskeyCredential[]> {
|
||||
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||
}
|
||||
|
||||
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
|
||||
@@ -382,8 +413,11 @@ export class StorageService {
|
||||
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
|
||||
}
|
||||
|
||||
async countAccountPasskeyCredentialsByUserId(userId: string): Promise<number> {
|
||||
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
||||
async countAccountPasskeyCredentialsByUserId(
|
||||
userId: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<number> {
|
||||
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
|
||||
}
|
||||
|
||||
async updateAccountPasskeyCounter(
|
||||
@@ -414,8 +448,12 @@ export class StorageService {
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAccountPasskeyCredential(userId: string, id: string): Promise<boolean> {
|
||||
return deleteStoredAccountPasskeyCredential(this.db, userId, id);
|
||||
async deleteAccountPasskeyCredential(
|
||||
userId: string,
|
||||
id: string,
|
||||
purpose: AccountPasskeyCredential['purpose'] = 'login'
|
||||
): Promise<boolean> {
|
||||
return deleteStoredAccountPasskeyCredential(this.db, userId, id, purpose);
|
||||
}
|
||||
|
||||
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
|
||||
@@ -437,6 +475,10 @@ export class StorageService {
|
||||
return findStoredCipher(this.db, id);
|
||||
}
|
||||
|
||||
async getCipherForUser(id: string, userId: string): Promise<Cipher | null> {
|
||||
return findStoredCipherForUser(this.db, id, userId);
|
||||
}
|
||||
|
||||
async saveCipher(cipher: Cipher): Promise<void> {
|
||||
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
|
||||
}
|
||||
@@ -487,6 +529,10 @@ export class StorageService {
|
||||
return findStoredFolder(this.db, id);
|
||||
}
|
||||
|
||||
async getFolderForUser(id: string, userId: string): Promise<Folder | null> {
|
||||
return findStoredFolderForUser(this.db, id, userId);
|
||||
}
|
||||
|
||||
async saveFolder(folder: Folder): Promise<void> {
|
||||
await saveStoredFolder(this.db, folder);
|
||||
}
|
||||
@@ -525,6 +571,10 @@ export class StorageService {
|
||||
return findStoredAttachment(this.db, id);
|
||||
}
|
||||
|
||||
async getAttachmentForUser(id: string, userId: string): Promise<Attachment | null> {
|
||||
return findStoredAttachmentForUser(this.db, id, userId);
|
||||
}
|
||||
|
||||
async saveAttachment(attachment: Attachment): Promise<void> {
|
||||
await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment);
|
||||
}
|
||||
@@ -533,6 +583,10 @@ export class StorageService {
|
||||
await deleteStoredAttachment(this.db, id);
|
||||
}
|
||||
|
||||
async deleteAttachmentForUser(id: string, userId: string): Promise<void> {
|
||||
await deleteStoredAttachmentForUser(this.db, id, userId);
|
||||
}
|
||||
|
||||
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
|
||||
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
|
||||
}
|
||||
@@ -553,6 +607,10 @@ export class StorageService {
|
||||
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
|
||||
}
|
||||
|
||||
async addAttachmentToCipherForUser(cipherId: string, attachmentId: string, userId: string): Promise<void> {
|
||||
await attachStoredAttachmentToCipherForUser(this.db, cipherId, attachmentId, userId);
|
||||
}
|
||||
|
||||
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
||||
await deleteStoredAttachmentsByCipher(this.db, cipherId);
|
||||
}
|
||||
@@ -613,6 +671,10 @@ export class StorageService {
|
||||
return findStoredSend(this.db, id);
|
||||
}
|
||||
|
||||
async getSendForUser(id: string, userId: string): Promise<Send | null> {
|
||||
return findStoredSendForUser(this.db, id, userId);
|
||||
}
|
||||
|
||||
async saveSend(send: Send): Promise<void> {
|
||||
await saveStoredSend(this.db, this.safeBind.bind(this), send);
|
||||
}
|
||||
@@ -762,6 +824,10 @@ export class StorageService {
|
||||
return findStoredAuthRequestById(this.db, id);
|
||||
}
|
||||
|
||||
async getAuthRequestByIdForUser(id: string, userId: string): Promise<AuthRequestRecord | null> {
|
||||
return findStoredAuthRequestByIdForUser(this.db, id, userId);
|
||||
}
|
||||
|
||||
async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
|
||||
return listStoredAuthRequestsByUserId(this.db, userId);
|
||||
}
|
||||
@@ -823,6 +889,24 @@ export class StorageService {
|
||||
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
|
||||
}
|
||||
|
||||
async consumeTotpLoginCounter(userId: string, timeCounter: number, consumedAtMs: number = Date.now()): Promise<boolean> {
|
||||
if (!Number.isSafeInteger(timeCounter) || timeCounter < 0) return false;
|
||||
const result = await consumeStoredTotpLoginCounter(
|
||||
this.db,
|
||||
this.shouldRunPeriodicCleanup.bind(this),
|
||||
StorageService.lastTotpReplayCleanupAt,
|
||||
StorageService.TOTP_REPLAY_CLEANUP_INTERVAL_MS,
|
||||
userId,
|
||||
timeCounter,
|
||||
consumedAtMs,
|
||||
StorageService.TOTP_REPLAY_MARKER_TTL_MS
|
||||
);
|
||||
if (result.cleanedUpAt !== null) {
|
||||
StorageService.lastTotpReplayCleanupAt = result.cleanedUpAt;
|
||||
}
|
||||
return result.consumed;
|
||||
}
|
||||
|
||||
// --- Revision dates ---
|
||||
|
||||
async getRevisionDate(userId: string): Promise<string> {
|
||||
|
||||
+23
-5
@@ -14,15 +14,17 @@ export interface Env {
|
||||
WEBAUTHN_RP_ID?: string;
|
||||
WEBAUTHN_RP_NAME?: string;
|
||||
WEBAUTHN_ALLOWED_ORIGINS?: string;
|
||||
YUBICO_CLIENT_ID?: string;
|
||||
YUBICO_SECRET_KEY?: string;
|
||||
YUBICO_VALIDATION_URLS?: string;
|
||||
'globalSettings__yubico__clientId'?: string;
|
||||
'globalSettings__yubico__key'?: string;
|
||||
'globalSettings__yubico__validationUrls'?: string;
|
||||
}
|
||||
|
||||
export type UserRole = 'admin' | 'user';
|
||||
export type UserStatus = 'active' | 'banned';
|
||||
|
||||
// Sample JWT secret used by `.dev.vars.example`.
|
||||
// If runtime JWT_SECRET equals this value, treat it as unsafe.
|
||||
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
|
||||
|
||||
// Attachment model
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
@@ -53,6 +55,12 @@ export interface User {
|
||||
verifyDevices?: boolean;
|
||||
totpSecret: string | null;
|
||||
totpRecoveryCode: string | null;
|
||||
yubikeyKey1: string | null;
|
||||
yubikeyKey2: string | null;
|
||||
yubikeyKey3: string | null;
|
||||
yubikeyKey4: string | null;
|
||||
yubikeyKey5: string | null;
|
||||
yubikeyNfc: boolean;
|
||||
apiKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -244,6 +252,7 @@ export type AccountPasskeyPrfStatus = 0 | 1 | 2;
|
||||
export interface AccountPasskeyCredential {
|
||||
id: string;
|
||||
userId: string;
|
||||
purpose: 'login' | 'twoFactor';
|
||||
name: string;
|
||||
publicKey: string;
|
||||
credentialId: string;
|
||||
@@ -259,7 +268,12 @@ export interface AccountPasskeyCredential {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet';
|
||||
export type AccountPasskeyChallengeScope =
|
||||
| 'Authentication'
|
||||
| 'CreateCredential'
|
||||
| 'UpdateKeySet'
|
||||
| 'TwoFactorAuthentication'
|
||||
| 'TwoFactorCreate';
|
||||
|
||||
export interface AccountPasskeyChallenge {
|
||||
challengeHash: string;
|
||||
@@ -307,6 +321,7 @@ export interface DeviceResponse {
|
||||
type: number;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
lastActivityDate?: string | null;
|
||||
lastSeenAt?: string | null;
|
||||
hasStoredDevice?: boolean;
|
||||
isTrusted: boolean;
|
||||
@@ -466,6 +481,8 @@ export interface TokenResponse {
|
||||
ResetMasterPassword: boolean;
|
||||
scope: string;
|
||||
unofficialServer: boolean;
|
||||
UserVerificationToken?: string;
|
||||
userVerificationToken?: string;
|
||||
MasterPasswordPolicy?: {
|
||||
minComplexity: number;
|
||||
minLength: number;
|
||||
@@ -499,6 +516,7 @@ export interface ProfileResponse {
|
||||
masterPasswordHint: string | null;
|
||||
culture: string;
|
||||
twoFactorEnabled: boolean;
|
||||
yubikeyEnabled?: boolean;
|
||||
key: string;
|
||||
privateKey: string | null;
|
||||
accountKeys: any | null;
|
||||
|
||||
@@ -59,7 +59,9 @@ export async function sha256Base64Url(value: string): Promise<string> {
|
||||
}
|
||||
|
||||
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
|
||||
return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS;
|
||||
return scope === 'CreateCredential' || scope === 'TwoFactorCreate'
|
||||
? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS
|
||||
: ACCOUNT_PASSKEY_TOKEN_TTL_MS;
|
||||
}
|
||||
|
||||
export async function createAccountPasskeyToken(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const API_KEY_HASH_PREFIX = 'sha256:';
|
||||
|
||||
export function constantTimeEquals(a: string, b: string): boolean {
|
||||
const encA = new TextEncoder().encode(a);
|
||||
const encB = new TextEncoder().encode(b);
|
||||
if (encA.length !== encB.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < encA.length; i++) {
|
||||
diff |= encA[i] ^ encB[i];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function toHex(bytes: ArrayBuffer): string {
|
||||
return [...new Uint8Array(bytes)]
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function isStoredApiKeyHash(value: string | null | undefined): boolean {
|
||||
return String(value || '').startsWith(API_KEY_HASH_PREFIX);
|
||||
}
|
||||
|
||||
export async function hashApiKey(apiKey: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(apiKey));
|
||||
return `${API_KEY_HASH_PREFIX}${toHex(digest)}`;
|
||||
}
|
||||
|
||||
export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise<boolean> {
|
||||
const stored = String(storedApiKey || '').trim();
|
||||
if (!isStoredApiKeyHash(stored)) return false;
|
||||
|
||||
const hashed = await hashApiKey(apiKey);
|
||||
return constantTimeEquals(hashed, stored);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
const ACTIVE_DOWNLOAD_MEDIA_TYPES = new Set([
|
||||
'application/xhtml+xml',
|
||||
'application/xml',
|
||||
'image/svg+xml',
|
||||
'text/html',
|
||||
'text/xml',
|
||||
]);
|
||||
|
||||
const SAFE_ICON_MEDIA_TYPES = new Set([
|
||||
'image/avif',
|
||||
'image/bmp',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/vnd.microsoft.icon',
|
||||
'image/webp',
|
||||
'image/x-icon',
|
||||
]);
|
||||
|
||||
function normalizeMediaType(contentType: string | null | undefined): string {
|
||||
return String(contentType || '')
|
||||
.split(';', 1)[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isSafeWebsiteIconContentType(contentType: string | null | undefined): boolean {
|
||||
return SAFE_ICON_MEDIA_TYPES.has(normalizeMediaType(contentType));
|
||||
}
|
||||
|
||||
export function sanitizeDownloadContentType(contentType: string | null | undefined): string {
|
||||
const mediaType = normalizeMediaType(contentType);
|
||||
if (!mediaType) return 'application/octet-stream';
|
||||
if (ACTIVE_DOWNLOAD_MEDIA_TYPES.has(mediaType)) {
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
return contentType || mediaType;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { DEFAULT_DEV_SECRET, Env } from '../types';
|
||||
import { Env } from '../types';
|
||||
import { errorResponse } from './response';
|
||||
|
||||
export interface DirectUploadPayload {
|
||||
@@ -28,7 +28,7 @@ export function buildDirectUploadUrl(request: Request, path: string, token: stri
|
||||
|
||||
export function getSafeJwtSecret(env: Env): string | null {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||
return null;
|
||||
}
|
||||
return secret;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Env, ProfileResponse, User } from '../types';
|
||||
import { buildAccountKeys } from './user-decryption';
|
||||
import { isYubiKeyEnabled } from './yubico-otp';
|
||||
|
||||
export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
||||
void env;
|
||||
@@ -16,7 +17,8 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: user.masterPasswordHint,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
twoFactorEnabled: !!user.totpSecret || isYubiKeyEnabled(user),
|
||||
yubikeyEnabled: isYubiKeyEnabled(user),
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys,
|
||||
|
||||
@@ -100,7 +100,9 @@ export function applyCors(
|
||||
headers.set('X-Frame-Options', 'DENY');
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
|
||||
if (!headers.has('Content-Security-Policy')) {
|
||||
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
|
||||
}
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
|
||||
+16
-7
@@ -70,17 +70,22 @@ function normalizeToken(token: string): string {
|
||||
return token.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
|
||||
export async function findMatchingTotpCounter(
|
||||
secretRaw: string,
|
||||
tokenRaw: string,
|
||||
nowMs: number = Date.now()
|
||||
): Promise<number | null> {
|
||||
const token = normalizeToken(tokenRaw);
|
||||
if (!/^\d{6}$/.test(token)) return false;
|
||||
if (!/^\d{6}$/.test(token)) return null;
|
||||
|
||||
const secret = base32Decode(secretRaw);
|
||||
if (!secret) return false;
|
||||
if (!secret) return null;
|
||||
|
||||
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
|
||||
let matched = false;
|
||||
let matchedCounter: number | null = null;
|
||||
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
|
||||
const expected = await hotp(secret, currentCounter + delta);
|
||||
const candidateCounter = currentCounter + delta;
|
||||
const expected = await hotp(secret, candidateCounter);
|
||||
// Constant-time comparison: always check all windows, never short-circuit.
|
||||
const a = new TextEncoder().encode(expected);
|
||||
const b = new TextEncoder().encode(token);
|
||||
@@ -88,9 +93,13 @@ export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs
|
||||
for (let i = 0; i < a.length && i < b.length; i++) {
|
||||
diff |= a[i] ^ b[i];
|
||||
}
|
||||
if (diff === 0) matched = true;
|
||||
if (diff === 0 && matchedCounter == null) matchedCounter = candidateCounter;
|
||||
}
|
||||
return matched;
|
||||
return matchedCounter;
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
|
||||
return (await findMatchingTotpCounter(secretRaw, tokenRaw, nowMs)) != null;
|
||||
}
|
||||
|
||||
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Env } from '../types';
|
||||
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
||||
|
||||
const USER_VERIFICATION_TOKEN_TYPE = 'nodewarden.user-verification.v1';
|
||||
const USER_VERIFICATION_TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export type UserVerificationPurpose = 'backup.settings.repair';
|
||||
|
||||
interface UserVerificationTokenPayload {
|
||||
typ: typeof USER_VERIFICATION_TOKEN_TYPE;
|
||||
userId: string;
|
||||
method: 'passkey';
|
||||
purpose: UserVerificationPurpose;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
function textBytes(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
async function importHmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
||||
}
|
||||
|
||||
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
|
||||
const key = await importHmacKey(secret);
|
||||
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
|
||||
}
|
||||
|
||||
function encodeJson(value: unknown): string {
|
||||
return bytesToBase64Url(textBytes(JSON.stringify(value)));
|
||||
}
|
||||
|
||||
function decodeJson<T>(value: string): T | null {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPasskeyUserVerificationToken(
|
||||
env: Env,
|
||||
userId: string,
|
||||
purpose: UserVerificationPurpose
|
||||
): Promise<string> {
|
||||
const now = Date.now();
|
||||
const payload: UserVerificationTokenPayload = {
|
||||
typ: USER_VERIFICATION_TOKEN_TYPE,
|
||||
userId,
|
||||
method: 'passkey',
|
||||
purpose,
|
||||
iat: now,
|
||||
exp: now + USER_VERIFICATION_TOKEN_TTL_MS,
|
||||
};
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
|
||||
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
|
||||
return `${data}.${signature}`;
|
||||
}
|
||||
|
||||
export async function verifyPasskeyUserVerificationToken(
|
||||
env: Env,
|
||||
token: string,
|
||||
userId: string,
|
||||
purpose: UserVerificationPurpose
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length !== 3) return false;
|
||||
const data = `${parts[0]}.${parts[1]}`;
|
||||
const expected = await hmacSha256(env.JWT_SECRET, data);
|
||||
const actual = base64UrlToBytes(parts[2]);
|
||||
if (actual.length !== expected.length) return false;
|
||||
|
||||
let diff = 0;
|
||||
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
|
||||
if (diff !== 0) return false;
|
||||
|
||||
const payload = decodeJson<UserVerificationTokenPayload>(parts[1]);
|
||||
if (!payload || payload.typ !== USER_VERIFICATION_TOKEN_TYPE) return false;
|
||||
if (payload.userId !== userId || payload.purpose !== purpose || payload.method !== 'passkey') return false;
|
||||
if (!Number.isFinite(payload.exp) || payload.exp < Date.now()) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { Env, User } from '../types';
|
||||
|
||||
const YUBIKEY_PUBLIC_ID_LENGTH = 12;
|
||||
const YUBIKEY_MIN_OTP_LENGTH = 32;
|
||||
const YUBIKEY_MAX_OTP_LENGTH = 48;
|
||||
const YUBICO_DEFAULT_VALIDATION_URL = 'https://api.yubico.com/wsapi/2.0/verify';
|
||||
const YUBICO_GET_API_KEY_URL = 'https://upgrade.yubico.com/getapikey/';
|
||||
const MODHEX_RE = /^[cbdefghijklnrtuv]+$/;
|
||||
|
||||
export interface YubicoApiCredentials {
|
||||
clientId: string;
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
export function normalizeYubiKeyOtp(input: string): string {
|
||||
return String(input || '').replace(/\s+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
export function yubiKeyPublicIdFromOtp(input: string): string | null {
|
||||
const otp = normalizeYubiKeyOtp(input);
|
||||
if (otp.length === YUBIKEY_PUBLIC_ID_LENGTH && MODHEX_RE.test(otp)) return otp;
|
||||
if (otp.length < YUBIKEY_MIN_OTP_LENGTH || otp.length > YUBIKEY_MAX_OTP_LENGTH) return null;
|
||||
if (!MODHEX_RE.test(otp)) return null;
|
||||
return otp.slice(0, YUBIKEY_PUBLIC_ID_LENGTH);
|
||||
}
|
||||
|
||||
export function isYubiKeyPublicId(input: string): boolean {
|
||||
const value = normalizeYubiKeyOtp(input);
|
||||
return value.length === YUBIKEY_PUBLIC_ID_LENGTH && MODHEX_RE.test(value);
|
||||
}
|
||||
|
||||
function isYubiKeyOtp(input: string): boolean {
|
||||
const otp = normalizeYubiKeyOtp(input);
|
||||
return otp.length >= YUBIKEY_MIN_OTP_LENGTH && otp.length <= YUBIKEY_MAX_OTP_LENGTH && MODHEX_RE.test(otp);
|
||||
}
|
||||
|
||||
export function userYubiKeyPublicIds(user: User): string[] {
|
||||
return [
|
||||
user.yubikeyKey1,
|
||||
user.yubikeyKey2,
|
||||
user.yubikeyKey3,
|
||||
user.yubikeyKey4,
|
||||
user.yubikeyKey5,
|
||||
].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function isYubiKeyEnabled(user: User): boolean {
|
||||
return userYubiKeyPublicIds(user).length > 0;
|
||||
}
|
||||
|
||||
export function yubicoCredentialsFromEnv(env: Env): YubicoApiCredentials | null {
|
||||
const clientId = String(env['globalSettings__yubico__clientId'] || env.YUBICO_CLIENT_ID || '').trim();
|
||||
const secretKey = String(env['globalSettings__yubico__key'] || env.YUBICO_SECRET_KEY || '').trim();
|
||||
return clientId ? { clientId, secretKey } : null;
|
||||
}
|
||||
|
||||
function randomNonce(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function parseYubicoResponse(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const idx = line.indexOf('=');
|
||||
if (idx <= 0) continue;
|
||||
out[line.slice(0, idx)] = line.slice(idx + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function base64ToBytes(input: string): Uint8Array {
|
||||
const binary = atob(input);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) out[index] = binary.charCodeAt(index);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToBase64(input: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of input) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function hmacSha1Base64(base64Key: string, message: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
base64ToBytes(base64Key),
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
return bytesToBase64(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message))));
|
||||
}
|
||||
|
||||
function constantTimeStringEquals(a: string, b: string): boolean {
|
||||
const aBytes = new TextEncoder().encode(a);
|
||||
const bBytes = new TextEncoder().encode(b);
|
||||
let diff = aBytes.length ^ bBytes.length;
|
||||
for (let index = 0; index < aBytes.length && index < bBytes.length; index += 1) {
|
||||
diff |= aBytes[index] ^ bBytes[index];
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function canonicalQuery(params: URLSearchParams): string {
|
||||
return Array.from(params.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
function validationUrls(env: Env): string[] {
|
||||
const configured = String(env['globalSettings__yubico__validationUrls'] || env.YUBICO_VALIDATION_URLS || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
return configured.length > 0 ? configured : [YUBICO_DEFAULT_VALIDATION_URL];
|
||||
}
|
||||
|
||||
export async function requestYubicoApiCredentials(email: string, otpInput: string): Promise<YubicoApiCredentials | null> {
|
||||
const otp = normalizeYubiKeyOtp(otpInput);
|
||||
if (!isYubiKeyOtp(otp)) return null;
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set('email', String(email || '').trim().toLowerCase());
|
||||
body.set('otp', otp);
|
||||
body.set('terms_conditions', 'consented');
|
||||
|
||||
const response = await fetch(YUBICO_GET_API_KEY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const html = await response.text();
|
||||
const clientId = /Client ID:<\/th>\s*<td><b>(\d+)<\/b>/i.exec(html)?.[1] || '';
|
||||
const secretKey = /Secret key:<\/th>\s*<td><code>([^<]+)<\/code>/i.exec(html)?.[1] || '';
|
||||
return clientId ? { clientId, secretKey } : null;
|
||||
}
|
||||
|
||||
export async function verifyYubicoOtp(
|
||||
env: Env,
|
||||
otpInput: string,
|
||||
credentials: YubicoApiCredentials | null = yubicoCredentialsFromEnv(env)
|
||||
): Promise<boolean> {
|
||||
const otp = normalizeYubiKeyOtp(otpInput);
|
||||
if (!isYubiKeyOtp(otp)) return false;
|
||||
|
||||
const clientId = String(credentials?.clientId || '').trim();
|
||||
if (!clientId) return false;
|
||||
|
||||
const nonce = randomNonce();
|
||||
const secretKey = String(credentials?.secretKey || '').trim();
|
||||
const params = new URLSearchParams({
|
||||
id: clientId,
|
||||
nonce,
|
||||
otp,
|
||||
});
|
||||
if (secretKey) {
|
||||
try {
|
||||
params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const baseUrl of validationUrls(env)) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}?${params.toString()}`, { method: 'GET' });
|
||||
if (!response.ok) continue;
|
||||
const parsed = parseYubicoResponse(await response.text());
|
||||
if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue;
|
||||
if (secretKey) {
|
||||
if (!parsed.h) continue;
|
||||
const signedParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (key !== 'h') signedParams.set(key, value);
|
||||
}
|
||||
if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>NodeWarden WebAuthn Connector</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--primary: #2563eb;
|
||||
--primary-strong: #1d4ed8;
|
||||
--text: #101828;
|
||||
--muted: #667085;
|
||||
--line: #d8e0ec;
|
||||
--panel: #ffffff;
|
||||
--surface: #f6f8fb;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 28px 18px;
|
||||
}
|
||||
|
||||
.connector-card {
|
||||
width: min(100%, 430px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 26px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: #344054;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.remember input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 10px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--primary-strong);
|
||||
border-color: var(--primary-strong);
|
||||
}
|
||||
|
||||
button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: none;
|
||||
border-radius: 10px;
|
||||
padding: 11px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.msg.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.msg.error {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.msg.success {
|
||||
border: 1px solid #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="connector-card" aria-labelledby="title">
|
||||
<div class="brand">
|
||||
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
|
||||
<strong>NodeWarden</strong>
|
||||
</div>
|
||||
<h1 id="title">Verify your identity</h1>
|
||||
<p id="subtitle">Use your security key to finish two-step verification.</p>
|
||||
<div class="form">
|
||||
<div id="msg" class="msg" role="status" aria-live="polite"></div>
|
||||
<label class="remember">
|
||||
<input id="remember" type="checkbox" />
|
||||
<span id="remember-label">Trust this device for 30 days</span>
|
||||
</label>
|
||||
<button id="webauthn-button" type="button">Read security key</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var sentSuccess = false;
|
||||
|
||||
var text = pickText(params.get("locale") || navigator.language || "en");
|
||||
document.documentElement.lang = params.get("locale") || navigator.language || "en";
|
||||
|
||||
var titleEl = document.getElementById("title");
|
||||
var subtitleEl = document.getElementById("subtitle");
|
||||
var rememberEl = document.getElementById("remember");
|
||||
var rememberLabelEl = document.getElementById("remember-label");
|
||||
var buttonEl = document.getElementById("webauthn-button");
|
||||
var msgEl = document.getElementById("msg");
|
||||
|
||||
titleEl.textContent = text.title;
|
||||
subtitleEl.textContent = text.subtitle;
|
||||
rememberLabelEl.textContent = text.remember;
|
||||
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
|
||||
|
||||
buttonEl.addEventListener("click", start);
|
||||
|
||||
function pickText(locale) {
|
||||
var normalized = String(locale || "en").toLowerCase();
|
||||
if (normalized.indexOf("zh") === 0) {
|
||||
return {
|
||||
title: "\u9a8c\u8bc1\u8eab\u4efd",
|
||||
subtitle: "\u4f7f\u7528\u5b89\u5168\u5bc6\u94a5\u5b8c\u6210\u4e24\u6b65\u9a8c\u8bc1\u3002",
|
||||
remember: "30 \u5929\u5185\u4fe1\u4efb\u6b64\u8bbe\u5907",
|
||||
button: "\u8bfb\u53d6\u5b89\u5168\u5bc6\u94a5",
|
||||
awaiting: "\u7b49\u5f85\u5b89\u5168\u5bc6\u94a5\u4ea4\u4e92...",
|
||||
success: "\u9a8c\u8bc1\u5b8c\u6210",
|
||||
unsupported: "\u5f53\u524d\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u5b89\u5168\u5bc6\u94a5",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "Verify your identity",
|
||||
subtitle: "Use your security key to finish two-step verification.",
|
||||
remember: "Trust this device for 30 days",
|
||||
button: "Read security key",
|
||||
awaiting: "Awaiting security key interaction...",
|
||||
success: "Verification complete",
|
||||
unsupported: "This browser does not support security keys",
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRepeated(value) {
|
||||
if (!value) return "";
|
||||
var out = String(value);
|
||||
for (var i = 0; i < 2; i += 1) {
|
||||
try {
|
||||
var next = decodeURIComponent(out);
|
||||
if (next === out) break;
|
||||
out = next;
|
||||
} catch (_error) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function showMessage(kind, message) {
|
||||
msgEl.textContent = String(message || "");
|
||||
msgEl.className = "msg show " + kind;
|
||||
}
|
||||
|
||||
function decodeBase64Unicode(value) {
|
||||
var input = String(value || "").replace(/ /g, "+");
|
||||
try {
|
||||
return decodeURIComponent(Array.prototype.map.call(atob(input), function (char) {
|
||||
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(""));
|
||||
} catch (_error) {
|
||||
var normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
return decodeURIComponent(Array.prototype.map.call(atob(normalized), function (char) {
|
||||
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(""));
|
||||
}
|
||||
}
|
||||
|
||||
function bytesFromBase64Url(value) {
|
||||
var normalized = String(value || "").replace(/-/g, "+").replace(/_/g, "/");
|
||||
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
var binary = atob(normalized);
|
||||
var bytes = new Uint8Array(binary.length);
|
||||
for (var i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function base64UrlFromBuffer(value) {
|
||||
if (!value) return undefined;
|
||||
var bytes = value instanceof Uint8Array
|
||||
? value
|
||||
: new Uint8Array(value);
|
||||
var binary = "";
|
||||
for (var i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function readPublicKeyOptions() {
|
||||
var data = params.get("data");
|
||||
if (!data) throw new Error("No data.");
|
||||
var decoded = decodeBase64Unicode(data);
|
||||
if (params.get("v") === "1") {
|
||||
return JSON.parse(decoded);
|
||||
}
|
||||
var payload = JSON.parse(decoded);
|
||||
return typeof payload.data === "string" ? JSON.parse(payload.data) : payload.data;
|
||||
}
|
||||
|
||||
function normalizeOptions(options) {
|
||||
if (!options || typeof options !== "object") throw new Error("Cannot parse data.");
|
||||
var copy = Object.assign({}, options);
|
||||
copy.challenge = bytesFromBase64Url(copy.challenge);
|
||||
if (Array.isArray(copy.allowCredentials)) {
|
||||
copy.allowCredentials = copy.allowCredentials.map(function (credential) {
|
||||
return Object.assign({}, credential, {
|
||||
id: bytesFromBase64Url(credential.id),
|
||||
});
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function credentialToDataString(credential) {
|
||||
var response = credential.response;
|
||||
var clientDataJSON = base64UrlFromBuffer(response.clientDataJSON);
|
||||
var data = {
|
||||
id: credential.id,
|
||||
rawId: base64UrlFromBuffer(credential.rawId),
|
||||
type: credential.type,
|
||||
extensions: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
|
||||
clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
|
||||
response: {
|
||||
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
|
||||
clientDataJson: clientDataJSON,
|
||||
clientDataJSON: clientDataJSON,
|
||||
signature: base64UrlFromBuffer(response.signature),
|
||||
userHandle: response.userHandle ? base64UrlFromBuffer(response.userHandle) : undefined,
|
||||
},
|
||||
};
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (sentSuccess) return;
|
||||
if (!("credentials" in navigator) || !window.PublicKeyCredential) {
|
||||
showMessage("error", text.unsupported);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
msgEl.className = "msg";
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.textContent = decodeRepeated(params.get("btnAwaitingInteractionText")) || text.awaiting;
|
||||
var publicKey = normalizeOptions(readPublicKeyOptions());
|
||||
var credential = await navigator.credentials.get({ publicKey: publicKey });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error("No security key was selected.");
|
||||
}
|
||||
window.postMessage({
|
||||
command: "webAuthnResult",
|
||||
data: credentialToDataString(credential),
|
||||
remember: rememberEl.checked,
|
||||
}, "*");
|
||||
sentSuccess = true;
|
||||
showMessage("success", text.success);
|
||||
} catch (error) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
|
||||
showMessage("error", error && error.message ? error.message : String(error || "WebAuthn failed."));
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+165
-34
@@ -11,6 +11,7 @@ import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
||||
import JwtWarningPage from '@/components/JwtWarningPage';
|
||||
import {
|
||||
createAuthedFetch,
|
||||
deriveLoginHash,
|
||||
getAuthorizedDevices,
|
||||
clearProfileSnapshot,
|
||||
getCurrentDeviceIdentifier,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
loadProfileSnapshot,
|
||||
saveProfileSnapshot,
|
||||
revokeCurrentSession,
|
||||
getTotpStatus,
|
||||
getTwoFactorProviderStatus,
|
||||
getVaultRevisionDate,
|
||||
saveSession,
|
||||
stripProfileSecrets,
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
type PendingPasskeyPassword,
|
||||
type PendingTotp,
|
||||
} from '@/lib/app-auth';
|
||||
import { assertTwoFactorPasskey } from '@/lib/account-passkeys';
|
||||
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
||||
import useAdminActions from '@/hooks/useAdminActions';
|
||||
import useBackupActions from '@/hooks/useBackupActions';
|
||||
@@ -151,6 +153,8 @@ const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
||||
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
|
||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
|
||||
type ThemePreference = 'system' | 'light' | 'dark';
|
||||
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
|
||||
@@ -237,6 +241,7 @@ export default function App() {
|
||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
|
||||
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
|
||||
const [authRequestDialogSelectedId, setAuthRequestDialogSelectedId] = useState<string | null>(null);
|
||||
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
|
||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||
@@ -264,6 +269,11 @@ export default function App() {
|
||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
|
||||
const repairAttemptRef = useRef<string>('');
|
||||
const loginScopedBackupRepairAuthRef = useRef<{
|
||||
accessToken: string;
|
||||
masterPasswordHash?: string | null;
|
||||
userVerificationToken?: string | null;
|
||||
} | null>(null);
|
||||
const uriChecksumRepairAttemptRef = useRef<string>('');
|
||||
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
||||
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
||||
@@ -506,6 +516,14 @@ export default function App() {
|
||||
}, [phase, session?.email, location, navigate]);
|
||||
|
||||
async function finalizeLogin(login: CompletedLogin) {
|
||||
loginScopedBackupRepairAuthRef.current =
|
||||
login.session.accessToken && (login.freshMasterPasswordHash || login.freshUserVerificationToken)
|
||||
? {
|
||||
accessToken: login.session.accessToken,
|
||||
masterPasswordHash: login.freshMasterPasswordHash || null,
|
||||
userVerificationToken: login.freshUserVerificationToken || null,
|
||||
}
|
||||
: null;
|
||||
setSession(login.session);
|
||||
setProfile(login.profile);
|
||||
setUnlockPreparing(false);
|
||||
@@ -639,19 +657,38 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectTotpProvider(providerType: number) {
|
||||
if (totpSubmitting) return;
|
||||
setPendingTotp((current) => {
|
||||
if (!current || current.providerType === providerType) return current;
|
||||
const canUseProvider = current.availableProviders.includes(providerType);
|
||||
if (!canUseProvider) return current;
|
||||
return {
|
||||
...current,
|
||||
providerType,
|
||||
providerData: current.providerDataByType[providerType],
|
||||
};
|
||||
});
|
||||
setTotpCode('');
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (totpSubmitting) return;
|
||||
if (!pendingTotp) return;
|
||||
if (!totpCode.trim()) {
|
||||
pushToast('error', t('txt_please_input_totp_code'));
|
||||
const isPasskeyTwoFactor = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
||||
if (!isPasskeyTwoFactor && !totpCode.trim()) {
|
||||
pushToast('error', pendingTotp.providerType === TWO_FACTOR_PROVIDER_YUBIKEY ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code'));
|
||||
return;
|
||||
}
|
||||
setTotpSubmitting(true);
|
||||
try {
|
||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||
const token = isPasskeyTwoFactor
|
||||
? await assertTwoFactorPasskey(pendingTotp.providerData)
|
||||
: totpCode;
|
||||
const login = await performTotpLogin(pendingTotp, token, rememberDevice);
|
||||
await finalizeLogin(login);
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||
pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : isPasskeyTwoFactor ? t('txt_passkey_verification_failed') : t('txt_totp_verify_failed'));
|
||||
} finally {
|
||||
setTotpSubmitting(false);
|
||||
}
|
||||
@@ -936,11 +973,14 @@ export default function App() {
|
||||
confirm={null}
|
||||
onCancelConfirm={() => {}}
|
||||
pendingTotpOpen={false}
|
||||
pendingTotpProviderType={0}
|
||||
pendingTotpAvailableProviders={[]}
|
||||
totpCode=""
|
||||
rememberDevice={false}
|
||||
onTotpCodeChange={() => {}}
|
||||
onRememberDeviceChange={() => {}}
|
||||
onConfirmTotp={() => {}}
|
||||
onSelectTotpProvider={() => {}}
|
||||
onCancelTotp={() => {}}
|
||||
onUseRecoveryCode={() => {}}
|
||||
totpSubmitting={false}
|
||||
@@ -1066,9 +1106,9 @@ export default function App() {
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const totpStatusQuery = useQuery({
|
||||
queryKey: ['totp-status', vaultCacheKey || session?.email],
|
||||
queryFn: () => getTotpStatus(authedFetch),
|
||||
const twoFactorStatusQuery = useQuery({
|
||||
queryKey: ['two-factor-status', vaultCacheKey || session?.email],
|
||||
queryFn: () => getTwoFactorProviderStatus(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@@ -1085,18 +1125,38 @@ export default function App() {
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
async function deriveCurrentMasterPasswordHash(masterPassword: string): Promise<string> {
|
||||
const email = String(profile?.email || session?.email || '').trim().toLowerCase();
|
||||
if (!email) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalizedPassword = String(masterPassword || '');
|
||||
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(email, normalizedPassword, defaultKdfIterations);
|
||||
return derived.hash;
|
||||
}
|
||||
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
|
||||
const pendingAuthRequestsQuery = useQuery({
|
||||
queryKey: pendingAuthRequestsQueryKey,
|
||||
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;
|
||||
const authRequestDialogOpen = !!latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId;
|
||||
const selectedPendingAuthRequest = authRequestDialogSelectedId
|
||||
? pendingAuthRequests.find((request) => request.id === authRequestDialogSelectedId) || null
|
||||
: null;
|
||||
const authRequestDialogRequest = selectedPendingAuthRequest || (
|
||||
latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId
|
||||
? latestPendingAuthRequest
|
||||
: null
|
||||
);
|
||||
const authRequestDialogOpen = !!authRequestDialogRequest;
|
||||
|
||||
async function beginApproveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||
setAuthRequestDialogSelectedId(authRequest.id);
|
||||
setAuthRequestDialogDismissedId(null);
|
||||
}
|
||||
|
||||
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
@@ -1110,6 +1170,7 @@ export default function App() {
|
||||
requestApproved: true,
|
||||
});
|
||||
setAuthRequestDialogDismissedId(null);
|
||||
setAuthRequestDialogSelectedId(null);
|
||||
pushToast('success', t('txt_auth_request_approved'));
|
||||
await pendingAuthRequestsQuery.refetch();
|
||||
} finally {
|
||||
@@ -1125,6 +1186,7 @@ export default function App() {
|
||||
requestApproved: false,
|
||||
});
|
||||
setAuthRequestDialogDismissedId(null);
|
||||
setAuthRequestDialogSelectedId(null);
|
||||
pushToast('success', t('txt_auth_request_denied'));
|
||||
await pendingAuthRequestsQuery.refetch();
|
||||
} finally {
|
||||
@@ -1189,13 +1251,25 @@ export default function App() {
|
||||
if (!isAdminProfile(profile)) return;
|
||||
if (repairAttemptRef.current === session.accessToken) return;
|
||||
|
||||
const loginScopedRepairAuth = loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken
|
||||
? loginScopedBackupRepairAuthRef.current
|
||||
: null;
|
||||
repairAttemptRef.current = session.accessToken;
|
||||
void silentlyRepairBackupSettingsIfNeeded(session, profile);
|
||||
void (async () => {
|
||||
try {
|
||||
await silentlyRepairBackupSettingsIfNeeded(session, profile, loginScopedRepairAuth);
|
||||
} finally {
|
||||
if (loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken) {
|
||||
loginScopedBackupRepairAuthRef.current = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.accessToken) return;
|
||||
repairAttemptRef.current = '';
|
||||
loginScopedBackupRepairAuthRef.current = null;
|
||||
uriChecksumRepairAttemptRef.current = '';
|
||||
}, [session?.accessToken]);
|
||||
|
||||
@@ -1767,7 +1841,7 @@ export default function App() {
|
||||
onNotify: pushToast,
|
||||
onProfileUpdated: setProfile,
|
||||
onSetConfirm: setConfirm,
|
||||
refetchTotpStatus: totpStatusQuery.refetch,
|
||||
refetchTwoFactorStatus: twoFactorStatusQuery.refetch,
|
||||
refetchAuthorizedDevices: authorizedDevicesQuery.refetch,
|
||||
});
|
||||
const adminActions = useAdminActions({
|
||||
@@ -1890,6 +1964,7 @@ export default function App() {
|
||||
session,
|
||||
mobileLayout,
|
||||
mobileSidebarToggleKey,
|
||||
themePreference,
|
||||
importRoute: IMPORT_ROUTE,
|
||||
settingsHomeRoute: SETTINGS_HOME_ROUTE,
|
||||
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
|
||||
@@ -1904,10 +1979,13 @@ export default function App() {
|
||||
invites: invitesQuery.data || [],
|
||||
adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data),
|
||||
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
|
||||
totpEnabled: !!totpStatusQuery.data?.enabled,
|
||||
totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled,
|
||||
yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled,
|
||||
passkey2faEnabled: !!twoFactorStatusQuery.data?.passkeyEnabled,
|
||||
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,
|
||||
@@ -1916,6 +1994,7 @@ export default function App() {
|
||||
onNavigate: navigate,
|
||||
onLogout: handleLogout,
|
||||
onNotify: pushToast,
|
||||
onThemePreferenceChange: setThemePreference,
|
||||
onImport: vaultSendActions.importVault,
|
||||
onImportEncryptedRaw: vaultSendActions.importEncryptedRaw,
|
||||
onExport: vaultSendActions.exportVault,
|
||||
@@ -1950,11 +2029,20 @@ export default function App() {
|
||||
sendUploadPercent: vaultSendActions.sendUploadPercent,
|
||||
onChangePassword: accountSecurityActions.changePassword,
|
||||
onSavePasswordHint: accountSecurityActions.savePasswordHint,
|
||||
onEnableTotp: async (secret: string, token: string) => {
|
||||
await accountSecurityActions.enableTotp(secret, token);
|
||||
await totpStatusQuery.refetch();
|
||||
onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
|
||||
await accountSecurityActions.enableTotp(secret, token, masterPassword);
|
||||
await twoFactorStatusQuery.refetch();
|
||||
},
|
||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||
onGetYubiKeySettings: accountSecurityActions.getYubiKeySettings,
|
||||
onSaveYubiKeySettings: accountSecurityActions.saveYubiKeySettings,
|
||||
onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials,
|
||||
onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials,
|
||||
onDisableYubiKey: accountSecurityActions.disableYubiKey,
|
||||
onGetTwoFactorPasskeySettings: accountSecurityActions.getTwoFactorPasskeySettings,
|
||||
onCreateTwoFactorPasskey: accountSecurityActions.createTwoFactorPasskey,
|
||||
onDeleteTwoFactorPasskey: accountSecurityActions.deleteTwoFactorPasskey,
|
||||
onDisableTwoFactorPasskeys: accountSecurityActions.disableTwoFactorPasskeys,
|
||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||
onGetApiKey: accountSecurityActions.getApiKey,
|
||||
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||
@@ -1962,12 +2050,16 @@ export default function App() {
|
||||
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
|
||||
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
|
||||
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
|
||||
onRefreshTwoFactorStatus: async () => {
|
||||
await twoFactorStatusQuery.refetch();
|
||||
},
|
||||
pendingAuthRequests,
|
||||
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isFetching,
|
||||
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading,
|
||||
pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading,
|
||||
onRefreshPendingAuthRequests: async () => {
|
||||
await pendingAuthRequestsQuery.refetch();
|
||||
},
|
||||
onApproveAuthRequest: approveAuthRequest,
|
||||
onApproveAuthRequest: beginApproveAuthRequest,
|
||||
onDenyAuthRequest: denyAuthRequest,
|
||||
onLockTimeoutChange: setLockTimeoutMinutes,
|
||||
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
||||
@@ -1980,34 +2072,64 @@ 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),
|
||||
onClearAuditLogs: () => clearAuditLogs(authedFetch),
|
||||
onExportBackup: backupActions.exportBackup,
|
||||
onImportBackup: backupActions.importBackup,
|
||||
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
|
||||
onExportBackup: async (masterPassword: string, includeAttachments?: boolean) => {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
return backupActions.exportBackup(hash, includeAttachments);
|
||||
},
|
||||
onImportBackup: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
return backupActions.importBackup(hash, file, replaceExisting);
|
||||
},
|
||||
onImportBackupAllowingChecksumMismatch: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
return backupActions.importBackupAllowingChecksumMismatch(hash, file, replaceExisting);
|
||||
},
|
||||
onLoadBackupSettings: () => queryClient.ensureQueryData({
|
||||
queryKey: ['admin-backup-settings', vaultCacheKey],
|
||||
queryFn: () => backupActions.loadSettings(),
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
onSaveBackupSettings: backupActions.saveSettings,
|
||||
onRunRemoteBackup: backupActions.runRemoteBackup,
|
||||
onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
const saved = await backupActions.saveSettings(hash, settings);
|
||||
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], saved);
|
||||
return saved;
|
||||
},
|
||||
onRunRemoteBackup: async (masterPassword: string, destinationId?: string | null) => {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
const result = await backupActions.runRemoteBackup(hash, destinationId);
|
||||
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], result.settings);
|
||||
return result;
|
||||
},
|
||||
onListRemoteBackups: backupActions.listRemoteBackups,
|
||||
onDownloadRemoteBackup: backupActions.downloadRemoteBackup,
|
||||
onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
|
||||
},
|
||||
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
|
||||
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
|
||||
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
|
||||
onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
|
||||
},
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
|
||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||
return backupActions.restoreRemoteBackupAllowingChecksumMismatch(hash, destinationId, path, replaceExisting);
|
||||
},
|
||||
};
|
||||
const effectiveMainRoutesProps = IS_DEMO_MODE
|
||||
? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
|
||||
@@ -2125,11 +2247,14 @@ export default function App() {
|
||||
confirm={confirm}
|
||||
onCancelConfirm={() => setConfirm(null)}
|
||||
pendingTotpOpen={!!pendingTotp}
|
||||
pendingTotpProviderType={pendingTotp?.providerType ?? 0}
|
||||
pendingTotpAvailableProviders={pendingTotp?.availableProviders ?? []}
|
||||
totpCode={totpCode}
|
||||
rememberDevice={rememberDevice}
|
||||
onTotpCodeChange={setTotpCode}
|
||||
onRememberDeviceChange={setRememberDevice}
|
||||
onConfirmTotp={() => void handleTotpVerify()}
|
||||
onSelectTotpProvider={handleSelectTotpProvider}
|
||||
onCancelTotp={() => {
|
||||
if (totpSubmitting) return;
|
||||
setPendingTotp(null);
|
||||
@@ -2184,11 +2309,14 @@ export default function App() {
|
||||
confirm={confirm}
|
||||
onCancelConfirm={() => setConfirm(null)}
|
||||
pendingTotpOpen={false}
|
||||
pendingTotpProviderType={0}
|
||||
pendingTotpAvailableProviders={[]}
|
||||
totpCode=""
|
||||
rememberDevice={false}
|
||||
onTotpCodeChange={() => {}}
|
||||
onRememberDeviceChange={() => {}}
|
||||
onConfirmTotp={() => {}}
|
||||
onSelectTotpProvider={() => {}}
|
||||
onCancelTotp={() => {}}
|
||||
onUseRecoveryCode={() => {}}
|
||||
totpSubmitting={false}
|
||||
@@ -2215,21 +2343,24 @@ export default function App() {
|
||||
/>
|
||||
<AuthRequestApprovalDialog
|
||||
open={authRequestDialogOpen}
|
||||
authRequest={latestPendingAuthRequest}
|
||||
authRequest={authRequestDialogRequest}
|
||||
submitting={!!authRequestSubmittingId}
|
||||
onApprove={() => {
|
||||
if (!latestPendingAuthRequest) return;
|
||||
void approveAuthRequest(latestPendingAuthRequest).catch((error) => {
|
||||
if (!authRequestDialogRequest) return;
|
||||
void approveAuthRequest(authRequestDialogRequest).catch((error) => {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
|
||||
});
|
||||
}}
|
||||
onDeny={() => {
|
||||
if (!latestPendingAuthRequest) return;
|
||||
void denyAuthRequest(latestPendingAuthRequest).catch((error) => {
|
||||
if (!authRequestDialogRequest) return;
|
||||
void denyAuthRequest(authRequestDialogRequest).catch((error) => {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
|
||||
});
|
||||
}}
|
||||
onClose={() => setAuthRequestDialogDismissedId(latestPendingAuthRequest?.id || null)}
|
||||
onClose={() => {
|
||||
setAuthRequestDialogSelectedId(null);
|
||||
setAuthRequestDialogDismissedId(authRequestDialogRequest?.id || null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import ToastHost from '@/components/ToastHost';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -21,11 +22,14 @@ interface AppGlobalOverlaysProps {
|
||||
confirm: AppConfirmState | null;
|
||||
onCancelConfirm: () => void;
|
||||
pendingTotpOpen: boolean;
|
||||
pendingTotpProviderType?: number;
|
||||
pendingTotpAvailableProviders?: number[];
|
||||
totpCode: string;
|
||||
rememberDevice: boolean;
|
||||
onTotpCodeChange: (value: string) => void;
|
||||
onRememberDeviceChange: (checked: boolean) => void;
|
||||
onConfirmTotp: () => void;
|
||||
onSelectTotpProvider: (providerType: number) => void;
|
||||
onCancelTotp: () => void;
|
||||
onUseRecoveryCode: () => void;
|
||||
totpSubmitting: boolean;
|
||||
@@ -37,7 +41,40 @@ interface AppGlobalOverlaysProps {
|
||||
disableTotpSubmitting: boolean;
|
||||
}
|
||||
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const TWO_FACTOR_PROVIDER_ORDER = [
|
||||
TWO_FACTOR_PROVIDER_WEBAUTHN,
|
||||
TWO_FACTOR_PROVIDER_YUBIKEY,
|
||||
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
|
||||
] as const;
|
||||
|
||||
function uniqueSupportedProviders(providerTypes: number[] | undefined): number[] {
|
||||
const available = new Set(providerTypes || []);
|
||||
return TWO_FACTOR_PROVIDER_ORDER.filter((provider) => available.has(provider));
|
||||
}
|
||||
|
||||
function twoFactorProviderLabel(providerType: number): string {
|
||||
if (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN) return t('txt_passkey');
|
||||
if (providerType === TWO_FACTOR_PROVIDER_YUBIKEY) return t('txt_otp_from_yubikey');
|
||||
return t('txt_authenticator_app');
|
||||
}
|
||||
|
||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
|
||||
const availableProviders = useMemo(
|
||||
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
|
||||
[props.pendingTotpAvailableProviders]
|
||||
);
|
||||
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
|
||||
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
|
||||
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
||||
|
||||
useEffect(() => {
|
||||
setMethodChooserOpen(false);
|
||||
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
@@ -55,10 +92,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.pendingTotpOpen}
|
||||
title={t('txt_two_step_verification')}
|
||||
message={t('txt_password_is_already_verified')}
|
||||
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : isWebAuthn ? (
|
||||
<span className="dialog-title-stack">
|
||||
<span>{t('txt_two_step_verification')}</span>
|
||||
<span>{t('txt_passkey')}</span>
|
||||
</span>
|
||||
) : t('txt_two_step_verification')}
|
||||
message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : isWebAuthn ? t('txt_use_passkey_to_complete_two_step_verification') : t('txt_password_is_already_verified')}
|
||||
confirmText={t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
hideCancel
|
||||
closeButton
|
||||
showIcon={false}
|
||||
confirmDisabled={props.totpSubmitting}
|
||||
cancelDisabled={props.totpSubmitting}
|
||||
@@ -67,16 +110,52 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
afterActions={(
|
||||
<div className="dialog-extra">
|
||||
<div className="dialog-divider" />
|
||||
{alternateProviders.length > 0 && (
|
||||
<div className="two-factor-method-switcher">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary dialog-btn"
|
||||
disabled={props.totpSubmitting}
|
||||
aria-expanded={methodChooserOpen}
|
||||
onClick={() => setMethodChooserOpen((open) => !open)}
|
||||
>
|
||||
{t('txt_select_another_verification_method')}
|
||||
</button>
|
||||
{methodChooserOpen && (
|
||||
<div className="two-factor-method-list" role="list" aria-label={t('txt_select_two_step_login_method')}>
|
||||
<div className="two-factor-method-label">{t('txt_select_two_step_login_method')}</div>
|
||||
{alternateProviders.map((providerType) => (
|
||||
<button
|
||||
key={providerType}
|
||||
type="button"
|
||||
className="btn btn-secondary two-factor-method-option"
|
||||
disabled={props.totpSubmitting}
|
||||
onClick={() => {
|
||||
setMethodChooserOpen(false);
|
||||
props.onSelectTotpProvider(providerType);
|
||||
}}
|
||||
>
|
||||
{twoFactorProviderLabel(providerType)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
|
||||
{t('txt_use_recovery_code')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_totp_code')}</span>
|
||||
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
{isWebAuthn ? (
|
||||
<p className="muted-inline settings-field-note">{t('txt_touch_your_passkey_when_prompted')}</p>
|
||||
) : (
|
||||
<label className="field">
|
||||
<span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
|
||||
<input className="input" type={isYubiKeyOtp ? 'password' : 'text'} value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
)}
|
||||
<label className="check-line check-line-compact">
|
||||
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
||||
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
||||
@@ -88,7 +167,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
title={t('txt_disable_totp')}
|
||||
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
||||
confirmText={t('txt_disable_totp')}
|
||||
cancelText={t('txt_cancel')}
|
||||
hideCancel
|
||||
closeButton
|
||||
danger
|
||||
showIcon={false}
|
||||
confirmDisabled={props.disableTotpSubmitting}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
|
||||
import type { AuditLogFilters } from '@/lib/api/admin';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, TwoFactorPasskeySettings, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
@@ -39,6 +39,7 @@ export interface AppMainRoutesProps {
|
||||
session: SessionState | null;
|
||||
mobileLayout: boolean;
|
||||
mobileSidebarToggleKey: number;
|
||||
themePreference: 'system' | 'light' | 'dark';
|
||||
importRoute: string;
|
||||
settingsHomeRoute: string;
|
||||
settingsAccountRoute: string;
|
||||
@@ -54,9 +55,12 @@ export interface AppMainRoutesProps {
|
||||
adminLoading: boolean;
|
||||
adminError: string;
|
||||
totpEnabled: boolean;
|
||||
yubikeyEnabled: boolean;
|
||||
passkey2faEnabled: boolean;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
authorizedDevices: AuthorizedDevice[];
|
||||
currentDeviceIdentifier: string;
|
||||
authorizedDevicesLoading: boolean;
|
||||
authorizedDevicesError: string;
|
||||
domainRules: DomainRules | null;
|
||||
@@ -65,6 +69,7 @@ export interface AppMainRoutesProps {
|
||||
onNavigate: (path: string) => void;
|
||||
onLogout: () => void;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
onThemePreferenceChange: (preference: 'system' | 'light' | 'dark') => void;
|
||||
onImport: (
|
||||
payload: CiphersImportPayload,
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||
@@ -107,8 +112,17 @@ export interface AppMainRoutesProps {
|
||||
sendUploadPercent: number | null;
|
||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetYubiKeySettings: (masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onSaveYubiKeySettings: (keys: string[], nfc: boolean, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onDisableYubiKey: (masterPassword: string) => Promise<void>;
|
||||
onGetTwoFactorPasskeySettings: (masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onCreateTwoFactorPasskey: (name: string, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDeleteTwoFactorPasskey: (id: number, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDisableTwoFactorPasskeys: (masterPassword: string) => Promise<void>;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
@@ -116,8 +130,10 @@ export interface AppMainRoutesProps {
|
||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||
onRefreshTwoFactorStatus: () => Promise<void>;
|
||||
pendingAuthRequests: AuthRequest[];
|
||||
pendingAuthRequestsLoading: boolean;
|
||||
pendingAuthRequestsRefreshing: boolean;
|
||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
@@ -130,30 +146,32 @@ 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>;
|
||||
onClearAuditLogs: () => Promise<number>;
|
||||
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
||||
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onExportBackup: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
|
||||
onImportBackup: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onImportBackupAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onSaveBackupSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
|
||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
}
|
||||
|
||||
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
@@ -262,12 +280,26 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<SettingsPage
|
||||
profile={props.profile}
|
||||
totpEnabled={props.totpEnabled}
|
||||
yubikeyEnabled={props.yubikeyEnabled}
|
||||
passkey2faEnabled={props.passkey2faEnabled}
|
||||
themePreference={props.themePreference}
|
||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||
onThemePreferenceChange={props.onThemePreferenceChange}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onChangePassword={props.onChangePassword}
|
||||
onSavePasswordHint={props.onSavePasswordHint}
|
||||
onEnableTotp={props.onEnableTotp}
|
||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||
onGetYubiKeySettings={props.onGetYubiKeySettings}
|
||||
onSaveYubiKeySettings={props.onSaveYubiKeySettings}
|
||||
onSaveYubiKeyApiCredentials={props.onSaveYubiKeyApiCredentials}
|
||||
onBootstrapYubiKeyApiCredentials={props.onBootstrapYubiKeyApiCredentials}
|
||||
onDisableYubiKey={props.onDisableYubiKey}
|
||||
onGetTwoFactorPasskeySettings={props.onGetTwoFactorPasskeySettings}
|
||||
onCreateTwoFactorPasskey={props.onCreateTwoFactorPasskey}
|
||||
onDeleteTwoFactorPasskey={props.onDeleteTwoFactorPasskey}
|
||||
onDisableTwoFactorPasskeys={props.onDisableTwoFactorPasskeys}
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onGetApiKey={props.onGetApiKey}
|
||||
onRotateApiKey={props.onRotateApiKey}
|
||||
@@ -275,11 +307,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
||||
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
||||
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
||||
pendingAuthRequests={props.pendingAuthRequests}
|
||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||
onRefreshTwoFactorStatus={props.onRefreshTwoFactorStatus}
|
||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||
onNotify={props.onNotify}
|
||||
@@ -352,10 +380,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}
|
||||
@@ -364,6 +394,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}
|
||||
/>
|
||||
@@ -412,10 +443,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>
|
||||
|
||||
@@ -34,18 +34,18 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
|
||||
|
||||
interface BackupCenterPageProps {
|
||||
currentUserId: string | null;
|
||||
onExport: (includeAttachments?: boolean) => Promise<void>;
|
||||
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onExport: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
|
||||
onImport: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onImportAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onLoadSettings: () => Promise<AdminBackupSettings>;
|
||||
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onSaveSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,15 @@ type PendingRestoreIntegrity =
|
||||
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
|
||||
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
|
||||
|
||||
type PendingBackupVerification =
|
||||
| { action: 'export' }
|
||||
| { action: 'saveSettings' }
|
||||
| { action: 'deleteDestination'; destinationId: string; settings: AdminBackupSettings }
|
||||
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
|
||||
| { action: 'runRemoteBackup' }
|
||||
| { action: 'downloadRemote'; path: string }
|
||||
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
|
||||
|
||||
interface BackupProgressPhase {
|
||||
titleKey: string;
|
||||
detailKey: string;
|
||||
@@ -184,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);
|
||||
@@ -193,6 +202,9 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
|
||||
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
|
||||
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
||||
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
|
||||
const [backupPasswordValue, setBackupPasswordValue] = useState('');
|
||||
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
|
||||
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
|
||||
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
||||
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
||||
@@ -209,7 +221,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const selectedDestination = getDestinationById(settings, selectedDestinationId);
|
||||
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
|
||||
const selectedDestinationIsSaved = !!savedSelectedDestination;
|
||||
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup;
|
||||
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup || backupPasswordSubmitting;
|
||||
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
|
||||
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
|
||||
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
|
||||
@@ -226,6 +238,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
|
||||
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
|
||||
const canBrowseSelectedDestination = !!savedSelectedDestination;
|
||||
const backupPasswordPromptTitle =
|
||||
pendingBackupVerification?.action === 'export'
|
||||
? t('txt_backup_export')
|
||||
: pendingBackupVerification?.action === 'saveSettings' || pendingBackupVerification?.action === 'deleteDestination'
|
||||
? t('txt_backup_save_settings')
|
||||
: pendingBackupVerification?.action === 'runRemoteBackup'
|
||||
? t('txt_backup_run_manual')
|
||||
: pendingBackupVerification?.action === 'downloadRemote'
|
||||
? t('txt_backup_remote_download')
|
||||
: pendingBackupVerification?.action === 'restoreRemote'
|
||||
? t('txt_backup_import')
|
||||
: t('txt_backup_import');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -478,10 +502,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
|
||||
};
|
||||
|
||||
setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
|
||||
setBackupPasswordValue('');
|
||||
setConfirmDeleteDestinationOpen(false);
|
||||
}
|
||||
|
||||
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) {
|
||||
setSavingSettings(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
const saved = await props.onSaveSettings(nextSettings);
|
||||
const saved = await props.onSaveSettings(masterPassword, payload);
|
||||
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
|
||||
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
|
||||
setSavedSettings(saved);
|
||||
@@ -507,11 +537,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (exporting) return;
|
||||
setPendingBackupVerification({ action: 'export' });
|
||||
setBackupPasswordValue('');
|
||||
}
|
||||
|
||||
async function executeExport(masterPassword: string) {
|
||||
setLocalError('');
|
||||
setExporting(true);
|
||||
try {
|
||||
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
|
||||
await props.onExport(exportIncludeAttachments);
|
||||
await props.onExport(masterPassword, exportIncludeAttachments);
|
||||
props.onNotify('success', t('txt_backup_export_success'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
||||
@@ -527,6 +563,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
replaceExisting: boolean,
|
||||
allowChecksumMismatch: boolean = false,
|
||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||
) {
|
||||
if (importing) return;
|
||||
if (!selectedFile) {
|
||||
const message = t('txt_backup_file_required');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
return;
|
||||
}
|
||||
setPendingBackupVerification({
|
||||
action: 'import',
|
||||
replaceExisting,
|
||||
allowChecksumMismatch,
|
||||
knownIntegrity,
|
||||
});
|
||||
setBackupPasswordValue('');
|
||||
}
|
||||
|
||||
async function executeLocalRestore(
|
||||
masterPassword: string,
|
||||
replaceExisting: boolean,
|
||||
allowChecksumMismatch: boolean = false,
|
||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||
) {
|
||||
if (importing) return;
|
||||
if (!selectedFile) {
|
||||
@@ -547,8 +605,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
delayMs: replaceExisting ? 480 : 1400,
|
||||
});
|
||||
const result = allowChecksumMismatch
|
||||
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting)
|
||||
: await props.onImport(selectedFile, replaceExisting);
|
||||
? await props.onImportAllowingChecksumMismatch(masterPassword, selectedFile, replaceExisting)
|
||||
: await props.onImport(masterPassword, selectedFile, replaceExisting);
|
||||
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
|
||||
const skippedMessage = buildSkippedImportMessage(result);
|
||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||
@@ -573,12 +631,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
}
|
||||
|
||||
async function handleSaveSettings() {
|
||||
if (savingSettings) return;
|
||||
setPendingBackupVerification({ action: 'saveSettings' });
|
||||
setBackupPasswordValue('');
|
||||
}
|
||||
|
||||
async function executeSaveSettings(masterPassword: string) {
|
||||
const payload = buildSettingsPayloadForSelectedDestination();
|
||||
const destinationIdToInvalidate = selectedDestinationId;
|
||||
setSavingSettings(true);
|
||||
setLocalError('');
|
||||
try {
|
||||
const saved = await props.onSaveSettings(payload);
|
||||
const saved = await props.onSaveSettings(masterPassword, payload);
|
||||
const nextSelected =
|
||||
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|
||||
|| getFirstVisibleDestinationId(saved)
|
||||
@@ -613,6 +677,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
}
|
||||
|
||||
async function handleRunRemoteBackup() {
|
||||
if (!selectedDestination || runningRemoteBackup) return;
|
||||
setPendingBackupVerification({ action: 'runRemoteBackup' });
|
||||
setBackupPasswordValue('');
|
||||
}
|
||||
|
||||
async function executeRunRemoteBackup(masterPassword: string) {
|
||||
if (!selectedDestination) return;
|
||||
setRunningRemoteBackup(true);
|
||||
setLocalError('');
|
||||
@@ -621,7 +691,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
source: 'remote',
|
||||
includeAttachments: !!selectedDestination.includeAttachments,
|
||||
});
|
||||
const result = await props.onRunRemoteBackup(selectedDestination.id);
|
||||
const result = await props.onRunRemoteBackup(masterPassword, selectedDestination.id);
|
||||
setSavedSettings(result.settings);
|
||||
setSettings(result.settings);
|
||||
setSelectedDestinationId(selectedDestination.id);
|
||||
@@ -638,12 +708,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
}
|
||||
|
||||
async function handleDownloadRemote(path: string) {
|
||||
setPendingBackupVerification({ action: 'downloadRemote', path });
|
||||
setBackupPasswordValue('');
|
||||
}
|
||||
|
||||
async function executeDownloadRemote(masterPassword: string, path: string) {
|
||||
if (!savedSelectedDestination) return;
|
||||
setDownloadingRemotePath(path);
|
||||
setDownloadingRemotePercent(null);
|
||||
setLocalError('');
|
||||
try {
|
||||
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
||||
await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
|
||||
setLocalError(message);
|
||||
@@ -724,6 +799,25 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
replaceExisting: boolean,
|
||||
allowChecksumMismatch: boolean = false,
|
||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||
) {
|
||||
if (restoringRemotePath) return;
|
||||
if (!savedSelectedDestination) return;
|
||||
setPendingBackupVerification({
|
||||
action: 'restoreRemote',
|
||||
path,
|
||||
replaceExisting,
|
||||
allowChecksumMismatch,
|
||||
knownIntegrity,
|
||||
});
|
||||
setBackupPasswordValue('');
|
||||
}
|
||||
|
||||
async function executeRemoteRestore(
|
||||
masterPassword: string,
|
||||
path: string,
|
||||
replaceExisting: boolean,
|
||||
allowChecksumMismatch: boolean = false,
|
||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||
) {
|
||||
if (restoringRemotePath) return;
|
||||
if (!savedSelectedDestination) return;
|
||||
@@ -738,8 +832,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
delayMs: replaceExisting ? 480 : 1400,
|
||||
});
|
||||
const result = allowChecksumMismatch
|
||||
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting)
|
||||
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
|
||||
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(masterPassword, savedSelectedDestination.id, path, replaceExisting)
|
||||
: await props.onRestoreRemoteBackup(masterPassword, savedSelectedDestination.id, path, replaceExisting);
|
||||
setConfirmRemoteReplaceOpen(false);
|
||||
setPendingRemoteRestorePath('');
|
||||
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
|
||||
@@ -762,6 +856,38 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitBackupPasswordPrompt(): Promise<void> {
|
||||
const request = pendingBackupVerification;
|
||||
const masterPassword = backupPasswordValue;
|
||||
if (!request || backupPasswordSubmitting) return;
|
||||
if (!masterPassword.trim()) {
|
||||
props.onNotify('error', t('txt_master_password_is_required'));
|
||||
return;
|
||||
}
|
||||
setBackupPasswordSubmitting(true);
|
||||
setPendingBackupVerification(null);
|
||||
setBackupPasswordValue('');
|
||||
try {
|
||||
if (request.action === 'export') {
|
||||
await executeExport(masterPassword);
|
||||
} else if (request.action === 'saveSettings') {
|
||||
await executeSaveSettings(masterPassword);
|
||||
} else if (request.action === 'deleteDestination') {
|
||||
await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
|
||||
} else if (request.action === 'import') {
|
||||
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||
} else if (request.action === 'runRemoteBackup') {
|
||||
await executeRunRemoteBackup(masterPassword);
|
||||
} else if (request.action === 'downloadRemote') {
|
||||
await executeDownloadRemote(masterPassword, request.path);
|
||||
} else if (request.action === 'restoreRemote') {
|
||||
await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||
}
|
||||
} finally {
|
||||
setBackupPasswordSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="backup-grid">
|
||||
<input
|
||||
@@ -848,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">
|
||||
@@ -893,6 +1018,33 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
</div>
|
||||
), document.body) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingBackupVerification !== null}
|
||||
title={backupPasswordPromptTitle}
|
||||
message={t('txt_enter_master_password_to_continue')}
|
||||
confirmText={t('txt_continue')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={backupPasswordSubmitting || !backupPasswordValue.trim()}
|
||||
cancelDisabled={backupPasswordSubmitting}
|
||||
onConfirm={() => void submitBackupPasswordPrompt()}
|
||||
onCancel={() => {
|
||||
if (backupPasswordSubmitting) return;
|
||||
setPendingBackupVerification(null);
|
||||
setBackupPasswordValue('');
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={backupPasswordValue}
|
||||
onInput={(event) => setBackupPasswordValue((event.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmLocalRestoreOpen}
|
||||
title={t('txt_backup_import')}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { TriangleAlert } from 'lucide-preact';
|
||||
import { TriangleAlert, X } from 'lucide-preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
title: ComponentChildren;
|
||||
message?: string;
|
||||
variant?: 'default' | 'warning';
|
||||
showIcon?: boolean;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
hideCancel?: boolean;
|
||||
hideConfirm?: boolean;
|
||||
closeButton?: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
cancelDisabled?: boolean;
|
||||
onConfirm: () => void;
|
||||
@@ -88,6 +90,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||
const titleId = `${dialogId}-title`;
|
||||
const messageId = `${dialogId}-message`;
|
||||
const hasMessage = !!props.message;
|
||||
const canDismiss = !props.cancelDisabled && !closing;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -191,7 +194,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={messageId}
|
||||
aria-describedby={hasMessage ? messageId : undefined}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
onSubmit={(e) => {
|
||||
@@ -211,17 +214,33 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{props.closeButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-close-btn"
|
||||
aria-label={t('txt_close')}
|
||||
disabled={props.cancelDisabled}
|
||||
onClick={() => {
|
||||
if (props.cancelDisabled) return;
|
||||
props.onCancel();
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
<h3 id={titleId} className="dialog-title">{props.title}</h3>
|
||||
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||
{hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
|
||||
{props.children}
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
disabled={props.confirmDisabled}
|
||||
data-dialog-confirm="true"
|
||||
>
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
{!props.hideConfirm && (
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
disabled={props.confirmDisabled}
|
||||
data-dialog-confirm="true"
|
||||
>
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
)}
|
||||
{!props.hideCancel && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -5,7 +5,7 @@ import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface JwtWarningPageProps {
|
||||
reason: 'missing' | 'default' | 'too_short';
|
||||
reason: 'missing' | 'too_short';
|
||||
minLength: number;
|
||||
}
|
||||
|
||||
@@ -21,9 +21,7 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
|
||||
const title =
|
||||
props.reason === 'missing'
|
||||
? t('txt_jwt_title_missing')
|
||||
: props.reason === 'default'
|
||||
? t('txt_jwt_title_default')
|
||||
: t('txt_jwt_title_too_short');
|
||||
: t('txt_jwt_title_too_short');
|
||||
|
||||
const isMissing = props.reason === 'missing';
|
||||
const fixTitle = isMissing ? t('txt_jwt_how_to_fix_add') : t('txt_jwt_how_to_fix_replace');
|
||||
|
||||
@@ -8,41 +8,13 @@ interface NotFoundPageProps {
|
||||
}
|
||||
|
||||
export default function NotFoundPage(props: NotFoundPageProps) {
|
||||
const starBoxes = [1, 2, 3, 4];
|
||||
const stars = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
return (
|
||||
<main className="not-found-page">
|
||||
<div className="not-found-space" aria-hidden="true">
|
||||
{starBoxes.map((box) => (
|
||||
<div key={box} className={`not-found-star-box not-found-star-box-${box}`}>
|
||||
{stars.map((star) => (
|
||||
<span key={star} className={`not-found-star not-found-star-position-${star}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="not-found-shell" aria-labelledby="not-found-title">
|
||||
<div className="not-found-brand">
|
||||
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
|
||||
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
|
||||
</div>
|
||||
|
||||
<div className="not-found-astro-stage" aria-hidden="true">
|
||||
<div className="not-found-astronaut">
|
||||
<div className="not-found-astro-head" />
|
||||
<div className="not-found-astro-arm not-found-astro-arm-left" />
|
||||
<div className="not-found-astro-arm not-found-astro-arm-right" />
|
||||
<div className="not-found-astro-body">
|
||||
<div className="not-found-astro-panel" />
|
||||
</div>
|
||||
<div className="not-found-astro-leg not-found-astro-leg-left" />
|
||||
<div className="not-found-astro-leg not-found-astro-leg-right" />
|
||||
<div className="not-found-astro-pack" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="not-found-copy">
|
||||
<div className="not-found-code">404</div>
|
||||
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,49 +107,68 @@ 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}
|
||||
/>
|
||||
|
||||
<section className="card">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h3 className="flush-title">{t('txt_device_management')}</h3>
|
||||
<div className="muted-inline section-note">
|
||||
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h3 className="flush-title">{t('txt_authorized_devices')}</h3>
|
||||
<div className="muted-inline section-note">
|
||||
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||
<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')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger small" onClick={props.onRemoveAll}>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{t('txt_remove_all_devices')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_refresh')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
{t('txt_revoke_all_trusted')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-danger small" onClick={props.onRemoveAll}>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{t('txt_remove_all_devices')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
|
||||
{!!props.error && (
|
||||
<div className="local-error">
|
||||
<span>{props.error}</span>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<table className="table authorized-devices-table">
|
||||
{!!props.error && (
|
||||
<div className="local-error">
|
||||
<span>{props.error}</span>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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" />
|
||||
@@ -136,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>
|
||||
@@ -148,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 && (
|
||||
@@ -220,20 +274,20 @@ 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>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,21 +54,18 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
||||
<>
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_1')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
|
||||
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
|
||||
<a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a>
|
||||
{t('txt_backup_recommend_koofr_step_2_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_3')}
|
||||
<strong>2.</strong> {t('txt_backup_recommend_koofr_step_3')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_4')}
|
||||
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_4')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>5.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '}
|
||||
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '}
|
||||
<a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a>
|
||||
{t('txt_backup_recommend_koofr_step_5_suffix')}
|
||||
</div>
|
||||
@@ -98,13 +95,10 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
||||
return (
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_1')}
|
||||
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_2')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_2')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_pcloud_step_3')}
|
||||
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_3')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -112,18 +106,87 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
||||
return (
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_1')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
|
||||
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
|
||||
<a href="https://infini-cloud.net/en/modules/mypage/usage/" target="_blank" rel="noreferrer">My Page</a>
|
||||
{t('txt_backup_recommend_infinicloud_step_2_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
|
||||
<strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>4.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
|
||||
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'backblaze-b2':
|
||||
return (
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_backblaze_step_2_prefix')}{' '}
|
||||
<a href={provider.bucketsUrl} target="_blank" rel="noreferrer">Buckets</a>
|
||||
{t('txt_backup_recommend_backblaze_step_2_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_backblaze_step_3')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_backblaze_step_4_prefix')}{' '}
|
||||
<a href={provider.applicationKeysUrl} target="_blank" rel="noreferrer">Application Keys</a>
|
||||
{t('txt_backup_recommend_backblaze_step_4_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>4.</strong> {t('txt_backup_recommend_backblaze_step_5')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>5.</strong> {t('txt_backup_recommend_s3_path_prefix_step')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'cloudflare-r2':
|
||||
return (
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_cloudflare_r2_step_1_prefix')}{' '}
|
||||
<a href={provider.bucketUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_cloudflare_r2_bucket_link')}</a>
|
||||
{t('txt_backup_recommend_cloudflare_r2_step_1_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_cloudflare_r2_step_2_prefix')}{' '}
|
||||
<a href={provider.apiTokenUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_cloudflare_r2_api_link')}</a>
|
||||
{t('txt_backup_recommend_cloudflare_r2_step_2_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_cloudflare_r2_step_3')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>4.</strong> {t('txt_backup_recommend_cloudflare_r2_step_4')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>5.</strong> {t('txt_backup_recommend_cloudflare_r2_step_5')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'tigris':
|
||||
return (
|
||||
<div className="backup-recommendation-steps">
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>1.</strong> {t('txt_backup_recommend_tigris_step_2_prefix')}{' '}
|
||||
<a href={provider.bucketUrl} target="_blank" rel="noreferrer">Create Bucket</a>
|
||||
{t('txt_backup_recommend_tigris_step_2_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>2.</strong> {t('txt_backup_recommend_tigris_step_3_prefix')}{' '}
|
||||
<a href={provider.accessKeyUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_tigris_access_key_link')}</a>
|
||||
{t('txt_backup_recommend_tigris_step_3_suffix')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>3.</strong> {t('txt_backup_recommend_tigris_step_4')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>4.</strong> {t('txt_backup_recommend_tigris_step_5')}
|
||||
</div>
|
||||
<div className="backup-recommendation-step">
|
||||
<strong>5.</strong> {t('txt_backup_recommend_s3_path_prefix_step')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -147,6 +210,9 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
<div className="backup-inline-note">
|
||||
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
|
||||
: props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary')
|
||||
: props.selectedRecommendedProvider.id === 'backblaze-b2' ? t('txt_backup_recommend_backblaze_summary')
|
||||
: props.selectedRecommendedProvider.id === 'cloudflare-r2' ? t('txt_backup_recommend_cloudflare_r2_summary')
|
||||
: props.selectedRecommendedProvider.id === 'tigris' ? t('txt_backup_recommend_tigris_summary')
|
||||
: t('txt_backup_recommend_pcloud_summary')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,7 +453,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="nodewarden/backups"
|
||||
placeholder="nodewarden"
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
@@ -504,7 +570,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
className="input"
|
||||
value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
placeholder="nodewarden/backups"
|
||||
placeholder=""
|
||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||
...destination,
|
||||
destination: {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||
import { hasLinkedStorages } from '@/lib/backup-recommendations';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
||||
|
||||
const MOBILE_RECOMMENDATIONS_QUERY = '(max-width: 760px)';
|
||||
|
||||
interface BackupOperationsSidebarProps {
|
||||
disableWhileBusy: boolean;
|
||||
exporting: boolean;
|
||||
@@ -18,7 +21,30 @@ interface BackupOperationsSidebarProps {
|
||||
onSelectProvider: (providerId: string) => void;
|
||||
}
|
||||
|
||||
function getDefaultRecommendationsOpen() {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return true;
|
||||
}
|
||||
return !window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY).matches;
|
||||
}
|
||||
|
||||
export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
||||
const [recommendationsOpen, setRecommendationsOpen] = useState(getDefaultRecommendationsOpen);
|
||||
const [recommendationsTouched, setRecommendationsTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function' || recommendationsTouched) {
|
||||
return;
|
||||
}
|
||||
|
||||
const media = window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY);
|
||||
const syncOpenState = () => setRecommendationsOpen(!media.matches);
|
||||
|
||||
syncOpenState();
|
||||
media.addEventListener('change', syncOpenState);
|
||||
return () => media.removeEventListener('change', syncOpenState);
|
||||
}, [recommendationsTouched]);
|
||||
|
||||
return (
|
||||
<aside className="backup-operations-sidebar">
|
||||
<div className="section-head">
|
||||
@@ -41,7 +67,14 @@ export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details className="backup-recommendations-disclosure">
|
||||
<details
|
||||
className="backup-recommendations-disclosure"
|
||||
open={recommendationsOpen}
|
||||
onToggle={(event) => {
|
||||
setRecommendationsTouched(true);
|
||||
setRecommendationsOpen((event.currentTarget as HTMLDetailsElement).open);
|
||||
}}
|
||||
>
|
||||
<summary className="backup-recommendations-summary">
|
||||
<span>
|
||||
<strong>{t('txt_backup_recommend_title')}</strong>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Download, FileArchive, FolderOpen, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact';
|
||||
import { Download, FileArchive, FolderOpen, FolderUp, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact';
|
||||
import type { RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||
import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -32,26 +32,32 @@ 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" />
|
||||
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_backup_remote_title')}</h3>
|
||||
{props.canBrowse ? (
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_backup_remote_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!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">
|
||||
@@ -59,20 +65,28 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
||||
<span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span>
|
||||
</div>
|
||||
|
||||
<div className="actions backup-browser-nav">
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}>
|
||||
<FolderOpen size={14} className="btn-icon" />
|
||||
{t('txt_backup_remote_root')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null}
|
||||
onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')}
|
||||
>
|
||||
<RotateCcw size={14} className="btn-icon" />
|
||||
{t('txt_backup_remote_up')}
|
||||
</button>
|
||||
<div className="backup-browser-nav">
|
||||
<div className="actions backup-browser-nav-left">
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}>
|
||||
<FolderOpen size={14} className="btn-icon" />
|
||||
{t('txt_backup_remote_root')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null}
|
||||
onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')}
|
||||
>
|
||||
<FolderUp size={14} className="btn-icon" />
|
||||
{t('txt_backup_remote_up')}
|
||||
</button>
|
||||
</div>
|
||||
{props.canBrowse ? (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_backup_remote_refresh')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{props.loadingRemoteBrowser ? (
|
||||
@@ -80,6 +94,12 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
||||
) : props.remoteBrowser.items.length ? (
|
||||
<>
|
||||
<div className="backup-browser-list">
|
||||
<div className="backup-browser-head" aria-hidden="true">
|
||||
<span>{t('txt_name')}</span>
|
||||
<span>{t('txt_backup_remote_modified')}</span>
|
||||
<span>{t('txt_backup_remote_size')}</span>
|
||||
<span>{t('txt_actions')}</span>
|
||||
</div>
|
||||
{props.visibleItems.map((item) => (
|
||||
<div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row">
|
||||
<button
|
||||
@@ -92,10 +112,12 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
||||
{item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />}
|
||||
<span className="backup-browser-name">{item.name}</span>
|
||||
</button>
|
||||
<div className="backup-browser-meta">
|
||||
<span>{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}</span>
|
||||
<span>{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}</span>
|
||||
</div>
|
||||
<span className="backup-browser-meta backup-browser-modified">
|
||||
{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}
|
||||
</span>
|
||||
<span className="backup-browser-meta backup-browser-size">
|
||||
{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}
|
||||
</span>
|
||||
<div className="actions backup-browser-actions">
|
||||
{item.isDirectory ? (
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||
import jsQR from 'jsqr';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||
@@ -171,16 +172,38 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
return new window.BarcodeDetector({ formats: ['qr_code'] });
|
||||
};
|
||||
|
||||
const decodeTotpQrImage = async (source: ImageBitmapSource): Promise<boolean> => {
|
||||
const decodeTotpQrCanvas = (source: ImageBitmap | HTMLVideoElement): string => {
|
||||
const width = 'videoWidth' in source ? source.videoWidth : source.width;
|
||||
const height = 'videoHeight' in source ? source.videoHeight : source.height;
|
||||
if (!width || !height) return '';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return '';
|
||||
// jsQR ignores alpha and reads RGB directly, so transparent pixels would be
|
||||
// treated as black. Composite over white first so transparent-background QR
|
||||
// exports do not become black-on-black and fail to decode.
|
||||
context.fillStyle = '#ffffff';
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.drawImage(source, 0, 0, width, height);
|
||||
const imageData = context.getImageData(0, 0, width, height);
|
||||
return String(jsQR(imageData.data, width, height)?.data || '').trim();
|
||||
};
|
||||
|
||||
const decodeTotpQrImage = async (source: ImageBitmap): Promise<boolean> => {
|
||||
const detector = createTotpQrDetector();
|
||||
if (!detector) {
|
||||
setTotpQrStatus(t('txt_totp_qr_unsupported'));
|
||||
return false;
|
||||
if (detector) {
|
||||
try {
|
||||
const results = await detector.detect(source);
|
||||
const value = String(results[0]?.rawValue || '').trim();
|
||||
if (value && applyTotpQrValue(value)) return true;
|
||||
} catch {
|
||||
// Fall back to jsQR when the native detector is present but not usable.
|
||||
}
|
||||
}
|
||||
const results = await detector.detect(source);
|
||||
const value = String(results[0]?.rawValue || '').trim();
|
||||
if (!value) return false;
|
||||
return applyTotpQrValue(value);
|
||||
const value = decodeTotpQrCanvas(source);
|
||||
return value ? applyTotpQrValue(value) : false;
|
||||
};
|
||||
|
||||
const handleTotpQrFile = async (file: File | null) => {
|
||||
@@ -206,14 +229,8 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
return;
|
||||
}
|
||||
let stopped = false;
|
||||
let lastCanvasScan = 0;
|
||||
const detector = createTotpQrDetector();
|
||||
if (!detector) {
|
||||
setTotpQrStatus(t('txt_totp_qr_unsupported'));
|
||||
return () => {
|
||||
stopped = true;
|
||||
stopTotpQrScanner();
|
||||
};
|
||||
}
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
|
||||
return () => {
|
||||
@@ -230,8 +247,25 @@ export default function VaultEditor(props: VaultEditorProps) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results = await detector.detect(video);
|
||||
const value = String(results[0]?.rawValue || '').trim();
|
||||
let value = '';
|
||||
if (detector) {
|
||||
try {
|
||||
const results = await detector.detect(video);
|
||||
value = String(results[0]?.rawValue || '').trim();
|
||||
} catch {
|
||||
// Fall back to jsQR when the native detector is present but not usable.
|
||||
}
|
||||
}
|
||||
// The jsQR fallback runs a synchronous full-frame decode, so throttle
|
||||
// it to a few times per second instead of every animation frame to
|
||||
// avoid pegging the CPU while a code is being aligned.
|
||||
if (!value) {
|
||||
const now = performance.now();
|
||||
if (now - lastCanvasScan >= 250) {
|
||||
lastCanvasScan = now;
|
||||
value = decodeTotpQrCanvas(video);
|
||||
}
|
||||
}
|
||||
if (value && applyTotpQrValue(value)) return;
|
||||
} catch {
|
||||
// Keep the camera active; transient frame decode failures are common.
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import {
|
||||
changeMasterPassword,
|
||||
bootstrapYubiKeyOtpApiCredentials,
|
||||
deleteAllAuthorizedDevices,
|
||||
deleteAuthorizedDevice,
|
||||
deleteAuthorizedDevices,
|
||||
deriveLoginHash,
|
||||
deleteAccountPasskey as deleteAccountPasskeyApi,
|
||||
deleteTwoFactorPasskey as deleteTwoFactorPasskeyApi,
|
||||
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
|
||||
disableTwoFactorPasskeys as disableTwoFactorPasskeysApi,
|
||||
disableYubiKeyOtp,
|
||||
getCurrentDeviceIdentifier,
|
||||
getApiKey,
|
||||
getAccountPasskeyAttestationOptions,
|
||||
getAccountPasskeyUpdateAssertionOptions,
|
||||
getTotpRecoveryCode,
|
||||
getTwoFactorPasskeyChallenge,
|
||||
getTwoFactorPasskeySettings as getTwoFactorPasskeySettingsApi,
|
||||
getYubiKeyOtpSettings,
|
||||
listAccountPasskeys,
|
||||
rotateApiKey,
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
saveAccountPasskey,
|
||||
saveTwoFactorPasskey,
|
||||
saveYubiKeyOtpApiCredentials,
|
||||
saveYubiKeyOtpSettings,
|
||||
setTotp,
|
||||
trustAuthorizedDevicePermanently,
|
||||
updateAuthorizedDeviceName,
|
||||
@@ -27,11 +38,12 @@ import {
|
||||
buildAccountPasskeyPrfKeySet,
|
||||
buildAccountPasskeyPrfKeySetFromPrfKey,
|
||||
createAccountPasskeyCredential,
|
||||
createTwoFactorPasskeyCredential,
|
||||
} from '@/lib/account-passkeys';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||
import type { AuthedFetch } from '@/lib/api/shared';
|
||||
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState, TwoFactorPasskeySettings, YubiKeyOtpSettings } from '@/lib/types';
|
||||
|
||||
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
|
||||
@@ -46,7 +58,7 @@ interface UseAccountSecurityActionsOptions {
|
||||
onNotify: Notify;
|
||||
onProfileUpdated: (profile: Profile) => void;
|
||||
onSetConfirm: (next: AppConfirmState | null) => void;
|
||||
refetchTotpStatus: () => Promise<unknown>;
|
||||
refetchTwoFactorStatus: () => Promise<unknown>;
|
||||
refetchAuthorizedDevices: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -62,7 +74,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onNotify,
|
||||
onProfileUpdated,
|
||||
onSetConfirm,
|
||||
refetchTotpStatus,
|
||||
refetchTwoFactorStatus,
|
||||
refetchAuthorizedDevices,
|
||||
} = options;
|
||||
|
||||
@@ -145,14 +157,30 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
}
|
||||
},
|
||||
|
||||
async enableTotp(secret: string, token: string) {
|
||||
async enableTotp(secret: string, token: string, masterPassword: string) {
|
||||
if (!profile) {
|
||||
const error = new Error(t('txt_profile_unavailable'));
|
||||
onNotify('error', error.message);
|
||||
throw error;
|
||||
}
|
||||
if (!secret.trim() || !token.trim()) {
|
||||
const error = new Error(t('txt_secret_and_code_are_required'));
|
||||
onNotify('error', error.message);
|
||||
throw error;
|
||||
}
|
||||
if (!masterPassword) {
|
||||
const error = new Error(t('txt_master_password_is_required'));
|
||||
onNotify('error', error.message);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
|
||||
const derived = await deriveLoginHash(profile.email, masterPassword, defaultKdfIterations);
|
||||
await setTotp(authedFetch, {
|
||||
enabled: true,
|
||||
secret: secret.trim(),
|
||||
token: token.trim(),
|
||||
masterPasswordHash: derived.hash,
|
||||
});
|
||||
onNotify('success', t('txt_totp_enabled'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
|
||||
@@ -170,13 +198,118 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
||||
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
||||
clearDisableTotpDialog();
|
||||
await refetchTotpStatus();
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_totp_disabled'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_disable_totp_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
async getYubiKeySettings(masterPassword: string): Promise<YubiKeyOtpSettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
return getYubiKeyOtpSettings(authedFetch, derived.hash);
|
||||
},
|
||||
|
||||
async saveYubiKeySettings(keys: string[], nfc: boolean, masterPassword: string): Promise<YubiKeyOtpSettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const settings = await saveYubiKeyOtpSettings(authedFetch, { keys, nfc, masterPasswordHash: derived.hash });
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_yubikeys_updated'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async saveYubiKeyApiCredentials(clientId: string, secretKey: string, masterPassword: string): Promise<YubiKeyOtpSettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const settings = await saveYubiKeyOtpApiCredentials(authedFetch, {
|
||||
masterPasswordHash: derived.hash,
|
||||
yubicoClientId: clientId,
|
||||
yubicoSecretKey: secretKey,
|
||||
});
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_yubikey_config_updated'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async bootstrapYubiKeyApiCredentials(otp: string, masterPassword: string): Promise<YubiKeyOtpSettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const settings = await bootstrapYubiKeyOtpApiCredentials(authedFetch, {
|
||||
masterPasswordHash: derived.hash,
|
||||
otp,
|
||||
});
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_yubikey_config_updated'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async disableYubiKey(masterPassword: string): Promise<void> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
await disableYubiKeyOtp(authedFetch, derived.hash);
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_yubikey_disabled'));
|
||||
},
|
||||
|
||||
async getTwoFactorPasskeySettings(masterPassword: string): Promise<TwoFactorPasskeySettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
return getTwoFactorPasskeySettingsApi(authedFetch, derived.hash);
|
||||
},
|
||||
|
||||
async createTwoFactorPasskey(name: string, masterPassword: string): Promise<TwoFactorPasskeySettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const normalizedName = String(name || '').trim() || t('txt_passkey');
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const challenge = await getTwoFactorPasskeyChallenge(authedFetch, derived.hash);
|
||||
const deviceResponse = await createTwoFactorPasskeyCredential(challenge);
|
||||
const settings = await saveTwoFactorPasskey(authedFetch, {
|
||||
name: normalizedName,
|
||||
masterPasswordHash: derived.hash,
|
||||
deviceResponse,
|
||||
});
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_two_step_passkey_added'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async deleteTwoFactorPasskey(id: number, masterPassword: string): Promise<TwoFactorPasskeySettings> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
const settings = await deleteTwoFactorPasskeyApi(authedFetch, { id, masterPasswordHash: derived.hash });
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_two_step_passkey_removed'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async disableTwoFactorPasskeys(masterPassword: string): Promise<void> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||
await disableTwoFactorPasskeysApi(authedFetch, derived.hash);
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_two_step_passkeys_disabled'));
|
||||
},
|
||||
|
||||
async getRecoveryCode(masterPassword: string): Promise<string> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
@@ -218,24 +351,33 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
const normalizedName = String(name || '').trim() || t('txt_account_passkey');
|
||||
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
|
||||
const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
|
||||
const pending = await createAccountPasskeyCredential(options);
|
||||
const pending = await createAccountPasskeyCredential(options, directUnlock);
|
||||
let keySet = null;
|
||||
let savedWithoutDirectUnlock = false;
|
||||
if (directUnlock) {
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
try {
|
||||
keySet = await buildAccountPasskeyPrfKeySet(pending, {
|
||||
symEncKey: session.symEncKey,
|
||||
symMacKey: session.symMacKey,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof AccountPasskeyPrfUnavailableError)) throw error;
|
||||
if (!pending.supportsPrf) {
|
||||
const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey();
|
||||
if (!shouldSaveLoginOnly) {
|
||||
onNotify('warning', t('txt_account_passkey_not_saved'));
|
||||
return null;
|
||||
}
|
||||
savedWithoutDirectUnlock = true;
|
||||
} else {
|
||||
try {
|
||||
keySet = await buildAccountPasskeyPrfKeySet(pending, {
|
||||
symEncKey: session.symEncKey,
|
||||
symMacKey: session.symMacKey,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof AccountPasskeyPrfUnavailableError)) throw error;
|
||||
const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey();
|
||||
if (!shouldSaveLoginOnly) {
|
||||
onNotify('warning', t('txt_account_passkey_not_saved'));
|
||||
return null;
|
||||
}
|
||||
savedWithoutDirectUnlock = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const credential = await saveAccountPasskey(authedFetch, {
|
||||
@@ -364,6 +506,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'),
|
||||
@@ -418,7 +592,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
session?.symEncKey,
|
||||
session?.symMacKey,
|
||||
refetchAuthorizedDevices,
|
||||
refetchTotpStatus,
|
||||
refetchTwoFactorStatus,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
try {
|
||||
await revokeInvite(authedFetch, code);
|
||||
await refetchInvites();
|
||||
onNotify('success', t('txt_invite_revoked'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_invite_failed'));
|
||||
}
|
||||
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 deleteInvite(authedFetch, code);
|
||||
await refetchInvites();
|
||||
onNotify('success', t('txt_invite_deleted'));
|
||||
} catch (error) {
|
||||
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() {
|
||||
|
||||
@@ -27,9 +27,10 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
async exportBackup(includeAttachments: boolean = false) {
|
||||
async exportBackup(masterPasswordHash: string, includeAttachments: boolean = false) {
|
||||
const payload = await buildCompleteAdminBackupExport(
|
||||
authedFetch,
|
||||
masterPasswordHash,
|
||||
includeAttachments,
|
||||
async (event: BackupExportClientProgressEvent) => {
|
||||
dispatchBackupProgress(event);
|
||||
@@ -48,14 +49,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
||||
});
|
||||
},
|
||||
|
||||
async importBackup(file: File, replaceExisting: boolean = false) {
|
||||
const result = await importAdminBackup(authedFetch, file, replaceExisting);
|
||||
async importBackup(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
|
||||
const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting);
|
||||
onImported?.();
|
||||
return result;
|
||||
},
|
||||
|
||||
async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) {
|
||||
const result = await importAdminBackup(authedFetch, file, replaceExisting, true);
|
||||
async importBackupAllowingChecksumMismatch(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
|
||||
const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting, true);
|
||||
onImported?.();
|
||||
return result;
|
||||
},
|
||||
@@ -64,20 +65,20 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
||||
return getAdminBackupSettings(authedFetch);
|
||||
},
|
||||
|
||||
async saveSettings(settings: Parameters<typeof saveAdminBackupSettings>[1]) {
|
||||
return saveAdminBackupSettings(authedFetch, settings);
|
||||
async saveSettings(masterPasswordHash: string, settings: Parameters<typeof saveAdminBackupSettings>[2]) {
|
||||
return saveAdminBackupSettings(authedFetch, masterPasswordHash, settings);
|
||||
},
|
||||
|
||||
async runRemoteBackup(destinationId?: string | null) {
|
||||
return runAdminBackupNow(authedFetch, destinationId);
|
||||
async runRemoteBackup(masterPasswordHash: string, destinationId?: string | null) {
|
||||
return runAdminBackupNow(authedFetch, masterPasswordHash, destinationId);
|
||||
},
|
||||
|
||||
async listRemoteBackups(destinationId: string, path: string) {
|
||||
return listRemoteBackups(authedFetch, destinationId, path);
|
||||
},
|
||||
|
||||
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
|
||||
const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress);
|
||||
async downloadRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
|
||||
const payload = await fetchRemoteBackupPayload(authedFetch, masterPasswordHash, destinationId, path, onProgress);
|
||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||
},
|
||||
|
||||
@@ -89,14 +90,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
||||
await deleteRemoteBackup(authedFetch, destinationId, path);
|
||||
},
|
||||
|
||||
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting);
|
||||
async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||
const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting);
|
||||
onRestored?.();
|
||||
return result;
|
||||
},
|
||||
|
||||
async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true);
|
||||
async restoreRemoteBackupAllowingChecksumMismatch(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||
const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting, true);
|
||||
onRestored?.();
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -150,6 +163,22 @@ function shouldRetryWithLegacyPrf(error: unknown): boolean {
|
||||
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
|
||||
}
|
||||
|
||||
function shouldRetryCreateWithoutPrf(error: unknown): boolean {
|
||||
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
|
||||
const message = error instanceof DOMException || error instanceof Error ? error.message : '';
|
||||
return (
|
||||
name === 'NotSupportedError' ||
|
||||
name === 'SyntaxError' ||
|
||||
name === 'TypeError' ||
|
||||
(name === 'UnknownError' && /transient/i.test(message))
|
||||
);
|
||||
}
|
||||
|
||||
async function canRequestPrfExtension(): Promise<boolean> {
|
||||
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getPublicKeyCredentialWithPrf(
|
||||
options: PublicKeyCredentialRequestOptions,
|
||||
salt: Uint8Array,
|
||||
@@ -265,17 +294,39 @@ export async function assertAccountPasskey(
|
||||
}
|
||||
|
||||
export async function createAccountPasskeyCredential(
|
||||
response: { options: unknown; token: string }
|
||||
response: { options: unknown; token: string },
|
||||
requestPrf: boolean = false
|
||||
): Promise<PendingAccountPasskeyCredential> {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error(t('txt_passkey_browser_not_supported'));
|
||||
}
|
||||
const nativeOptions = cloneCreationOptions(response.options);
|
||||
(nativeOptions as any).extensions = {
|
||||
...((nativeOptions as any).extensions || {}),
|
||||
prf: {},
|
||||
const noPrfOptions = withoutCreatePrfExtension(nativeOptions);
|
||||
const createWithOptions = async (options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential> => {
|
||||
const credential = await navigator.credentials.create({ publicKey: options });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error(t('txt_no_passkey_created'));
|
||||
}
|
||||
return credential;
|
||||
};
|
||||
const credential = await navigator.credentials.create({ publicKey: nativeOptions });
|
||||
let credential: PublicKeyCredential;
|
||||
if (requestPrf && await canRequestPrfExtension()) {
|
||||
const prfOptions: PublicKeyCredentialCreationOptions = {
|
||||
...noPrfOptions,
|
||||
extensions: {
|
||||
...((noPrfOptions as any).extensions || {}),
|
||||
prf: {},
|
||||
} as any,
|
||||
};
|
||||
try {
|
||||
credential = await createWithOptions(prfOptions);
|
||||
} catch (error) {
|
||||
if (!shouldRetryCreateWithoutPrf(error)) throw error;
|
||||
credential = await createWithOptions(noPrfOptions);
|
||||
}
|
||||
} else {
|
||||
credential = await createWithOptions(noPrfOptions);
|
||||
}
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error(t('txt_no_passkey_created'));
|
||||
}
|
||||
@@ -289,6 +340,28 @@ export async function createAccountPasskeyCredential(
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTwoFactorPasskeyCredential(options: unknown): Promise<Record<string, unknown>> {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error(t('txt_passkey_browser_not_supported'));
|
||||
}
|
||||
const credential = await navigator.credentials.create({ publicKey: cloneCreationOptions(options) });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error(t('txt_no_passkey_created'));
|
||||
}
|
||||
return attestationRequest(credential);
|
||||
}
|
||||
|
||||
export async function assertTwoFactorPasskey(options: unknown): Promise<string> {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error(t('txt_passkey_browser_not_supported'));
|
||||
}
|
||||
const credential = await navigator.credentials.get({ publicKey: cloneRequestOptions(options) });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error(t('txt_invalid_passkey_assertion_response'));
|
||||
}
|
||||
return JSON.stringify(assertionRequest(credential));
|
||||
}
|
||||
|
||||
function parseRsaEncryptedUserKey(value: string): Uint8Array {
|
||||
const text = String(value || '').trim();
|
||||
const [type, payload] = text.split('.');
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+234
-7
@@ -7,6 +7,8 @@ import type {
|
||||
SessionState,
|
||||
TokenError,
|
||||
TokenSuccess,
|
||||
TwoFactorPasskeySettings,
|
||||
YubiKeyOtpSettings,
|
||||
} from '../types';
|
||||
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
||||
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
|
||||
@@ -240,6 +242,7 @@ export async function loginWithPassword(
|
||||
passwordHash: string,
|
||||
options?: {
|
||||
totpCode?: string;
|
||||
twoFactorProvider?: number;
|
||||
rememberDevice?: boolean;
|
||||
useRememberToken?: boolean;
|
||||
signal?: AbortSignal;
|
||||
@@ -259,7 +262,7 @@ export async function loginWithPassword(
|
||||
body.set('twoFactorProvider', '5');
|
||||
body.set('twoFactorToken', rememberedToken);
|
||||
} else if (options?.totpCode) {
|
||||
body.set('twoFactorProvider', '0');
|
||||
body.set('twoFactorProvider', String(options.twoFactorProvider ?? 0));
|
||||
body.set('twoFactorToken', options.totpCode);
|
||||
if (options.rememberDevice) {
|
||||
body.set('twoFactorRemember', '1');
|
||||
@@ -591,11 +594,14 @@ export async function changeMasterPassword(
|
||||
const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32);
|
||||
const oldMac = await hkdfExpand(current.masterKey, 'mac', 32);
|
||||
const userSym = await decryptBw(args.profileKey, oldEnc, oldMac);
|
||||
if (userSym.length !== 64) {
|
||||
throw new Error('Invalid profile key');
|
||||
}
|
||||
const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32);
|
||||
const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32);
|
||||
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
|
||||
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
|
||||
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac);
|
||||
const newKey = await encryptBw(userSym, nextEnc, nextMac);
|
||||
const newMasterPasswordHash = bytesToBase64(nextHash);
|
||||
|
||||
const resp = await authedFetch('/api/accounts/password', {
|
||||
@@ -647,6 +653,203 @@ export async function setTotp(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeYubiKeySettings(raw: any): YubiKeyOtpSettings {
|
||||
return {
|
||||
enabled: !!(raw?.enabled ?? raw?.Enabled),
|
||||
keys: [
|
||||
String(raw?.key1 ?? raw?.Key1 ?? ''),
|
||||
String(raw?.key2 ?? raw?.Key2 ?? ''),
|
||||
String(raw?.key3 ?? raw?.Key3 ?? ''),
|
||||
String(raw?.key4 ?? raw?.Key4 ?? ''),
|
||||
String(raw?.key5 ?? raw?.Key5 ?? ''),
|
||||
],
|
||||
nfc: !!(raw?.nfc ?? raw?.Nfc),
|
||||
yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured),
|
||||
yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''),
|
||||
yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getYubiKeyOtpSettings(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<YubiKeyOtpSettings> {
|
||||
const resp = await authedFetch('/api/two-factor/get-yubikey', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
||||
}
|
||||
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function saveYubiKeyOtpSettings(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { keys: string[]; nfc: boolean; masterPasswordHash: string }
|
||||
): Promise<YubiKeyOtpSettings> {
|
||||
const resp = await authedFetch('/api/two-factor/yubikey', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
key1: payload.keys[0] || '',
|
||||
key2: payload.keys[1] || '',
|
||||
key3: payload.keys[2] || '',
|
||||
key4: payload.keys[3] || '',
|
||||
key5: payload.keys[4] || '',
|
||||
nfc: payload.nfc,
|
||||
masterPasswordHash: payload.masterPasswordHash,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_update_failed')));
|
||||
}
|
||||
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function saveYubiKeyOtpApiCredentials(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { masterPasswordHash: string; yubicoClientId: string; yubicoSecretKey: string }
|
||||
): Promise<YubiKeyOtpSettings> {
|
||||
const resp = await authedFetch('/api/two-factor/yubikey/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_config_update_failed')));
|
||||
}
|
||||
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function bootstrapYubiKeyOtpApiCredentials(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { masterPasswordHash: string; otp: string }
|
||||
): Promise<YubiKeyOtpSettings> {
|
||||
const resp = await authedFetch('/api/two-factor/yubikey/bootstrap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_auto_config_failed')));
|
||||
}
|
||||
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function disableYubiKeyOtp(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/two-factor/disable', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 3, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_yubikey_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTwoFactorPasskeySettings(raw: any): TwoFactorPasskeySettings {
|
||||
const keys = Array.isArray(raw?.keys) ? raw.keys : Array.isArray(raw?.Keys) ? raw.Keys : [];
|
||||
return {
|
||||
enabled: !!(raw?.enabled ?? raw?.Enabled),
|
||||
keys: keys
|
||||
.map((item: any) => ({
|
||||
id: Number(item?.id ?? item?.Id),
|
||||
name: String(item?.name || item?.Name || ''),
|
||||
migrated: !!(item?.migrated ?? item?.Migrated),
|
||||
}))
|
||||
.filter((item: { id: number }) => Number.isInteger(item.id) && item.id > 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTwoFactorPasskeySettings(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<TwoFactorPasskeySettings> {
|
||||
const resp = await authedFetch('/api/two-factor/get-webauthn', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
||||
}
|
||||
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function getTwoFactorPasskeyChallenge(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<unknown> {
|
||||
const resp = await authedFetch('/api/two-factor/get-webauthn-challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed')));
|
||||
}
|
||||
return parseJson<unknown>(resp);
|
||||
}
|
||||
|
||||
export async function saveTwoFactorPasskey(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { id?: number; name: string; masterPasswordHash: string; deviceResponse: unknown }
|
||||
): Promise<TwoFactorPasskeySettings> {
|
||||
const resp = await authedFetch('/api/two-factor/webauthn', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed')));
|
||||
}
|
||||
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function deleteTwoFactorPasskey(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { id: number; masterPasswordHash: string }
|
||||
): Promise<TwoFactorPasskeySettings> {
|
||||
const resp = await authedFetch('/api/two-factor/webauthn', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed')));
|
||||
}
|
||||
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function disableTwoFactorPasskeys(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/two-factor/disable', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 7, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_passkey_two_step_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyMasterPassword(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
@@ -804,11 +1007,21 @@ export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<nu
|
||||
return stamp;
|
||||
}
|
||||
|
||||
export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> {
|
||||
const resp = await authedFetch('/api/accounts/totp');
|
||||
if (!resp.ok) throw new Error('Failed to load TOTP status');
|
||||
const body = (await parseJson<{ enabled?: boolean }>(resp)) || {};
|
||||
return { enabled: !!body.enabled };
|
||||
export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Promise<{ totpEnabled: boolean; yubikeyEnabled: boolean; passkeyEnabled: boolean }> {
|
||||
const resp = await authedFetch('/api/two-factor');
|
||||
if (!resp.ok) throw new Error('Failed to load two-factor status');
|
||||
const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
|
||||
const providers = Array.isArray(body.data) ? body.data : Array.isArray(body.Data) ? body.Data : [];
|
||||
const enabledTypes = new Set(
|
||||
providers
|
||||
.map((provider: any) => Number(provider?.type ?? provider?.Type))
|
||||
.filter((type) => Number.isFinite(type))
|
||||
);
|
||||
return {
|
||||
totpEnabled: enabledTypes.has(0),
|
||||
yubikeyEnabled: enabledTypes.has(3),
|
||||
passkeyEnabled: enabledTypes.has(7),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTotpRecoveryCode(
|
||||
@@ -885,6 +1098,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,
|
||||
|
||||
@@ -49,6 +49,11 @@ export interface BackupSettingsRepairStateResponse {
|
||||
portable: BackupSettingsPortablePayload | null;
|
||||
}
|
||||
|
||||
export interface BackupUserVerificationPayload {
|
||||
masterPasswordHash?: string | null;
|
||||
userVerificationToken?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminBackupRunResponse {
|
||||
object: 'backup-run';
|
||||
result: {
|
||||
@@ -173,12 +178,13 @@ async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array)
|
||||
|
||||
export async function exportAdminBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string,
|
||||
includeAttachments: boolean = false
|
||||
): Promise<AdminBackupExportPayload> {
|
||||
const resp = await authedFetch('/api/admin/backup/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ includeAttachments }),
|
||||
body: JSON.stringify({ includeAttachments, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
||||
|
||||
@@ -201,10 +207,11 @@ export async function downloadAdminBackupAttachmentBlob(
|
||||
|
||||
export async function buildCompleteAdminBackupExport(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string,
|
||||
includeAttachments: boolean = false,
|
||||
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
|
||||
): Promise<AdminBackupExportPayload> {
|
||||
const payload = await exportAdminBackup(authedFetch, includeAttachments);
|
||||
const payload = await exportAdminBackup(authedFetch, masterPasswordHash, includeAttachments);
|
||||
if (!includeAttachments) {
|
||||
await onProgress?.({
|
||||
operation: 'backup-export',
|
||||
@@ -278,12 +285,13 @@ export async function getAdminBackupSettings(authedFetch: AuthedFetch): Promise<
|
||||
|
||||
export async function saveAdminBackupSettings(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string,
|
||||
settings: AdminBackupSettings
|
||||
): Promise<AdminBackupSettings> {
|
||||
const resp = await authedFetch('/api/admin/backup/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
body: JSON.stringify({ ...settings, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
|
||||
const body = await parseJson<AdminBackupSettings>(resp);
|
||||
@@ -305,12 +313,13 @@ export async function getAdminBackupSettingsRepairState(
|
||||
|
||||
export async function repairAdminBackupSettings(
|
||||
authedFetch: AuthedFetch,
|
||||
verification: BackupUserVerificationPayload,
|
||||
settings: AdminBackupSettings
|
||||
): Promise<AdminBackupSettings> {
|
||||
const resp = await authedFetch('/api/admin/backup/settings/repair', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
body: JSON.stringify({ ...settings, ...verification }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
|
||||
const body = await parseJson<AdminBackupSettings>(resp);
|
||||
@@ -320,12 +329,13 @@ export async function repairAdminBackupSettings(
|
||||
|
||||
export async function runAdminBackupNow(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string,
|
||||
destinationId?: string | null
|
||||
): Promise<AdminBackupRunResponse> {
|
||||
const resp = await authedFetch('/api/admin/backup/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(destinationId ? { destinationId } : {}),
|
||||
body: JSON.stringify(destinationId ? { destinationId, masterPasswordHash } : { masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed')));
|
||||
const body = await parseJson<AdminBackupRunResponse>(resp);
|
||||
@@ -351,14 +361,16 @@ export async function listRemoteBackups(
|
||||
|
||||
export async function downloadRemoteBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string,
|
||||
destinationId: string,
|
||||
path: string,
|
||||
onProgress?: (percent: number | null) => void
|
||||
): Promise<AdminBackupExportPayload> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('destinationId', destinationId);
|
||||
params.set('path', path);
|
||||
const resp = await authedFetch(`/api/admin/backup/remote/download?${params.toString()}`, { method: 'GET' });
|
||||
const resp = await authedFetch('/api/admin/backup/remote/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
||||
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
|
||||
const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip');
|
||||
@@ -418,6 +430,7 @@ export async function inspectRemoteBackupIntegrity(
|
||||
|
||||
export async function restoreRemoteBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string,
|
||||
destinationId: string,
|
||||
path: string,
|
||||
replaceExisting: boolean = false,
|
||||
@@ -426,7 +439,7 @@ export async function restoreRemoteBackup(
|
||||
const resp = await authedFetch('/api/admin/backup/remote/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }),
|
||||
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
|
||||
const body = await parseJson<AdminBackupImportResponse>(resp);
|
||||
@@ -436,12 +449,14 @@ export async function restoreRemoteBackup(
|
||||
|
||||
export async function importAdminBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string,
|
||||
file: File,
|
||||
replaceExisting: boolean = false,
|
||||
allowChecksumMismatch: boolean = false
|
||||
): Promise<AdminBackupImportResponse> {
|
||||
const formData = new FormData();
|
||||
formData.set('file', file, file.name || 'nodewarden_backup.zip');
|
||||
formData.set('masterPasswordHash', masterPasswordHash);
|
||||
if (replaceExisting) {
|
||||
formData.set('replaceExisting', '1');
|
||||
}
|
||||
|
||||
+139
-14
@@ -34,6 +34,10 @@ export interface PendingTotp {
|
||||
passwordHash: string;
|
||||
masterKey: Uint8Array;
|
||||
kdfIterations: number;
|
||||
providerType: number;
|
||||
providerData?: unknown;
|
||||
availableProviders: number[];
|
||||
providerDataByType: Record<number, unknown>;
|
||||
}
|
||||
|
||||
export interface PendingPasskeyPassword {
|
||||
@@ -42,7 +46,7 @@ export interface PendingPasskeyPassword {
|
||||
kdfIterations: number;
|
||||
}
|
||||
|
||||
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
||||
export type JwtUnsafeReason = 'missing' | 'too_short';
|
||||
|
||||
export interface BootstrapAppResult {
|
||||
defaultKdfIterations: number;
|
||||
@@ -66,6 +70,100 @@ export interface CompletedLogin {
|
||||
session: SessionState;
|
||||
profile: Profile;
|
||||
profilePromise: Promise<Profile>;
|
||||
freshMasterPasswordHash?: string | null;
|
||||
freshUserVerificationToken?: string | null;
|
||||
}
|
||||
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const SUPPORTED_TWO_FACTOR_PROVIDERS = [
|
||||
TWO_FACTOR_PROVIDER_WEBAUTHN,
|
||||
TWO_FACTOR_PROVIDER_YUBIKEY,
|
||||
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
|
||||
] as const;
|
||||
|
||||
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
|
||||
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
|
||||
}
|
||||
|
||||
type TwoFactorTokenError = {
|
||||
TwoFactorProviders?: unknown;
|
||||
TwoFactorProviders2?: unknown;
|
||||
CustomResponse?: {
|
||||
TwoFactorProviders?: unknown;
|
||||
TwoFactorProviders2?: unknown;
|
||||
};
|
||||
error_description?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function readTwoFactorProviders(error: TwoFactorTokenError): unknown {
|
||||
return error.TwoFactorProviders ?? error.CustomResponse?.TwoFactorProviders ?? error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
|
||||
}
|
||||
|
||||
function readTwoFactorProviderData(error: TwoFactorTokenError, providerType: number): unknown {
|
||||
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
|
||||
if (!providers2 || typeof providers2 !== 'object') return undefined;
|
||||
const record = providers2 as Record<string, unknown>;
|
||||
return record[String(providerType)] ?? (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN ? record.WebAuthn : undefined);
|
||||
}
|
||||
|
||||
function twoFactorProviderTypeFromValue(value: unknown): number | null {
|
||||
const raw = value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>).Type ?? (value as Record<string, unknown>).type
|
||||
: value;
|
||||
const text = String(raw ?? '').trim();
|
||||
if (!text) return null;
|
||||
const normalized = text.toLowerCase();
|
||||
const numeric = Number(text);
|
||||
const provider = Number.isFinite(numeric)
|
||||
? numeric
|
||||
: normalized === 'webauthn'
|
||||
? TWO_FACTOR_PROVIDER_WEBAUTHN
|
||||
: normalized === 'yubikey' || normalized === 'yubikeyotp'
|
||||
? TWO_FACTOR_PROVIDER_YUBIKEY
|
||||
: normalized === 'authenticator' || normalized === 'totp'
|
||||
? TWO_FACTOR_PROVIDER_AUTHENTICATOR
|
||||
: Number.NaN;
|
||||
return SUPPORTED_TWO_FACTOR_PROVIDERS.includes(provider as any) ? provider : null;
|
||||
}
|
||||
|
||||
function sortTwoFactorProviders(providerTypes: number[]): number[] {
|
||||
const unique = new Set(providerTypes);
|
||||
return SUPPORTED_TWO_FACTOR_PROVIDERS.filter((provider) => unique.has(provider));
|
||||
}
|
||||
|
||||
function readTwoFactorProviderTypes(providers: unknown): number[] {
|
||||
const providerTypes: number[] = [];
|
||||
if (Array.isArray(providers)) {
|
||||
for (const provider of providers) {
|
||||
const providerType = twoFactorProviderTypeFromValue(provider);
|
||||
if (providerType != null) providerTypes.push(providerType);
|
||||
}
|
||||
} else if (providers && typeof providers === 'object') {
|
||||
for (const [key, value] of Object.entries(providers as Record<string, unknown>)) {
|
||||
if (value === false) continue;
|
||||
const providerType = twoFactorProviderTypeFromValue(key);
|
||||
if (providerType != null) providerTypes.push(providerType);
|
||||
}
|
||||
}
|
||||
return sortTwoFactorProviders(providerTypes);
|
||||
}
|
||||
|
||||
function readTwoFactorProviderDataMap(error: TwoFactorTokenError): Record<number, unknown> {
|
||||
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
|
||||
if (!providers2 || typeof providers2 !== 'object') return {};
|
||||
const out: Record<number, unknown> = {};
|
||||
for (const [key, value] of Object.entries(providers2 as Record<string, unknown>)) {
|
||||
const providerType = twoFactorProviderTypeFromValue(key);
|
||||
if (providerType != null) out[providerType] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolvePendingTwoFactorProvider(providers: unknown): number {
|
||||
return readTwoFactorProviderTypes(providers)[0] ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
||||
}
|
||||
|
||||
export type PasswordLoginResult =
|
||||
@@ -319,7 +417,8 @@ export async function completeLogin(
|
||||
token: TokenSuccess,
|
||||
email: string,
|
||||
masterKey: Uint8Array,
|
||||
fallbackKdfIterations: number
|
||||
fallbackKdfIterations: number,
|
||||
freshMasterPasswordHash?: string | null
|
||||
): Promise<CompletedLogin> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
||||
@@ -348,6 +447,8 @@ export async function completeLogin(
|
||||
session: { ...baseSession, ...keys },
|
||||
profile,
|
||||
profilePromise: getProfile(tempFetch),
|
||||
freshMasterPasswordHash: freshMasterPasswordHash || null,
|
||||
freshUserVerificationToken: readTokenUserVerificationToken(token),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -360,7 +461,8 @@ async function completeLoginWithVaultKeys(
|
||||
token: TokenSuccess,
|
||||
email: string,
|
||||
keys: { symEncKey: string; symMacKey: string },
|
||||
fallbackKdfIterations: number
|
||||
fallbackKdfIterations: number,
|
||||
freshMasterPasswordHash?: string | null
|
||||
): Promise<CompletedLogin> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
||||
@@ -385,6 +487,8 @@ async function completeLoginWithVaultKeys(
|
||||
session: { ...baseSession, ...keys },
|
||||
profile,
|
||||
profilePromise: getProfile(tempFetch),
|
||||
freshMasterPasswordHash: freshMasterPasswordHash || null,
|
||||
freshUserVerificationToken: readTokenUserVerificationToken(token),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -400,12 +504,16 @@ export async function performPasswordLogin(
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
|
||||
};
|
||||
}
|
||||
|
||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
if (tokenError.TwoFactorProviders) {
|
||||
const tokenError = token as TwoFactorTokenError;
|
||||
const providers = readTwoFactorProviders(tokenError);
|
||||
if (providers) {
|
||||
const providerType = resolvePendingTwoFactorProvider(providers);
|
||||
const availableProviders = readTwoFactorProviderTypes(providers);
|
||||
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
|
||||
return {
|
||||
kind: 'totp',
|
||||
pendingTotp: {
|
||||
@@ -413,6 +521,10 @@ export async function performPasswordLogin(
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
providerType,
|
||||
providerData: providerDataByType[providerType] ?? readTwoFactorProviderData(tokenError, providerType),
|
||||
availableProviders: availableProviders.length ? availableProviders : [providerType],
|
||||
providerDataByType,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -476,7 +588,7 @@ export async function completePasskeyPasswordLogin(
|
||||
password: string
|
||||
): Promise<CompletedLogin> {
|
||||
const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations);
|
||||
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations);
|
||||
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations, derived.hash);
|
||||
}
|
||||
|
||||
export async function performTotpLogin(
|
||||
@@ -486,13 +598,17 @@ export async function performTotpLogin(
|
||||
): Promise<CompletedLogin> {
|
||||
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, {
|
||||
totpCode: totpCode.trim(),
|
||||
twoFactorProvider: pendingTotp.providerType,
|
||||
rememberDevice,
|
||||
});
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations);
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash);
|
||||
}
|
||||
const tokenError = token as { error_description?: string; error?: string };
|
||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
||||
const fallback = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN
|
||||
? t('txt_passkey_verification_failed')
|
||||
: t('txt_totp_verify_failed');
|
||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, fallback));
|
||||
}
|
||||
|
||||
export async function performRecoverTwoFactorLogin(
|
||||
@@ -508,7 +624,7 @@ export async function performRecoverTwoFactorLogin(
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
|
||||
newRecoveryCode: recovered.newRecoveryCode || null,
|
||||
};
|
||||
}
|
||||
@@ -557,6 +673,7 @@ export async function performUnlock(
|
||||
session: offline.session,
|
||||
profile: offline.profile,
|
||||
profilePromise: Promise.resolve(offline.profile),
|
||||
freshMasterPasswordHash: null,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
@@ -571,7 +688,7 @@ export async function performUnlock(
|
||||
return unlockOffline();
|
||||
}
|
||||
|
||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
let token: TokenSuccess | TwoFactorTokenError;
|
||||
try {
|
||||
token = await loginWithPassword(normalizedEmail, derived.hash, {
|
||||
useRememberToken: true,
|
||||
@@ -589,12 +706,16 @@ export async function performUnlock(
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
|
||||
};
|
||||
}
|
||||
|
||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
if (tokenError.TwoFactorProviders) {
|
||||
const tokenError = token as TwoFactorTokenError;
|
||||
const providers = readTwoFactorProviders(tokenError);
|
||||
if (providers) {
|
||||
const providerType = resolvePendingTwoFactorProvider(providers);
|
||||
const availableProviders = readTwoFactorProviderTypes(providers);
|
||||
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
|
||||
return {
|
||||
kind: 'totp',
|
||||
pendingTotp: {
|
||||
@@ -602,6 +723,10 @@ export async function performUnlock(
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
providerType,
|
||||
providerData: providerDataByType[providerType] ?? readTwoFactorProviderData(tokenError, providerType),
|
||||
availableProviders: availableProviders.length ? availableProviders : [providerType],
|
||||
providerDataByType,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface RecommendedStorageLink {
|
||||
}
|
||||
|
||||
export interface RecommendedProviderBase {
|
||||
id: 'infinicloud' | 'koofr' | 'pcloud';
|
||||
id: 'infinicloud' | 'koofr' | 'pcloud' | 'backblaze-b2' | 'cloudflare-r2' | 'tigris';
|
||||
name: string;
|
||||
capacity: string;
|
||||
protocol: 'webdav' | 's3';
|
||||
@@ -28,7 +28,25 @@ export interface PcloudProvider extends RecommendedProviderBase {
|
||||
id: 'pcloud';
|
||||
}
|
||||
|
||||
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider;
|
||||
export interface BackblazeB2Provider extends RecommendedProviderBase {
|
||||
id: 'backblaze-b2';
|
||||
bucketsUrl: string;
|
||||
applicationKeysUrl: string;
|
||||
}
|
||||
|
||||
export interface CloudflareR2Provider extends RecommendedProviderBase {
|
||||
id: 'cloudflare-r2';
|
||||
bucketUrl: string;
|
||||
apiTokenUrl: string;
|
||||
}
|
||||
|
||||
export interface TigrisProvider extends RecommendedProviderBase {
|
||||
id: 'tigris';
|
||||
bucketUrl: string;
|
||||
accessKeyUrl: string;
|
||||
}
|
||||
|
||||
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider | BackblazeB2Provider | CloudflareR2Provider | TigrisProvider;
|
||||
|
||||
export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
|
||||
{
|
||||
@@ -61,6 +79,33 @@ export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
|
||||
signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7',
|
||||
hasAffiliateLink: true,
|
||||
},
|
||||
{
|
||||
id: 'backblaze-b2',
|
||||
name: 'Backblaze B2',
|
||||
capacity: '10G',
|
||||
protocol: 's3',
|
||||
signupUrl: 'https://secure.backblaze.com/user_signin.htm',
|
||||
bucketsUrl: 'https://secure.backblaze.com/b2_buckets.htm',
|
||||
applicationKeysUrl: 'https://secure.backblaze.com/app_keys.htm',
|
||||
},
|
||||
{
|
||||
id: 'cloudflare-r2',
|
||||
name: 'Cloudflare R2',
|
||||
capacity: '10G',
|
||||
protocol: 's3',
|
||||
signupUrl: 'https://dash.cloudflare.com/?to=/:account/r2/new',
|
||||
bucketUrl: 'https://dash.cloudflare.com/?to=/:account/r2/new',
|
||||
apiTokenUrl: 'https://dash.cloudflare.com/?to=/:account/r2/api-tokens/create?type=user',
|
||||
},
|
||||
{
|
||||
id: 'tigris',
|
||||
name: 'Tigris',
|
||||
capacity: '5G',
|
||||
protocol: 's3',
|
||||
signupUrl: 'https://console.storage.dev/signup',
|
||||
bucketUrl: 'https://console.storage.dev/createbucket',
|
||||
accessKeyUrl: 'https://console.storage.dev/createaccesskey',
|
||||
},
|
||||
];
|
||||
|
||||
export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider {
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { Profile, SessionState } from './types';
|
||||
|
||||
export async function silentlyRepairBackupSettingsIfNeeded(
|
||||
activeSession: SessionState,
|
||||
activeProfile: Profile
|
||||
activeProfile: Profile,
|
||||
verification?: { masterPasswordHash?: string | null; userVerificationToken?: string | null } | null
|
||||
): Promise<void> {
|
||||
if (activeProfile.role !== 'admin') return;
|
||||
if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return;
|
||||
@@ -14,8 +15,9 @@ export async function silentlyRepairBackupSettingsIfNeeded(
|
||||
try {
|
||||
const state = await getAdminBackupSettingsRepairState(tempFetch);
|
||||
if (!state.needsRepair || !state.portable) return;
|
||||
if (!verification?.masterPasswordHash && !verification?.userVerificationToken) return;
|
||||
const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession);
|
||||
await repairAdminBackupSettings(tempFetch, repairedSettings);
|
||||
await repairAdminBackupSettings(tempFetch, verification, repairedSettings);
|
||||
} catch (error) {
|
||||
console.error('Backup settings auto-repair failed:', error);
|
||||
}
|
||||
|
||||
+29
-13
@@ -907,6 +907,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
||||
adminLoading: false,
|
||||
adminError: '',
|
||||
totpEnabled: true,
|
||||
passkey2faEnabled: false,
|
||||
authorizedDevices: state.authorizedDevices,
|
||||
authorizedDevicesLoading: false,
|
||||
authorizedDevicesError: '',
|
||||
@@ -1060,6 +1061,16 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
||||
onSavePasswordHint: readonly,
|
||||
onEnableTotp: readonly,
|
||||
onOpenDisableTotp: readonlyVoid,
|
||||
onGetTwoFactorPasskeySettings: async () => ({ enabled: false, keys: [] }),
|
||||
onCreateTwoFactorPasskey: async () => {
|
||||
await readonly();
|
||||
return { enabled: false, keys: [] };
|
||||
},
|
||||
onDeleteTwoFactorPasskey: async () => {
|
||||
await readonly();
|
||||
return { enabled: false, keys: [] };
|
||||
},
|
||||
onDisableTwoFactorPasskeys: readonly,
|
||||
onGetRecoveryCode: readonlyString,
|
||||
onGetApiKey: readonlyString,
|
||||
onRotateApiKey: readonlyString,
|
||||
@@ -1127,6 +1138,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 +1159,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) => {
|
||||
@@ -1156,32 +1172,32 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
||||
notify('success', t('txt_logs_cleared'));
|
||||
return 0;
|
||||
},
|
||||
onExportBackup: async () => {
|
||||
onExportBackup: async (_masterPassword: string) => {
|
||||
notify('success', t('txt_backup_export_success'));
|
||||
},
|
||||
onImportBackup: async () => {
|
||||
onImportBackup: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
|
||||
resetDemoVaultState(state);
|
||||
notify('success', t('txt_backup_import_success_relogin'));
|
||||
return createDemoImportBackupResult();
|
||||
},
|
||||
onImportBackupAllowingChecksumMismatch: async () => {
|
||||
onImportBackupAllowingChecksumMismatch: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
|
||||
resetDemoVaultState(state);
|
||||
notify('success', t('txt_backup_import_success_relogin'));
|
||||
return createDemoImportBackupResult();
|
||||
},
|
||||
onLoadBackupSettings: async () => state.backupSettings,
|
||||
onSaveBackupSettings: async (settings) => {
|
||||
onSaveBackupSettings: async (_masterPassword: string, settings) => {
|
||||
const next = cloneJson(settings);
|
||||
state.setBackupSettings(next);
|
||||
notify('success', t('txt_backup_settings_saved'));
|
||||
return next;
|
||||
},
|
||||
onRunRemoteBackup: async (destinationId?: string | null) => {
|
||||
onRunRemoteBackup: async (_masterPassword: string, destinationId?: string | null) => {
|
||||
notify('success', t('txt_backup_remote_run_success'));
|
||||
return createDemoBackupRun(state.backupSettings, destinationId);
|
||||
},
|
||||
onListRemoteBackups: async (destinationId: string, path: string) => createDemoRemoteBrowser(destinationId, path),
|
||||
onDownloadRemoteBackup: async () => {
|
||||
onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
|
||||
notify('success', t('txt_demo_download_prepared'));
|
||||
},
|
||||
onInspectRemoteBackup: async (_destinationId: string, path: string) => ({
|
||||
@@ -1199,13 +1215,13 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
||||
onDeleteRemoteBackup: async () => {
|
||||
notify('success', t('txt_backup_remote_delete_success'));
|
||||
},
|
||||
onRestoreRemoteBackup: async (_destinationId, path) => {
|
||||
onRestoreRemoteBackup: async (_masterPassword: string, _destinationId, path) => {
|
||||
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
|
||||
resetDemoVaultState(state);
|
||||
notify('success', t('txt_backup_remote_restore_completed_verified'));
|
||||
return createDemoImportBackupResult();
|
||||
},
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: async (_destinationId, path) => {
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: async (_masterPassword: string, _destinationId, path) => {
|
||||
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
|
||||
resetDemoVaultState(state);
|
||||
notify('success', t('txt_backup_remote_restore_completed_verified'));
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,57 @@ const en: Record<string, string> = {
|
||||
"nav_import_export": "Import & Export",
|
||||
"nav_group_data_backup": "Data & Backup",
|
||||
"nav_group_management": "Management",
|
||||
"txt_settings_appearance": "Appearance",
|
||||
"txt_theme": "Theme",
|
||||
"txt_use_system_theme": "Use system theme",
|
||||
"txt_light_theme": "Light",
|
||||
"txt_dark_theme": "Dark",
|
||||
"txt_theme_saved_locally": "Choose a theme for your web vault.",
|
||||
"txt_display_language_help": "Change the web vault language.",
|
||||
"txt_two_step_login": "Two-step login",
|
||||
"txt_keys": "Keys",
|
||||
"txt_manage": "Manage",
|
||||
"txt_providers": "Providers",
|
||||
"txt_authenticator_app": "Authenticator app",
|
||||
"txt_authenticator_app_help": "Enter a code generated by an authenticator app.",
|
||||
"txt_passkey_provider_help": "Use a FIDO2-compatible security key or biometric authenticator.",
|
||||
"txt_yubico_otp_security_key": "Yubico OTP security key",
|
||||
"txt_yubico_otp_security_key_help": "Use a YubiKey 4, 5, or NEO device.",
|
||||
"txt_yubikey_setup_intro": "Insert your YubiKey into a USB port. Select the first empty YubiKey field below, touch the YubiKey button, then save the form.",
|
||||
"txt_yubikey_plug_in": "Insert your YubiKey into a USB port.",
|
||||
"txt_yubikey_select_empty_field": "Select the first empty YubiKey input field below.",
|
||||
"txt_yubikey_touch_button": "Touch the YubiKey button.",
|
||||
"txt_yubikey_save_form": "Save the form.",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "NFC support",
|
||||
"txt_yubikey_supports_nfc": "One of my keys supports NFC.",
|
||||
"txt_yubikey_supports_nfc_desc": "If one of your YubiKeys supports NFC, mobile apps can prompt you when NFC is available.",
|
||||
"txt_disable_all_keys": "Disable all keys",
|
||||
"txt_yubikeys_updated": "YubiKeys updated",
|
||||
"txt_yubikey_update_failed": "Failed to update YubiKeys",
|
||||
"txt_disable_yubikey_failed": "Failed to disable YubiKeys",
|
||||
"txt_yubikey_disabled": "YubiKeys disabled",
|
||||
"txt_yubikey_enabled": "YubiKey is enabled.",
|
||||
"txt_yubikey_config_required": "Yubico validation is not configured",
|
||||
"txt_yubikey_config_required_help": "Enter one YubiKey OTP first. NodeWarden will automatically request and save the instance Client ID and Secret key, then open the YubiKey setup form.",
|
||||
"txt_otp_from_yubikey": "OTP from YubiKey",
|
||||
"txt_please_input_yubikey_otp": "Please input YubiKey OTP",
|
||||
"txt_yubikey_verify_failed": "YubiKey verification failed",
|
||||
"txt_press_yubikey_to_authenticate": "Press your YubiKey to authenticate.",
|
||||
"txt_yubikey_auto_configure": "Get and save automatically",
|
||||
"txt_yubikey_validation_credentials": "Yubico validation credentials",
|
||||
"txt_view": "View",
|
||||
"txt_yubikey_config_updated": "Yubico validation credentials updated",
|
||||
"txt_yubikey_config_update_failed": "Failed to update Yubico validation credentials",
|
||||
"txt_yubikey_auto_config_failed": "Failed to get Yubico validation credentials",
|
||||
"txt_yubikey_reconfigure_help": "Enter a fresh OTP to request and replace these credentials automatically.",
|
||||
"txt_yubikey_auto_configure_again": "Get again automatically",
|
||||
"txt_setting_coming_soon": "Coming soon.",
|
||||
"txt_totp_manage_intro": "Scan the QR code or enter the key in your authenticator app, then enter the verification code.",
|
||||
"txt_two_step_recovery_code_warning": "If you cannot access your two-step login provider, your one-time recovery code can be used to disable two-step login. Store the recovery code somewhere safe.",
|
||||
"txt_your_two_step_recovery_code": "Your Bitwarden two-step login recovery code:",
|
||||
"txt_name_account_passkey_after_verification": "Passkey created. Name it to help you recognize it.",
|
||||
"txt_account_passkey_name_help": "0 / 50 characters",
|
||||
"txt_page_not_found": "Page Not Found",
|
||||
"txt_page_not_found_hint": "The page may have been removed, expired, or the link is incomplete.",
|
||||
"txt_back_to_home": "Back To Home",
|
||||
@@ -85,6 +136,37 @@ const en: Record<string, string> = {
|
||||
"txt_backup_recommend_pcloud_step_1": "Register a pCloud account with just your email address.",
|
||||
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ as the WebDAV server URL.",
|
||||
"txt_backup_recommend_pcloud_step_3": "Use your registration email as the WebDAV username and your account password as the WebDAV password.",
|
||||
"txt_backup_recommend_backblaze_summary": "S3-compatible object storage with 10 GB free and no credit card required.",
|
||||
"txt_backup_recommend_backblaze_step_1": "Register or sign in to a Backblaze account.",
|
||||
"txt_backup_recommend_backblaze_step_2_prefix": "Open",
|
||||
"txt_backup_recommend_backblaze_step_2_suffix": ", click Create a Bucket, enter only the bucket name, leave the other settings unchanged, and create it.",
|
||||
"txt_backup_recommend_backblaze_step_3": "After creation, put the displayed Endpoint into S3 Endpoint URL, use the bucket name for Bucket Name, and use the middle segment of the endpoint, such as us-west-004, for Region.",
|
||||
"txt_backup_recommend_backblaze_step_4_prefix": "Open",
|
||||
"txt_backup_recommend_backblaze_step_4_suffix": ", click Add a New Application Key, enter any Name of Key, leave the other settings unchanged, and create it.",
|
||||
"txt_backup_recommend_backblaze_step_5": "Use keyID as the access key and applicationKey as the secret key.",
|
||||
"txt_backup_recommend_cloudflare_r2_summary": "S3-compatible object storage with 10 GB free, but it requires credit card verification.",
|
||||
"txt_backup_recommend_cloudflare_r2_bucket_link": "Create bucket page",
|
||||
"txt_backup_recommend_cloudflare_r2_api_link": "API token page",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Open the",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", enter only the bucket name, and create it directly.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Open the",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", select Object Read & Write for permissions, and create it directly.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_3": "Ignore the token value after creation. Fill Access Key ID into Access ID, and Secret Access Key into Access Password.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_4": "Copy the address shown below into S3 Endpoint URL, fill Bucket Name exactly as shown, and leave Region as auto.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_5": "Set Path Prefix as needed, for example nodewarden, or leave it empty if you do not want a folder prefix.",
|
||||
"txt_backup_recommend_s3_path_prefix_step": "Set Path Prefix as needed, for example nodewarden, or leave it empty if you do not want a folder prefix.",
|
||||
"txt_backup_recommend_tigris_summary": "S3-compatible object storage with 5 GB free and no credit card required.",
|
||||
"txt_backup_recommend_tigris_signup_link": "signup page",
|
||||
"txt_backup_recommend_tigris_bucket_link": "Create Bucket page",
|
||||
"txt_backup_recommend_tigris_access_key_link": "Create Access Key page",
|
||||
"txt_backup_recommend_tigris_step_1_prefix": "Open the",
|
||||
"txt_backup_recommend_tigris_step_1_suffix": ", sign up, and log in to Tigris.",
|
||||
"txt_backup_recommend_tigris_step_2_prefix": "Open",
|
||||
"txt_backup_recommend_tigris_step_2_suffix": ", enter only the bucket name, leave everything else unchanged, and create it.",
|
||||
"txt_backup_recommend_tigris_step_3_prefix": "Then open the",
|
||||
"txt_backup_recommend_tigris_step_3_suffix": ", use any name you like, and create it.",
|
||||
"txt_backup_recommend_tigris_step_4": "Ignore Endpoint URL IAM after creation. Fill the other displayed values into the backup page using the matching field names.",
|
||||
"txt_backup_recommend_tigris_step_5": "Finally, click Manage Key Permissions and turn on Admin Access, otherwise writing backups will fail.",
|
||||
"txt_backup_add_destination": "Add Destination",
|
||||
"txt_backup_schedule_panel_title": "Automatic Schedule",
|
||||
"txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.",
|
||||
@@ -193,10 +275,14 @@ 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",
|
||||
"txt_backup_remote_current_path": "Current Folder",
|
||||
"txt_backup_remote_modified": "Modified",
|
||||
"txt_backup_remote_size": "Size",
|
||||
"txt_backup_remote_load_failed": "Loading remote backups failed",
|
||||
"txt_backup_remote_invalid_response": "Invalid remote backup response",
|
||||
"txt_backup_remote_download_failed": "Downloading remote backup failed",
|
||||
@@ -214,6 +300,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",
|
||||
@@ -479,8 +633,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)",
|
||||
@@ -489,16 +652,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.",
|
||||
@@ -552,7 +720,7 @@ const en: Record<string, string> = {
|
||||
"txt_jwt_secret_value_label": "Value:",
|
||||
"txt_jwt_secret_value_requirement": "Random string with at least {min} characters",
|
||||
"txt_jwt_what_is": "What is JWT?",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing, too short, or still using the sample value, the instance is not safe to use normally.",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing or too short, the instance is not safe to use normally.",
|
||||
"txt_how_to_fix": "How to fix",
|
||||
"txt_jwt_fix_step_1": "Open your deployment environment variables.",
|
||||
"txt_jwt_fix_step_2": "If your current key is not random enough, use the 32-character generator below.",
|
||||
@@ -638,6 +806,23 @@ const en: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "Password hint must be 120 characters or fewer",
|
||||
"txt_passkey": "Passkey",
|
||||
"txt_passkeys": "Passkeys",
|
||||
"txt_register": "Register",
|
||||
"txt_key_list": "Key list",
|
||||
"txt_select_another_verification_method": "Select another verification method",
|
||||
"txt_select_two_step_login_method": "Select two-step login method",
|
||||
"txt_two_step_passkeys": "Passkey two-step login",
|
||||
"txt_two_step_passkeys_help": "Manage passkeys used only for two-step login.",
|
||||
"txt_two_step_passkey_name_placeholder": "Security key",
|
||||
"txt_add_two_step_passkey": "Add passkey",
|
||||
"txt_two_step_passkey_added": "Passkey two-step login updated",
|
||||
"txt_two_step_passkey_removed": "Passkey removed",
|
||||
"txt_two_step_passkeys_disabled": "Passkey two-step login disabled",
|
||||
"txt_disable_passkey_two_step_failed": "Failed to disable passkey two-step login",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "Use your passkey to complete two-step verification.",
|
||||
"txt_touch_your_passkey_when_prompted": "Continue and approve the browser passkey prompt.",
|
||||
"txt_no_two_step_passkeys": "No two-step passkeys",
|
||||
"txt_remove_last_passkey_hint": "Disable passkey two-step login to remove the last key.",
|
||||
"txt_passkey_setup_failed": "Passkey setup failed",
|
||||
"txt_passkey_created_at_value": "Created on {value}",
|
||||
"txt_account_passkey": "Account passkey",
|
||||
"txt_account_passkeys": "Account passkeys",
|
||||
@@ -721,6 +906,8 @@ const en: Record<string, string> = {
|
||||
"txt_scope": "scope",
|
||||
"txt_grant_type": "grant_type",
|
||||
"txt_refresh": "Refresh",
|
||||
"txt_refresh_status": "Refresh status",
|
||||
"txt_load_failed": "Failed to load",
|
||||
"txt_refresh_in_seconds_s": "Refresh in {seconds}s",
|
||||
"txt_regenerate": "Regenerate",
|
||||
"txt_registration_succeeded_please_sign_in": "Registration succeeded. Please sign in.",
|
||||
@@ -730,6 +917,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",
|
||||
@@ -771,6 +963,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",
|
||||
@@ -1040,7 +1235,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",
|
||||
|
||||
@@ -11,6 +11,57 @@ const es: Record<string, string> = {
|
||||
"nav_import_export": "Importar y exportar",
|
||||
"nav_group_data_backup": "Datos y copias",
|
||||
"nav_group_management": "Gestión",
|
||||
"txt_settings_appearance": "Apariencia",
|
||||
"txt_theme": "Tema",
|
||||
"txt_use_system_theme": "Usar tema del sistema",
|
||||
"txt_light_theme": "Claro",
|
||||
"txt_dark_theme": "Oscuro",
|
||||
"txt_theme_saved_locally": "Elige un tema para tu bóveda web.",
|
||||
"txt_display_language_help": "Cambia el idioma de la bóveda web.",
|
||||
"txt_two_step_login": "Inicio de sesión en dos pasos",
|
||||
"txt_keys": "Claves",
|
||||
"txt_manage": "Gestionar",
|
||||
"txt_providers": "Proveedores",
|
||||
"txt_authenticator_app": "Aplicación autenticadora",
|
||||
"txt_authenticator_app_help": "Introduce un código generado por una aplicación autenticadora.",
|
||||
"txt_passkey_provider_help": "Usa una llave de seguridad compatible con FIDO2 o autenticación biométrica.",
|
||||
"txt_yubico_otp_security_key": "Llave de seguridad Yubico OTP",
|
||||
"txt_yubico_otp_security_key_help": "Usa un dispositivo YubiKey 4, 5 o NEO.",
|
||||
"txt_yubikey_setup_intro": "Inserta tu YubiKey en un puerto USB. Selecciona el primer campo YubiKey vacío, toca el botón de la YubiKey y guarda el formulario.",
|
||||
"txt_yubikey_plug_in": "Inserta tu YubiKey en un puerto USB.",
|
||||
"txt_yubikey_select_empty_field": "Selecciona el primer campo YubiKey vacío.",
|
||||
"txt_yubikey_touch_button": "Toca el botón de la YubiKey.",
|
||||
"txt_yubikey_save_form": "Guarda el formulario.",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "Compatibilidad NFC",
|
||||
"txt_yubikey_supports_nfc": "Una de mis llaves admite NFC.",
|
||||
"txt_yubikey_supports_nfc_desc": "Si una de tus YubiKeys admite NFC, las apps móviles pueden avisarte cuando NFC esté disponible.",
|
||||
"txt_disable_all_keys": "Desactivar todas las llaves",
|
||||
"txt_yubikeys_updated": "YubiKeys actualizadas",
|
||||
"txt_yubikey_update_failed": "No se pudieron actualizar las YubiKeys",
|
||||
"txt_disable_yubikey_failed": "No se pudieron desactivar las YubiKeys",
|
||||
"txt_yubikey_disabled": "YubiKeys desactivadas",
|
||||
"txt_yubikey_enabled": "YubiKey activada.",
|
||||
"txt_yubikey_config_required": "La validación de Yubico no está configurada",
|
||||
"txt_yubikey_config_required_help": "Introduce primero un OTP de YubiKey. NodeWarden solicitará y guardará automáticamente el Client ID y la Secret key de la instancia, y luego abrirá el formulario de YubiKey.",
|
||||
"txt_otp_from_yubikey": "OTP de YubiKey",
|
||||
"txt_please_input_yubikey_otp": "Introduce el OTP de YubiKey",
|
||||
"txt_yubikey_verify_failed": "No se pudo verificar la YubiKey",
|
||||
"txt_press_yubikey_to_authenticate": "Pulsa tu YubiKey para autenticarte.",
|
||||
"txt_yubikey_auto_configure": "Obtener y guardar automáticamente",
|
||||
"txt_yubikey_validation_credentials": "Credenciales de validación de Yubico",
|
||||
"txt_view": "Ver",
|
||||
"txt_yubikey_config_updated": "Credenciales de validación de Yubico actualizadas",
|
||||
"txt_yubikey_config_update_failed": "No se pudieron actualizar las credenciales de validación de Yubico",
|
||||
"txt_yubikey_auto_config_failed": "No se pudieron obtener las credenciales de validación de Yubico",
|
||||
"txt_yubikey_reconfigure_help": "Introduce un OTP nuevo para solicitar y reemplazar estas credenciales automáticamente.",
|
||||
"txt_yubikey_auto_configure_again": "Obtener de nuevo automáticamente",
|
||||
"txt_setting_coming_soon": "Próximamente.",
|
||||
"txt_totp_manage_intro": "Escanea el código QR o introduce la clave en tu aplicación autenticadora, luego escribe el código de verificación.",
|
||||
"txt_two_step_recovery_code_warning": "Si no puedes acceder a tu proveedor de inicio de sesión en dos pasos, tu código de recuperación de un solo uso puede desactivar el inicio de sesión en dos pasos. Guarda el código en un lugar seguro.",
|
||||
"txt_your_two_step_recovery_code": "Tu código de recuperación de inicio de sesión en dos pasos de Bitwarden:",
|
||||
"txt_name_account_passkey_after_verification": "Passkey creada. Ponle un nombre para reconocerla.",
|
||||
"txt_account_passkey_name_help": "0 / 50 caracteres como máximo",
|
||||
"txt_page_not_found": "Página no encontrada",
|
||||
"txt_page_not_found_hint": "La página pudo haberse eliminado, expirado, o el enlace está incompleto.",
|
||||
"txt_back_to_home": "Volver al inicio",
|
||||
@@ -85,6 +136,37 @@ const es: Record<string, string> = {
|
||||
"txt_backup_recommend_pcloud_step_1": "Registre una cuenta pCloud solo con su dirección de correo.",
|
||||
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ como URL del servidor WebDAV.",
|
||||
"txt_backup_recommend_pcloud_step_3": "Use su correo de registro como nombre de usuario WebDAV y su contraseña de cuenta como contraseña WebDAV.",
|
||||
"txt_backup_recommend_backblaze_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis y sin tarjeta de crédito.",
|
||||
"txt_backup_recommend_backblaze_step_1": "Registre o inicie sesión en una cuenta de Backblaze.",
|
||||
"txt_backup_recommend_backblaze_step_2_prefix": "Abra",
|
||||
"txt_backup_recommend_backblaze_step_2_suffix": ", haga clic en Create a Bucket, introduzca solo el nombre del bucket, deje lo demás sin cambios y créelo.",
|
||||
"txt_backup_recommend_backblaze_step_3": "Después de crearlo, ponga el Endpoint mostrado en S3 Endpoint URL, use el nombre del bucket en Bucket Name y la parte central del endpoint, como us-west-004, en Region.",
|
||||
"txt_backup_recommend_backblaze_step_4_prefix": "Abra",
|
||||
"txt_backup_recommend_backblaze_step_4_suffix": ", haga clic en Add a New Application Key, introduzca cualquier Name of Key, deje lo demás sin cambios y créelo.",
|
||||
"txt_backup_recommend_backblaze_step_5": "Use keyID como clave de acceso y applicationKey como clave secreta.",
|
||||
"txt_backup_recommend_cloudflare_r2_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis, pero requiere verificación con tarjeta de crédito.",
|
||||
"txt_backup_recommend_cloudflare_r2_bucket_link": "página para crear bucket",
|
||||
"txt_backup_recommend_cloudflare_r2_api_link": "página de token API",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Abra la",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", introduzca solo el nombre del bucket y créelo directamente.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Abra la",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", seleccione Object Read & Write en permisos y créelo directamente.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_3": "Ignore el valor del token después de crearlo. Use Access Key ID como ID de acceso y Secret Access Key como contraseña de acceso.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_4": "Copie la dirección mostrada abajo en S3 Endpoint URL, rellene Bucket Name tal como aparece y deje Region en auto.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_5": "Configure Path Prefix si lo necesita, por ejemplo nodewarden, o déjelo vacío si no quiere un prefijo de carpeta.",
|
||||
"txt_backup_recommend_s3_path_prefix_step": "Configure Path Prefix si lo necesita, por ejemplo nodewarden, o déjelo vacío si no quiere un prefijo de carpeta.",
|
||||
"txt_backup_recommend_tigris_summary": "Almacenamiento de objetos compatible con S3 con 5 GB gratis y sin tarjeta de crédito.",
|
||||
"txt_backup_recommend_tigris_signup_link": "página de registro",
|
||||
"txt_backup_recommend_tigris_bucket_link": "página Create Bucket",
|
||||
"txt_backup_recommend_tigris_access_key_link": "página Create Access Key",
|
||||
"txt_backup_recommend_tigris_step_1_prefix": "Abra la",
|
||||
"txt_backup_recommend_tigris_step_1_suffix": ", regístrese e inicie sesión en Tigris.",
|
||||
"txt_backup_recommend_tigris_step_2_prefix": "Abra",
|
||||
"txt_backup_recommend_tigris_step_2_suffix": ", introduzca solo el nombre del bucket, deje todo lo demás sin cambios y créelo.",
|
||||
"txt_backup_recommend_tigris_step_3_prefix": "Luego abra la",
|
||||
"txt_backup_recommend_tigris_step_3_suffix": ", use cualquier nombre y créela.",
|
||||
"txt_backup_recommend_tigris_step_4": "Ignore Endpoint URL IAM después de crearla. Rellene los demás valores mostrados en la página de copia de seguridad usando los nombres correspondientes.",
|
||||
"txt_backup_recommend_tigris_step_5": "Por último, haga clic en Manage Key Permissions y active Admin Access; de lo contrario, no podrá escribir copias de seguridad.",
|
||||
"txt_backup_add_destination": "Añadir destino",
|
||||
"txt_backup_schedule_panel_title": "Programación automática",
|
||||
"txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.",
|
||||
@@ -193,10 +275,14 @@ 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",
|
||||
"txt_backup_remote_current_path": "Carpeta actual",
|
||||
"txt_backup_remote_modified": "Modificado",
|
||||
"txt_backup_remote_size": "Tamaño",
|
||||
"txt_backup_remote_load_failed": "Error al cargar copias de seguridad remotas",
|
||||
"txt_backup_remote_invalid_response": "Respuesta de copia de seguridad remota no válida",
|
||||
"txt_backup_remote_download_failed": "Error al descargar copia de seguridad remota",
|
||||
@@ -214,6 +300,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",
|
||||
@@ -479,8 +633,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",
|
||||
@@ -489,16 +652,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.",
|
||||
@@ -552,7 +720,7 @@ const es: Record<string, string> = {
|
||||
"txt_jwt_secret_value_label": "Valor:",
|
||||
"txt_jwt_secret_value_requirement": "Cadena aleatoria de al menos {min} caracteres",
|
||||
"txt_jwt_what_is": "Qué es JWT",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente, es demasiado corta o todavía usa el valor de ejemplo, la instancia no es segura para uso normal.",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente o es demasiado corta, la instancia no es segura para uso normal.",
|
||||
"txt_how_to_fix": "Cómo corregirlo",
|
||||
"txt_jwt_fix_step_1": "Abra las variables de entorno de su despliegue.",
|
||||
"txt_jwt_fix_step_2": "Si su clave actual no es lo suficientemente aleatoria, use el generador de 32 caracteres a continuación.",
|
||||
@@ -638,6 +806,23 @@ const es: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "La pista de contraseña debe tener 120 caracteres o menos",
|
||||
"txt_passkey": "Clave de acceso",
|
||||
"txt_passkeys": "Claves de acceso",
|
||||
"txt_register": "Registrar",
|
||||
"txt_key_list": "Lista de claves",
|
||||
"txt_select_another_verification_method": "Seleccionar otro método de verificación",
|
||||
"txt_select_two_step_login_method": "Seleccionar método de inicio de sesión en dos pasos",
|
||||
"txt_two_step_passkeys": "Inicio de sesión en dos pasos con clave de acceso",
|
||||
"txt_two_step_passkeys_help": "Administra claves de acceso usadas solo para el inicio de sesión en dos pasos.",
|
||||
"txt_two_step_passkey_name_placeholder": "Llave de seguridad",
|
||||
"txt_add_two_step_passkey": "Agregar clave de acceso",
|
||||
"txt_two_step_passkey_added": "Inicio de sesión en dos pasos con clave de acceso actualizado",
|
||||
"txt_two_step_passkey_removed": "Clave de acceso eliminada",
|
||||
"txt_two_step_passkeys_disabled": "Inicio de sesión en dos pasos con clave de acceso desactivado",
|
||||
"txt_disable_passkey_two_step_failed": "No se pudo desactivar el inicio de sesión en dos pasos con clave de acceso",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "Usa tu clave de acceso para completar la verificación en dos pasos.",
|
||||
"txt_touch_your_passkey_when_prompted": "Continúa y aprueba la solicitud de clave de acceso del navegador.",
|
||||
"txt_no_two_step_passkeys": "No hay claves de acceso en dos pasos",
|
||||
"txt_remove_last_passkey_hint": "Desactiva el inicio de sesión en dos pasos con clave de acceso para eliminar la última clave.",
|
||||
"txt_passkey_setup_failed": "Error al configurar la clave de acceso",
|
||||
"txt_passkey_created_at_value": "Creado el {value}",
|
||||
"txt_account_passkey": "Clave de acceso de cuenta",
|
||||
"txt_account_passkeys": "Claves de acceso de cuenta",
|
||||
@@ -721,6 +906,8 @@ const es: Record<string, string> = {
|
||||
"txt_scope": "Ámbito",
|
||||
"txt_grant_type": "Tipo de concesión",
|
||||
"txt_refresh": "Actualizar",
|
||||
"txt_refresh_status": "Actualizar estado",
|
||||
"txt_load_failed": "No se pudo cargar",
|
||||
"txt_refresh_in_seconds_s": "Actualizar en {seconds}s",
|
||||
"txt_regenerate": "Regenerar",
|
||||
"txt_registration_succeeded_please_sign_in": "Registro completado. Inicie sesión.",
|
||||
@@ -730,6 +917,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",
|
||||
@@ -771,6 +963,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",
|
||||
@@ -1040,7 +1235,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",
|
||||
|
||||
@@ -12,6 +12,57 @@ const ru: Record<string, string> = {
|
||||
"nav_import_export": "Импорт и экспорт",
|
||||
"nav_group_data_backup": "Данные и резервные копии",
|
||||
"nav_group_management": "Управление",
|
||||
"txt_settings_appearance": "Внешний вид",
|
||||
"txt_theme": "Тема",
|
||||
"txt_use_system_theme": "Использовать системную тему",
|
||||
"txt_light_theme": "Светлая",
|
||||
"txt_dark_theme": "Темная",
|
||||
"txt_theme_saved_locally": "Выберите тему для веб-хранилища.",
|
||||
"txt_display_language_help": "Изменить язык веб-хранилища.",
|
||||
"txt_two_step_login": "Двухэтапный вход",
|
||||
"txt_keys": "Ключи",
|
||||
"txt_manage": "Управлять",
|
||||
"txt_providers": "Поставщики",
|
||||
"txt_authenticator_app": "Приложение-аутентификатор",
|
||||
"txt_authenticator_app_help": "Введите код, созданный приложением-аутентификатором.",
|
||||
"txt_passkey_provider_help": "Используйте FIDO2-совместимый ключ безопасности или биометрический аутентификатор.",
|
||||
"txt_yubico_otp_security_key": "Ключ безопасности Yubico OTP",
|
||||
"txt_yubico_otp_security_key_help": "Используйте устройство YubiKey 4, 5 или NEO.",
|
||||
"txt_yubikey_setup_intro": "Вставьте YubiKey в USB-порт. Выберите первое пустое поле YubiKey ниже, коснитесь кнопки YubiKey и сохраните форму.",
|
||||
"txt_yubikey_plug_in": "Вставьте YubiKey в USB-порт.",
|
||||
"txt_yubikey_select_empty_field": "Выберите первое пустое поле YubiKey ниже.",
|
||||
"txt_yubikey_touch_button": "Коснитесь кнопки YubiKey.",
|
||||
"txt_yubikey_save_form": "Сохраните форму.",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "Поддержка NFC",
|
||||
"txt_yubikey_supports_nfc": "Один из моих ключей поддерживает NFC.",
|
||||
"txt_yubikey_supports_nfc_desc": "Если один из ваших YubiKey поддерживает NFC, мобильные приложения смогут подсказать вам, когда NFC доступен.",
|
||||
"txt_disable_all_keys": "Отключить все ключи",
|
||||
"txt_yubikeys_updated": "YubiKey обновлены",
|
||||
"txt_yubikey_update_failed": "Не удалось обновить YubiKey",
|
||||
"txt_disable_yubikey_failed": "Не удалось отключить YubiKey",
|
||||
"txt_yubikey_disabled": "YubiKey отключены",
|
||||
"txt_yubikey_enabled": "YubiKey включен.",
|
||||
"txt_yubikey_config_required": "Проверка Yubico не настроена",
|
||||
"txt_yubikey_config_required_help": "Сначала введите один OTP с YubiKey. NodeWarden автоматически запросит и сохранит Client ID и Secret key экземпляра, затем откроет форму настройки YubiKey.",
|
||||
"txt_otp_from_yubikey": "OTP с YubiKey",
|
||||
"txt_please_input_yubikey_otp": "Введите OTP с YubiKey",
|
||||
"txt_yubikey_verify_failed": "Не удалось проверить YubiKey",
|
||||
"txt_press_yubikey_to_authenticate": "Нажмите YubiKey для проверки.",
|
||||
"txt_yubikey_auto_configure": "Получить и сохранить автоматически",
|
||||
"txt_yubikey_validation_credentials": "Учетные данные проверки Yubico",
|
||||
"txt_view": "Показать",
|
||||
"txt_yubikey_config_updated": "Учетные данные проверки Yubico обновлены",
|
||||
"txt_yubikey_config_update_failed": "Не удалось обновить учетные данные проверки Yubico",
|
||||
"txt_yubikey_auto_config_failed": "Не удалось получить учетные данные проверки Yubico",
|
||||
"txt_yubikey_reconfigure_help": "Введите новый OTP, чтобы автоматически запросить и заменить эти учетные данные.",
|
||||
"txt_yubikey_auto_configure_again": "Получить снова автоматически",
|
||||
"txt_setting_coming_soon": "Скоро появится.",
|
||||
"txt_totp_manage_intro": "Отсканируйте QR-код или введите ключ в приложении-аутентификаторе, затем введите код проверки.",
|
||||
"txt_two_step_recovery_code_warning": "Если вы не можете получить доступ к поставщику двухэтапного входа, одноразовый код восстановления можно использовать для отключения двухэтапного входа. Сохраните код в надежном месте.",
|
||||
"txt_your_two_step_recovery_code": "Ваш код восстановления двухэтапного входа Bitwarden:",
|
||||
"txt_name_account_passkey_after_verification": "Ключ доступа создан. Назовите его, чтобы легче узнавать.",
|
||||
"txt_account_passkey_name_help": "0 / не более 50 символов",
|
||||
"txt_page_not_found": "Страница не найдена",
|
||||
"txt_page_not_found_hint": "Страница могла быть удалена, срок ее действия истек, или ссылка неполная.",
|
||||
"txt_back_to_home": "На главную",
|
||||
@@ -86,6 +137,37 @@ const ru: Record<string, string> = {
|
||||
"txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.",
|
||||
"txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.",
|
||||
"txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля WebDAV.",
|
||||
"txt_backup_recommend_backblaze_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ и без кредитной карты.",
|
||||
"txt_backup_recommend_backblaze_step_1": "Зарегистрируйте учетную запись Backblaze или войдите в нее.",
|
||||
"txt_backup_recommend_backblaze_step_2_prefix": "Откройте",
|
||||
"txt_backup_recommend_backblaze_step_2_suffix": ", нажмите Create a Bucket, введите только имя bucket, оставьте остальные настройки без изменений и создайте его.",
|
||||
"txt_backup_recommend_backblaze_step_3": "После создания вставьте показанный Endpoint в S3 Endpoint URL, имя bucket укажите в Bucket Name, а среднюю часть endpoint, например us-west-004, используйте как Region.",
|
||||
"txt_backup_recommend_backblaze_step_4_prefix": "Откройте",
|
||||
"txt_backup_recommend_backblaze_step_4_suffix": ", нажмите Add a New Application Key, введите любое Name of Key, оставьте остальные настройки без изменений и создайте ключ.",
|
||||
"txt_backup_recommend_backblaze_step_5": "Используйте keyID как ключ доступа, а applicationKey как секретный ключ.",
|
||||
"txt_backup_recommend_cloudflare_r2_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ, но с обязательной проверкой кредитной карты.",
|
||||
"txt_backup_recommend_cloudflare_r2_bucket_link": "страницу создания bucket",
|
||||
"txt_backup_recommend_cloudflare_r2_api_link": "страницу API token",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Откройте",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", введите только имя bucket и сразу создайте его.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Откройте",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", выберите Object Read & Write в разрешениях и сразу создайте токен.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_3": "После создания игнорируйте token value. Введите Access Key ID как ID доступа, а Secret Access Key как пароль доступа.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_4": "Вставьте показанный ниже адрес в S3 Endpoint URL, заполните Bucket Name как показано и оставьте Region в значении auto.",
|
||||
"txt_backup_recommend_cloudflare_r2_step_5": "Укажите Path Prefix при необходимости, например nodewarden, или оставьте пустым, если префикс папки не нужен.",
|
||||
"txt_backup_recommend_s3_path_prefix_step": "Укажите Path Prefix при необходимости, например nodewarden, или оставьте пустым, если префикс папки не нужен.",
|
||||
"txt_backup_recommend_tigris_summary": "S3-совместимое объектное хранилище с бесплатными 5 ГБ и без кредитной карты.",
|
||||
"txt_backup_recommend_tigris_signup_link": "страницу регистрации",
|
||||
"txt_backup_recommend_tigris_bucket_link": "страницу Create Bucket",
|
||||
"txt_backup_recommend_tigris_access_key_link": "страницу Create Access Key",
|
||||
"txt_backup_recommend_tigris_step_1_prefix": "Откройте",
|
||||
"txt_backup_recommend_tigris_step_1_suffix": ", зарегистрируйтесь и войдите в Tigris.",
|
||||
"txt_backup_recommend_tigris_step_2_prefix": "Откройте",
|
||||
"txt_backup_recommend_tigris_step_2_suffix": ", введите только имя bucket, ничего больше не меняйте и создайте его.",
|
||||
"txt_backup_recommend_tigris_step_3_prefix": "Затем откройте",
|
||||
"txt_backup_recommend_tigris_step_3_suffix": ", введите любое имя и создайте ключ.",
|
||||
"txt_backup_recommend_tigris_step_4": "После создания игнорируйте Endpoint URL IAM. Остальные показанные значения заполните на странице резервного копирования по совпадающим названиям полей.",
|
||||
"txt_backup_recommend_tigris_step_5": "В конце нажмите Manage Key Permissions и включите Admin Access, иначе запись резервных копий не будет работать.",
|
||||
"txt_backup_add_destination": "Добавить пункт назначения",
|
||||
"txt_backup_schedule_panel_title": "Автоматическое расписание",
|
||||
"txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.",
|
||||
@@ -193,10 +275,14 @@ 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": "Неизвестное время",
|
||||
"txt_backup_remote_current_path": "Текущая папка",
|
||||
"txt_backup_remote_modified": "Изменено",
|
||||
"txt_backup_remote_size": "Размер",
|
||||
"txt_backup_remote_load_failed": "Не удалось загрузить удаленные резервные копии.",
|
||||
"txt_backup_remote_invalid_response": "Неверный ответ удаленного резервного копирования",
|
||||
"txt_backup_remote_download_failed": "Не удалось загрузить удаленную резервную копию.",
|
||||
@@ -214,6 +300,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",
|
||||
@@ -479,8 +633,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": "Срок действия приглашения (часы)",
|
||||
@@ -489,16 +652,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": "Регистрация временно недоступна. Повторите попытку один раз.",
|
||||
@@ -552,7 +720,7 @@ const ru: Record<string, string> = {
|
||||
"txt_jwt_secret_value_label": "Значение:",
|
||||
"txt_jwt_secret_value_requirement": "Случайная строка, содержащая не менее {min} символов.",
|
||||
"txt_jwt_what_is": "Что такое JWT?",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует, слишком короткий или все еще использует образец значения, обычное использование экземпляра небезопасно.",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует или слишком короткий, обычное использование экземпляра небезопасно.",
|
||||
"txt_how_to_fix": "Как исправить",
|
||||
"txt_jwt_fix_step_1": "Откройте переменные среды развертывания.",
|
||||
"txt_jwt_fix_step_2": "Если ваш текущий ключ недостаточно случайный, используйте 32-значный генератор ниже.",
|
||||
@@ -638,6 +806,23 @@ const ru: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "Подсказка к паролю должна содержать не более 120 символов.",
|
||||
"txt_passkey": "Ключ доступа",
|
||||
"txt_passkeys": "Ключи доступа",
|
||||
"txt_register": "Зарегистрировать",
|
||||
"txt_key_list": "Список ключей",
|
||||
"txt_select_another_verification_method": "Выбрать другой способ проверки",
|
||||
"txt_select_two_step_login_method": "Выберите способ двухэтапного входа",
|
||||
"txt_two_step_passkeys": "Двухэтапный вход с ключом доступа",
|
||||
"txt_two_step_passkeys_help": "Управление ключами доступа, которые используются только для двухэтапного входа.",
|
||||
"txt_two_step_passkey_name_placeholder": "Ключ безопасности",
|
||||
"txt_add_two_step_passkey": "Добавить ключ доступа",
|
||||
"txt_two_step_passkey_added": "Двухэтапный вход с ключом доступа обновлен",
|
||||
"txt_two_step_passkey_removed": "Ключ доступа удален",
|
||||
"txt_two_step_passkeys_disabled": "Двухэтапный вход с ключом доступа отключен",
|
||||
"txt_disable_passkey_two_step_failed": "Не удалось отключить двухэтапный вход с ключом доступа",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "Используйте ключ доступа, чтобы завершить двухэтапную проверку.",
|
||||
"txt_touch_your_passkey_when_prompted": "Продолжите и подтвердите запрос ключа доступа в браузере.",
|
||||
"txt_no_two_step_passkeys": "Нет ключей доступа для двухэтапного входа",
|
||||
"txt_remove_last_passkey_hint": "Отключите двухэтапный вход с ключом доступа, чтобы удалить последний ключ.",
|
||||
"txt_passkey_setup_failed": "Не удалось настроить ключ доступа",
|
||||
"txt_passkey_created_at_value": "Создано {value}",
|
||||
"txt_account_passkey": "Ключ доступа аккаунта",
|
||||
"txt_account_passkeys": "Ключи доступа аккаунта",
|
||||
@@ -721,6 +906,8 @@ const ru: Record<string, string> = {
|
||||
"txt_scope": "Область доступа",
|
||||
"txt_grant_type": "Тип авторизации",
|
||||
"txt_refresh": "Обновить",
|
||||
"txt_refresh_status": "Обновить статус",
|
||||
"txt_load_failed": "Не удалось загрузить",
|
||||
"txt_refresh_in_seconds_s": "Обновить через {seconds} с.",
|
||||
"txt_regenerate": "Регенерировать",
|
||||
"txt_registration_succeeded_please_sign_in": "Регистрация прошла успешно. Пожалуйста, войдите в систему.",
|
||||
@@ -730,6 +917,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": "Раскрыть",
|
||||
@@ -771,6 +963,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": "Выберите элемент",
|
||||
@@ -1040,7 +1235,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",
|
||||
|
||||
@@ -11,6 +11,57 @@ const zhCN: Record<string, string> = {
|
||||
"nav_import_export": "导入导出",
|
||||
"nav_group_data_backup": "数据与备份",
|
||||
"nav_group_management": "管理",
|
||||
"txt_settings_appearance": "外观",
|
||||
"txt_theme": "主题",
|
||||
"txt_use_system_theme": "使用系统主题",
|
||||
"txt_light_theme": "浅色",
|
||||
"txt_dark_theme": "深色",
|
||||
"txt_theme_saved_locally": "为您的网页密码库选择一个主题。",
|
||||
"txt_display_language_help": "更改网页密码库的语言。",
|
||||
"txt_two_step_login": "两步登录",
|
||||
"txt_keys": "密钥",
|
||||
"txt_manage": "管理",
|
||||
"txt_providers": "提供程序",
|
||||
"txt_authenticator_app": "验证器 App",
|
||||
"txt_authenticator_app_help": "输入验证器 App 生成的代码。",
|
||||
"txt_passkey_provider_help": "使用兼容 FIDO2 的安全密钥或生物识别验证器。",
|
||||
"txt_yubico_otp_security_key": "Yubico OTP 安全密钥",
|
||||
"txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 设备。",
|
||||
"txt_yubikey_setup_intro": "将 YubiKey 插入计算机的 USB 端口。在下面选择第一个空的 YubiKey 输入字段。触摸 YubiKey 的按钮、保存。",
|
||||
"txt_yubikey_plug_in": "将 YubiKey 插入计算机的 USB 端口",
|
||||
"txt_yubikey_select_empty_field": "在下面选择第一个空的 YubiKey 输入字段",
|
||||
"txt_yubikey_touch_button": "触摸 YubiKey 的按钮、保存",
|
||||
"txt_yubikey_save_form": "保存",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "NFC 支持",
|
||||
"txt_yubikey_supports_nfc": "我的某个密钥支持 NFC",
|
||||
"txt_yubikey_supports_nfc_desc": "",
|
||||
"txt_disable_all_keys": "停用全部密钥",
|
||||
"txt_yubikeys_updated": "YubiKey 已更新",
|
||||
"txt_yubikey_update_failed": "更新 YubiKey 失败",
|
||||
"txt_disable_yubikey_failed": "停用 YubiKey 失败",
|
||||
"txt_yubikey_disabled": "YubiKey 已停用",
|
||||
"txt_yubikey_enabled": "YubiKey 已启用。",
|
||||
"txt_yubikey_config_required": "尚未配置 Yubico 验证",
|
||||
"txt_yubikey_config_required_help": "请先输入一次 YubiKey OTP。NodeWarden 会自动获取并保存实例级 Client ID 和 Secret key,成功后再进入 YubiKey 设置表单。",
|
||||
"txt_otp_from_yubikey": "来自 YubiKey 的 OTP",
|
||||
"txt_please_input_yubikey_otp": "请输入 YubiKey OTP",
|
||||
"txt_yubikey_verify_failed": "YubiKey 验证失败",
|
||||
"txt_press_yubikey_to_authenticate": "按下 YubiKey 进行验证。",
|
||||
"txt_yubikey_auto_configure": "自动获取并保存",
|
||||
"txt_yubikey_validation_credentials": "Yubico 验证凭据",
|
||||
"txt_view": "查看",
|
||||
"txt_yubikey_config_updated": "Yubico 验证凭据已更新",
|
||||
"txt_yubikey_config_update_failed": "更新 Yubico 验证凭据失败",
|
||||
"txt_yubikey_auto_config_failed": "获取 Yubico 验证凭据失败",
|
||||
"txt_yubikey_reconfigure_help": "输入一个新的 OTP,可以重新自动获取并替换当前凭据。",
|
||||
"txt_yubikey_auto_configure_again": "重新自动获取",
|
||||
"txt_setting_coming_soon": "即将推出。",
|
||||
"txt_totp_manage_intro": "扫描二维码或在验证器 App 中输入密钥,然后输入验证码。",
|
||||
"txt_two_step_recovery_code_warning": "当您无法访问两步登录提供程序时,您的一次性恢复代码可用于停用两步登录。请将其妥善保管。",
|
||||
"txt_your_two_step_recovery_code": "您的 Bitwarden 两步登录恢复代码:",
|
||||
"txt_name_account_passkey_after_verification": "通行密钥创建成功!为您的通行密钥命名以帮助您识别它。",
|
||||
"txt_account_passkey_name_help": "0 / 最多 50 个字符",
|
||||
"txt_page_not_found": "页面不存在",
|
||||
"txt_page_not_found_hint": "这个页面可能已经删除、过期,或者链接不完整。",
|
||||
"txt_back_to_home": "回到首页",
|
||||
@@ -85,6 +136,37 @@ const zhCN: Record<string, string> = {
|
||||
"txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。",
|
||||
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。",
|
||||
"txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。",
|
||||
"txt_backup_recommend_backblaze_summary": "兼容 S3 的对象存储,免费容量 10 GB,无需信用卡。",
|
||||
"txt_backup_recommend_backblaze_step_1": "先注册或登录 Backblaze 账号。",
|
||||
"txt_backup_recommend_backblaze_step_2_prefix": "打开",
|
||||
"txt_backup_recommend_backblaze_step_2_suffix": ",点击创建一个桶,只输入桶名字,其他地方不修改,然后创建。",
|
||||
"txt_backup_recommend_backblaze_step_3": "创建后显示的 Endpoint 填到 S3 端点 URL;桶名字填到存储桶名称;区域填 Endpoint 中间那段,例如 us-west-004。",
|
||||
"txt_backup_recommend_backblaze_step_4_prefix": "打开",
|
||||
"txt_backup_recommend_backblaze_step_4_suffix": ",点击 Add a New Application Key,随便输入 Name of Key,其他地方不动,然后创建。",
|
||||
"txt_backup_recommend_backblaze_step_5": "生成结果里的 keyID 填到 访问 ID,applicationKey 填到 访问密码。",
|
||||
"txt_backup_recommend_cloudflare_r2_summary": "兼容 S3 的对象存储,免费容量 10 GB,需要信用卡认证。",
|
||||
"txt_backup_recommend_cloudflare_r2_bucket_link": "创建储存桶页面",
|
||||
"txt_backup_recommend_cloudflare_r2_api_link": "API 创建页面",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "打开",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ",只输入存储桶名称,直接创建。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "打开",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ",权限全选“对象读和写”,直接创建。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_3": "创建后令牌值不用管;Access Key ID 填到 访问 ID,Secret Access Key 填到 访问密码。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_4": "把下面显示的地址填到 S3 端点 URL;存储桶名称如实填写;区域保持 auto 不改。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_5": "路径前缀按需要填写,例如 nodewarden;不想分目录可以留空。",
|
||||
"txt_backup_recommend_s3_path_prefix_step": "路径前缀按需要填写,例如 nodewarden;不想分目录可以留空。",
|
||||
"txt_backup_recommend_tigris_summary": "兼容 S3 的对象存储。免费容量 5 GB,无需信用卡。",
|
||||
"txt_backup_recommend_tigris_signup_link": "注册页面",
|
||||
"txt_backup_recommend_tigris_bucket_link": "Create Bucket 页面",
|
||||
"txt_backup_recommend_tigris_access_key_link": "Create Access Key 页面",
|
||||
"txt_backup_recommend_tigris_step_1_prefix": "打开",
|
||||
"txt_backup_recommend_tigris_step_1_suffix": ",注册并登录 Tigris。",
|
||||
"txt_backup_recommend_tigris_step_2_prefix": "打开",
|
||||
"txt_backup_recommend_tigris_step_2_suffix": ",只输入桶的名字,其他地方不动,直接创建。",
|
||||
"txt_backup_recommend_tigris_step_3_prefix": "然后打开",
|
||||
"txt_backup_recommend_tigris_step_3_suffix": ",名字随意,直接创建。",
|
||||
"txt_backup_recommend_tigris_step_4": "创建后显示的 Endpoint URL IAM 不用管;其余显示出来的内容按名称填写到备份页面里。",
|
||||
"txt_backup_recommend_tigris_step_5": "最后点击 Manage Key Permissions,把 Admin Access 打开,否则无法写入。",
|
||||
"txt_backup_add_destination": "新增地点",
|
||||
"txt_backup_schedule_panel_title": "自动备份计划",
|
||||
"txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。",
|
||||
@@ -193,10 +275,14 @@ 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": "未知时间",
|
||||
"txt_backup_remote_current_path": "当前目录",
|
||||
"txt_backup_remote_modified": "修改时间",
|
||||
"txt_backup_remote_size": "大小",
|
||||
"txt_backup_remote_load_failed": "读取远端备份失败",
|
||||
"txt_backup_remote_invalid_response": "远端备份响应无效",
|
||||
"txt_backup_remote_download_failed": "下载远端备份失败",
|
||||
@@ -214,6 +300,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",
|
||||
@@ -266,15 +420,15 @@ const zhCN: Record<string, string> = {
|
||||
"txt_backup_webdav_username": "WebDAV 用户名",
|
||||
"txt_backup_webdav_password": "WebDAV 密码",
|
||||
"txt_backup_webdav_path": "远程目录",
|
||||
"txt_backup_s3_endpoint": "S3 端点",
|
||||
"txt_backup_s3_addressing_style": "S3 寻址方式",
|
||||
"txt_backup_s3_endpoint": "S3 端点 URL",
|
||||
"txt_backup_s3_addressing_style": "寻址方式",
|
||||
"txt_backup_s3_addressing_path_style": "path-style(默认)",
|
||||
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||
"txt_backup_s3_bucket": "存储桶",
|
||||
"txt_backup_s3_bucket": "存储桶名称",
|
||||
"txt_backup_s3_region": "区域",
|
||||
"txt_backup_s3_access_key": "访问密钥",
|
||||
"txt_backup_s3_secret_key": "秘密密钥",
|
||||
"txt_backup_s3_path": "远程路径",
|
||||
"txt_backup_s3_access_key": "访问 ID",
|
||||
"txt_backup_s3_secret_key": "访问密码",
|
||||
"txt_backup_s3_path": "路径前缀",
|
||||
"txt_backup_reserved_name": "预留类型名称",
|
||||
"txt_backup_reserved_notes": "预留备注",
|
||||
"txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明",
|
||||
@@ -479,8 +633,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": "邀请码有效期(小时)",
|
||||
@@ -489,16 +652,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": "注册暂时不可用,请重试一次",
|
||||
@@ -552,7 +720,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_jwt_secret_value_label": "值:",
|
||||
"txt_jwt_secret_value_requirement": "最低 {min} 位随机字符",
|
||||
"txt_jwt_what_is": "JWT 是什么",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失、过短,或者仍然使用示例值,实例就不能安全地正常使用。",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失或过短,实例就不能安全地正常使用。",
|
||||
"txt_how_to_fix": "处理步骤(添加 / 更换)",
|
||||
"txt_jwt_fix_step_1": "你可以继续下一步,不影响使用。",
|
||||
"txt_jwt_fix_step_2": "如果当前密钥不是强随机值,建议使用下方 32 位生成器。",
|
||||
@@ -638,6 +806,23 @@ const zhCN: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "密码提示最多只能输入 120 个字符",
|
||||
"txt_passkey": "通行密钥",
|
||||
"txt_passkeys": "通行密钥",
|
||||
"txt_register": "注册",
|
||||
"txt_key_list": "密钥列表",
|
||||
"txt_select_another_verification_method": "选择其他验证方式",
|
||||
"txt_select_two_step_login_method": "选择验证方式",
|
||||
"txt_two_step_passkeys": "通行密钥二步登录",
|
||||
"txt_two_step_passkeys_help": "管理仅用于二步登录的通行密钥。",
|
||||
"txt_two_step_passkey_name_placeholder": "安全密钥",
|
||||
"txt_add_two_step_passkey": "添加通行密钥",
|
||||
"txt_two_step_passkey_added": "通行密钥二步登录已更新",
|
||||
"txt_two_step_passkey_removed": "通行密钥已移除",
|
||||
"txt_two_step_passkeys_disabled": "通行密钥二步登录已禁用",
|
||||
"txt_disable_passkey_two_step_failed": "禁用通行密钥二步登录失败",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "使用通行密钥完成二步验证。",
|
||||
"txt_touch_your_passkey_when_prompted": "继续并在浏览器提示中批准通行密钥验证。",
|
||||
"txt_no_two_step_passkeys": "暂无二步登录通行密钥",
|
||||
"txt_remove_last_passkey_hint": "请禁用通行密钥二步登录来移除最后一把密钥。",
|
||||
"txt_passkey_setup_failed": "通行密钥设置失败",
|
||||
"txt_passkey_created_at_value": "创建于 {value}",
|
||||
"txt_account_passkey": "账号通行密钥",
|
||||
"txt_account_passkeys": "账号通行密钥",
|
||||
@@ -721,6 +906,8 @@ const zhCN: Record<string, string> = {
|
||||
"txt_scope": "权限范围",
|
||||
"txt_grant_type": "授权类型",
|
||||
"txt_refresh": "刷新",
|
||||
"txt_refresh_status": "刷新状态",
|
||||
"txt_load_failed": "加载失败",
|
||||
"txt_refresh_in_seconds_s": "{seconds} 秒后刷新",
|
||||
"txt_regenerate": "重新生成",
|
||||
"txt_registration_succeeded_please_sign_in": "注册成功,请登录",
|
||||
@@ -730,6 +917,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": "显示",
|
||||
@@ -771,6 +963,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": "请选择一个项目",
|
||||
@@ -1040,7 +1235,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": "修改用户状态",
|
||||
|
||||
@@ -11,6 +11,57 @@ const zhTW: Record<string, string> = {
|
||||
"nav_import_export": "導入導出",
|
||||
"nav_group_data_backup": "資料與備份",
|
||||
"nav_group_management": "管理",
|
||||
"txt_settings_appearance": "外觀",
|
||||
"txt_theme": "主題",
|
||||
"txt_use_system_theme": "使用系統主題",
|
||||
"txt_light_theme": "淺色",
|
||||
"txt_dark_theme": "深色",
|
||||
"txt_theme_saved_locally": "為您的網頁密碼庫選擇一個主題。",
|
||||
"txt_display_language_help": "更改網頁密碼庫的語言。",
|
||||
"txt_two_step_login": "兩步登入",
|
||||
"txt_keys": "密鑰",
|
||||
"txt_manage": "管理",
|
||||
"txt_providers": "提供程序",
|
||||
"txt_authenticator_app": "驗證器 App",
|
||||
"txt_authenticator_app_help": "輸入驗證器 App 生成的代碼。",
|
||||
"txt_passkey_provider_help": "使用兼容 FIDO2 的安全密鑰或生物識別驗證器。",
|
||||
"txt_yubico_otp_security_key": "Yubico OTP 安全密鑰",
|
||||
"txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 裝置。",
|
||||
"txt_yubikey_setup_intro": "將 YubiKey 插入電腦的 USB 連接埠。在下方選擇第一個空的 YubiKey 輸入欄位,觸摸 YubiKey 按鈕,然後保存表單。",
|
||||
"txt_yubikey_plug_in": "將 YubiKey 插入電腦的 USB 連接埠。",
|
||||
"txt_yubikey_select_empty_field": "在下方選擇第一個空的 YubiKey 輸入欄位。",
|
||||
"txt_yubikey_touch_button": "觸摸 YubiKey 按鈕。",
|
||||
"txt_yubikey_save_form": "保存表單。",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "NFC 支援",
|
||||
"txt_yubikey_supports_nfc": "我的某個密鑰支援 NFC。",
|
||||
"txt_yubikey_supports_nfc_desc": "如果您的某個 YubiKey 支援 NFC,行動裝置偵測到 NFC 可用時會提示您。",
|
||||
"txt_disable_all_keys": "停用全部密鑰",
|
||||
"txt_yubikeys_updated": "YubiKey 已更新",
|
||||
"txt_yubikey_update_failed": "更新 YubiKey 失敗",
|
||||
"txt_disable_yubikey_failed": "停用 YubiKey 失敗",
|
||||
"txt_yubikey_disabled": "YubiKey 已停用",
|
||||
"txt_yubikey_enabled": "YubiKey 已啟用。",
|
||||
"txt_yubikey_config_required": "尚未配置 Yubico 驗證",
|
||||
"txt_yubikey_config_required_help": "請先輸入一次 YubiKey OTP。NodeWarden 會自動取得並保存實例級 Client ID 和 Secret key,成功後再進入 YubiKey 設定表單。",
|
||||
"txt_otp_from_yubikey": "來自 YubiKey 的 OTP",
|
||||
"txt_please_input_yubikey_otp": "請輸入 YubiKey OTP",
|
||||
"txt_yubikey_verify_failed": "YubiKey 驗證失敗",
|
||||
"txt_press_yubikey_to_authenticate": "按下 YubiKey 進行驗證。",
|
||||
"txt_yubikey_auto_configure": "自動取得並保存",
|
||||
"txt_yubikey_validation_credentials": "Yubico 驗證憑據",
|
||||
"txt_view": "查看",
|
||||
"txt_yubikey_config_updated": "Yubico 驗證憑據已更新",
|
||||
"txt_yubikey_config_update_failed": "更新 Yubico 驗證憑據失敗",
|
||||
"txt_yubikey_auto_config_failed": "取得 Yubico 驗證憑據失敗",
|
||||
"txt_yubikey_reconfigure_help": "輸入一個新的 OTP,可以重新自動取得並替換目前憑據。",
|
||||
"txt_yubikey_auto_configure_again": "重新自動取得",
|
||||
"txt_setting_coming_soon": "即將推出。",
|
||||
"txt_totp_manage_intro": "掃描二維碼或在驗證器 App 中輸入密鑰,然後輸入驗證碼。",
|
||||
"txt_two_step_recovery_code_warning": "當您無法訪問兩步登入提供程序時,您的一次性恢復代碼可用於停用兩步登入。請將其妥善保管。",
|
||||
"txt_your_two_step_recovery_code": "您的 Bitwarden 兩步登入恢復代碼:",
|
||||
"txt_name_account_passkey_after_verification": "通行密鑰創建成功!為您的通行密鑰命名以幫助您識別它。",
|
||||
"txt_account_passkey_name_help": "0 / 最多 50 個字符",
|
||||
"txt_page_not_found": "頁面不存在",
|
||||
"txt_page_not_found_hint": "這個頁面可能已經刪除、過期,或者連結不完整。",
|
||||
"txt_back_to_home": "回到首頁",
|
||||
@@ -85,6 +136,37 @@ const zhTW: Record<string, string> = {
|
||||
"txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。",
|
||||
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。",
|
||||
"txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。",
|
||||
"txt_backup_recommend_backblaze_summary": "兼容 S3 的對象儲存,免費容量 10 GB,無需信用卡。",
|
||||
"txt_backup_recommend_backblaze_step_1": "先註冊或登入 Backblaze 賬號。",
|
||||
"txt_backup_recommend_backblaze_step_2_prefix": "打開",
|
||||
"txt_backup_recommend_backblaze_step_2_suffix": ",點擊創建一個桶,只輸入桶名字,其他地方不修改,然後創建。",
|
||||
"txt_backup_recommend_backblaze_step_3": "創建後顯示的 Endpoint 填到 S3 端點 URL;桶名字填到儲存桶名稱;區域填 Endpoint 中間那段,例如 us-west-004。",
|
||||
"txt_backup_recommend_backblaze_step_4_prefix": "打開",
|
||||
"txt_backup_recommend_backblaze_step_4_suffix": ",點擊 Add a New Application Key,隨便輸入 Name of Key,其他地方不動,然後創建。",
|
||||
"txt_backup_recommend_backblaze_step_5": "生成結果裡的 keyID 填存取金鑰,applicationKey 填秘密金鑰。",
|
||||
"txt_backup_recommend_cloudflare_r2_summary": "兼容 S3 的對象儲存,免費容量 10 GB,需要信用卡驗證。",
|
||||
"txt_backup_recommend_cloudflare_r2_bucket_link": "創建儲存桶頁面",
|
||||
"txt_backup_recommend_cloudflare_r2_api_link": "API 創建頁面",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "打開",
|
||||
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ",只輸入儲存桶名稱,直接創建。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "打開",
|
||||
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ",權限全選「對象讀和寫」,直接創建。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_3": "創建後令牌值不用管;Access Key ID 填到存取 ID,Secret Access Key 填到存取密碼。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_4": "把下面顯示的地址填到 S3 端點 URL;儲存桶名稱如實填寫;區域保持 auto 不改。",
|
||||
"txt_backup_recommend_cloudflare_r2_step_5": "路徑前綴按需要填寫,例如 nodewarden;不想分目錄可以留空。",
|
||||
"txt_backup_recommend_s3_path_prefix_step": "路徑前綴按需要填寫,例如 nodewarden;不想分目錄可以留空。",
|
||||
"txt_backup_recommend_tigris_summary": "兼容 S3 的對象儲存。免費容量 5 GB,無需信用卡。",
|
||||
"txt_backup_recommend_tigris_signup_link": "註冊頁面",
|
||||
"txt_backup_recommend_tigris_bucket_link": "Create Bucket 頁面",
|
||||
"txt_backup_recommend_tigris_access_key_link": "Create Access Key 頁面",
|
||||
"txt_backup_recommend_tigris_step_1_prefix": "打開",
|
||||
"txt_backup_recommend_tigris_step_1_suffix": ",註冊並登入 Tigris。",
|
||||
"txt_backup_recommend_tigris_step_2_prefix": "打開",
|
||||
"txt_backup_recommend_tigris_step_2_suffix": ",只輸入桶的名字,其他地方不動,直接創建。",
|
||||
"txt_backup_recommend_tigris_step_3_prefix": "然後打開",
|
||||
"txt_backup_recommend_tigris_step_3_suffix": ",名字隨意,直接創建。",
|
||||
"txt_backup_recommend_tigris_step_4": "創建後顯示的 Endpoint URL IAM 不用管;其餘顯示出來的內容按名稱填寫到備份頁面裡。",
|
||||
"txt_backup_recommend_tigris_step_5": "最後點擊 Manage Key Permissions,把 Admin Access 打開,否則無法寫入。",
|
||||
"txt_backup_add_destination": "新增地點",
|
||||
"txt_backup_schedule_panel_title": "自動備份計劃",
|
||||
"txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。",
|
||||
@@ -193,10 +275,14 @@ 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": "未知時間",
|
||||
"txt_backup_remote_current_path": "當前目錄",
|
||||
"txt_backup_remote_modified": "修改時間",
|
||||
"txt_backup_remote_size": "大小",
|
||||
"txt_backup_remote_load_failed": "讀取遠端備份失敗",
|
||||
"txt_backup_remote_invalid_response": "遠端備份響應無效",
|
||||
"txt_backup_remote_download_failed": "下載遠端備份失敗",
|
||||
@@ -214,6 +300,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",
|
||||
@@ -479,8 +633,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": "邀請碼有效期(小時)",
|
||||
@@ -489,16 +652,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": "註冊暫時不可用,請重試一次",
|
||||
@@ -552,7 +720,7 @@ const zhTW: Record<string, string> = {
|
||||
"txt_jwt_secret_value_label": "值:",
|
||||
"txt_jwt_secret_value_requirement": "最低 {min} 位隨機字符",
|
||||
"txt_jwt_what_is": "JWT 是什麼",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失、過短,或者仍然使用示例值,實例就不能安全地正常使用。",
|
||||
"txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失或過短,實例就不能安全地正常使用。",
|
||||
"txt_how_to_fix": "處理步驟(添加 / 更換)",
|
||||
"txt_jwt_fix_step_1": "你可以繼續下一步,不影響使用。",
|
||||
"txt_jwt_fix_step_2": "如果當前密鑰不是強隨機值,建議使用下方 32 位生成器。",
|
||||
@@ -638,6 +806,23 @@ const zhTW: Record<string, string> = {
|
||||
"txt_password_hint_too_long": "密碼提示最多隻能輸入 120 個字符",
|
||||
"txt_passkey": "通行密鑰",
|
||||
"txt_passkeys": "通行密鑰",
|
||||
"txt_register": "註冊",
|
||||
"txt_key_list": "密鑰列表",
|
||||
"txt_select_another_verification_method": "選擇其他驗證方式",
|
||||
"txt_select_two_step_login_method": "選擇驗證方式",
|
||||
"txt_two_step_passkeys": "通行密鑰兩步登入",
|
||||
"txt_two_step_passkeys_help": "管理僅用於兩步登入的通行密鑰。",
|
||||
"txt_two_step_passkey_name_placeholder": "安全密鑰",
|
||||
"txt_add_two_step_passkey": "新增通行密鑰",
|
||||
"txt_two_step_passkey_added": "通行密鑰兩步登入已更新",
|
||||
"txt_two_step_passkey_removed": "通行密鑰已移除",
|
||||
"txt_two_step_passkeys_disabled": "通行密鑰兩步登入已停用",
|
||||
"txt_disable_passkey_two_step_failed": "停用通行密鑰兩步登入失敗",
|
||||
"txt_use_passkey_to_complete_two_step_verification": "使用通行密鑰完成兩步驗證。",
|
||||
"txt_touch_your_passkey_when_prompted": "繼續並在瀏覽器提示中批准通行密鑰驗證。",
|
||||
"txt_no_two_step_passkeys": "暫無兩步登入通行密鑰",
|
||||
"txt_remove_last_passkey_hint": "請停用通行密鑰兩步登入來移除最後一把密鑰。",
|
||||
"txt_passkey_setup_failed": "通行密鑰設置失敗",
|
||||
"txt_passkey_created_at_value": "創建於 {value}",
|
||||
"txt_account_passkey": "賬號通行密鑰",
|
||||
"txt_account_passkeys": "賬號通行密鑰",
|
||||
@@ -721,6 +906,8 @@ const zhTW: Record<string, string> = {
|
||||
"txt_scope": "權限範圍",
|
||||
"txt_grant_type": "授權類型",
|
||||
"txt_refresh": "刷新",
|
||||
"txt_refresh_status": "刷新狀態",
|
||||
"txt_load_failed": "載入失敗",
|
||||
"txt_refresh_in_seconds_s": "{seconds} 秒後刷新",
|
||||
"txt_regenerate": "重新生成",
|
||||
"txt_registration_succeeded_please_sign_in": "註冊成功,請登錄",
|
||||
@@ -730,6 +917,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": "顯示",
|
||||
@@ -771,6 +963,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": "請選擇一個項目",
|
||||
@@ -1040,7 +1235,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": "修改使用者狀態",
|
||||
|
||||
@@ -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));
|
||||
|
||||
+24
-1
@@ -15,6 +15,7 @@ export interface Profile {
|
||||
name: string;
|
||||
key: string;
|
||||
masterPasswordHint?: string | null;
|
||||
yubikeyEnabled?: boolean;
|
||||
privateKey?: string | null;
|
||||
publicKey?: string | null;
|
||||
role: 'admin' | 'user';
|
||||
@@ -290,11 +291,20 @@ export interface ListResponse<T> {
|
||||
|
||||
export interface WebBootstrapResponse {
|
||||
defaultKdfIterations?: number;
|
||||
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null;
|
||||
jwtUnsafeReason?: 'missing' | 'too_short' | null;
|
||||
jwtSecretMinLength?: number;
|
||||
registrationInviteRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface YubiKeyOtpSettings {
|
||||
enabled: boolean;
|
||||
keys: [string, string, string, string, string];
|
||||
nfc: boolean;
|
||||
yubicoConfigured: boolean;
|
||||
yubicoClientId: string;
|
||||
yubicoSecretKey: string;
|
||||
}
|
||||
|
||||
export interface TokenSuccess {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
@@ -314,6 +324,8 @@ export interface TokenSuccess {
|
||||
ResetMasterPassword?: boolean;
|
||||
scope?: string;
|
||||
unofficialServer?: boolean;
|
||||
UserVerificationToken?: string;
|
||||
userVerificationToken?: string;
|
||||
UserDecryptionOptions?: unknown;
|
||||
userDecryptionOptions?: unknown;
|
||||
VaultKeys?: {
|
||||
@@ -338,6 +350,17 @@ export interface AccountPasskeyCredential {
|
||||
revisionDate?: string;
|
||||
}
|
||||
|
||||
export interface TwoFactorPasskeyCredential {
|
||||
id: number;
|
||||
name: string;
|
||||
migrated?: boolean;
|
||||
}
|
||||
|
||||
export interface TwoFactorPasskeySettings {
|
||||
enabled: boolean;
|
||||
keys: TwoFactorPasskeyCredential[];
|
||||
}
|
||||
|
||||
export interface AuthRequest {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
|
||||
+1
-21
@@ -14,7 +14,6 @@
|
||||
/* Unified product polish: refined, smooth, comfortable surfaces across desktop, mobile, and dark mode. */
|
||||
|
||||
/* ── surface consistency ── */
|
||||
.app-shell,
|
||||
.auth-card,
|
||||
.dialog-card,
|
||||
.card,
|
||||
@@ -36,12 +35,6 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
background: var(--panel-soft);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.mobile-tabbar,
|
||||
.app-side {
|
||||
@@ -104,7 +97,6 @@
|
||||
}
|
||||
|
||||
/* ── dark mode surface resets ── */
|
||||
:root[data-theme='dark'] .app-shell,
|
||||
:root[data-theme='dark'] .auth-card,
|
||||
:root[data-theme='dark'] .dialog-card,
|
||||
:root[data-theme='dark'] .card,
|
||||
@@ -259,17 +251,6 @@ h4 {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.app-page {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 56px;
|
||||
padding-inline: 16px;
|
||||
@@ -316,7 +297,7 @@ h4 {
|
||||
}
|
||||
|
||||
.app-main {
|
||||
grid-template-columns: 212px minmax(0, 1fr);
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@@ -916,7 +897,6 @@ textarea {
|
||||
background: var(--bg-accent);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .app-shell,
|
||||
:root[data-theme='dark'] .topbar,
|
||||
:root[data-theme='dark'] .app-side,
|
||||
:root[data-theme='dark'] .mobile-tabbar,
|
||||
|
||||
@@ -30,14 +30,11 @@
|
||||
|
||||
.not-found-page {
|
||||
@apply relative grid min-h-full place-items-center overflow-hidden p-6 text-center;
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgba(28, 118, 255, 0.24), transparent 27rem),
|
||||
radial-gradient(circle at 16% 84%, rgba(22, 163, 255, 0.10), transparent 22rem),
|
||||
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.not-found-shell {
|
||||
@apply relative z-20 grid w-full max-w-[620px] justify-items-center gap-5 px-4 py-7 text-center;
|
||||
@apply relative z-20 grid w-full max-w-[560px] justify-items-center gap-6 px-4 py-7 text-center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
@@ -356,7 +353,7 @@
|
||||
}
|
||||
|
||||
.not-found-logo {
|
||||
@apply h-14 w-[70px] flex-shrink-0 object-contain;
|
||||
@apply h-14 w-14 flex-shrink-0 object-contain;
|
||||
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
|
||||
}
|
||||
|
||||
@@ -377,17 +374,16 @@
|
||||
|
||||
.not-found-copy {
|
||||
@apply grid justify-items-center gap-3;
|
||||
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.not-found-shell h1 {
|
||||
@apply m-0 text-3xl font-extrabold leading-tight;
|
||||
color: #f8fbff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.not-found-shell p {
|
||||
@apply m-0 max-w-[420px] text-sm leading-relaxed;
|
||||
color: rgba(220, 232, 251, 0.82);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.not-found-action {
|
||||
@@ -396,10 +392,7 @@
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.not-found-page {
|
||||
background:
|
||||
radial-gradient(circle at 50% 36%, rgba(28, 118, 255, 0.24), transparent 18rem),
|
||||
radial-gradient(circle at 18% 82%, rgba(22, 163, 255, 0.10), transparent 16rem),
|
||||
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.not-found-shell {
|
||||
|
||||
@@ -200,10 +200,6 @@
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.20), 0 8px 24px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .app-shell {
|
||||
box-shadow: 0 4px 40px rgba(0, 0, 0, 0.30);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .list-item:hover {
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
|
||||
}
|
||||
@@ -340,6 +336,7 @@
|
||||
:root[data-theme='dark'] .backup-recommendation-step,
|
||||
:root[data-theme='dark'] .backup-recommendation-inline-note,
|
||||
:root[data-theme='dark'] .backup-recommendation-linked-item,
|
||||
:root[data-theme='dark'] .backup-browser-head,
|
||||
:root[data-theme='dark'] .backup-browser-meta,
|
||||
:root[data-theme='dark'] .backup-browser-empty,
|
||||
:root[data-theme='dark'] .backup-inline-note,
|
||||
@@ -351,6 +348,19 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .backup-recommendation-step a {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .backup-recommendation-step a:hover,
|
||||
:root[data-theme='dark'] .backup-recommendation-step a:focus-visible {
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .backup-browser-head {
|
||||
background: var(--panel-subtle);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .restore-progress-overlay {
|
||||
background: var(--overlay-strong);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user