mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 14:50:11 +00:00
Compare commits
20
Commits
v1.7.1
..
0d1bb196e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d1bb196e2 | ||
|
|
e31f82c0d6 | ||
|
|
f82dcc3c17 | ||
|
|
4378e1b430 | ||
|
|
5eeaf4e32e | ||
|
|
82f968e51f | ||
|
|
a5ad16ac27 | ||
|
|
6a1a8357bf | ||
|
|
31cfd19b6b | ||
|
|
4cd9ad00d2 | ||
|
|
31dcc76ee2 | ||
|
|
bf6ac7b405 | ||
|
|
1bfb9a647d | ||
|
|
e9272ec29a | ||
|
|
8942e5bd49 | ||
|
|
d722815999 | ||
|
|
ff85698edb | ||
|
|
c3dc53bac1 | ||
|
|
1acc31eda0 | ||
|
|
c694f1bfce |
@@ -1,7 +1,7 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Project Wiki/ 项目文档
|
- name: Project Wiki/ 项目文档
|
||||||
url: https://github.com/shuaiplus/nodewarden/wiki
|
url: https://nodewarden.app
|
||||||
about: |
|
about: |
|
||||||
Please check the documentation for common questions and troubleshooting steps.
|
Please check the documentation for common questions and troubleshooting steps.
|
||||||
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
|
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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
|
|
||||||
@@ -56,6 +56,7 @@ NodeWarden-compat/
|
|||||||
.codex-upstream/bitwarden-browser/
|
.codex-upstream/bitwarden-browser/
|
||||||
|
|
||||||
.reasonix/
|
.reasonix/
|
||||||
|
.upstream/
|
||||||
|
|
||||||
# Compatibility analysis documents
|
# Compatibility analysis documents
|
||||||
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||||
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||||
export const BACKUP_DEFAULT_S3_REGION = 'auto';
|
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_INTERVAL_HOURS = 24;
|
||||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||||
|
|
||||||
@@ -109,14 +110,14 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
|||||||
region: BACKUP_DEFAULT_S3_REGION,
|
region: BACKUP_DEFAULT_S3_REGION,
|
||||||
accessKeyId: '',
|
accessKeyId: '',
|
||||||
secretAccessKey: '',
|
secretAccessKey: '',
|
||||||
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
|
rootPath: BACKUP_DEFAULT_S3_ROOT_PATH,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
remotePath: BACKUP_DEFAULT_REMOTE_PATH,
|
remotePath: BACKUP_DEFAULT_WEBDAV_REMOTE_PATH,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export class BackupTransferRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
|
const failures: Array<{ destinationId: string; error: string }> = [];
|
||||||
try {
|
try {
|
||||||
await this.touchJob(token);
|
await this.touchJob(token);
|
||||||
const storage = new StorageService(this.env.DB);
|
const storage = new StorageService(this.env.DB);
|
||||||
@@ -230,6 +231,7 @@ export class BackupTransferRunner {
|
|||||||
scanStartMs = now.getTime();
|
scanStartMs = now.getTime();
|
||||||
for (const destination of dueDestinations) {
|
for (const destination of dueDestinations) {
|
||||||
await this.touchJob(token);
|
await this.touchJob(token);
|
||||||
|
try {
|
||||||
await executeConfiguredBackup(
|
await executeConfiguredBackup(
|
||||||
this.env,
|
this.env,
|
||||||
storage,
|
storage,
|
||||||
@@ -239,12 +241,20 @@ export class BackupTransferRunner {
|
|||||||
() => this.touchJob(token)
|
() => this.touchJob(token)
|
||||||
);
|
);
|
||||||
completed += 1;
|
completed += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({
|
||||||
|
destinationId: destination.id,
|
||||||
|
error: error instanceof Error ? error.message : 'Scheduled backup failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
ok: true,
|
ok: true,
|
||||||
completed,
|
completed,
|
||||||
|
failed: failures.length,
|
||||||
|
failures,
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -318,7 +328,8 @@ export class BackupTransferRunner {
|
|||||||
replaceExisting,
|
replaceExisting,
|
||||||
!checksumOk,
|
!checksumOk,
|
||||||
body.auditMetadata || null,
|
body.auditMetadata || null,
|
||||||
targetDeviceIdentifier
|
targetDeviceIdentifier,
|
||||||
|
() => this.touchJob(token)
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(JSON.stringify(result.result), {
|
return new Response(JSON.stringify(result.result), {
|
||||||
|
|||||||
+17
-6
@@ -249,7 +249,7 @@ export async function handleAdminListInvites(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/admin/invites/:code
|
// DELETE /api/admin/invites/:code
|
||||||
export async function handleAdminRevokeInvite(
|
export async function handleAdminDeleteInvite(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
actorUser: User,
|
actorUser: User,
|
||||||
@@ -260,12 +260,14 @@ export async function handleAdminRevokeInvite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const revoked = await storage.revokeInvite(code);
|
const deleted = await storage.deleteInvite(code);
|
||||||
if (!revoked) {
|
if (!deleted) {
|
||||||
return errorResponse('Invite not found or already inactive', 404);
|
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 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,12 +277,21 @@ export async function handleAdminDeleteAllInvites(
|
|||||||
env: Env,
|
env: Env,
|
||||||
actorUser: User
|
actorUser: User
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
void request;
|
|
||||||
if (!isAdmin(actorUser)) {
|
if (!isAdmin(actorUser)) {
|
||||||
return errorResponse('Forbidden', 403);
|
return errorResponse('Forbidden', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
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();
|
const deleted = await storage.deleteAllInvites();
|
||||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
|
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
|
||||||
deleted,
|
deleted,
|
||||||
|
|||||||
+61
-26
@@ -21,6 +21,7 @@ import {
|
|||||||
repairBackupSettings,
|
repairBackupSettings,
|
||||||
requireBackupDestination,
|
requireBackupDestination,
|
||||||
saveBackupSettings,
|
saveBackupSettings,
|
||||||
|
updateBackupDestinationRuntime,
|
||||||
} from '../services/backup-config';
|
} from '../services/backup-config';
|
||||||
import {
|
import {
|
||||||
type BackupImportExecutionResult,
|
type BackupImportExecutionResult,
|
||||||
@@ -260,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(
|
export async function executeConfiguredBackup(
|
||||||
env: Env,
|
env: Env,
|
||||||
storage: StorageService,
|
storage: StorageService,
|
||||||
@@ -287,12 +312,14 @@ export async function executeConfiguredBackup(
|
|||||||
const destination = requireBackupDestination(currentSettings, destinationId);
|
const destination = requireBackupDestination(currentSettings, destinationId);
|
||||||
|
|
||||||
const now = new Date();
|
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 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 {
|
try {
|
||||||
await touchLease();
|
await touchLease();
|
||||||
@@ -354,6 +381,7 @@ export async function executeConfiguredBackup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||||
|
let uploadVerificationMethod: 'metadata' | 'download' | null = null;
|
||||||
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await progress?.({
|
await progress?.({
|
||||||
@@ -373,14 +401,7 @@ export async function executeConfiguredBackup(
|
|||||||
stageTitle: 'txt_backup_remote_run_progress_verify_title',
|
stageTitle: 'txt_backup_remote_run_progress_verify_title',
|
||||||
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
|
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
|
||||||
});
|
});
|
||||||
const remoteFile = await remoteSession.download(archive.fileName);
|
uploadVerificationMethod = await verifyUploadedBackupArchive(remoteSession, archive);
|
||||||
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');
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
|
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
|
||||||
@@ -409,14 +430,16 @@ export async function executeConfiguredBackup(
|
|||||||
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
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 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 touchLease();
|
||||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
||||||
@@ -426,6 +449,7 @@ export async function executeConfiguredBackup(
|
|||||||
fileName: archive.fileName,
|
fileName: archive.fileName,
|
||||||
fileBytes: archive.bytes.byteLength,
|
fileBytes: archive.bytes.byteLength,
|
||||||
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
||||||
|
uploadVerificationMethod,
|
||||||
prunedFileCount,
|
prunedFileCount,
|
||||||
pruneError: pruneErrorMessage,
|
pruneError: pruneErrorMessage,
|
||||||
...(auditMetadata || {}),
|
...(auditMetadata || {}),
|
||||||
@@ -448,15 +472,18 @@ export async function executeConfiguredBackup(
|
|||||||
provider: upload.provider,
|
provider: upload.provider,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
destination.runtime.lastErrorAt = new Date().toISOString();
|
const errorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
||||||
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
|
||||||
await touchLease();
|
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 touchLease();
|
||||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
||||||
...getBackupDestinationSummary(destination),
|
...getBackupDestinationSummary(destination),
|
||||||
error: destination.runtime.lastErrorMessage,
|
error: errorMessage,
|
||||||
...(auditMetadata || {}),
|
...(auditMetadata || {}),
|
||||||
});
|
});
|
||||||
await progress?.({
|
await progress?.({
|
||||||
@@ -467,7 +494,7 @@ export async function executeConfiguredBackup(
|
|||||||
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
|
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
|
||||||
done: true,
|
done: true,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: destination.runtime.lastErrorMessage,
|
error: errorMessage,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -655,12 +682,18 @@ export async function importAndAuditRemoteBackupFile(
|
|||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
checksumMismatchAccepted: boolean,
|
checksumMismatchAccepted: boolean,
|
||||||
auditMetadata: Record<string, unknown> | null = null,
|
auditMetadata: Record<string, unknown> | null = null,
|
||||||
targetDeviceIdentifier: string | null = null
|
targetDeviceIdentifier: string | null = null,
|
||||||
|
keepAlive?: (() => Promise<void>) | null
|
||||||
): Promise<BackupImportExecutionResult> {
|
): Promise<BackupImportExecutionResult> {
|
||||||
|
const touchLease = async () => {
|
||||||
|
await keepAlive?.();
|
||||||
|
};
|
||||||
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
|
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
|
||||||
|
await touchLease();
|
||||||
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
|
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
|
||||||
const externalAttachmentCache = new Map<string, Uint8Array | null>();
|
const externalAttachmentCache = new Map<string, Uint8Array | null>();
|
||||||
const progress: BackupRestoreProgressReporter = async (event) => {
|
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||||
|
await touchLease();
|
||||||
await notifyUserBackupRestoreProgress(
|
await notifyUserBackupRestoreProgress(
|
||||||
env,
|
env,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
@@ -678,6 +711,7 @@ export async function importAndAuditRemoteBackupFile(
|
|||||||
replaceExisting,
|
replaceExisting,
|
||||||
{
|
{
|
||||||
loadAttachment: async (blobName) => {
|
loadAttachment: async (blobName) => {
|
||||||
|
await touchLease();
|
||||||
const normalized = String(blobName || '').trim();
|
const normalized = String(blobName || '').trim();
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
if (externalAttachmentCache.has(normalized)) {
|
if (externalAttachmentCache.has(normalized)) {
|
||||||
@@ -700,6 +734,7 @@ export async function importAndAuditRemoteBackupFile(
|
|||||||
} catch {
|
} catch {
|
||||||
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
|
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
|
||||||
}
|
}
|
||||||
|
await touchLease();
|
||||||
return externalAttachmentCache.get(normalized) || null;
|
return externalAttachmentCache.get(normalized) || null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+10
-2
@@ -32,6 +32,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
|
|||||||
// attachments, import/export, and current official clients.
|
// attachments, import/export, and current official clients.
|
||||||
export interface CipherResponseOptions {
|
export interface CipherResponseOptions {
|
||||||
preserveRepairableUris?: boolean;
|
preserveRepairableUris?: boolean;
|
||||||
|
validFolderIds?: ReadonlySet<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldPreserveRepairableCipherUris(request: Request): boolean {
|
export function shouldPreserveRepairableCipherUris(request: Request): boolean {
|
||||||
@@ -48,6 +49,12 @@ function normalizeOptionalId(value: unknown): string | null {
|
|||||||
return normalized ? normalized : 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 {
|
function readBooleanOrFallback(value: unknown, fallback: boolean): boolean {
|
||||||
return typeof value === 'boolean' ? value : fallback;
|
return typeof value === 'boolean' ? value : fallback;
|
||||||
}
|
}
|
||||||
@@ -727,7 +734,7 @@ export function cipherToResponse(
|
|||||||
// Pass through ALL stored cipher fields (known + unknown)
|
// Pass through ALL stored cipher fields (known + unknown)
|
||||||
...passthrough,
|
...passthrough,
|
||||||
// Server-computed / enforced fields (always override)
|
// Server-computed / enforced fields (always override)
|
||||||
folderId: normalizeOptionalId(cipher.folderId),
|
folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
|
||||||
type: Number(cipher.type) || 1,
|
type: Number(cipher.type) || 1,
|
||||||
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
||||||
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
||||||
@@ -785,9 +792,10 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
|
||||||
filteredCiphers.map((cipher) => cipher.id)
|
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.
|
// Build responses only for the current page to keep pagination cheap.
|
||||||
const responseOptions = cipherResponseOptionsForRequest(request);
|
const responseOptions = { ...cipherResponseOptionsForRequest(request), validFolderIds };
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
|
|||||||
@@ -88,12 +88,13 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
.map(buildWebAuthnPrfOption)
|
.map(buildWebAuthnPrfOption)
|
||||||
.filter((option): option is NonNullable<typeof option> => !!option);
|
.filter((option): option is NonNullable<typeof option> => !!option);
|
||||||
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
|
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
|
||||||
|
const validFolderIds = new Set(folders.map((folder) => folder.id));
|
||||||
|
|
||||||
const profile: ProfileResponse = buildProfileResponse(user, env);
|
const profile: ProfileResponse = buildProfileResponse(user, env);
|
||||||
|
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
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)) {
|
if (isCipherResponseSyncCompatible(response)) {
|
||||||
cipherResponses.push(response);
|
cipherResponses.push(response);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ import {
|
|||||||
handleAdminCreateInvite,
|
handleAdminCreateInvite,
|
||||||
handleAdminListInvites,
|
handleAdminListInvites,
|
||||||
handleAdminDeleteAllInvites,
|
handleAdminDeleteAllInvites,
|
||||||
handleAdminRevokeInvite,
|
handleAdminDeleteInvite,
|
||||||
handleAdminSetUserStatus,
|
handleAdminSetUserStatus,
|
||||||
handleAdminDeleteUser,
|
handleAdminDeleteUser,
|
||||||
handleAdminListAuditLogs,
|
handleAdminListAuditLogs,
|
||||||
@@ -52,7 +52,7 @@ export async function handleAdminRoute(
|
|||||||
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
||||||
if (adminInviteMatch && method === 'DELETE') {
|
if (adminInviteMatch && method === 'DELETE') {
|
||||||
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
|
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);
|
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ function buildConfigResponse(origin: string) {
|
|||||||
'email-verification': true,
|
'email-verification': true,
|
||||||
'pm-19051-send-email-verification': false,
|
'pm-19051-send-email-verification': false,
|
||||||
'pm-19148-innovation-archive': true,
|
'pm-19148-innovation-archive': true,
|
||||||
|
'pm-30529-webauthn-related-origins': true,
|
||||||
'unauth-ui-refresh': true,
|
'unauth-ui-refresh': true,
|
||||||
'web-push': false,
|
'web-push': false,
|
||||||
},
|
},
|
||||||
@@ -469,7 +470,7 @@ export async function handlePublicRoute(
|
|||||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
const origin = new URL(request.url).origin;
|
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') {
|
if (path === '/api/version' && method === 'GET') {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '../../shared/backup-schema';
|
} from '../../shared/backup-schema';
|
||||||
|
|
||||||
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
||||||
|
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
|
||||||
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
||||||
const MAX_BACKUP_DESTINATIONS = 24;
|
const MAX_BACKUP_DESTINATIONS = 24;
|
||||||
|
|
||||||
@@ -324,6 +325,47 @@ function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<strin
|
|||||||
return new Map(destinations.map((destination) => [destination.id, destination]));
|
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 {
|
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
|
||||||
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
|
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
|
||||||
}
|
}
|
||||||
@@ -387,27 +429,30 @@ export function normalizeBackupSettingsInput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function serializeBackupSettings(settings: BackupSettings): string {
|
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> {
|
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
|
||||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
|
||||||
|
mergeRuntimeStates(settings, await loadBackupRuntimeStates(storage))
|
||||||
|
);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
const settings = getDefaultBackupSettings(fallbackTimezone);
|
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||||
await saveBackupSettings(storage, env, settings);
|
await saveBackupSettings(storage, env, settings);
|
||||||
return settings;
|
return mergeRuntime(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelope = parseBackupSettingsEnvelope(raw);
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
if (!envelope) {
|
if (!envelope) {
|
||||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
await saveBackupSettings(storage, env, settings);
|
await saveBackupSettings(storage, env, settings);
|
||||||
return settings;
|
return mergeRuntime(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
return parseBackupSettings(decrypted, fallbackTimezone);
|
return mergeRuntime(parseBackupSettings(decrypted, fallbackTimezone));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Backup settings need administrator reactivation after restore');
|
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 users = await storage.getAllUsers();
|
||||||
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
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> {
|
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
||||||
@@ -596,9 +662,9 @@ export function hasBackupSlotBetween(
|
|||||||
const endMs = endExclusive.getTime();
|
const endMs = endExclusive.getTime();
|
||||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
||||||
|
|
||||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||||
? lastAttemptAt.getTime()
|
? lastSuccessAt.getTime()
|
||||||
: Number.NEGATIVE_INFINITY;
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
const dayCursor = new Date(startMs);
|
const dayCursor = new Date(startMs);
|
||||||
@@ -620,7 +686,7 @@ export function hasBackupSlotBetween(
|
|||||||
for (const slotStart of slotStarts) {
|
for (const slotStart of slotStarts) {
|
||||||
const slotStartMs = slotStart.getTime();
|
const slotStartMs = slotStart.getTime();
|
||||||
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
||||||
if (lastAttemptMs >= slotStartMs) continue;
|
if (lastSuccessMs >= slotStartMs) continue;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,9 +703,9 @@ export function isBackupDueNow(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (!destination.schedule.enabled) return false;
|
if (!destination.schedule.enabled) return false;
|
||||||
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
||||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||||
? lastAttemptAt.getTime()
|
? lastSuccessAt.getTime()
|
||||||
: Number.NEGATIVE_INFINITY;
|
: Number.NEGATIVE_INFINITY;
|
||||||
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
|
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||||
const slotStarts = getBackupSlotStartsForLocalDay(
|
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||||
@@ -652,7 +718,7 @@ export function isBackupDueNow(
|
|||||||
for (const slotStart of slotStarts) {
|
for (const slotStart of slotStarts) {
|
||||||
const slotStartMs = slotStart.getTime();
|
const slotStartMs = slotStart.getTime();
|
||||||
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
|
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
|
||||||
if (lastAttemptMs >= slotStartMs) return false;
|
if (lastSuccessMs >= slotStartMs) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ export interface RemoteBackupFile {
|
|||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupFileStat {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
remotePath: string;
|
||||||
|
size: number | null;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RemoteBackupFilePutOptions {
|
export interface RemoteBackupFilePutOptions {
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
}
|
}
|
||||||
@@ -433,6 +440,10 @@ async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
|
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 authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
const remotePath = webDavFullPath(config, relativePath);
|
const remotePath = webDavFullPath(config, relativePath);
|
||||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
@@ -441,11 +452,17 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
|
|||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (response.status === 404) return false;
|
if (response.status === 404) return null;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`WebDAV existence check failed: ${response.status}`);
|
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 {
|
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
|
||||||
@@ -540,10 +557,16 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
|
|||||||
const currentPath = normalizeRelativePath(relativePath);
|
const currentPath = normalizeRelativePath(relativePath);
|
||||||
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
||||||
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||||
|
const rootPrefix = trimSlashes(config.rootPath);
|
||||||
|
const items: RemoteBackupItem[] = [];
|
||||||
|
let continuationToken = '';
|
||||||
|
|
||||||
|
do {
|
||||||
const url = s3BucketBaseUrl(config);
|
const url = s3BucketBaseUrl(config);
|
||||||
url.searchParams.set('list-type', '2');
|
url.searchParams.set('list-type', '2');
|
||||||
url.searchParams.set('delimiter', '/');
|
url.searchParams.set('delimiter', '/');
|
||||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||||
|
if (continuationToken) url.searchParams.set('continuation-token', continuationToken);
|
||||||
|
|
||||||
const response = await signedS3Request(config, 'GET', url);
|
const response = await signedS3Request(config, 'GET', url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -551,8 +574,6 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const xml = await response.text();
|
const xml = await response.text();
|
||||||
const rootPrefix = trimSlashes(config.rootPath);
|
|
||||||
const items: RemoteBackupItem[] = [];
|
|
||||||
|
|
||||||
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
||||||
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
||||||
@@ -596,6 +617,9 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
continuationToken = extractXmlFirst(xml, 'NextContinuationToken') || '';
|
||||||
|
} while (continuationToken);
|
||||||
|
|
||||||
const deduped = new Map<string, RemoteBackupItem>();
|
const deduped = new Map<string, RemoteBackupItem>();
|
||||||
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
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> {
|
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 objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = s3ObjectUrl(config, objectKey);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedS3Request(config, 'HEAD', url);
|
const response = await signedS3Request(config, 'HEAD', url);
|
||||||
if (response.status === 404) return false;
|
if (response.status === 404) return null;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`S3 existence check failed: ${response.status}`);
|
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 {
|
interface ConfiguredDestinationAdapter {
|
||||||
@@ -656,6 +690,7 @@ interface ConfiguredDestinationAdapter {
|
|||||||
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||||
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
||||||
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
|
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||||
|
stat: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFileStat | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteBackupTransferSession {
|
export interface RemoteBackupTransferSession {
|
||||||
@@ -666,6 +701,7 @@ export interface RemoteBackupTransferSession {
|
|||||||
download(relativePath: string): Promise<RemoteBackupFile>;
|
download(relativePath: string): Promise<RemoteBackupFile>;
|
||||||
deleteFile(relativePath: string): Promise<void>;
|
deleteFile(relativePath: string): Promise<void>;
|
||||||
exists(relativePath: string): Promise<boolean>;
|
exists(relativePath: string): Promise<boolean>;
|
||||||
|
stat(relativePath: string): Promise<RemoteBackupFileStat | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveConfiguredDestinationAdapter(
|
function resolveConfiguredDestinationAdapter(
|
||||||
@@ -683,6 +719,7 @@ function resolveConfiguredDestinationAdapter(
|
|||||||
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
|
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
|
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
|
stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (destination.type === 's3') {
|
if (destination.type === 's3') {
|
||||||
@@ -695,6 +732,7 @@ function resolveConfiguredDestinationAdapter(
|
|||||||
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
|
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
|
||||||
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
|
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
|
||||||
exists: (config, relativePath) => existsInS3(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),
|
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
||||||
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
|
stat: async (relativePath: string) => adapter.stat(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,15 +151,23 @@ export async function revertInviteUsed(db: D1Database, code: string, userId: str
|
|||||||
return (result.meta.changes ?? 0) > 0;
|
return (result.meta.changes ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> {
|
export async function deleteInvite(db: D1Database, code: string): Promise<boolean> {
|
||||||
const now = new Date().toISOString();
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'")
|
.prepare('DELETE FROM invites WHERE code = ?')
|
||||||
.bind(now, code)
|
.bind(code)
|
||||||
.run();
|
.run();
|
||||||
return (result.meta.changes ?? 0) > 0;
|
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> {
|
export async function deleteAllInvites(db: D1Database): Promise<number> {
|
||||||
const result = await db.prepare('DELETE FROM invites').run();
|
const result = await db.prepare('DELETE FROM invites').run();
|
||||||
return Number(result.meta.changes ?? 0);
|
return Number(result.meta.changes ?? 0);
|
||||||
|
|||||||
@@ -44,9 +44,14 @@ export async function clearFolderFromCiphers(
|
|||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET folder_id = NULL, updated_at = ?,
|
SET folder_id = NULL, updated_at = ?,
|
||||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
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();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,9 +76,14 @@ export async function bulkDeleteFolders(
|
|||||||
`UPDATE ciphers
|
`UPDATE ciphers
|
||||||
SET folder_id = NULL, updated_at = ?,
|
SET folder_id = NULL, updated_at = ?,
|
||||||
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
|
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();
|
.run();
|
||||||
|
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
clearAuditLogs as clearStoredAuditLogs,
|
clearAuditLogs as clearStoredAuditLogs,
|
||||||
assignInviteUsedBy as assignStoredInviteUsedBy,
|
assignInviteUsedBy as assignStoredInviteUsedBy,
|
||||||
createInvite as createStoredInvite,
|
createInvite as createStoredInvite,
|
||||||
|
deleteInvite as deleteStoredInvite,
|
||||||
|
deleteInvalidInvites as deleteStoredInvalidInvites,
|
||||||
deleteAllInvites as deleteStoredInvites,
|
deleteAllInvites as deleteStoredInvites,
|
||||||
getInvite as findStoredInvite,
|
getInvite as findStoredInvite,
|
||||||
listAuditLogs as listStoredAuditLogs,
|
listAuditLogs as listStoredAuditLogs,
|
||||||
@@ -32,7 +34,6 @@ import {
|
|||||||
pruneAuditLogs as pruneStoredAuditLogs,
|
pruneAuditLogs as pruneStoredAuditLogs,
|
||||||
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
|
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
|
||||||
revertInviteUsed as revertStoredInviteUsed,
|
revertInviteUsed as revertStoredInviteUsed,
|
||||||
revokeInvite as revokeStoredInvite,
|
|
||||||
} from './storage-admin-repo';
|
} from './storage-admin-repo';
|
||||||
import {
|
import {
|
||||||
bulkDeleteFolders as deleteStoredFolders,
|
bulkDeleteFolders as deleteStoredFolders,
|
||||||
@@ -329,8 +330,12 @@ export class StorageService {
|
|||||||
return revertStoredInviteUsed(this.db, code, userId);
|
return revertStoredInviteUsed(this.db, code, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeInvite(code: string): Promise<boolean> {
|
async deleteInvite(code: string): Promise<boolean> {
|
||||||
return revokeStoredInvite(this.db, code);
|
return deleteStoredInvite(this.db, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInvalidInvites(): Promise<number> {
|
||||||
|
return deleteStoredInvalidInvites(this.db);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAllInvites(): Promise<number> {
|
async deleteAllInvites(): Promise<number> {
|
||||||
|
|||||||
+6
-4
@@ -1115,8 +1115,6 @@ export default function App() {
|
|||||||
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
|
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
|
||||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
refetchInterval: 15_000,
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
});
|
});
|
||||||
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
|
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
|
||||||
const latestPendingAuthRequest = pendingAuthRequests[0] || null;
|
const latestPendingAuthRequest = pendingAuthRequests[0] || null;
|
||||||
@@ -1959,6 +1957,7 @@ export default function App() {
|
|||||||
lockTimeoutMinutes,
|
lockTimeoutMinutes,
|
||||||
sessionTimeoutAction,
|
sessionTimeoutAction,
|
||||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||||
|
currentDeviceIdentifier: getCurrentDeviceIdentifier(),
|
||||||
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
||||||
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
|
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
|
||||||
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
|
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
|
||||||
@@ -2014,7 +2013,8 @@ export default function App() {
|
|||||||
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
|
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
|
||||||
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
|
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
|
||||||
pendingAuthRequests,
|
pendingAuthRequests,
|
||||||
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isFetching,
|
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading,
|
||||||
|
pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading,
|
||||||
onRefreshPendingAuthRequests: async () => {
|
onRefreshPendingAuthRequests: async () => {
|
||||||
await pendingAuthRequestsQuery.refetch();
|
await pendingAuthRequestsQuery.refetch();
|
||||||
},
|
},
|
||||||
@@ -2031,14 +2031,16 @@ export default function App() {
|
|||||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||||
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
|
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
|
||||||
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
||||||
|
onRemoveSelectedDevices: accountSecurityActions.openRemoveSelectedDevices,
|
||||||
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
|
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
|
||||||
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
|
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
|
||||||
onRefreshAdmin: adminActions.refreshAdmin,
|
onRefreshAdmin: adminActions.refreshAdmin,
|
||||||
onCreateInvite: adminActions.createInvite,
|
onCreateInvite: adminActions.createInvite,
|
||||||
|
onDeleteInvalidInvites: adminActions.deleteInvalidInvites,
|
||||||
onDeleteAllInvites: adminActions.deleteAllInvites,
|
onDeleteAllInvites: adminActions.deleteAllInvites,
|
||||||
onToggleUserStatus: adminActions.toggleUserStatus,
|
onToggleUserStatus: adminActions.toggleUserStatus,
|
||||||
onDeleteUser: adminActions.deleteUser,
|
onDeleteUser: adminActions.deleteUser,
|
||||||
onRevokeInvite: adminActions.revokeInvite,
|
onDeleteInvite: adminActions.deleteInvite,
|
||||||
onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters),
|
onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters),
|
||||||
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
|
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
|
||||||
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
|
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ interface AdminPageProps {
|
|||||||
error: string;
|
error: string;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onCreateInvite: (hours: number) => Promise<void>;
|
onCreateInvite: (hours: number) => Promise<void>;
|
||||||
|
onDeleteInvalidInvites: () => Promise<void>;
|
||||||
onDeleteAllInvites: () => Promise<void>;
|
onDeleteAllInvites: () => Promise<void>;
|
||||||
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>;
|
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>;
|
||||||
onDeleteUser: (userId: string) => Promise<void>;
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
onRevokeInvite: (code: string) => Promise<void>;
|
onDeleteInvite: (code: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage(props: AdminPageProps) {
|
export default function AdminPage(props: AdminPageProps) {
|
||||||
@@ -134,7 +135,10 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<h3>{t('txt_invites')}</h3>
|
<h3>{t('txt_invites')}</h3>
|
||||||
<div className="actions admin-invites-head-actions">
|
<div className="actions admin-invites-head-actions">
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
<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>
|
||||||
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}>
|
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}>
|
||||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
|
<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')}
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
|
||||||
</button>
|
</button>
|
||||||
{invite.status === 'active' && (
|
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteInvite(invite.code)}>
|
||||||
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}>
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||||
<Trash2 size={14} className="btn-icon" /> {t('txt_revoke')}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface AppMainRoutesProps {
|
|||||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
sessionTimeoutAction: 'lock' | 'logout';
|
sessionTimeoutAction: 'lock' | 'logout';
|
||||||
authorizedDevices: AuthorizedDevice[];
|
authorizedDevices: AuthorizedDevice[];
|
||||||
|
currentDeviceIdentifier: string;
|
||||||
authorizedDevicesLoading: boolean;
|
authorizedDevicesLoading: boolean;
|
||||||
authorizedDevicesError: string;
|
authorizedDevicesError: string;
|
||||||
domainRules: DomainRules | null;
|
domainRules: DomainRules | null;
|
||||||
@@ -118,6 +119,7 @@ export interface AppMainRoutesProps {
|
|||||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||||
pendingAuthRequests: AuthRequest[];
|
pendingAuthRequests: AuthRequest[];
|
||||||
pendingAuthRequestsLoading: boolean;
|
pendingAuthRequestsLoading: boolean;
|
||||||
|
pendingAuthRequestsRefreshing: boolean;
|
||||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
@@ -130,14 +132,16 @@ export interface AppMainRoutesProps {
|
|||||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
onTrustDevicePermanently: (device: AuthorizedDevice) => void;
|
onTrustDevicePermanently: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
|
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
|
||||||
onRevokeAllDeviceTrust: () => void;
|
onRevokeAllDeviceTrust: () => void;
|
||||||
onRemoveAllDevices: () => void;
|
onRemoveAllDevices: () => void;
|
||||||
onCreateInvite: (hours: number) => Promise<void>;
|
onCreateInvite: (hours: number) => Promise<void>;
|
||||||
onRefreshAdmin: () => void;
|
onRefreshAdmin: () => void;
|
||||||
|
onDeleteInvalidInvites: () => Promise<void>;
|
||||||
onDeleteAllInvites: () => Promise<void>;
|
onDeleteAllInvites: () => Promise<void>;
|
||||||
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
|
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
|
||||||
onDeleteUser: (userId: string) => Promise<void>;
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
onRevokeInvite: (code: string) => Promise<void>;
|
onDeleteInvite: (code: string) => Promise<void>;
|
||||||
onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
|
onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
|
||||||
onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
|
onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
|
||||||
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
|
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
|
||||||
@@ -275,11 +279,6 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
||||||
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
||||||
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
||||||
pendingAuthRequests={props.pendingAuthRequests}
|
|
||||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
|
||||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
|
||||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
|
||||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
|
||||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
@@ -352,10 +351,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<Suspense fallback={<RouteContentFallback />}>
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
<SecurityDevicesPage
|
<SecurityDevicesPage
|
||||||
devices={props.authorizedDevices}
|
devices={props.authorizedDevices}
|
||||||
|
currentDeviceIdentifier={props.currentDeviceIdentifier}
|
||||||
loading={props.authorizedDevicesLoading}
|
loading={props.authorizedDevicesLoading}
|
||||||
error={props.authorizedDevicesError}
|
error={props.authorizedDevicesError}
|
||||||
pendingAuthRequests={props.pendingAuthRequests}
|
pendingAuthRequests={props.pendingAuthRequests}
|
||||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||||
|
pendingAuthRequestsRefreshing={props.pendingAuthRequestsRefreshing}
|
||||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||||
@@ -364,6 +365,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||||
onTrustPermanently={props.onTrustDevicePermanently}
|
onTrustPermanently={props.onTrustDevicePermanently}
|
||||||
onRemoveDevice={props.onRemoveDevice}
|
onRemoveDevice={props.onRemoveDevice}
|
||||||
|
onRemoveSelectedDevices={props.onRemoveSelectedDevices}
|
||||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||||
onRemoveAll={props.onRemoveAllDevices}
|
onRemoveAll={props.onRemoveAllDevices}
|
||||||
/>
|
/>
|
||||||
@@ -412,10 +414,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
error={props.adminError}
|
error={props.adminError}
|
||||||
onRefresh={props.onRefreshAdmin}
|
onRefresh={props.onRefreshAdmin}
|
||||||
onCreateInvite={props.onCreateInvite}
|
onCreateInvite={props.onCreateInvite}
|
||||||
|
onDeleteInvalidInvites={props.onDeleteInvalidInvites}
|
||||||
onDeleteAllInvites={props.onDeleteAllInvites}
|
onDeleteAllInvites={props.onDeleteAllInvites}
|
||||||
onToggleUserStatus={props.onToggleUserStatus}
|
onToggleUserStatus={props.onToggleUserStatus}
|
||||||
onDeleteUser={props.onDeleteUser}
|
onDeleteUser={props.onDeleteUser}
|
||||||
onRevokeInvite={props.onRevokeInvite}
|
onDeleteInvite={props.onDeleteInvite}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type PendingRestoreIntegrity =
|
|||||||
type PendingBackupVerification =
|
type PendingBackupVerification =
|
||||||
| { action: 'export' }
|
| { action: 'export' }
|
||||||
| { action: 'saveSettings' }
|
| { action: 'saveSettings' }
|
||||||
|
| { action: 'deleteDestination'; destinationId: string; settings: AdminBackupSettings }
|
||||||
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
|
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
|
||||||
| { action: 'runRemoteBackup' }
|
| { action: 'runRemoteBackup' }
|
||||||
| { action: 'downloadRemote'; path: string }
|
| { action: 'downloadRemote'; path: string }
|
||||||
@@ -192,7 +193,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
||||||
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
||||||
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
||||||
const [localError, setLocalError] = useState('');
|
const [, setLocalError] = useState('');
|
||||||
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
|
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
|
||||||
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
|
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
|
||||||
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
|
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
|
||||||
@@ -240,7 +241,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const backupPasswordPromptTitle =
|
const backupPasswordPromptTitle =
|
||||||
pendingBackupVerification?.action === 'export'
|
pendingBackupVerification?.action === 'export'
|
||||||
? t('txt_backup_export')
|
? t('txt_backup_export')
|
||||||
: pendingBackupVerification?.action === 'saveSettings'
|
: pendingBackupVerification?.action === 'saveSettings' || pendingBackupVerification?.action === 'deleteDestination'
|
||||||
? t('txt_backup_save_settings')
|
? t('txt_backup_save_settings')
|
||||||
: pendingBackupVerification?.action === 'runRemoteBackup'
|
: pendingBackupVerification?.action === 'runRemoteBackup'
|
||||||
? t('txt_backup_run_manual')
|
? t('txt_backup_run_manual')
|
||||||
@@ -501,10 +502,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
|
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);
|
setSavingSettings(true);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
const saved = await props.onSaveSettings(nextSettings);
|
const saved = await props.onSaveSettings(masterPassword, payload);
|
||||||
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
|
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
|
||||||
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
|
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
|
||||||
setSavedSettings(saved);
|
setSavedSettings(saved);
|
||||||
@@ -865,6 +872,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
await executeExport(masterPassword);
|
await executeExport(masterPassword);
|
||||||
} else if (request.action === 'saveSettings') {
|
} else if (request.action === 'saveSettings') {
|
||||||
await executeSaveSettings(masterPassword);
|
await executeSaveSettings(masterPassword);
|
||||||
|
} else if (request.action === 'deleteDestination') {
|
||||||
|
await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
|
||||||
} else if (request.action === 'import') {
|
} else if (request.action === 'import') {
|
||||||
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||||
} else if (request.action === 'runRemoteBackup') {
|
} else if (request.action === 'runRemoteBackup') {
|
||||||
@@ -965,7 +974,6 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{localError ? <div className="local-error">{localError}</div> : null}
|
|
||||||
{restoreProgress && typeof document !== 'undefined' ? createPortal((
|
{restoreProgress && typeof document !== 'undefined' ? createPortal((
|
||||||
<div className="restore-progress-overlay" aria-live="polite">
|
<div className="restore-progress-overlay" aria-live="polite">
|
||||||
<section className="restore-progress-card restore-progress-modal">
|
<section className="restore-progress-card restore-progress-modal">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { t } from '@/lib/i18n';
|
|||||||
interface PendingAuthRequestsPanelProps {
|
interface PendingAuthRequestsPanelProps {
|
||||||
pendingAuthRequests: AuthRequest[];
|
pendingAuthRequests: AuthRequest[];
|
||||||
pendingAuthRequestsLoading: boolean;
|
pendingAuthRequestsLoading: boolean;
|
||||||
|
pendingAuthRequestsRefreshing?: boolean;
|
||||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
onDenyAuthRequest: (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) {
|
export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanelProps) {
|
||||||
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
|
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
|
||||||
|
const refreshing = props.pendingAuthRequestsLoading || !!props.pendingAuthRequestsRefreshing;
|
||||||
|
|
||||||
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||||
if (authRequestSubmittingId) return;
|
if (authRequestSubmittingId) return;
|
||||||
@@ -50,10 +52,10 @@ export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanel
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary small"
|
className="btn btn-secondary small"
|
||||||
disabled={props.pendingAuthRequestsLoading}
|
disabled={refreshing}
|
||||||
onClick={() => void props.onRefreshPendingAuthRequests()}
|
onClick={() => void props.onRefreshPendingAuthRequests()}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} className="btn-icon" />
|
<RefreshCw size={14} className={`btn-icon${refreshing ? ' btn-icon-spin' : ''}`} />
|
||||||
{t('txt_refresh')}
|
{t('txt_refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'preact/hooks';
|
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 ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
|
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
|
||||||
@@ -8,10 +8,12 @@ import { t } from '@/lib/i18n';
|
|||||||
|
|
||||||
interface SecurityDevicesPageProps {
|
interface SecurityDevicesPageProps {
|
||||||
devices: AuthorizedDevice[];
|
devices: AuthorizedDevice[];
|
||||||
|
currentDeviceIdentifier: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
pendingAuthRequests: AuthRequest[];
|
pendingAuthRequests: AuthRequest[];
|
||||||
pendingAuthRequestsLoading: boolean;
|
pendingAuthRequestsLoading: boolean;
|
||||||
|
pendingAuthRequestsRefreshing: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
@@ -20,6 +22,7 @@ interface SecurityDevicesPageProps {
|
|||||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||||
onTrustPermanently: (device: AuthorizedDevice) => void;
|
onTrustPermanently: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
|
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
|
||||||
onRevokeAll: () => void;
|
onRevokeAll: () => void;
|
||||||
onRemoveAll: () => void;
|
onRemoveAll: () => void;
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
|
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
|
||||||
const [deviceNote, setDeviceNote] = useState('');
|
const [deviceNote, setDeviceNote] = useState('');
|
||||||
const [savingNote, setSavingNote] = useState(false);
|
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> {
|
async function handleSaveDeviceNote(): Promise<void> {
|
||||||
if (!editingDevice || savingNote) return;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
@@ -83,6 +107,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
loadingVariant="compact"
|
loadingVariant="compact"
|
||||||
pendingAuthRequests={props.pendingAuthRequests}
|
pendingAuthRequests={props.pendingAuthRequests}
|
||||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||||
|
pendingAuthRequestsRefreshing={props.pendingAuthRequestsRefreshing}
|
||||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||||
@@ -91,7 +116,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="flush-title">{t('txt_device_management')}</h3>
|
<h3 className="flush-title">{t('txt_authorized_devices')}</h3>
|
||||||
<div className="muted-inline section-note">
|
<div className="muted-inline section-note">
|
||||||
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
||||||
</div>
|
</div>
|
||||||
@@ -101,6 +126,27 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<RefreshCw size={14} className="btn-icon" />
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
{t('txt_refresh')}
|
{t('txt_refresh')}
|
||||||
</button>
|
</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}>
|
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
|
||||||
<ShieldOff size={14} className="btn-icon" />
|
<ShieldOff size={14} className="btn-icon" />
|
||||||
{t('txt_revoke_all_trusted')}
|
{t('txt_revoke_all_trusted')}
|
||||||
@@ -111,10 +157,6 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card">
|
|
||||||
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
|
|
||||||
{!!props.error && (
|
{!!props.error && (
|
||||||
<div className="local-error">
|
<div className="local-error">
|
||||||
<span>{props.error}</span>
|
<span>{props.error}</span>
|
||||||
@@ -126,6 +168,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
)}
|
)}
|
||||||
<table className="table authorized-devices-table">
|
<table className="table authorized-devices-table">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
|
<col className="authorized-devices-col-select" />
|
||||||
<col className="authorized-devices-col-device" />
|
<col className="authorized-devices-col-device" />
|
||||||
<col className="authorized-devices-col-type" />
|
<col className="authorized-devices-col-type" />
|
||||||
<col className="authorized-devices-col-status" />
|
<col className="authorized-devices-col-status" />
|
||||||
@@ -136,6 +179,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>{t('txt_select')}</th>
|
||||||
<th>{t('txt_device')}</th>
|
<th>{t('txt_device')}</th>
|
||||||
<th>{t('txt_type')}</th>
|
<th>{t('txt_type')}</th>
|
||||||
<th>{t('txt_status')}</th>
|
<th>{t('txt_status')}</th>
|
||||||
@@ -148,6 +192,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{props.devices.map((device) => (
|
{props.devices.map((device) => (
|
||||||
<tr key={device.identifier}>
|
<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')}>
|
<td data-label={t('txt_device')}>
|
||||||
<div>{device.name || t('txt_unknown_device')}</div>
|
<div>{device.name || t('txt_unknown_device')}</div>
|
||||||
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
|
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
|
||||||
@@ -220,14 +274,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
))}
|
))}
|
||||||
{props.loading && props.devices.length === 0 && (
|
{props.loading && props.devices.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7}>
|
<td colSpan={8}>
|
||||||
<LoadingState lines={5} compact />
|
<LoadingState lines={5} compact />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{!props.loading && props.devices.length === 0 && (
|
{!props.loading && props.devices.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7}>
|
<td colSpan={8}>
|
||||||
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
|
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
|
|||||||
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import qrcode from 'qrcode-generator';
|
import qrcode from 'qrcode-generator';
|
||||||
import type { AccountPasskeyCredential, AuthRequest, Profile } from '@/lib/types';
|
import type { AccountPasskeyCredential, Profile } from '@/lib/types';
|
||||||
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
|
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
profile: Profile;
|
profile: Profile;
|
||||||
@@ -23,11 +22,6 @@ interface SettingsPageProps {
|
|||||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
||||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||||
pendingAuthRequests: AuthRequest[];
|
|
||||||
pendingAuthRequestsLoading: boolean;
|
|
||||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
|
||||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
|
||||||
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
|
||||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||||
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
|
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
@@ -515,15 +509,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<PendingAuthRequestsPanel
|
|
||||||
pendingAuthRequests={props.pendingAuthRequests}
|
|
||||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
|
||||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
|
||||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
|
||||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="settings-module sensitive-actions-module">
|
<section className="settings-module sensitive-actions-module">
|
||||||
<div className="sensitive-actions-grid">
|
<div className="sensitive-actions-grid">
|
||||||
<div className="sensitive-action">
|
<div className="sensitive-action">
|
||||||
|
|||||||
@@ -54,21 +54,18 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
|||||||
<>
|
<>
|
||||||
<div className="backup-recommendation-steps">
|
<div className="backup-recommendation-steps">
|
||||||
<div className="backup-recommendation-step">
|
<div className="backup-recommendation-step">
|
||||||
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_1')}
|
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
|
||||||
</div>
|
|
||||||
<div className="backup-recommendation-step">
|
|
||||||
<strong>2.</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>
|
<a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a>
|
||||||
{t('txt_backup_recommend_koofr_step_2_suffix')}
|
{t('txt_backup_recommend_koofr_step_2_suffix')}
|
||||||
</div>
|
</div>
|
||||||
<div className="backup-recommendation-step">
|
<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>
|
||||||
<div className="backup-recommendation-step">
|
<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>
|
||||||
<div className="backup-recommendation-step">
|
<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>
|
<a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a>
|
||||||
{t('txt_backup_recommend_koofr_step_5_suffix')}
|
{t('txt_backup_recommend_koofr_step_5_suffix')}
|
||||||
</div>
|
</div>
|
||||||
@@ -98,13 +95,10 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
|||||||
return (
|
return (
|
||||||
<div className="backup-recommendation-steps">
|
<div className="backup-recommendation-steps">
|
||||||
<div className="backup-recommendation-step">
|
<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>
|
||||||
<div className="backup-recommendation-step">
|
<div className="backup-recommendation-step">
|
||||||
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_2')}
|
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_3')}
|
||||||
</div>
|
|
||||||
<div className="backup-recommendation-step">
|
|
||||||
<strong>3.</strong> {t('txt_backup_recommend_pcloud_step_3')}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -112,18 +106,87 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
|||||||
return (
|
return (
|
||||||
<div className="backup-recommendation-steps">
|
<div className="backup-recommendation-steps">
|
||||||
<div className="backup-recommendation-step">
|
<div className="backup-recommendation-step">
|
||||||
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_1')}
|
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
|
||||||
</div>
|
|
||||||
<div className="backup-recommendation-step">
|
|
||||||
<strong>2.</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>
|
<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')}
|
{t('txt_backup_recommend_infinicloud_step_2_suffix')}
|
||||||
</div>
|
</div>
|
||||||
<div className="backup-recommendation-step">
|
<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>
|
||||||
<div className="backup-recommendation-step">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -147,6 +210,9 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
<div className="backup-inline-note">
|
<div className="backup-inline-note">
|
||||||
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
|
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
|
||||||
: props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_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')}
|
: t('txt_backup_recommend_pcloud_summary')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -387,7 +453,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
className="input"
|
className="input"
|
||||||
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
|
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
placeholder="nodewarden/backups"
|
placeholder="nodewarden"
|
||||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
...destination,
|
...destination,
|
||||||
destination: {
|
destination: {
|
||||||
@@ -504,7 +570,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
className="input"
|
className="input"
|
||||||
value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
|
value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
placeholder="nodewarden/backups"
|
placeholder=""
|
||||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
...destination,
|
...destination,
|
||||||
destination: {
|
destination: {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Download, FileUp } from 'lucide-preact';
|
import { Download, FileUp } from 'lucide-preact';
|
||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||||
import { hasLinkedStorages } from '@/lib/backup-recommendations';
|
import { hasLinkedStorages } from '@/lib/backup-recommendations';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
||||||
|
|
||||||
|
const MOBILE_RECOMMENDATIONS_QUERY = '(max-width: 760px)';
|
||||||
|
|
||||||
interface BackupOperationsSidebarProps {
|
interface BackupOperationsSidebarProps {
|
||||||
disableWhileBusy: boolean;
|
disableWhileBusy: boolean;
|
||||||
exporting: boolean;
|
exporting: boolean;
|
||||||
@@ -18,7 +21,30 @@ interface BackupOperationsSidebarProps {
|
|||||||
onSelectProvider: (providerId: string) => void;
|
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) {
|
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 (
|
return (
|
||||||
<aside className="backup-operations-sidebar">
|
<aside className="backup-operations-sidebar">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
@@ -41,7 +67,14 @@ export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<summary className="backup-recommendations-summary">
|
||||||
<span>
|
<span>
|
||||||
<strong>{t('txt_backup_recommend_title')}</strong>
|
<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 type { RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
|
import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -32,26 +32,32 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
|||||||
: t('txt_downloading_percent', { percent: props.downloadingRemotePercent });
|
: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="backup-divider" />
|
<div className="backup-divider" />
|
||||||
|
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h3>{t('txt_backup_remote_title')}</h3>
|
<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>
|
</div>
|
||||||
|
|
||||||
{!props.destinationIsSaved ? (
|
{!props.destinationIsSaved ? (
|
||||||
<div className="backup-browser-empty">{t('txt_backup_remote_save_first')}</div>
|
<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 ? (
|
) : !props.remoteBrowser ? (
|
||||||
<div className="backup-browser-empty">{t('txt_backup_remote_cached_empty')}</div>
|
renderRefreshPrompt()
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="backup-browser-path">
|
<div className="backup-browser-path">
|
||||||
@@ -59,7 +65,8 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
|||||||
<span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span>
|
<span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="actions backup-browser-nav">
|
<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('')}>
|
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}>
|
||||||
<FolderOpen size={14} className="btn-icon" />
|
<FolderOpen size={14} className="btn-icon" />
|
||||||
{t('txt_backup_remote_root')}
|
{t('txt_backup_remote_root')}
|
||||||
@@ -70,16 +77,29 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
|||||||
disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null}
|
disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null}
|
||||||
onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')}
|
onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')}
|
||||||
>
|
>
|
||||||
<RotateCcw size={14} className="btn-icon" />
|
<FolderUp size={14} className="btn-icon" />
|
||||||
{t('txt_backup_remote_up')}
|
{t('txt_backup_remote_up')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ? (
|
{props.loadingRemoteBrowser ? (
|
||||||
<div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div>
|
<div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div>
|
||||||
) : props.remoteBrowser.items.length ? (
|
) : props.remoteBrowser.items.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="backup-browser-list">
|
<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) => (
|
{props.visibleItems.map((item) => (
|
||||||
<div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row">
|
<div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row">
|
||||||
<button
|
<button
|
||||||
@@ -92,10 +112,12 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
|||||||
{item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />}
|
{item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />}
|
||||||
<span className="backup-browser-name">{item.name}</span>
|
<span className="backup-browser-name">{item.name}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="backup-browser-meta">
|
<span className="backup-browser-meta backup-browser-modified">
|
||||||
<span>{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}</span>
|
{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}
|
||||||
<span>{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}</span>
|
</span>
|
||||||
</div>
|
<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">
|
<div className="actions backup-browser-actions">
|
||||||
{item.isDirectory ? (
|
{item.isDirectory ? (
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}>
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
changeMasterPassword,
|
changeMasterPassword,
|
||||||
deleteAllAuthorizedDevices,
|
deleteAllAuthorizedDevices,
|
||||||
deleteAuthorizedDevice,
|
deleteAuthorizedDevice,
|
||||||
|
deleteAuthorizedDevices,
|
||||||
deriveLoginHash,
|
deriveLoginHash,
|
||||||
deleteAccountPasskey as deleteAccountPasskeyApi,
|
deleteAccountPasskey as deleteAccountPasskeyApi,
|
||||||
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
|
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
|
||||||
@@ -234,11 +235,19 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
const normalizedName = String(name || '').trim() || t('txt_account_passkey');
|
const normalizedName = String(name || '').trim() || t('txt_account_passkey');
|
||||||
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
|
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
|
||||||
const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
|
const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
|
||||||
const pending = await createAccountPasskeyCredential(options);
|
const pending = await createAccountPasskeyCredential(options, directUnlock);
|
||||||
let keySet = null;
|
let keySet = null;
|
||||||
let savedWithoutDirectUnlock = false;
|
let savedWithoutDirectUnlock = false;
|
||||||
if (directUnlock) {
|
if (directUnlock) {
|
||||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
|
if (!pending.supportsPrf) {
|
||||||
|
const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey();
|
||||||
|
if (!shouldSaveLoginOnly) {
|
||||||
|
onNotify('warning', t('txt_account_passkey_not_saved'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
savedWithoutDirectUnlock = true;
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
keySet = await buildAccountPasskeyPrfKeySet(pending, {
|
keySet = await buildAccountPasskeyPrfKeySet(pending, {
|
||||||
symEncKey: session.symEncKey,
|
symEncKey: session.symEncKey,
|
||||||
@@ -254,6 +263,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
savedWithoutDirectUnlock = true;
|
savedWithoutDirectUnlock = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const credential = await saveAccountPasskey(authedFetch, {
|
const credential = await saveAccountPasskey(authedFetch, {
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
token: pending.token,
|
token: pending.token,
|
||||||
@@ -380,6 +390,38 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openRemoveSelectedDevices(devices: AuthorizedDevice[]) {
|
||||||
|
const selectedDevices = devices.filter((device) => String(device.identifier || '').trim());
|
||||||
|
if (selectedDevices.length === 0) {
|
||||||
|
onNotify('warning', t('txt_no_devices_selected'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const includesCurrentDevice = selectedDevices.some((device) => device.identifier === getCurrentDeviceIdentifier());
|
||||||
|
onSetConfirm({
|
||||||
|
title: t('txt_remove_selected_devices', { count: selectedDevices.length }),
|
||||||
|
message: includesCurrentDevice
|
||||||
|
? t('txt_remove_selected_devices_and_sign_out_current', { count: selectedDevices.length })
|
||||||
|
: t('txt_remove_selected_devices_confirm', { count: selectedDevices.length }),
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await deleteAuthorizedDevices(authedFetch, selectedDevices);
|
||||||
|
onNotify('success', t('txt_selected_devices_removed', { count: selectedDevices.length }));
|
||||||
|
if (includesCurrentDevice) {
|
||||||
|
onLogoutNow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refetchAuthorizedDevices();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_remove_selected_devices_failed'));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
openRevokeAllDeviceTrust() {
|
openRevokeAllDeviceTrust() {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_revoke_all_trusted_devices'),
|
title: t('txt_revoke_all_trusted_devices'),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'preact/hooks';
|
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 { t } from '@/lib/i18n';
|
||||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||||
import type { AuthedFetch } from '@/lib/api/shared';
|
import type { AuthedFetch } from '@/lib/api/shared';
|
||||||
@@ -45,14 +45,44 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async revokeInvite(code: string) {
|
async deleteInvite(code: string) {
|
||||||
|
onSetConfirm({
|
||||||
|
title: t('txt_delete_invite'),
|
||||||
|
message: t('txt_delete_invite_confirm_message'),
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await revokeInvite(authedFetch, code);
|
await deleteInvite(authedFetch, code);
|
||||||
await refetchInvites();
|
await refetchInvites();
|
||||||
onNotify('success', t('txt_invite_revoked'));
|
onNotify('success', t('txt_invite_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_invite_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_invite_failed'));
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteInvalidInvites() {
|
||||||
|
onSetConfirm({
|
||||||
|
title: t('txt_delete_invalid_invites'),
|
||||||
|
message: t('txt_delete_invalid_invites_confirm_message'),
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await deleteInvalidInvites(authedFetch);
|
||||||
|
await refetchInvites();
|
||||||
|
onNotify('success', t('txt_invalid_invites_deleted'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_invalid_invites_failed'));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteAllInvites() {
|
async deleteAllInvites() {
|
||||||
|
|||||||
@@ -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 {
|
function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | undefined {
|
||||||
const result = (credential.getClientExtensionResults() as any).prf?.results?.first;
|
const result = (credential.getClientExtensionResults() as any).prf?.results?.first;
|
||||||
return result instanceof ArrayBuffer ? result : undefined;
|
return result instanceof ArrayBuffer ? result : undefined;
|
||||||
@@ -150,6 +163,22 @@ function shouldRetryWithLegacyPrf(error: unknown): boolean {
|
|||||||
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
|
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(
|
async function getPublicKeyCredentialWithPrf(
|
||||||
options: PublicKeyCredentialRequestOptions,
|
options: PublicKeyCredentialRequestOptions,
|
||||||
salt: Uint8Array,
|
salt: Uint8Array,
|
||||||
@@ -265,17 +294,39 @@ export async function assertAccountPasskey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createAccountPasskeyCredential(
|
export async function createAccountPasskeyCredential(
|
||||||
response: { options: unknown; token: string }
|
response: { options: unknown; token: string },
|
||||||
|
requestPrf: boolean = false
|
||||||
): Promise<PendingAccountPasskeyCredential> {
|
): Promise<PendingAccountPasskeyCredential> {
|
||||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||||
throw new Error(t('txt_passkey_browser_not_supported'));
|
throw new Error(t('txt_passkey_browser_not_supported'));
|
||||||
}
|
}
|
||||||
const nativeOptions = cloneCreationOptions(response.options);
|
const nativeOptions = cloneCreationOptions(response.options);
|
||||||
(nativeOptions as any).extensions = {
|
const noPrfOptions = withoutCreatePrfExtension(nativeOptions);
|
||||||
...((nativeOptions as any).extensions || {}),
|
const createWithOptions = async (options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential> => {
|
||||||
prf: {},
|
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)) {
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
throw new Error(t('txt_no_passkey_created'));
|
throw new Error(t('txt_no_passkey_created'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,14 @@ export async function createInvite(authedFetch: AuthedFetch, hours: number): Pro
|
|||||||
if (!resp.ok) throw new Error('Create invite failed');
|
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' });
|
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> {
|
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> {
|
||||||
|
|||||||
@@ -885,6 +885,20 @@ export async function deleteAuthorizedDevice(
|
|||||||
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
|
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function 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(
|
export async function updateAuthorizedDeviceName(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
deviceIdentifier: string,
|
deviceIdentifier: string,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export interface RecommendedStorageLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RecommendedProviderBase {
|
export interface RecommendedProviderBase {
|
||||||
id: 'infinicloud' | 'koofr' | 'pcloud';
|
id: 'infinicloud' | 'koofr' | 'pcloud' | 'backblaze-b2' | 'cloudflare-r2' | 'tigris';
|
||||||
name: string;
|
name: string;
|
||||||
capacity: string;
|
capacity: string;
|
||||||
protocol: 'webdav' | 's3';
|
protocol: 'webdav' | 's3';
|
||||||
@@ -28,7 +28,25 @@ export interface PcloudProvider extends RecommendedProviderBase {
|
|||||||
id: 'pcloud';
|
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[] = [
|
export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
|
||||||
{
|
{
|
||||||
@@ -61,6 +79,33 @@ export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
|
|||||||
signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7',
|
signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7',
|
||||||
hasAffiliateLink: true,
|
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 {
|
export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider {
|
||||||
|
|||||||
+10
-5
@@ -1127,6 +1127,13 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
onRefreshAdmin: () => {
|
onRefreshAdmin: () => {
|
||||||
notify('success', t('txt_demo_admin_refreshed'));
|
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 () => {
|
onDeleteAllInvites: async () => {
|
||||||
state.setInvites([]);
|
state.setInvites([]);
|
||||||
notify('success', t('txt_all_invites_deleted'));
|
notify('success', t('txt_all_invites_deleted'));
|
||||||
@@ -1141,11 +1148,9 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
state.setUsers((prev) => prev.filter((user) => user.id !== userId));
|
state.setUsers((prev) => prev.filter((user) => user.id !== userId));
|
||||||
notify('success', t('txt_user_deleted'));
|
notify('success', t('txt_user_deleted'));
|
||||||
},
|
},
|
||||||
onRevokeInvite: async (code) => {
|
onDeleteInvite: async (code) => {
|
||||||
state.setInvites((prev) => prev.map((invite) => (
|
state.setInvites((prev) => prev.filter((invite) => invite.code !== code));
|
||||||
invite.code === code ? { ...invite, status: 'inactive' } : invite
|
notify('success', t('txt_invite_deleted'));
|
||||||
)));
|
|
||||||
notify('success', t('txt_invite_revoked'));
|
|
||||||
},
|
},
|
||||||
onLoadAuditLogSettings: async () => ({ retentionDays: 90, maxEntries: null }),
|
onLoadAuditLogSettings: async () => ({ retentionDays: 90, maxEntries: null }),
|
||||||
onSaveAuditLogSettings: async (settings) => {
|
onSaveAuditLogSettings: async (settings) => {
|
||||||
|
|||||||
@@ -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 {
|
function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const fields = Array.isArray(item.fields) ? item.fields : [];
|
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);
|
appendFieldLine(lines, field.name, field.value);
|
||||||
}
|
}
|
||||||
if (type !== 1 && type !== 2) {
|
if (type !== 1 && type !== 2) {
|
||||||
appendFieldLine(lines, 'nodewardenType', sourceTypeLabel(type));
|
const sourceLabel = sourceTypeLabel(type);
|
||||||
appendRecordFieldLines(lines, sourceTypeLabel(type), item[sourceTypeLabel(type)]);
|
appendFieldLine(lines, 'nodewardenType', sourceLabel);
|
||||||
|
appendKnownRecordFieldLines(lines, sourceLabel, item[sourceLabel]);
|
||||||
}
|
}
|
||||||
return lines.join('\n');
|
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] });
|
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 = {
|
const key = {
|
||||||
'Account is disabled': 'txt_server_error_account_disabled',
|
'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',
|
'Client IP is required': 'txt_server_error_client_ip_required',
|
||||||
'ClientId or clientSecret is incorrect. Try again': 'txt_server_error_client_credentials_incorrect',
|
'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 already registered': 'txt_server_error_email_already_registered',
|
||||||
'Email and password are required': 'txt_server_error_email_password_required',
|
'Email and password are required': 'txt_server_error_email_password_required',
|
||||||
'Email is required': 'txt_server_error_email_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 invalid or expired': 'txt_server_error_invite_invalid_or_expired',
|
||||||
'Invite code is required': 'txt_server_error_invite_required',
|
'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 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 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 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 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',
|
'JWT_SECRET must be at least 32 characters': 'txt_server_error_jwt_secret_too_short',
|
||||||
'Parameter error': 'txt_server_error_parameter_error',
|
'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',
|
'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',
|
'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',
|
'TOTP token is required': 'txt_server_error_totp_token_required',
|
||||||
'Two factor required.': 'txt_server_error_two_factor_required',
|
'Two factor required.': 'txt_server_error_two_factor_required',
|
||||||
'Two-step token is invalid. Try again.': 'txt_server_error_two_factor_invalid',
|
'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',
|
'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];
|
}[normalized];
|
||||||
|
|
||||||
return key ? t(key) : normalized;
|
return key ? t(key) : normalized;
|
||||||
|
|||||||
@@ -85,6 +85,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_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_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_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_add_destination": "Add Destination",
|
||||||
"txt_backup_schedule_panel_title": "Automatic Schedule",
|
"txt_backup_schedule_panel_title": "Automatic Schedule",
|
||||||
"txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.",
|
"txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.",
|
||||||
@@ -193,10 +224,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_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_loading": "Loading remote backups...",
|
||||||
"txt_backup_remote_cached_empty": "Click Refresh to load this destination.",
|
"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_empty": "No backup files found in this folder.",
|
||||||
"txt_backup_remote_folder": "Folder",
|
"txt_backup_remote_folder": "Folder",
|
||||||
"txt_backup_remote_unknown_time": "Unknown time",
|
"txt_backup_remote_unknown_time": "Unknown time",
|
||||||
"txt_backup_remote_current_path": "Current Folder",
|
"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_load_failed": "Loading remote backups failed",
|
||||||
"txt_backup_remote_invalid_response": "Invalid remote backup response",
|
"txt_backup_remote_invalid_response": "Invalid remote backup response",
|
||||||
"txt_backup_remote_download_failed": "Downloading remote backup failed",
|
"txt_backup_remote_download_failed": "Downloading remote backup failed",
|
||||||
@@ -214,6 +249,74 @@ const en: Record<string, string> = {
|
|||||||
"txt_backup_remote_run_invalid_response": "Invalid remote backup run response",
|
"txt_backup_remote_run_invalid_response": "Invalid remote backup run response",
|
||||||
"txt_backup_settings_invalid_response": "Invalid backup settings response",
|
"txt_backup_settings_invalid_response": "Invalid backup settings response",
|
||||||
"txt_backup_import_invalid_response": "Invalid backup import 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_destination": "Backup Destination",
|
||||||
"txt_backup_protocol_webdav": "WebDAV",
|
"txt_backup_protocol_webdav": "WebDAV",
|
||||||
"txt_backup_protocol_s3": "S3",
|
"txt_backup_protocol_s3": "S3",
|
||||||
@@ -479,8 +582,17 @@ const en: Record<string, string> = {
|
|||||||
"txt_identity_details": "Identity Details",
|
"txt_identity_details": "Identity Details",
|
||||||
"txt_ie_browser": "IE Browser",
|
"txt_ie_browser": "IE Browser",
|
||||||
"txt_create_invite_failed": "Failed to create invite",
|
"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_code_required": "Invite Code (Required)",
|
||||||
"txt_invite_created": "Invite created",
|
"txt_invite_created": "Invite created",
|
||||||
|
"txt_invite_deleted": "Invite deleted",
|
||||||
|
"txt_invalid_invites_deleted": "Invalid invites deleted",
|
||||||
"txt_invite_revoked": "Invite revoked",
|
"txt_invite_revoked": "Invite revoked",
|
||||||
"txt_revoke_invite_failed": "Failed to revoke invite",
|
"txt_revoke_invite_failed": "Failed to revoke invite",
|
||||||
"txt_invite_validity_hours": "Invite validity (hours)",
|
"txt_invite_validity_hours": "Invite validity (hours)",
|
||||||
@@ -489,16 +601,21 @@ const en: Record<string, string> = {
|
|||||||
"txt_server_error_account_disabled": "Account is disabled",
|
"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_credentials_incorrect": "Client ID or client secret is incorrect. Try again.",
|
||||||
"txt_server_error_client_ip_required": "Client IP is required",
|
"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_already_registered": "Email already registered",
|
||||||
"txt_server_error_email_password_required": "Email and password are required",
|
"txt_server_error_email_password_required": "Email and password are required",
|
||||||
"txt_server_error_email_required": "Email is 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_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_invalid_request_payload": "Invalid request payload",
|
||||||
"txt_server_error_invite_invalid_or_expired": "Invite code is invalid or expired",
|
"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_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_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_missing": "JWT_SECRET is not set",
|
||||||
"txt_server_error_jwt_secret_too_short": "JWT_SECRET must be at least 32 characters",
|
"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_parameter_error": "Parameter error",
|
||||||
"txt_server_error_refresh_token_required": "Session is missing. Please sign in again.",
|
"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.",
|
"txt_server_error_registration_retry": "Registration is temporarily unavailable. Please retry once.",
|
||||||
@@ -730,6 +847,11 @@ const en: Record<string, string> = {
|
|||||||
"txt_remove_all_devices": "Remove all devices",
|
"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_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_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_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_remove_device_and_sign_out_name": "Remove device \"{name}\", clear its trust, and sign it out?",
|
||||||
"txt_reveal": "Reveal",
|
"txt_reveal": "Reveal",
|
||||||
@@ -771,6 +893,9 @@ const en: Record<string, string> = {
|
|||||||
"txt_security_code": "Security Code",
|
"txt_security_code": "Security Code",
|
||||||
"txt_security_code_cvv": "Security Code (CVV)",
|
"txt_security_code_cvv": "Security Code (CVV)",
|
||||||
"txt_select_all": "Select All",
|
"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": "Select",
|
||||||
"txt_select_duplicate_items": "Select Duplicates",
|
"txt_select_duplicate_items": "Select Duplicates",
|
||||||
"txt_select_an_item": "Select an item",
|
"txt_select_an_item": "Select an item",
|
||||||
@@ -1040,7 +1165,9 @@ const en: Record<string, string> = {
|
|||||||
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
|
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
|
||||||
"txt_log_action_admin_backup_settings_update": "Update 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_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_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_invite_revoke": "Revoke invite",
|
||||||
"txt_log_action_admin_user_delete": "Delete user",
|
"txt_log_action_admin_user_delete": "Delete user",
|
||||||
"txt_log_action_admin_user_status": "Change user status",
|
"txt_log_action_admin_user_status": "Change user status",
|
||||||
|
|||||||
@@ -85,6 +85,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_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_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_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_add_destination": "Añadir destino",
|
||||||
"txt_backup_schedule_panel_title": "Programación automática",
|
"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.",
|
"txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.",
|
||||||
@@ -193,10 +224,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_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_loading": "Cargando copias remotas...",
|
||||||
"txt_backup_remote_cached_empty": "Haga clic en Actualizar para cargar este destino.",
|
"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_empty": "No se encontraron archivos de copia de seguridad en esta carpeta.",
|
||||||
"txt_backup_remote_folder": "Carpeta",
|
"txt_backup_remote_folder": "Carpeta",
|
||||||
"txt_backup_remote_unknown_time": "Hora desconocida",
|
"txt_backup_remote_unknown_time": "Hora desconocida",
|
||||||
"txt_backup_remote_current_path": "Carpeta actual",
|
"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_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_invalid_response": "Respuesta de copia de seguridad remota no válida",
|
||||||
"txt_backup_remote_download_failed": "Error al descargar copia de seguridad remota",
|
"txt_backup_remote_download_failed": "Error al descargar copia de seguridad remota",
|
||||||
@@ -214,6 +249,74 @@ const es: Record<string, string> = {
|
|||||||
"txt_backup_remote_run_invalid_response": "Respuesta de ejecución de copia de seguridad remota no válida",
|
"txt_backup_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_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_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_destination": "Destino de copia",
|
||||||
"txt_backup_protocol_webdav": "WebDAV",
|
"txt_backup_protocol_webdav": "WebDAV",
|
||||||
"txt_backup_protocol_s3": "S3",
|
"txt_backup_protocol_s3": "S3",
|
||||||
@@ -479,8 +582,17 @@ const es: Record<string, string> = {
|
|||||||
"txt_identity_details": "Detalles de identidad",
|
"txt_identity_details": "Detalles de identidad",
|
||||||
"txt_ie_browser": "Navegador Internet Explorer",
|
"txt_ie_browser": "Navegador Internet Explorer",
|
||||||
"txt_create_invite_failed": "Error al crear invitación",
|
"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_code_required": "Código de invitación (obligatorio)",
|
||||||
"txt_invite_created": "Invitación creada",
|
"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_invite_revoked": "Invitación revocada",
|
||||||
"txt_revoke_invite_failed": "Error al revocar invitación",
|
"txt_revoke_invite_failed": "Error al revocar invitación",
|
||||||
"txt_invite_validity_hours": "Validez de la invitación en horas",
|
"txt_invite_validity_hours": "Validez de la invitación en horas",
|
||||||
@@ -489,16 +601,21 @@ const es: Record<string, string> = {
|
|||||||
"txt_server_error_account_disabled": "La cuenta está deshabilitada",
|
"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_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_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_already_registered": "Este correo ya está registrado",
|
||||||
"txt_server_error_email_password_required": "Correo y contraseña son obligatorios",
|
"txt_server_error_email_password_required": "Correo y contraseña son obligatorios",
|
||||||
"txt_server_error_email_required": "El correo es obligatorio",
|
"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_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_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_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_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_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_missing": "JWT_SECRET no está configurado",
|
||||||
"txt_server_error_jwt_secret_too_short": "JWT_SECRET debe tener al menos 32 caracteres",
|
"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_parameter_error": "Error de parámetros",
|
||||||
"txt_server_error_refresh_token_required": "Falta la sesión. Inicia sesión de nuevo.",
|
"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.",
|
"txt_server_error_registration_retry": "El registro no está disponible temporalmente. Inténtalo una vez más.",
|
||||||
@@ -730,6 +847,11 @@ const es: Record<string, string> = {
|
|||||||
"txt_remove_all_devices": "Quitar todos los dispositivos",
|
"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_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_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_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_remove_device_and_sign_out_name": "¿Quitar dispositivo \"{name}\", limpiar su confianza y cerrar sesión?",
|
||||||
"txt_reveal": "Mostrar",
|
"txt_reveal": "Mostrar",
|
||||||
@@ -771,6 +893,9 @@ const es: Record<string, string> = {
|
|||||||
"txt_security_code": "Código de seguridad",
|
"txt_security_code": "Código de seguridad",
|
||||||
"txt_security_code_cvv": "Código de seguridad (CVV)",
|
"txt_security_code_cvv": "Código de seguridad (CVV)",
|
||||||
"txt_select_all": "Seleccionar todo",
|
"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": "Seleccionar",
|
||||||
"txt_select_duplicate_items": "Seleccionar duplicados",
|
"txt_select_duplicate_items": "Seleccionar duplicados",
|
||||||
"txt_select_an_item": "Seleccione un elemento",
|
"txt_select_an_item": "Seleccione un elemento",
|
||||||
@@ -1040,7 +1165,9 @@ const es: Record<string, string> = {
|
|||||||
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
|
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
|
||||||
"txt_log_action_admin_backup_settings_update": "Update 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_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_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_invite_revoke": "Revoke invite",
|
||||||
"txt_log_action_admin_user_delete": "Delete user",
|
"txt_log_action_admin_user_delete": "Delete user",
|
||||||
"txt_log_action_admin_user_status": "Change user status",
|
"txt_log_action_admin_user_status": "Change user status",
|
||||||
|
|||||||
@@ -86,6 +86,37 @@ const ru: Record<string, string> = {
|
|||||||
"txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.",
|
"txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.",
|
||||||
"txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.",
|
"txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.",
|
||||||
"txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля 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_add_destination": "Добавить пункт назначения",
|
||||||
"txt_backup_schedule_panel_title": "Автоматическое расписание",
|
"txt_backup_schedule_panel_title": "Автоматическое расписание",
|
||||||
"txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.",
|
"txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.",
|
||||||
@@ -193,10 +224,14 @@ const ru: Record<string, string> = {
|
|||||||
"txt_backup_restore_progress_remote_finalize_detail": "Сервер выполняет окончательную проверку, а затем переключает проверенные данные восстановления в живые таблицы.",
|
"txt_backup_restore_progress_remote_finalize_detail": "Сервер выполняет окончательную проверку, а затем переключает проверенные данные восстановления в живые таблицы.",
|
||||||
"txt_backup_remote_loading": "Загрузка удаленных резервных копий...",
|
"txt_backup_remote_loading": "Загрузка удаленных резервных копий...",
|
||||||
"txt_backup_remote_cached_empty": "Нажмите «Обновить», чтобы загрузить это место назначения.",
|
"txt_backup_remote_cached_empty": "Нажмите «Обновить», чтобы загрузить это место назначения.",
|
||||||
|
"txt_backup_remote_cached_empty_prefix": "Нажмите",
|
||||||
|
"txt_backup_remote_cached_empty_suffix": "чтобы загрузить это место назначения.",
|
||||||
"txt_backup_remote_empty": "В этой папке не найдено файлов резервных копий.",
|
"txt_backup_remote_empty": "В этой папке не найдено файлов резервных копий.",
|
||||||
"txt_backup_remote_folder": "Папка",
|
"txt_backup_remote_folder": "Папка",
|
||||||
"txt_backup_remote_unknown_time": "Неизвестное время",
|
"txt_backup_remote_unknown_time": "Неизвестное время",
|
||||||
"txt_backup_remote_current_path": "Текущая папка",
|
"txt_backup_remote_current_path": "Текущая папка",
|
||||||
|
"txt_backup_remote_modified": "Изменено",
|
||||||
|
"txt_backup_remote_size": "Размер",
|
||||||
"txt_backup_remote_load_failed": "Не удалось загрузить удаленные резервные копии.",
|
"txt_backup_remote_load_failed": "Не удалось загрузить удаленные резервные копии.",
|
||||||
"txt_backup_remote_invalid_response": "Неверный ответ удаленного резервного копирования",
|
"txt_backup_remote_invalid_response": "Неверный ответ удаленного резервного копирования",
|
||||||
"txt_backup_remote_download_failed": "Не удалось загрузить удаленную резервную копию.",
|
"txt_backup_remote_download_failed": "Не удалось загрузить удаленную резервную копию.",
|
||||||
@@ -214,6 +249,74 @@ const ru: Record<string, string> = {
|
|||||||
"txt_backup_remote_run_invalid_response": "Неверный ответ на удаленное резервное копирование.",
|
"txt_backup_remote_run_invalid_response": "Неверный ответ на удаленное резервное копирование.",
|
||||||
"txt_backup_settings_invalid_response": "Неверный ответ на настройки резервного копирования",
|
"txt_backup_settings_invalid_response": "Неверный ответ на настройки резервного копирования",
|
||||||
"txt_backup_import_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_destination": "Место назначения резервного копирования",
|
||||||
"txt_backup_protocol_webdav": "WebDAV",
|
"txt_backup_protocol_webdav": "WebDAV",
|
||||||
"txt_backup_protocol_s3": "S3",
|
"txt_backup_protocol_s3": "S3",
|
||||||
@@ -479,8 +582,17 @@ const ru: Record<string, string> = {
|
|||||||
"txt_identity_details": "Данные личности",
|
"txt_identity_details": "Данные личности",
|
||||||
"txt_ie_browser": "IE-браузер",
|
"txt_ie_browser": "IE-браузер",
|
||||||
"txt_create_invite_failed": "Не удалось создать приглашение",
|
"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_code_required": "Пригласительный код (обязательно)",
|
||||||
"txt_invite_created": "Приглашение создано",
|
"txt_invite_created": "Приглашение создано",
|
||||||
|
"txt_invite_deleted": "Приглашение удалено",
|
||||||
|
"txt_invalid_invites_deleted": "Недействительные приглашения удалены",
|
||||||
"txt_invite_revoked": "Приглашение отозвано",
|
"txt_invite_revoked": "Приглашение отозвано",
|
||||||
"txt_revoke_invite_failed": "Не удалось отозвать приглашение",
|
"txt_revoke_invite_failed": "Не удалось отозвать приглашение",
|
||||||
"txt_invite_validity_hours": "Срок действия приглашения (часы)",
|
"txt_invite_validity_hours": "Срок действия приглашения (часы)",
|
||||||
@@ -489,16 +601,21 @@ const ru: Record<string, string> = {
|
|||||||
"txt_server_error_account_disabled": "Учетная запись отключена",
|
"txt_server_error_account_disabled": "Учетная запись отключена",
|
||||||
"txt_server_error_client_credentials_incorrect": "ID клиента или секрет клиента неверны. Повторите попытку.",
|
"txt_server_error_client_credentials_incorrect": "ID клиента или секрет клиента неверны. Повторите попытку.",
|
||||||
"txt_server_error_client_ip_required": "Требуется IP клиента",
|
"txt_server_error_client_ip_required": "Требуется IP клиента",
|
||||||
|
"txt_server_error_forbidden": "У вас нет прав для выполнения этого действия.",
|
||||||
"txt_server_error_email_already_registered": "Этот адрес электронной почты уже зарегистрирован",
|
"txt_server_error_email_already_registered": "Этот адрес электронной почты уже зарегистрирован",
|
||||||
"txt_server_error_email_password_required": "Требуются адрес электронной почты и пароль",
|
"txt_server_error_email_password_required": "Требуются адрес электронной почты и пароль",
|
||||||
"txt_server_error_email_required": "Требуется адрес электронной почты",
|
"txt_server_error_email_required": "Требуется адрес электронной почты",
|
||||||
|
"txt_server_error_invalid_password": "Неверный пароль.",
|
||||||
"txt_server_error_invalid_refresh_token": "Сеанс истек. Войдите снова.",
|
"txt_server_error_invalid_refresh_token": "Сеанс истек. Войдите снова.",
|
||||||
|
"txt_server_error_invalid_user_verification_token": "Недействительный токен проверки пользователя.",
|
||||||
"txt_server_error_invalid_request_payload": "Недопустимый запрос",
|
"txt_server_error_invalid_request_payload": "Недопустимый запрос",
|
||||||
"txt_server_error_invite_invalid_or_expired": "Код приглашения недействителен или истек",
|
"txt_server_error_invite_invalid_or_expired": "Код приглашения недействителен или истек",
|
||||||
"txt_server_error_invite_required": "Требуется код приглашения",
|
"txt_server_error_invite_required": "Требуется код приглашения",
|
||||||
"txt_server_error_jwt_secret_default": "JWT_SECRET использует значение по умолчанию/пример. Измените его.",
|
"txt_server_error_jwt_secret_default": "JWT_SECRET использует значение по умолчанию/пример. Измените его.",
|
||||||
"txt_server_error_jwt_secret_missing": "JWT_SECRET не настроен",
|
"txt_server_error_jwt_secret_missing": "JWT_SECRET не настроен",
|
||||||
"txt_server_error_jwt_secret_too_short": "JWT_SECRET должен содержать не менее 32 символов",
|
"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_parameter_error": "Ошибка параметров",
|
||||||
"txt_server_error_refresh_token_required": "Сеанс отсутствует. Войдите снова.",
|
"txt_server_error_refresh_token_required": "Сеанс отсутствует. Войдите снова.",
|
||||||
"txt_server_error_registration_retry": "Регистрация временно недоступна. Повторите попытку один раз.",
|
"txt_server_error_registration_retry": "Регистрация временно недоступна. Повторите попытку один раз.",
|
||||||
@@ -730,6 +847,11 @@ const ru: Record<string, string> = {
|
|||||||
"txt_remove_all_devices": "Удалить все устройства",
|
"txt_remove_all_devices": "Удалить все устройства",
|
||||||
"txt_remove_all_devices_and_clear_all_2fa_trust": "Удалить все устройства и очистить все доверие 2FA?",
|
"txt_remove_all_devices_and_clear_all_2fa_trust": "Удалить все устройства и очистить все доверие 2FA?",
|
||||||
"txt_remove_all_devices_and_sign_out_all_sessions": "Удалить все устройства, отменить все доверительные отношения и выйти из системы на каждом устройстве?",
|
"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_name_and_clear_its_2fa_trust": "Удалить устройство «{name}» и очистить его доверие 2FA?",
|
||||||
"txt_remove_device_and_sign_out_name": "Удалить устройство «{name}», очистить его доверие и выйти из системы?",
|
"txt_remove_device_and_sign_out_name": "Удалить устройство «{name}», очистить его доверие и выйти из системы?",
|
||||||
"txt_reveal": "Раскрыть",
|
"txt_reveal": "Раскрыть",
|
||||||
@@ -771,6 +893,9 @@ const ru: Record<string, string> = {
|
|||||||
"txt_security_code": "Код безопасности",
|
"txt_security_code": "Код безопасности",
|
||||||
"txt_security_code_cvv": "Код безопасности (CVV)",
|
"txt_security_code_cvv": "Код безопасности (CVV)",
|
||||||
"txt_select_all": "Выбрать все",
|
"txt_select_all": "Выбрать все",
|
||||||
|
"txt_clear_selection": "Очистить выбор",
|
||||||
|
"txt_select_device_name": "Выбрать {name}",
|
||||||
|
"txt_no_devices_selected": "Устройства не выбраны",
|
||||||
"txt_select": "Выбрать",
|
"txt_select": "Выбрать",
|
||||||
"txt_select_duplicate_items": "Выберите дубликаты",
|
"txt_select_duplicate_items": "Выберите дубликаты",
|
||||||
"txt_select_an_item": "Выберите элемент",
|
"txt_select_an_item": "Выберите элемент",
|
||||||
@@ -1040,7 +1165,9 @@ const ru: Record<string, string> = {
|
|||||||
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
|
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
|
||||||
"txt_log_action_admin_backup_settings_update": "Update 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_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_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_invite_revoke": "Revoke invite",
|
||||||
"txt_log_action_admin_user_delete": "Delete user",
|
"txt_log_action_admin_user_delete": "Delete user",
|
||||||
"txt_log_action_admin_user_status": "Change user status",
|
"txt_log_action_admin_user_status": "Change user status",
|
||||||
|
|||||||
@@ -85,6 +85,37 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。",
|
"txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。",
|
||||||
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。",
|
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。",
|
||||||
"txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。",
|
"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_add_destination": "新增地点",
|
||||||
"txt_backup_schedule_panel_title": "自动备份计划",
|
"txt_backup_schedule_panel_title": "自动备份计划",
|
||||||
"txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。",
|
"txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。",
|
||||||
@@ -193,10 +224,14 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_backup_restore_progress_remote_finalize_detail": "服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。",
|
"txt_backup_restore_progress_remote_finalize_detail": "服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。",
|
||||||
"txt_backup_remote_loading": "正在读取远端备份...",
|
"txt_backup_remote_loading": "正在读取远端备份...",
|
||||||
"txt_backup_remote_cached_empty": "点击“刷新”后读取",
|
"txt_backup_remote_cached_empty": "点击“刷新”后读取",
|
||||||
|
"txt_backup_remote_cached_empty_prefix": "点击",
|
||||||
|
"txt_backup_remote_cached_empty_suffix": "后读取",
|
||||||
"txt_backup_remote_empty": "这个目录下还没有备份文件",
|
"txt_backup_remote_empty": "这个目录下还没有备份文件",
|
||||||
"txt_backup_remote_folder": "文件夹",
|
"txt_backup_remote_folder": "文件夹",
|
||||||
"txt_backup_remote_unknown_time": "未知时间",
|
"txt_backup_remote_unknown_time": "未知时间",
|
||||||
"txt_backup_remote_current_path": "当前目录",
|
"txt_backup_remote_current_path": "当前目录",
|
||||||
|
"txt_backup_remote_modified": "修改时间",
|
||||||
|
"txt_backup_remote_size": "大小",
|
||||||
"txt_backup_remote_load_failed": "读取远端备份失败",
|
"txt_backup_remote_load_failed": "读取远端备份失败",
|
||||||
"txt_backup_remote_invalid_response": "远端备份响应无效",
|
"txt_backup_remote_invalid_response": "远端备份响应无效",
|
||||||
"txt_backup_remote_download_failed": "下载远端备份失败",
|
"txt_backup_remote_download_failed": "下载远端备份失败",
|
||||||
@@ -214,6 +249,74 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_backup_remote_run_invalid_response": "远端备份执行响应无效",
|
"txt_backup_remote_run_invalid_response": "远端备份执行响应无效",
|
||||||
"txt_backup_settings_invalid_response": "备份设置响应无效",
|
"txt_backup_settings_invalid_response": "备份设置响应无效",
|
||||||
"txt_backup_import_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_destination": "备份地点",
|
||||||
"txt_backup_protocol_webdav": "WebDAV",
|
"txt_backup_protocol_webdav": "WebDAV",
|
||||||
"txt_backup_protocol_s3": "S3",
|
"txt_backup_protocol_s3": "S3",
|
||||||
@@ -266,15 +369,15 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_backup_webdav_username": "WebDAV 用户名",
|
"txt_backup_webdav_username": "WebDAV 用户名",
|
||||||
"txt_backup_webdav_password": "WebDAV 密码",
|
"txt_backup_webdav_password": "WebDAV 密码",
|
||||||
"txt_backup_webdav_path": "远程目录",
|
"txt_backup_webdav_path": "远程目录",
|
||||||
"txt_backup_s3_endpoint": "S3 端点",
|
"txt_backup_s3_endpoint": "S3 端点 URL",
|
||||||
"txt_backup_s3_addressing_style": "S3 寻址方式",
|
"txt_backup_s3_addressing_style": "寻址方式",
|
||||||
"txt_backup_s3_addressing_path_style": "path-style(默认)",
|
"txt_backup_s3_addressing_path_style": "path-style(默认)",
|
||||||
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-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_region": "区域",
|
||||||
"txt_backup_s3_access_key": "访问密钥",
|
"txt_backup_s3_access_key": "访问 ID",
|
||||||
"txt_backup_s3_secret_key": "秘密密钥",
|
"txt_backup_s3_secret_key": "访问密码",
|
||||||
"txt_backup_s3_path": "远程路径",
|
"txt_backup_s3_path": "路径前缀",
|
||||||
"txt_backup_reserved_name": "预留类型名称",
|
"txt_backup_reserved_name": "预留类型名称",
|
||||||
"txt_backup_reserved_notes": "预留备注",
|
"txt_backup_reserved_notes": "预留备注",
|
||||||
"txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明",
|
"txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明",
|
||||||
@@ -479,8 +582,17 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_identity_details": "身份详情",
|
"txt_identity_details": "身份详情",
|
||||||
"txt_ie_browser": "IE 浏览器",
|
"txt_ie_browser": "IE 浏览器",
|
||||||
"txt_create_invite_failed": "创建邀请码失败",
|
"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_code_required": "邀请码(必填)",
|
||||||
"txt_invite_created": "邀请码已创建",
|
"txt_invite_created": "邀请码已创建",
|
||||||
|
"txt_invite_deleted": "邀请码已删除",
|
||||||
|
"txt_invalid_invites_deleted": "无效邀请码已删除",
|
||||||
"txt_invite_revoked": "邀请码已撤销",
|
"txt_invite_revoked": "邀请码已撤销",
|
||||||
"txt_revoke_invite_failed": "撤销邀请码失败",
|
"txt_revoke_invite_failed": "撤销邀请码失败",
|
||||||
"txt_invite_validity_hours": "邀请码有效期(小时)",
|
"txt_invite_validity_hours": "邀请码有效期(小时)",
|
||||||
@@ -489,16 +601,21 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_server_error_account_disabled": "账号已被禁用",
|
"txt_server_error_account_disabled": "账号已被禁用",
|
||||||
"txt_server_error_client_credentials_incorrect": "客户端 ID 或客户端密钥不正确,请重试",
|
"txt_server_error_client_credentials_incorrect": "客户端 ID 或客户端密钥不正确,请重试",
|
||||||
"txt_server_error_client_ip_required": "无法获取客户端 IP",
|
"txt_server_error_client_ip_required": "无法获取客户端 IP",
|
||||||
|
"txt_server_error_forbidden": "你没有权限执行此操作。",
|
||||||
"txt_server_error_email_already_registered": "该邮箱已注册",
|
"txt_server_error_email_already_registered": "该邮箱已注册",
|
||||||
"txt_server_error_email_password_required": "邮箱和密码不能为空",
|
"txt_server_error_email_password_required": "邮箱和密码不能为空",
|
||||||
"txt_server_error_email_required": "邮箱不能为空",
|
"txt_server_error_email_required": "邮箱不能为空",
|
||||||
|
"txt_server_error_invalid_password": "密码无效。",
|
||||||
"txt_server_error_invalid_refresh_token": "登录状态已失效,请重新登录",
|
"txt_server_error_invalid_refresh_token": "登录状态已失效,请重新登录",
|
||||||
|
"txt_server_error_invalid_user_verification_token": "用户验证令牌无效。",
|
||||||
"txt_server_error_invalid_request_payload": "请求内容无效",
|
"txt_server_error_invalid_request_payload": "请求内容无效",
|
||||||
"txt_server_error_invite_invalid_or_expired": "邀请码无效或已过期",
|
"txt_server_error_invite_invalid_or_expired": "邀请码无效或已过期",
|
||||||
"txt_server_error_invite_required": "邀请码不能为空",
|
"txt_server_error_invite_required": "邀请码不能为空",
|
||||||
"txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默认示例值,请修改后再继续",
|
"txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默认示例值,请修改后再继续",
|
||||||
"txt_server_error_jwt_secret_missing": "JWT_SECRET 未设置",
|
"txt_server_error_jwt_secret_missing": "JWT_SECRET 未设置",
|
||||||
"txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 个字符",
|
"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_parameter_error": "请求参数错误",
|
||||||
"txt_server_error_refresh_token_required": "登录状态缺失,请重新登录",
|
"txt_server_error_refresh_token_required": "登录状态缺失,请重新登录",
|
||||||
"txt_server_error_registration_retry": "注册暂时不可用,请重试一次",
|
"txt_server_error_registration_retry": "注册暂时不可用,请重试一次",
|
||||||
@@ -730,6 +847,11 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_remove_all_devices": "移除所有设备",
|
"txt_remove_all_devices": "移除所有设备",
|
||||||
"txt_remove_all_devices_and_clear_all_2fa_trust": "确认移除所有设备并清除全部 2FA 信任吗?",
|
"txt_remove_all_devices_and_clear_all_2fa_trust": "确认移除所有设备并清除全部 2FA 信任吗?",
|
||||||
"txt_remove_all_devices_and_sign_out_all_sessions": "确认移除所有设备、清除全部信任,并让所有设备重新登录吗?",
|
"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_name_and_clear_its_2fa_trust": "确认移除设备“{name}”并清除其 2FA 信任吗?",
|
||||||
"txt_remove_device_and_sign_out_name": "确认移除设备“{name}”,清除其信任,并让它重新登录吗?",
|
"txt_remove_device_and_sign_out_name": "确认移除设备“{name}”,清除其信任,并让它重新登录吗?",
|
||||||
"txt_reveal": "显示",
|
"txt_reveal": "显示",
|
||||||
@@ -771,6 +893,9 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_security_code": "安全码",
|
"txt_security_code": "安全码",
|
||||||
"txt_security_code_cvv": "安全码 (CVV)",
|
"txt_security_code_cvv": "安全码 (CVV)",
|
||||||
"txt_select_all": "全选",
|
"txt_select_all": "全选",
|
||||||
|
"txt_clear_selection": "取消选择",
|
||||||
|
"txt_select_device_name": "选择 {name}",
|
||||||
|
"txt_no_devices_selected": "未选择设备",
|
||||||
"txt_select": "请选择",
|
"txt_select": "请选择",
|
||||||
"txt_select_duplicate_items": "选择重复项",
|
"txt_select_duplicate_items": "选择重复项",
|
||||||
"txt_select_an_item": "请选择一个项目",
|
"txt_select_an_item": "请选择一个项目",
|
||||||
@@ -1040,7 +1165,9 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_log_action_admin_backup_settings_repair": "修复备份设置",
|
"txt_log_action_admin_backup_settings_repair": "修复备份设置",
|
||||||
"txt_log_action_admin_backup_settings_update": "更新备份设置",
|
"txt_log_action_admin_backup_settings_update": "更新备份设置",
|
||||||
"txt_log_action_admin_invite_create": "创建邀请",
|
"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_all": "清空邀请",
|
||||||
|
"txt_log_action_admin_invite_delete_invalid": "删除无效邀请",
|
||||||
"txt_log_action_admin_invite_revoke": "撤销邀请",
|
"txt_log_action_admin_invite_revoke": "撤销邀请",
|
||||||
"txt_log_action_admin_user_delete": "删除用户",
|
"txt_log_action_admin_user_delete": "删除用户",
|
||||||
"txt_log_action_admin_user_status": "修改用户状态",
|
"txt_log_action_admin_user_status": "修改用户状态",
|
||||||
|
|||||||
@@ -85,6 +85,37 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。",
|
"txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。",
|
||||||
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。",
|
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。",
|
||||||
"txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。",
|
"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_add_destination": "新增地點",
|
||||||
"txt_backup_schedule_panel_title": "自動備份計劃",
|
"txt_backup_schedule_panel_title": "自動備份計劃",
|
||||||
"txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。",
|
"txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。",
|
||||||
@@ -193,10 +224,14 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_backup_restore_progress_remote_finalize_detail": "服務器正在執行最終校驗,校驗通過後會把已驗證的數據切換為正式數據。",
|
"txt_backup_restore_progress_remote_finalize_detail": "服務器正在執行最終校驗,校驗通過後會把已驗證的數據切換為正式數據。",
|
||||||
"txt_backup_remote_loading": "正在讀取遠端備份...",
|
"txt_backup_remote_loading": "正在讀取遠端備份...",
|
||||||
"txt_backup_remote_cached_empty": "點擊“刷新”後讀取",
|
"txt_backup_remote_cached_empty": "點擊“刷新”後讀取",
|
||||||
|
"txt_backup_remote_cached_empty_prefix": "點擊",
|
||||||
|
"txt_backup_remote_cached_empty_suffix": "後讀取",
|
||||||
"txt_backup_remote_empty": "這個目錄下還沒有備份文件",
|
"txt_backup_remote_empty": "這個目錄下還沒有備份文件",
|
||||||
"txt_backup_remote_folder": "文件夾",
|
"txt_backup_remote_folder": "文件夾",
|
||||||
"txt_backup_remote_unknown_time": "未知時間",
|
"txt_backup_remote_unknown_time": "未知時間",
|
||||||
"txt_backup_remote_current_path": "當前目錄",
|
"txt_backup_remote_current_path": "當前目錄",
|
||||||
|
"txt_backup_remote_modified": "修改時間",
|
||||||
|
"txt_backup_remote_size": "大小",
|
||||||
"txt_backup_remote_load_failed": "讀取遠端備份失敗",
|
"txt_backup_remote_load_failed": "讀取遠端備份失敗",
|
||||||
"txt_backup_remote_invalid_response": "遠端備份響應無效",
|
"txt_backup_remote_invalid_response": "遠端備份響應無效",
|
||||||
"txt_backup_remote_download_failed": "下載遠端備份失敗",
|
"txt_backup_remote_download_failed": "下載遠端備份失敗",
|
||||||
@@ -214,6 +249,74 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_backup_remote_run_invalid_response": "遠端備份執行響應無效",
|
"txt_backup_remote_run_invalid_response": "遠端備份執行響應無效",
|
||||||
"txt_backup_settings_invalid_response": "備份設置響應無效",
|
"txt_backup_settings_invalid_response": "備份設置響應無效",
|
||||||
"txt_backup_import_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_destination": "備份地點",
|
||||||
"txt_backup_protocol_webdav": "WebDAV",
|
"txt_backup_protocol_webdav": "WebDAV",
|
||||||
"txt_backup_protocol_s3": "S3",
|
"txt_backup_protocol_s3": "S3",
|
||||||
@@ -479,8 +582,17 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_identity_details": "身份詳情",
|
"txt_identity_details": "身份詳情",
|
||||||
"txt_ie_browser": "IE 瀏覽器",
|
"txt_ie_browser": "IE 瀏覽器",
|
||||||
"txt_create_invite_failed": "創建邀請碼失敗",
|
"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_code_required": "邀請碼(必填)",
|
||||||
"txt_invite_created": "邀請碼已創建",
|
"txt_invite_created": "邀請碼已創建",
|
||||||
|
"txt_invite_deleted": "邀請碼已刪除",
|
||||||
|
"txt_invalid_invites_deleted": "無效邀請碼已刪除",
|
||||||
"txt_invite_revoked": "邀請碼已撤銷",
|
"txt_invite_revoked": "邀請碼已撤銷",
|
||||||
"txt_revoke_invite_failed": "撤銷邀請碼失敗",
|
"txt_revoke_invite_failed": "撤銷邀請碼失敗",
|
||||||
"txt_invite_validity_hours": "邀請碼有效期(小時)",
|
"txt_invite_validity_hours": "邀請碼有效期(小時)",
|
||||||
@@ -489,16 +601,21 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_server_error_account_disabled": "帳號已被禁用",
|
"txt_server_error_account_disabled": "帳號已被禁用",
|
||||||
"txt_server_error_client_credentials_incorrect": "客戶端 ID 或客戶端密鑰不正確,請重試",
|
"txt_server_error_client_credentials_incorrect": "客戶端 ID 或客戶端密鑰不正確,請重試",
|
||||||
"txt_server_error_client_ip_required": "無法獲取客戶端 IP",
|
"txt_server_error_client_ip_required": "無法獲取客戶端 IP",
|
||||||
|
"txt_server_error_forbidden": "你沒有權限執行此操作。",
|
||||||
"txt_server_error_email_already_registered": "該郵箱已註冊",
|
"txt_server_error_email_already_registered": "該郵箱已註冊",
|
||||||
"txt_server_error_email_password_required": "郵箱和密碼不能為空",
|
"txt_server_error_email_password_required": "郵箱和密碼不能為空",
|
||||||
"txt_server_error_email_required": "郵箱不能為空",
|
"txt_server_error_email_required": "郵箱不能為空",
|
||||||
|
"txt_server_error_invalid_password": "密碼無效。",
|
||||||
"txt_server_error_invalid_refresh_token": "登入狀態已失效,請重新登入",
|
"txt_server_error_invalid_refresh_token": "登入狀態已失效,請重新登入",
|
||||||
|
"txt_server_error_invalid_user_verification_token": "用戶驗證令牌無效。",
|
||||||
"txt_server_error_invalid_request_payload": "請求內容無效",
|
"txt_server_error_invalid_request_payload": "請求內容無效",
|
||||||
"txt_server_error_invite_invalid_or_expired": "邀請碼無效或已過期",
|
"txt_server_error_invite_invalid_or_expired": "邀請碼無效或已過期",
|
||||||
"txt_server_error_invite_required": "邀請碼不能為空",
|
"txt_server_error_invite_required": "邀請碼不能為空",
|
||||||
"txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默認示例值,請修改後再繼續",
|
"txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默認示例值,請修改後再繼續",
|
||||||
"txt_server_error_jwt_secret_missing": "JWT_SECRET 未設置",
|
"txt_server_error_jwt_secret_missing": "JWT_SECRET 未設置",
|
||||||
"txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 個字符",
|
"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_parameter_error": "請求參數錯誤",
|
||||||
"txt_server_error_refresh_token_required": "登入狀態缺失,請重新登入",
|
"txt_server_error_refresh_token_required": "登入狀態缺失,請重新登入",
|
||||||
"txt_server_error_registration_retry": "註冊暫時不可用,請重試一次",
|
"txt_server_error_registration_retry": "註冊暫時不可用,請重試一次",
|
||||||
@@ -730,6 +847,11 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_remove_all_devices": "移除所有設備",
|
"txt_remove_all_devices": "移除所有設備",
|
||||||
"txt_remove_all_devices_and_clear_all_2fa_trust": "確認移除所有設備並清除全部 2FA 信任嗎?",
|
"txt_remove_all_devices_and_clear_all_2fa_trust": "確認移除所有設備並清除全部 2FA 信任嗎?",
|
||||||
"txt_remove_all_devices_and_sign_out_all_sessions": "確認移除所有設備、清除全部信任,並讓所有設備重新登錄嗎?",
|
"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_name_and_clear_its_2fa_trust": "確認移除設備“{name}”並清除其 2FA 信任嗎?",
|
||||||
"txt_remove_device_and_sign_out_name": "確認移除設備“{name}”,清除其信任,並讓它重新登錄嗎?",
|
"txt_remove_device_and_sign_out_name": "確認移除設備“{name}”,清除其信任,並讓它重新登錄嗎?",
|
||||||
"txt_reveal": "顯示",
|
"txt_reveal": "顯示",
|
||||||
@@ -771,6 +893,9 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_security_code": "安全碼",
|
"txt_security_code": "安全碼",
|
||||||
"txt_security_code_cvv": "安全碼 (CVV)",
|
"txt_security_code_cvv": "安全碼 (CVV)",
|
||||||
"txt_select_all": "全選",
|
"txt_select_all": "全選",
|
||||||
|
"txt_clear_selection": "取消選擇",
|
||||||
|
"txt_select_device_name": "選擇 {name}",
|
||||||
|
"txt_no_devices_selected": "未選擇設備",
|
||||||
"txt_select": "請選擇",
|
"txt_select": "請選擇",
|
||||||
"txt_select_duplicate_items": "選擇重複項",
|
"txt_select_duplicate_items": "選擇重複項",
|
||||||
"txt_select_an_item": "請選擇一個項目",
|
"txt_select_an_item": "請選擇一個項目",
|
||||||
@@ -1040,7 +1165,9 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_log_action_admin_backup_settings_repair": "修復備份設定",
|
"txt_log_action_admin_backup_settings_repair": "修復備份設定",
|
||||||
"txt_log_action_admin_backup_settings_update": "更新備份設定",
|
"txt_log_action_admin_backup_settings_update": "更新備份設定",
|
||||||
"txt_log_action_admin_invite_create": "建立邀請",
|
"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_all": "清空邀請",
|
||||||
|
"txt_log_action_admin_invite_delete_invalid": "刪除無效邀請",
|
||||||
"txt_log_action_admin_invite_revoke": "撤銷邀請",
|
"txt_log_action_admin_invite_revoke": "撤銷邀請",
|
||||||
"txt_log_action_admin_user_delete": "刪除使用者",
|
"txt_log_action_admin_user_delete": "刪除使用者",
|
||||||
"txt_log_action_admin_user_status": "修改使用者狀態",
|
"txt_log_action_admin_user_status": "修改使用者狀態",
|
||||||
|
|||||||
@@ -1,6 +1,102 @@
|
|||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import { addFolder, cardBrand, makeLoginCipher, nameFromUrl, normalizeUri, parseCsv, parseSerializedUris, processKvp, txt, val } from '@/lib/import-format-shared';
|
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'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseBitwardenCsvFieldLines(rawFields: unknown): BitwardenCsvFieldLine[] {
|
||||||
|
return String(rawFields || '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const delim = line.lastIndexOf(': ');
|
||||||
|
if (delim < 0) return null;
|
||||||
|
const key = txt(line.slice(0, delim));
|
||||||
|
const value = txt(line.slice(delim + 2));
|
||||||
|
return key && value ? { key, value } : null;
|
||||||
|
})
|
||||||
|
.filter((line): line is BitwardenCsvFieldLine => !!line);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
export function parseChromeCsv(textRaw: string): CiphersImportPayload {
|
||||||
const rows = parseCsv(textRaw);
|
const rows = parseCsv(textRaw);
|
||||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||||
@@ -62,19 +158,33 @@ export function parseSafariCsv(textRaw: string): CiphersImportPayload {
|
|||||||
export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
|
export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
|
||||||
const rows = parseCsv(textRaw);
|
const rows = parseCsv(textRaw);
|
||||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
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) {
|
for (const row of rows) {
|
||||||
const type = txt(row.type).toLowerCase() || 'login';
|
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') {
|
if (type === 'note' || type === 'secure note' || type === 'securenote') {
|
||||||
const cipher = {
|
const cipher = {
|
||||||
type: 2,
|
type: 2,
|
||||||
@@ -91,7 +201,7 @@ export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
|
|||||||
passwordHistory: null,
|
passwordHistory: null,
|
||||||
sshKey: null,
|
sshKey: null,
|
||||||
};
|
};
|
||||||
applyBitwardenCustomFields(cipher, row.fields);
|
applyBitwardenCustomFields(cipher, fieldLines);
|
||||||
const idx = result.ciphers.push(cipher) - 1;
|
const idx = result.ciphers.push(cipher) - 1;
|
||||||
addFolder(result, row.folder, idx);
|
addFolder(result, row.folder, idx);
|
||||||
continue;
|
continue;
|
||||||
@@ -101,7 +211,7 @@ export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
|
|||||||
cipher.notes = val(row.notes);
|
cipher.notes = val(row.notes);
|
||||||
cipher.favorite = txt(row.favorite) === '1';
|
cipher.favorite = txt(row.favorite) === '1';
|
||||||
cipher.reprompt = Number(row.reprompt ?? 0) || 0;
|
cipher.reprompt = Number(row.reprompt ?? 0) || 0;
|
||||||
applyBitwardenCustomFields(cipher, row.fields);
|
applyBitwardenCustomFields(cipher, fieldLines);
|
||||||
const login = cipher.login as Record<string, unknown>;
|
const login = cipher.login as Record<string, unknown>;
|
||||||
login.username = val(row.login_username, val(row.username));
|
login.username = val(row.login_username, val(row.username));
|
||||||
login.password = val(row.login_password, val(row.password));
|
login.password = val(row.login_password, val(row.password));
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
/* Unified product polish: refined, smooth, comfortable surfaces across desktop, mobile, and dark mode. */
|
/* Unified product polish: refined, smooth, comfortable surfaces across desktop, mobile, and dark mode. */
|
||||||
|
|
||||||
/* ── surface consistency ── */
|
/* ── surface consistency ── */
|
||||||
.app-shell,
|
|
||||||
.auth-card,
|
.auth-card,
|
||||||
.dialog-card,
|
.dialog-card,
|
||||||
.card,
|
.card,
|
||||||
@@ -36,12 +35,6 @@
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
|
||||||
background: var(--panel-soft);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar,
|
.topbar,
|
||||||
.mobile-tabbar,
|
.mobile-tabbar,
|
||||||
.app-side {
|
.app-side {
|
||||||
@@ -104,7 +97,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── dark mode surface resets ── */
|
/* ── dark mode surface resets ── */
|
||||||
:root[data-theme='dark'] .app-shell,
|
|
||||||
:root[data-theme='dark'] .auth-card,
|
:root[data-theme='dark'] .auth-card,
|
||||||
:root[data-theme='dark'] .dialog-card,
|
:root[data-theme='dark'] .dialog-card,
|
||||||
:root[data-theme='dark'] .card,
|
:root[data-theme='dark'] .card,
|
||||||
@@ -259,17 +251,6 @@ h4 {
|
|||||||
letter-spacing: 0;
|
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 {
|
.topbar {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
padding-inline: 16px;
|
padding-inline: 16px;
|
||||||
@@ -916,7 +897,6 @@ textarea {
|
|||||||
background: var(--bg-accent);
|
background: var(--bg-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .app-shell,
|
|
||||||
:root[data-theme='dark'] .topbar,
|
:root[data-theme='dark'] .topbar,
|
||||||
:root[data-theme='dark'] .app-side,
|
:root[data-theme='dark'] .app-side,
|
||||||
:root[data-theme='dark'] .mobile-tabbar,
|
:root[data-theme='dark'] .mobile-tabbar,
|
||||||
|
|||||||
@@ -200,10 +200,6 @@
|
|||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.20), 0 8px 24px rgba(0, 0, 0, 0.16);
|
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 {
|
: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);
|
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-step,
|
||||||
:root[data-theme='dark'] .backup-recommendation-inline-note,
|
:root[data-theme='dark'] .backup-recommendation-inline-note,
|
||||||
:root[data-theme='dark'] .backup-recommendation-linked-item,
|
: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-meta,
|
||||||
:root[data-theme='dark'] .backup-browser-empty,
|
:root[data-theme='dark'] .backup-browser-empty,
|
||||||
:root[data-theme='dark'] .backup-inline-note,
|
:root[data-theme='dark'] .backup-inline-note,
|
||||||
@@ -351,6 +348,19 @@
|
|||||||
color: var(--muted);
|
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 {
|
:root[data-theme='dark'] .restore-progress-overlay {
|
||||||
background: var(--overlay-strong);
|
background: var(--overlay-strong);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
|
|||||||
@@ -172,6 +172,10 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
@apply shrink-0;
|
@apply shrink-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-icon-spin {
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.btn.full {
|
.btn.full {
|
||||||
@apply my-2.5 h-12 w-full;
|
@apply my-2.5 h-12 w-full;
|
||||||
font-size: var(--font-md);
|
font-size: var(--font-md);
|
||||||
|
|||||||
@@ -193,6 +193,18 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-recommendation-step a {
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendation-step a:hover,
|
||||||
|
.backup-recommendation-step a:focus-visible {
|
||||||
|
color: #1742b0;
|
||||||
|
}
|
||||||
|
|
||||||
.backup-recommendation-inline-note {
|
.backup-recommendation-inline-note {
|
||||||
color: #475467;
|
color: #475467;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -351,16 +363,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-nav {
|
.backup-browser-nav {
|
||||||
@apply mb-2.5;
|
@apply mb-2.5 flex items-center justify-between gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-nav-left {
|
||||||
|
@apply min-w-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-list {
|
.backup-browser-list {
|
||||||
@apply overflow-hidden rounded-xl border bg-white;
|
@apply overflow-hidden rounded-lg border bg-white;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-browser-head {
|
||||||
|
@apply grid items-center gap-3 px-3 py-2 text-[11px] font-bold uppercase tracking-[0.08em];
|
||||||
|
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
color: #64748b;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-head span:nth-child(2),
|
||||||
|
.backup-browser-head span:nth-child(3),
|
||||||
|
.backup-browser-head span:nth-child(4) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.backup-browser-pagination {
|
.backup-browser-pagination {
|
||||||
@apply mt-2.5 flex items-center justify-end gap-2.5;
|
@apply mt-2.5 flex items-center justify-center gap-2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-page-indicator {
|
.backup-browser-page-indicator {
|
||||||
@@ -373,13 +403,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-row {
|
.backup-browser-row {
|
||||||
@apply grid items-center gap-2.5 px-3 py-2.5;
|
@apply grid items-center gap-3 px-3 py-2;
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||||
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-entry {
|
.backup-browser-entry {
|
||||||
@apply inline-flex cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left;
|
@apply inline-flex cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-entry.file {
|
.backup-browser-entry.file {
|
||||||
@@ -392,12 +424,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-meta {
|
.backup-browser-meta {
|
||||||
@apply grid justify-items-end gap-1 text-right text-[13px];
|
@apply block text-right text-[13px];
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-browser-size {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.backup-browser-actions {
|
.backup-browser-actions {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-empty {
|
.backup-browser-empty {
|
||||||
@@ -406,6 +443,10 @@
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-browser-refresh-prompt {
|
||||||
|
@apply inline-flex flex-wrap items-center justify-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
.backup-inline-note {
|
.backup-inline-note {
|
||||||
@apply m-0 mb-3 leading-[1.5];
|
@apply m-0 mb-3 leading-[1.5];
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
@@ -1479,8 +1520,12 @@
|
|||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authorized-devices-col-select {
|
||||||
|
width: 4%;
|
||||||
|
}
|
||||||
|
|
||||||
.authorized-devices-col-device {
|
.authorized-devices-col-device {
|
||||||
width: 28%;
|
width: 26%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.authorized-devices-col-type {
|
.authorized-devices-col-type {
|
||||||
@@ -1503,6 +1548,12 @@
|
|||||||
width: 26%;
|
width: 26%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authorized-device-checkbox {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
.authorized-devices-table td:first-child {
|
.authorized-devices-table td:first-child {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,24 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-browser-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-meta {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-nav {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-twofactor-grid {
|
.settings-twofactor-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1110,6 +1128,24 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-browser-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-meta {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-nav {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.backup-grid {
|
.backup-grid {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
.app-page {
|
.app-page {
|
||||||
@apply relative min-h-full bg-transparent p-5;
|
@apply relative min-h-full bg-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
@apply relative mx-auto flex max-w-[1600px] flex-col overflow-hidden border bg-panel-soft;
|
@apply relative flex flex-col;
|
||||||
height: calc(100vh - 40px);
|
height: 100vh;
|
||||||
border-color: var(--line);
|
background: var(--bg-accent);
|
||||||
@apply rounded-3xl;
|
|
||||||
box-shadow:
|
|
||||||
0 20px 60px rgba(15, 23, 42, 0.12),
|
|
||||||
0 8px 24px rgba(15, 23, 42, 0.08),
|
|
||||||
0 0 0 1px rgba(15, 23, 42, 0.04);
|
|
||||||
transition: box-shadow var(--dur-medium) var(--ease-smooth);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
|
|||||||
Reference in New Issue
Block a user